比较提交

...
31 次代码提交
作者 SHA1 备注 提交日期
lyizai 6ff45a48ae fix: 给「寻路状态」进度日志加节流(1.11.1)
mineflayer-pathfinder 每用满一个 tick 的计算预算(tickTimeout,默认 40ms)就发一次
path_update,而原来的处理函数每次都记一条 —— 实测 4 分钟写了 3893 条(约 16 条/秒)。
既淹没了真正有用的日志(按 120 字节/行算一天能写上百 MB,而本服磁盘只剩 2.4G),
也让「寻路到底卡在哪」更难看清。

现在只在三种情况下记:
1. 状态发生变化(partial → success / noPath / timeout)—— 最要紧的信号;
2. 距上一条超过 2 秒;
3. 已探索节点数比上一条多出 1000 以上 —— 长规划里仍能看出推进速度,
   不丢定位卡死所需的信息。

测试 480 → 481。
2026-10-02 01:28:03 +08:00
lyizai 6e420e13f0 docs: 记录 yaw 修复结果与剩下的两个拦路虎
⑤ 已定位并修复(1.11.0):卡住不是地形问题,是朝向 —— bot.entity.yaw 无界增长,
抓包证据 yaw° 在 -1373 与 19254 之间跳(相邻两包折合差 114°),
坐标在 0.9 格内橡皮筋;修复后朝向变平滑(0,-9,-17,-26,-34…),速度 0.35 → 0.56 格/秒。

⑥ 剩下的拦路虎:服务端把机器人钉在固定坐标上。手动移动测试报告位移 2.40~4.81 格
(客户端确实走了),但一查状态位置又回到同一点;被服务器传送另有 16~194 次/30 秒。
客户端走、服务端拉回,净位移为 0 —— 这一层已不在代码控制范围内。

⑦ 服务端提示过「邦国已激活科技: 疆土开拓者 I 剩余时间: 0时13分3秒」——
开拓可能要求该邦国科技处于激活状态且有时间限制,尚未验证。
2026-10-01 18:16:07 +08:00
lyizai ab598a7101 fix: 修掉 yaw 无界增长 —— 它让机器人原地走之字形、净位移恒为 0(1.11.0)
mineflayer 收到服务端 position 包时会直接 bot.entity.yaw = conv.fromNotchianYaw(…)
不做取模,而 GrimCompat 又在它基础上逐 tick 累加,两者叠加让 yaw 一路涨到
19254°(≈336 弧度)。

抓包实测的后果:
  yaw°:  -1373 → 19254 → -1650 → -1407 → -1373 → 19254 → …
  坐标:  (6757.50, 11953.70) ⇄ (6757.89, 11954.12)   0.4 格内反复振荡
相邻两包的朝向折合相差约 114°,而机器人一直按着前进键 ——
于是它原地走之字形、净位移恒为 0,看起来像「卡在墙前反复起跳」
(物理录制里 jump 74/240、水平碰撞 195/240、onGround 只有 79/240)。

这是扩地跑不完那 763 格长途的直接原因,也解释了出站包里 look 包(38490)
比 position_look(23804)还多。

修复:新增 wrapAngle,_onPhysicsTick 每 tick 归一化 entity.yaw 到 (-π, π],
setLook 也归一化传入的目标角度。归一化只改表示不改朝向
(shortestAngleDelta(huge, wrapped) === 0),对旋转逻辑完全透明。

测试 478 → 480。
2026-10-01 18:08:41 +08:00
lyizai 841d20afef docs: 记录「卡在崎岖地形前原地起跳」这一形态
物理录制实测(机器人停在距目标 763 格处时):
  forward 按下 237/240、sprint 237/240、jump 74/240
  有水平位移 232/240、水平碰撞 195/240、onGround 只有 79/240
  inputMovedTicks 229/237(每 tick 都在动)但净位移 ≈ 0

即它一直在走、一直在跳,81% 的 tick 报告水平碰撞,三分之二时间不在空中就是刚落地
—— 卡在一个跳不上去也绕不过去的坎前。这是目前让 763 格长途走不完的直接原因。

关掉 allowParkour / allow1by1towers 后没有改善,已还原为开启。
2026-10-01 17:49:25 +08:00
lyizai 1339765152 docs: 记录当前进展与剩下的四个拦路虎
修好高度偏好后机器人确实能朝目标走(实测 20 分钟前进 95 格),
但要真正开垦出第一块地还要跨过:

① 几何约束:规划器按到玩家的曼哈顿距离取最近的合法候选,而机器人出生在王城 ——
   它在「养老院」这个 33037 区块(约 180×180 区块)的大邦国正中间。
   实测最近候选在 53 区块(≈768 格)外,且 targetCount=1 只返回一个候选,
   长途跋涉是邦国几何决定的,绕不开。

② 服务器踢人,三种原因都实测到过:
   An internal error occurred in your connection(最常见,1~6 分钟一次)、
   You are sending too many packets!、Flying is not enabled on this server,
   外加未验证时的「验证超时」。实测出站 57.9 个/秒(pong 30.6、协议补包 23.9、
   位置包 1.35)。注意协议补包当初的立论依据已被证伪(真因是高度偏好),
   它们是否仍必要、是否就是「发包太多」的原因尚未验证。

③ 半路会死:fell from a high place / was slain by Zombie。
   摔死已处理 —— 服务器配置里 maxDropDown 被改成 64(钳制上限),
   而原版超过 3 格每格 1 点、23 格必死,已改回 4(代码默认本来就是 3)。
   被僵尸杀还没处理。

④ 推进速度只有物理速度的 1/5(净位移 0.405 格/秒 vs velocity 0.2~3 格/秒),
   差额是被服务端拉回。已补上 hasHorizontalCollision(1.10.0),
   该标志确实一直在被漏报(补写计数涨到 1 万+),但提速效果尚未量化验证。
2026-10-01 17:33:23 +08:00
lyizai 1b958cb804 fix: 自动恢复不再因一次失败的尝试而永久丢失
1.9.3 的 tick() 里先取走 resumeConfig 再调 start(),而 start() 在
「已进入世界但还没完成登录」时会直接返回 false(isReady() 要求 loggedIn)。
本服掉线频繁、重连后常常短暂处于这个状态,于是第一次失败的尝试就把这次恢复
永久丢掉了,任务再也不会自己接上 —— 实测监控里任务长时间停在
「机器人尚未就绪(未连接 / 未进入世界)」就是这个原因。

现在:① 等 bm.isReady() 才尝试;② 只有 start() 真的返回 true 才清掉登记,
失败时保留并在下一 tick 继续试,日志打「自动恢复未成功(…),稍后重试」。

顺带:服务器上保存的 pathing.maxDropDown 被改成了 64(钳制上限),
而原版摔落伤害超过 3 格后每格 1 点、23 格必死 —— 实测机器人正是
「fell from a high place」摔死并重生回王城,白走了 100 多格。
已在服务器配置上改回 4(代码默认本来就是 3)。

测试 477 → 478。
2026-10-01 17:22:11 +08:00
lyizai 2ae2833568 feat: 补上出站包一直漏报的 hasHorizontalCollision(1.10.0)
针对「机器人一直在走、净位移却只有物理速度的 1/5」这个现象。

mineflayer 在 physics.js 里把 flags.hasHorizontalCollision 写成 undefined
(源码注释还标着「1.21.3+」),位标志序列化后恒为 0 —— 而原版客户端在水平碰到
方块时会上报 1。机器人在崎岖地形上几乎一直在水平碰撞,却始终上报「没碰撞」,
正是反作弊 Prediction 最容易判错的形态:服务端按「该碰撞」预测、客户端说
「没碰撞」,于是被判异常并拉回。

物理引擎其实早已算出真实值(moveEntity 里给 entity.isCollidedHorizontally 赋值),
只是从没被发出去。现在在 _fixOutboundOnGround 里按真实值补上,由
grim.reportHorizontalCollision(默认开)控制。

实测依据(王城外山区,2 秒粒度采样 120 秒):
- controls 几乎每 tick 都是 forward+sprint,velocity 约 0.2~3 格/秒;
- 净位移只有 48.6 格 / 120 秒 = 0.405 格/秒,差额就是被服务端拉回的部分。

顺带把 _fixOutboundOnGround 拆成 _fixOnGroundFlag / _fixHorizontalCollisionFlag
两个职责单一的方法;控制台配置面板新增对应开关,
packetStats.horizontalCollisionFixed 暴露补写次数。

测试 473 → 477。
2026-10-01 17:04:25 +08:00
lyizai ed55308885 docs: 记录突破 —— 修掉高度偏好后扩地真的跑起来了
TODO 第 1 节从 🔴 改为 🟢,写清根因、生产对照实测与修复后的推进数据。

根因:AltitudeGoal 把高度偏置加在启发值上,而 mineflayer-pathfinder 每次
compute() 返回 partial 时用 h 最小的节点当 partial 路径终点,于是「又高又偏」
的节点胜出,A* 永不收敛,机器人一次移动指令都发不出来。

生产对照(王城平台,其余配置相同):
- altitudePreference = true  → inputTicks = 0,永远「寻路超时」
- altitudePreference = false → inputTicks = 237,blockedTicks = 0,每 tick 都在走

第二个拦路虎(1.9.3):服务器每 1~6 分钟踢一次,原来掉线即永久停住;
autoResumeAfterReconnect 现在会在重连后自动接上。

修复后连续 20 分钟实测:距目标 763 → 668 格(前进 95 格),全程未掉线;
原点自救 4/4 成功,每次都能从登录服大厅回到真实世界。

仍待优化:推进速度约 5 格/分钟,卡在「寻路重试耗尽 → 往边上走两步 →
跳过目标 → 重新规划同一目标」的循环里(waitTimeoutSeconds 偏长、
跳过目标后立刻重选同一个目标)。
2026-10-01 16:57:04 +08:00
lyizai 0bed12e189 feat: 掉线重连后自动恢复扩地任务(autoResumeAfterReconnect)
本服会周期性把机器人踢下线(实测约 1~6 分钟一次,踢出原因是
"An internal error occurred in your connection"),每次都会走到「世界已退出,任务停止」。
原来的行为是就此停住 —— 机器人虽然能被原点自救送回真实世界(实测 4/4 成功),
却再也不会继续扩地,于是永远停在原地。

现在世界退出时把本次任务的配置记下来(resumeConfig),
重新进入世界后自动 start() 接上;状态文案改为「世界已退出,等待重连后自动恢复」,
并打印「已重新进入世界,自动恢复扩地任务」。

只有因世界退出而停的那次会登记:手动停止 / 到开垦时段外 / 耐久保护停机
都会在 stop() 里清空 resumeConfig,不会被重连重新拉起。
开关 autoResumeAfterReconnect 默认开启,可关。

测试 470 → 473。
2026-10-01 16:34:56 +08:00
lyizai 5513ef9f5f fix: 物理录制器区分「被服务端推回」与「输入没进物理」
原来 frozenTicks 只看「有输入 + tick 级无位移 + 物理没报告碰撞」,
但服务端推回发生在 tick 之间,tick 级位移同样是 0 —— 实测大厅里就是这个形态:
collidedHorizontally = false、frozenTicks = 194,而物理其实每 tick 都在动。

现在再按水平速度是否非零细分:
- pushedBackTicks:速度非零 → 物理确实动了,位移被服务端位置包抹掉;
- frozenTicks:速度也是 0 → 这才是「输入根本没进物理」。

类注释里的判定表也同步改成三分法(被挡住 / 被推回 / 输入没生效)。

测试 469 → 470。
2026-10-01 16:28:14 +08:00
lyizai 468c719e37 docs: 记录「规划→卡住→清空」死循环的确切机制
读 mineflayer-pathfinder 源码定位到:index.js 末尾有「徒劳检测」——
3.5 秒内到不了下一个路径点就 resetPath('stuck') 清空路径。
机器人被墙挡住时必然触发,于是每 3.5 秒一个循环:
路径已生成 → 卡住 → 清空 → 路径已生成 → …

可靠信号:resetPath 每次都会补一对 diggingAborted/diggingCompleted 监听器,
所以日志里出现 MaxListenersExceededWarning: 11 diggingAborted listeners
就说明「路径正在被反复清空」。

另两个清空触发点(排查时注意):
- blockUpdate:路径附近方块一变就 resetPath('block_updated');
- chunkColumnLoad:相邻新区块加载就 resetPath('chunk_loaded'),
  服务器频繁传送时会反复触发,A* 的 thinkTimeout 因每次新建 AStar 而永不到期。

结论:只要被墙挡住,就会无限循环「规划→卡住→清空」,既不放弃目标也不脱困。
需要先把它挪到开阔地;长期应累计 path_reset('stuck') 次数,
连续 N 次就判定「此处出不去」并上报 / 换目标。

同时更新当前拦路虎的优先级:第一是机器人被 /u return 送进王城建筑后出不来
(四向只有西面可走、东面头顶是火把),第二才是装备耐久,第三是 /u return 摔伤。
2026-10-01 00:39:46 +08:00
lyizai 3a508f6818 fix: 高度偏好默认关闭 —— 它让寻路器完全规划不出路径(自动扩地的直接拦路虎)
实测定位:高度偏好的启发值实现与 mineflayer-pathfinder 的内部约定冲突。
该库每次 compute() 返回 partial 时,用 h 最小的节点当 partial 路径的终点
(astar.js: if (neighborNode.h < this.bestNode.h) this.bestNode = neighborNode),
而 AltitudeGoal 把 h 减去了 权重 × y,于是「又高又偏」的节点 h 反而更小,
partial 路径被引向高处而不是目标附近 —— A* 一直在算、永不收敛,
机器人一次移动指令都发不出来。

王城平台上的对照实测(其余配置完全相同):
- altitudePreference = true  → inputTicks = 0,任务永远停在「寻路超时,重新寻路」
- altitudePreference = false → inputTicks = 237,立刻开始移动

顺带排除:searchRadius 从 -1 改到 128 对结果没有任何影响,
所以不是「搜索空间无界」导致的;pathfinder 与 manual 两种执行器表现一致。

改动:
- expansionConfig 默认 altitudePreference 改为 false,并在注释里写清原因与
  「想重新启用应该怎么做」(把偏置改成低处加移动代价,保持启发值只反映真实距离);
- AltitudeGoal 的类注释补上完整推导与实测数据,避免以后有人又把它打开;
- 新增回归用例:断言默认关闭,并用启发值差值把「为什么必然出问题」固化下来。

测试 468 → 469,全绿。
2026-10-01 00:34:51 +08:00
lyizai c89f4f8f0f docs: 固化物理录制实测结论 —— 三种「走不动」必须分开看
用 1.9.0 新增的物理 tick 录制逐 tick 抓「输入 → 输出」,把三种完全不同的现象区分开了:

- 登录服大厅:物理每 tick 正常走 0.116 格,服务端每 2 tick 推回一次
  → 真回弹,但那是未完成验证的惩罚,不是 bug;
- 真实世界建筑内:物理加速到 0.21 格/tick(≈4.3 格/秒,正常步行速度),
  入站位置包 0~1 个(服务端没插手),collidedHorizontally=true、blockedTicks 42~59/60
  → 纯粹是撞墙(地形),不是协议问题;
- 真实世界卡在低处(y=72,王城平台在 y≈146):inputTicks=0,
  两种执行器都是 timeout → 寻路器 60 秒规划超时,从未产生可执行路径。

frozenTicks 在所有场景都是 0,说明「输入没进物理」从未出现;
searchRadius 从 -1 改到 128 对结果没有影响。物理引擎、协议补包、Grim 合规层均正常。

当前扩地的真正拦路虎(按优先级):
1. 耐久保护(主手剩余耐久 15% ≤ 15%)一启动就回城停机,需人工修装备;
2. 机器人掉到低处后规划不出路径,需先带回高处;
3. /u return 会造成摔落伤害(实测 health 3.79 → 1.24,更早一次直接摔死)。
2026-10-01 00:17:22 +08:00
lyizai b2b0317af7 feat: 新增物理 tick 录制,把「走不动」的三种原因切开(1.9.0)
抓包已经证明「手动按键时客户端坐标一字未变、服务端入站位置包为 0」,
问题在 prismarine-physics 这一层;但抓包只能看到「发出去的包」,
看不到物理引擎内部读到了什么输入、算出了什么、有没有真的移动。
本提交补上这最后一块拼图。

新增 src/core/physicsRecorder.js + 控制台接口
(POST /api/bots/:id/physics/start|stop、GET /api/bots/:id/physics):

逐 tick 记录位置增量、速度、onGround、isCollidedHorizontally、控制键快照、
脚下/脚部/头部方块名、physicsEnabled;汇总给出四个判定计数:
- inputTicks        有移动输入的 tick 数
- inputMovedTicks   其中真的产生了水平位移的
- blockedTicks      有输入、没位移、物理报告碰撞  → 被方块挡住(地形)
- frozenTicks       有输入、没位移、物理没报告碰撞 → 输入根本没进物理

即:抓包看「发了什么」,本录制器看「物理读到了什么、动没动」,两者配合即可定位。

细节:
- 录制开始时先取一次当前位置作基线,避免第一个 tick 因「没有上一 tick 可比」
  被误判成「有输入但没动」;
- 滚动缓冲只保留最近 240 个 tick(约 12 秒),只放内存、不写盘;
- 录制全程 try 包裹,诊断自身出异常绝不影响物理循环;
- FakeBot 补上与 mineflayer 一致的 controlState(带 getter 的实时控制键代理)。

测试:451 → 468 个用例(含三种故障判定的回归用例与控制台接口用例),全绿。
2026-09-30 21:05:38 +08:00
lyizai 0c6700642e docs: 固化抓包对照结论 —— 「走不动」在客户端物理层,不是服务端回弹
用新增的出站包抓取(1.8.0)做了三组对照 + 四方向逐包序列,结论与 TODO 原有框架相反,
先写进 TODO.md 免得下一个接手的人继续沿错误方向排查。

手动按住方向键 3 秒的实测(任务未启动、机器人空闲):
- 入站位置包 0 个 —— 服务端全程没插手,根本没有「回弹」
- 客户端自己的坐标与朝向一字未变;moveDiag.velocity 恒为 (0, -0.078, 0)
- 而 player_input 位标志是 backward: true —— 控制键确实按下去了
- 四方向位移 forward 0.02 / back 0.00 / left 1.22 / right 1.40(正常应约 12 格)

推论:
- 是客户端物理引擎没有把按键积分成位移(prismarine-physics 的 applyHeading
  没有产生水平速度),下一步要加 physicsDiag 直接看 physicsEnabled /
  physicsTick 计数 / simulate 前后 onGround / world.getBlock 命中 / control 快照;
- 服务端回弹只在「任务驱动移动」时出现(任务中入站 6/秒、出站 position_look 25/秒),
  是伴生现象而非病因,这与「两套地面修正开关对位移毫无影响」的实测一致;
- 原记的「手动按键移动完全正常(forward 2 秒 4~8 格)」已不成立,该基准需重建。

顺带记下两条协议事实:flags.hasHorizontalCollision 恒为 0(mineflayer 写成 undefined),
teleport_confirm.teleportId 是该服务端自定义的大负数。
2026-09-29 22:08:50 +08:00
lyizai 8ab2d59eb0 feat: 新增出站包抓取,供「走不动」逐包对照(TODO 假设 5)
线上实测确认手动执行器(1.7.0 备选方案)不能解决「走不动」:前 20 秒走 13 格后
即卡在「执行中断(stuck)→重规划→缩短段长→跳过目标→重新规划同一目标」的死循环。
只靠 packetStats.outboundTypes 的计数看不出「发出去的内容对不对」,所以补一个
能录原始出站序列、并能在「能走」与「走不动」两种状态下 diff 的诊断能力。

新增 src/core/packetCapture.js:
- 只在录制期间挂钩 client.write,其余时间零开销(该钩子每个出站包都要过一遍);
- 录到的内容做降深 + 截断的 JSON 化处理,向量对象只留坐标
  (move_minecart 这类包能带上万个浮点数,不处理会把缓冲区撑爆);
- 只放内存、有上限(默认 4000 包),不写盘。

接口(控制台):
- POST /api/bots/:id/capture/start(可带 seconds 到点自动停止)
- POST /api/bots/:id/capture/stop
- GET  /api/bots/:id/capture(支持 ?types= 与 ?limit=)

顺带修掉两个先前就存在的问题:
- scripts/check-protocol.js 其实一直跑不通:minecraft-data 的
  types.packet[1][1].type[1].fields[包名] 给的是类型名字符串,
  要再去 types[名字] 取 ['container', [字段...]] 才能展开,原实现直接 .map() 必然抛错;
  另有读 node_modules 少一层 '..' 的路径问题。
- 两条租约用例(expansionController / targetLease)的时序余量只有 15ms,
  全量并行跑测试时偶发失败;改为 TTL 400ms 配 280ms 睡眠,各留 120ms 余量,
  断言内容不变。

测试:436 → 451 个用例,连续 16 轮全量运行全绿。
2026-09-29 22:01:02 +08:00
lyizai 51664c55b6 feat: 1.7.0 落地 TODO 全部待办(地面修正 / 手动执行器 / 自救 / 回城 / 租约 / 通知)
按 TODO.md 的优先级把待办全部落地为代码,详见 CHANGELOG 的 [1.7.0]。

- 「走不动」攻关:地面状态修正(syncGroundToPhysics / trustSetbackOnGround)、
  出站包类型分布、备选执行器 manualMover(只借 A* 规划、自己按键执行)
- 原点自救分两轮并统计成败;回城前评估「原地是否更差」并记录前后坐标
- 新增目标区块租约表(targetLease)避免多机器人撞同一块
- 定时消息支持 cron 与「期望回复」确认;新增人工介入 Webhook 通知(notifier)
- 大地图后台预热与视野裁剪;控制台日志按帧批量写入、配置面板支持搜索

顺带修掉两个影响部署的细节:
- 部署脚本(deploy.js / ssh-helper.js)改为也能从 .env 读 SSH 凭据 ——
  在命令行传密码会留在 shell 历史与进程列表里,写进已被忽略的 .env 更安全;
  显式环境变量仍然优先。.env.example 补上占位符并说明这些只在本机用。
- package.json / package-lock.json 版本对齐到 1.7.0(此前停留在 1.1.0,
  与 CHANGELOG 的语义化版本声明脱节)。

测试:368 → 436 个用例,`npm test` 与 `npm run check` 全绿。
2026-09-28 22:46:39 +08:00
lyizai 8d1b8d5122 docs: 部署方法固化进仓库(一键部署脚本 + 完整运维文档)
之前部署用的 SSH 助手放在系统临时目录里,重启就丢了,下一位接手时根本拿不到。
现在把它搬进仓库并补齐文档:

新增
- scripts/ssh-helper.js:SSH 执行与 SFTP 上传。Windows 自带 OpenSSH 但没有
  sshpass/plink,无法非交互传密码,所以用 ssh2 直接实现协议层。
  凭据全部走环境变量(SSH_HOST/SSH_PORT/SSH_USER/SSH_PASS),脚本里不含任何密钥;
  ssh2 是 devDependencies,未安装时提示安装而不是裸崩
- scripts/deploy.js:一键部署 —— 打包 → 上传 → 远程执行 update.sh →
  轮询确认控制台返回 200,任一步失败都明确报错。可选 DEPLOY_APP / DEPLOY_PORT
- npm scripts:deploy、ssh

文档
- TODO.md 新增「部署方法」章节:一键部署与手动部署两种步骤、辅助脚本说明、
  update.sh 的行为、pm2 与日志的常用运维命令(含回滚)、服务器上关键文件清单、
  新增环境变量时的三件事(.env 真实值 + .env.example 占位符 + 确认 gitignore)
- README「部署到服务器」开头加一键更新说明,并指向 TODO.md 的完整章节
- 顺带提醒旧版 Fabric 模组源码在临时目录里会被清理,接手后建议先备份

测试 368 全绿;一键部署已实跑验证(4/4 校验通过,控制台 200 OK)。
2026-09-28 20:45:08 +08:00
lyizai 0cd65cf9be fix: 修正原点(00)两种原因的判断依据 —— 两种情况下脚下都是基岩
用户指出:死亡被卡到 00 时脚下**也是基岩**,所以原来用
lockBelow === 'bedrock' 区分「登录服」和「死亡卡住」是错的,
会把死亡卡住误判成登录服,于是不做自救、只让用户去输验证码。

改成以「服务器有没有要求输入验证码」为唯一可靠信号:
- 要求验证码 -> 判定为登录服,提示人工发验证码(不做无意义的左右移动)
- 没要求验证码 -> 判定为死亡卡住,自动左右移动触发服务器重新传送

自救本身在两种情况下都无害(在登录服里按左右键不会有后果),
所以即使判断错了也不会把事情弄得更糟。

另外:验证码提示往往比 spawn 晚到,所以 _handleAuthPrompt 里也补了一次判断 ——
若提示到达时机器人正好在原点附近,会明确点出「被反复传送刷屏」正是登录服造成的,
把这个因果关系讲清楚,避免用户只看到传送刷屏而不知原因。

同步修正 TODO.md 与 CHANGELOG 里那条错误结论,并标注「不要用基岩做判断依据」。

测试 363 -> 368,全部通过。
2026-09-28 20:32:49 +08:00
lyizai 9ca80d2f6e feat: 回城护栏 + 原点自救 + 移动诊断,并留下 TODO 交接文档
先纠正上一轮的误判:那个「寻路恒定 47 节点」是机器人卡在百米深坑里造成的,
我把它归因成配置问题并反复触发 /u return 回城,把玩家手动摆好的位置又拉走了 —— 抱歉。

回城护栏(破坏性操作不再自动执行)
- returnOnTimeout 默认关闭;即便打开,returnGraceSeconds(默认 300 秒)内也不执行,
  避免把玩家刚在客户端摆好的位置清掉
- returnCommand 可配置(默认 u return);每次回城都明确记日志说明会丢失当前位置
- 因未启用而跳过时也记一条说明,避免用户以为「怎么不回城了」

区分「被传送到原点」的两种原因(以前日志里长得一样,常被误判成机器人坏了)
- 脚下是 bedrock -> 在登录服,必须人工输入验证码,给出明确提示
- 脚下不是基岩 -> 死亡后被卡住,自动左右移动触发服务器重新传送

新增两个诊断字段(排查「走不动」的关键手段)
- moveDiag:当前按下的控制键、速度、寻路器 isMoving/goal/剩余路径点数。
  能一刀切开「寻路器没执行」与「执行了但服务端不认」
- pathingDiag:起点可走邻居数、寻路器能否读到方块、四向可见距离

_isStandingOnGround() 增加「服务端是否正把我们按在原地」的判断

排查结论(详见 TODO.md)
- 排除:GrimCompat 朝向限速(A/B 实测关闭后回弹 6.0 -> 5.5 次/秒,无变化)
- 排除:疾跑(关闭后仍 4.8 次/秒)
- 排除:世界数据错误(实测脚下 stone)
- 关键线索:寻路时 forward/sprint 确实按下、isMoving=true,但水平速度恒为 0
  (velocity.y=-0.078 恰是自由落体第一 tick 的值,说明速度每 tick 被清零),
  且 bot.entity.onGround 被服务端位置包反复置为 false,物理引擎一直按空中处理。
  下一步应从「让 entity.onGround 与出站包一致」入手。

TODO.md 面向下一位开发者:包含项目现状与部署、完整证据链、按优先级排好的假设与
验证命令、备选方案(绕开 pathfinder 自己写转向控制器)、以及其它待办。

测试 360 -> 363,全部通过。
2026-09-28 20:21:41 +08:00
lyizai 0783249d1e fix: 定位「寻路恒定 47 节点」的根因(专属配置被冻结)并改为按进度判超时
找到「寻路怎么调都只探索 47 个节点」的真凶
- 启动任务时若带了配置,会被整份存为该机器人的专属配置覆盖。它是一份**完整快照**,
  等于把当时那一刻的全局配置冻结在这个机器人身上,之后改全局配置再也影响不到它。
  实测这台机器人的覆盖里 allowBreak/allowParkour 都是 false,所以我前面所有
  「放开挖方块 / 跑酷 / 不限搜索半径」的排查全部无效,一直在跟同一份旧配置较劲。
- 清除覆盖后寻路器的邻居生成数从 **2 变成 15**,机器人开始真正移动。
- 现在保存覆盖时会明确记一条日志(含策略/邦国/数量),说明「此后全局改动不影响它」。
- 新增「清除专属配置」按钮与 POST /api/bots/:id/settings/clear-config,
  并在配置面板提示该机器人是否有专属覆盖 —— 这是「改了全局却没生效」的唯一退路。

寻路超时改为按进度判定
- 原来只看墙钟时间:目标常在几百格外,而寻路速度受地形与反作弊回弹影响可能只有
  正常步行的十分之一,于是「走得慢但在稳步接近」会被误判成卡死,然后 /u return
  回城把进度全部清零。现在只要与目标的曼哈顿距离有实质缩短就重置这一段计时。
- 首次观察只记录距离基准、不续期,保证「原地不动」仍会照常超时。

新增寻路诊断(排查这类问题的关键手段)
- GET /api/bots/:id/status 的 pathingDiag 暴露:起点可走邻居数与具体坐标、
  寻路器能否通过 world.getBlock 读到方块、四向可见距离。
- 邻居数极少(如 2)说明不是目标太远,而是移动生成器从起点就找不到分支,
  此时调任何搜索参数都没用。

测试 355 -> 360,全部通过。
2026-09-28 19:43:15 +08:00
lyizai 42a9dd6cf0 feat: 新增机器人上/下线按钮,识别「账号已在别处登录」与「等待验证码」
下线按钮
- 顶栏新增「下线 / 上线」切换:下线会断开连接、停止自动重连,并停掉定时消息与
  扩地任务;需要在游戏客户端登同一账号时必须先下线,否则会被服务端以
  「您已经连接到此代理服务器了」反复踢掉
- 接口 POST /api/bots/:id/offline 与 /online;上线时会清掉「已在别处登录」标记
  并把重连计数归零,否则重连会一直退避到最大间隔

识别两类此前「看起来像启动不了」的情况
- 「您已经连接到此代理服务器了」:服务端踢人时发的是聊天组件对象,
  原来 JSON.stringify 之后没法看,关键信息被淹没。新增 extractKickReason()
  提取纯文本,命中后给出「请先在客户端退出」的可操作提示,
  并直接把重连退避到最大间隔,不再频繁硬撞服务器
- 「请直接输入验证码进行验证」:账号绑定过之后服务器仍可能每次登录都要求验证码,
  而原来的正则只认「完成验证 / 身份验证」,漏掉了这句话,用户只看到机器人卡在大厅。
  现在补上匹配,并在控制台弹出醒目提示 + 把输入框 placeholder 换成「输入验证码后回车发送」

测试 343 -> 355,全部通过。
2026-09-28 14:21:14 +08:00
lyizai 71e78f06dc fix: 按实测响应修正 SimpCore API-V2 的字段名
接入 V2 密钥后实跑发现字段名与文档描述对不上,全部按真实响应修正:

- /players 返回的是对象 {online,max,players:[...]} 而不是裸数组,
  原解析会拿到空列表;且里面没有坐标与延迟,只有所在世界
- /dragon 的字段是 dragonAlive,不是 alive,也没有 health / phase
- /time 直接给算好的时刻 {world,tick,phase,time12,time24},
  原实现还在用 world_time/day_time/is_day 自己换算,全部取不到
- /players/{name}/equipment 的物品是 {type,key,amount,damage,maxDurability,unbreakable}
  且挂在 equipment 子对象下;damage 是已损耗值,原来会把「损耗 157」当成
  「剩余 157」显示,现在换算成 remainingDurability 并显示「剩余/上限」+ 百分比
- 429 限流时服务端带 retryAfterSeconds,现在提示里直接写出还要等多少秒
  (末影龙 1 次/分钟,实测提示「还需等待 16 秒」)

另外给 SimpApiV2Client 加了可注入的 fetchImpl,便于测试替换网络层。

实测(DiLiDaLaDaLa):在线 8 人、游戏时间 19:45 NIGHT、
下界合金剑 剩余 1992/2031、头盔 剩余 198/407。

测试 336 -> 343,全部通过。
2026-09-28 13:56:41 +08:00
lyizai b9cae1aac2 feat: 接齐 SimpCore 开放 API 全量接口并新增 API 数据浏览弹层
以前只用了「邦国领土」这一类接口,控制台也只能看到一行「已启用/未启用」,
大量服务端公开信息拿得到却看不到。现在按官方文档 https://open.bgjq.top/api-docs.html 补齐。

新增
- SimpApiClient 从 7 个接口补到 16 个:/info/players、/info/unions/{key}/members、
  /tech、/contributions、/info/buffs、/info/diplomacy、/info/bans、/info/logs、
  /info/announcements
- SimpApiV2Client(新):/players、/players/{name}/equipment、/time、/dragon。
  文档明确 API 与 API-V2 是两套独立、互不通用的密钥体系,因此单独实例与配置
  (apiV2Enabled / apiV2Key,或环境变量 SIMP_API_V2_KEY);V2 配额很小
  (末影龙 1 次/分钟),一律按需查询、不做轮询
- 控制台「查看 API 数据」弹层,五个页签:
  概览(两套密钥状态/请求数/缓存/健康检查)· 邦国(27 个,点开看成员/科技/捐献/领土统计)
  · 玩家(1600+ 可搜索,点开看详情与所属邦国)· 全服(加成/外交/封禁/日志/公告)
  · 实时 V2(在线玩家/游戏时间/末影龙/装备)
- /api/simp/* 一整套只读代理接口,统一处理「未配 Key / 上游错误」并给出可读提示

修复(解析字段名以实测响应为准)
- 捐献:响应只有 player_uuid 没有玩家名 -> 先用成员表补全、查不到再退到全服玩家表;
  时间字段是 contributed_at;补充 reason(用途)列;amount 可为负(邦国支出)
- 公告:message 是 Minecraft 聊天组件 JSON 字符串 -> 新增 componentToText() 压平为纯文本
- 封禁:实际字段 banned_player_name / banned_by_player_name / banned_at / unbanned
- 日志:实际字段 username / action / timestamp
- 玩家表:上下线时间是 last_join_time / last_quit_time
- 王城坐标:服务端可能返回 null,原来 Number(null) 会算成 0,
  伪装成「王城在世界原点 (0,0)」;现在显式判空返回 null

变更
- 快捷发送去掉 /spawn 与 /list,只留 /u return、/u claimmode、/u restore confirm

测试 326 -> 336,全部通过。
2026-09-28 13:45:23 +08:00
lyizai f1316bf58c refactor: 控制台输入框不再区分聊天与指令,只留一个入口
聊天和指令本来就是同一个东西:以 / 开头按游戏指令发送,否则按公屏消息发送。
服务端 /api/bots/:id/chat 一直就是这么分流的,前端多一个模式下拉框纯属多余。

- 删掉「公屏聊天 / 游戏指令」下拉框与重复的「快捷公屏消息」输入框
- 只保留:一个输入框 + 快捷发送按钮 + 移动测试按钮
- 接口返回新增 kind 字段(chat / command)说明实际走的是哪条路
- Toast 区分「消息已发送」与「指令已发送:/xxx」
- 快捷发送补上 /u restore confirm(配合定时消息常用)
- 空内容提示从「消息为空」改为「内容为空」(不再只针对聊天)

测试 325 -> 326,全部通过。
2026-09-28 01:00:05 +08:00
lyizai a83dea9677 feat: 大地图跟随机器人并支持可调图幅,新增定时消息,控制台界面重做并适配移动端
大地图跟随机器人 + 可调图幅
- 爬取与显示区域改为以机器人当前位置为中心(bigMap.followBot 默认开启),
  不再傻傻地爬世界原点周边
- 界面新增「图幅」下拉(±512/1024/2048/4096/8192/全世界)与「爬取周边」按钮,
  只爬当前图幅内的瓦片:层级 0 每张覆盖 4096 格,±4096 只需 9 张,全世界要 100 张
- POST /api/bigmap/crawl 支持 centerX/centerZ/rangeBlocks;GET /api/bigmap 支持
  rangeBlocks/bot 查询参数;启动预爬取会先等机器人进入世界再以其位置为中心

领土地图以机器人所在邦国为中心
- 之前初始视图用的是控制器的 playerChunk,而任务启动前它是 (0,0),
  导致地图永远停在世界原点附近。现在按「机器人所在区块 → 包含它的邦国 →
  该邦国王城/外接矩形」定位

定时重复发送消息(src/bot/scheduledMessenger.js)
- 每个机器人可配多条定时任务,按固定间隔重复发送同一内容,如每 300 秒 /u restore confirm
- 以 / 开头按游戏指令发送,否则按公屏消息发送
- 只在已连接且通过登录验证时发送;未就绪的周期顺延且不补发,避免重连后一次性连发
- 与指令节流队列共用,间隔下限 5 秒、单机器人最多 20 条
- 控制台新增「定时消息」卡片(新增/删除/启停 + 剩余秒数倒计时)

控制台界面重做 + 移动端适配
- 重新设计的深色主题:统一圆角、阴影、焦点态与悬停态,卡片式分层
- 响应式三档:≥1200px 三栏;≤1200px 两栏;≤820px 单栏 + 底部导航
- 移动端:触控目标 ≥36px、地图/日志高度按视口自适应、避让底部安全区、
  横屏压矮地图、尊重「减少动效」偏好、Toast 全宽
- 「添加玩家」折叠进 details;顶栏窄屏自动换行且按钮等分

修复
- POST /api/bigmap/crawl 响应把中心与图幅字段传错(实际爬取正确,返回值显示成原点)
- 定时任务保存后接口返回原始列表而非归一化结果,前端拿不到剩余秒数

测试 308 -> 325,全部通过。
2026-09-28 00:27:58 +08:00
lyizai fcd5fb2bd9 fix: 修复登录按钮死锁,移植旧版 player_input 补包解决「机器人走不动」
控制台登录按钮点不动(致命)
- bindEvents() 只在 startApp() 里执行,而 startApp() 只在登录成功后才跑,
  未登录时登录按钮根本没绑上事件,形成死锁。现在在 main() 里无条件绑定。

机器人走不动(致命,根因定位并修复)
- mineflayer 不发 player_input 包:1.21.2+ 服务端的移动预测依赖它,原版客户端每 tick 都发,
  而 mineflayer 只在骑乘/跳下/潜行时发,地面移动的 forward/jump/sprint 完全不发。
  带反作弊的服务器判定「没有任何输入却发生位移」,每 tick 把玩家拉回原位置。
  实测:修复前入站强制位置同步约 17 次/秒、按方向键 2 秒位移 0.00 格;
  修复后入站降到 0 次、位移 0.53~1.40 格。该修复来自旧版机器人(感谢原作者)。
- 同时补发 tick_end(原版每 tick 末尾发送,反作弊用它判定 tick 边界,mineflayer 完全不发)
- 资源包未自动接受会卡在加载界面进不了世界,现自动接受
- configure() 每次配置变更都重复注册寻路事件监听,导致同一事件被处理多次

长距离分段寻路
- mineflayer-pathfinder 只能规划已加载区块范围(实测约 8 区块 ≈ 130 格),
  扩地目标常在几十个区块外,A* 瞬间 noPath。现在自动拆段:走到已加载范围边缘 → 新区块加载 → 再规划。
  段长用 bot.blockAt 对未加载区块返回 null 的特性二分探测,不依赖世界内部结构。

新增
- pathing.thinkTimeoutMs / tickTimeoutMs / searchRadius(负值表示不限制剪枝)
- 位置包收发计数、player_input/tick_end 计数、游戏模式、维度、脚下方块、
  六向「脚部/头部/地面」与可通行判定等诊断字段
- 手动移动测试按钮(绕开寻路器直接按方向键并回报真实位移)

已知限制
- 机器人当前所在位置 (6759,72,11926) 无法寻路:A* 恒定只展开 47 个节点就 noPath。
  已逐项排除挖方块/放方块/跑酷/搭塔/开门/最大下落(试到 64 格)/高度偏好/水域代价/
  搜索半径(试到不限制)/时间预算/依赖版本(与能正常移动的旧机器人完全一致)。
  旧机器人日志证明寻路可用(2 次 goal_reached、2 秒走 8.7 格),但它当时在 y=-59,
  本机器人被 /spawn 放到了 y=72。下一步需把机器人挪到开阔地再试。

测试 290 -> 308,全部通过。
2026-09-27 23:52:29 +08:00
lyizai abecdd2648 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,全部通过。
2026-09-27 23:06:39 +08:00
lyizai 2671da95d5 fix: 打通登录门禁死锁,失败信息区分未连接/登录被拒/未通过验证
继续修复部署时暴露的门禁设计问题:

- 登录门禁死锁:/login、/register、简幻通的 /auth 本身就是登录流程的一部分,
  原先门禁把它们一并拦下,形成「要先登录才能发登录指令」,账号永远卡在未验证状态。
  新增 server.preLoginCommands 白名单(默认 login,l,register,reg,auth),
  这些前缀在未验证时也放行,其余指令仍要求已完成验证。

- 离线时指令被静默丢弃却回报「已发送」:bot.chat() 在未连接时无处可发。
  canSendCommand() 现在要求已连接并进入世界,失败信息区分
  「未连接」「登录被服务器拒绝」「未通过登录验证」三种情况并给出对应建议。

- 前端配置表单支持 list 类型字段(逗号分隔的字符串数组)。

测试从 252 增至 263,全部通过。
2026-09-27 22:11:52 +08:00
lyizai d6b1339867 fix: 修正服务器反复传送刷屏日志、识别外站账号绑定流程、重连指数退避
部署到 Debian 服务器时暴露的三个真实缺陷:

1. 「被服务器传送」日志洪水
   账号未完成服务器验证时服务端每 tick 强推一次位置,实测 3 分钟刷出 748 行日志,
   几十分钟就会冲光 2000 条的日志环形缓冲并把 WebSocket 打满。
   现改为首次立即记录、其余按 30 秒窗口聚合成一条,断开连接时刷出未汇总计数。

2. 外站账号绑定流程无法识别
   原先只认 /login 与 /register,遇到简幻通这类要求 /auth <UID> <验证码> 或扫码绑定的
   服务器时,表现只是「连上→被传送→120 秒后被踢→重连」的静默死循环。
   现新增 NEED_AUTH_PATTERN 识别并输出可操作的中文告警(每连接一次),
   新增 server.authCommand 模板供有固定验证码的服务器自动过验证。

3. 重连无退避
   连续被踢时每 10 秒硬撞服务器。现按指数退避拉长(server.reconnectMaxDelayMs,
   默认上限 120 秒),成功进入世界后清零;抽出 computeReconnectDelay 便于测试。

顺带补充 README 的「部署到服务器」章节(Node ≥ 22 要求、私有运行时、pm2、日志分文件、排障表)。

测试从 242 增至 252,全部通过。
2026-09-27 22:02:24 +08:00
lyizai 39624da2d5 feat: 支持多机器人挂机、开拓日志、公屏完整日志与大地图
多机器人(src/bot/botPool.js、src/config/botsConfig.js)
- .env 用 MC_BOTS 一次配置多个账号(JSON 数组或 用户名:密码 逗号分隔),未配置时回退单机器人
- 每个机器人独立连接 / 登录 / 扩地状态机,共享一份领土数据服务与运行日志
- 密码只从 .env 读取;data/bots.json 只存启用状态、备注名、自动开始与各自的配置覆盖
- 每个机器人可覆盖全局配置(策略 / 目标邦国 / 目标数量等),未覆盖字段回退全局默认

开拓日志(src/core/expansionLog.js)
- 逐条记录开拓玩家、邦国、区块、方块坐标、时间与判定来源,追加写入 data/expansion-log.jsonl
- 支持按机器人 / 邦国 / 时间筛选、统计、导出带 BOM 的 CSV,超过 16MB 自动轮转

游戏公屏完整日志与聊天框(src/core/chatLog.js)
- 每个机器人的全部公屏 / 系统 / 动作栏消息按机器人分文件留档,自身发出的聊天与指令同样留档
- 控制台新增公屏聊天页签与消息发送框

登录验证门禁
- 游戏侧:未完成登录验证时拒绝发送指令 / 聊天,isReady() 也不放行扩地任务;
  新增 server.loginConfirmTimeoutMs 兜底,避免服务器不回提示导致永远卡在未登录
- 控制台侧:新增 WEB_PASSWORD 与 /api/login、/api/session,页面登录框验证通过才允许操作

大地图(src/core/bigMapService.js)
- 爬取服务器地图站点(squaremap)的瓦片图片与领土标记,一张图上看全部机器人坐标
- 坐标系对源站实测标定并用 27 个王城交叉验证;瓦片磁盘缓存,层级 0 启动预爬取、更高层级按需穿透
- 领土按 markers.json 原始多边形绘制(预编译 Path2D,even-odd 保留孔洞),叠加王城与机器人标签

配套改造
- TerritoryModel 新增 flyingChunksOf / ownChunksOf,邦国归属改为按名称显式传入,
  不再依赖全局 selfStateName(多机器人会互相覆盖)
- Web 接口以 /api/bots/:id/... 为主,原单机器人接口保留为默认机器人的等价别名
- 新增 scripts/check-web-ids.js 校验前端元素引用一致性

测试从 154 增至 242,全部通过。
2026-09-27 21:22:57 +08:00
共修改 55 个文件,包含 18848 行新增和 592 行删除
+45 -9
查看文件
@@ -5,26 +5,62 @@
MC_HOST=bgjq.simpfun.cn
MC_PORT=25565
MC_VERSION=1.21.4
# 离线登录用游戏名;正版登录时忽略此项(需先 `npx mineflayer --auth microsoft` 之外的 device-code 流程)
# ---- 多机器人(推荐):同时挂多个玩家,用逗号分隔「用户名:密码」 ----
# 密码只从这里读取,绝不写入代码、配置文件或控制台存储
MC_BOTS=<bot-name-1>:<password-1>,<bot-name-2>:<password-2>
# 用户名/密码里含逗号或冒号时,改用 JSON 数组写法:
# MC_BOTS=[{"username":"bot1","password":"p1"},{"username":"bot2","password":"p2","label":"二号机"}]
# ---- 单机器人兜底:未设置 MC_BOTS 时生效 ----
MC_USERNAME=<your-bot-name>
MC_PASSWORD=<your-server-password>
# offline=离线服(服务器用 /login 密码登录);microsoft=正版
MC_AUTH=offline
# 服务器登录/注册密码(离线服必填;正版服留空)
MC_PASSWORD=<your-server-password>
# ==================== Web 控制台 ====================
WEB_HOST=127.0.0.1
WEB_PORT=3081
# 控制台访问令牌(留空则仅监听 127.0.0.1 且不校验;非本机访问必须设置)
# 控制台令牌(可写进 URL 的 ?token=,便于直接点击打开;留空则本机免登录)
WEB_TOKEN=
# 控制台登录密码:配置后页面会弹出登录框,验证通过才允许操作机器人的控制台 / 聊天框 / 扩地任务
WEB_PASSWORD=
# ==================== 领土数据源 ====================
# Dynmap 地图站点根地址(末尾不带斜杠),程序会请求 <DYNMAP_BASE>/tiles/<DYNMAP_WORLD>/markers.json
# ==================== 领土数据源与大地图 ====================
# 地图站点根地址(末尾不带斜杠)
# 程序会请求:
# <DYNMAP_BASE>/tiles/<DYNMAP_WORLD>/markers.json 领土标记
# <DYNMAP_BASE>/tiles/<DYNMAP_WORLD>/<z>/<x>_<y>.png 大地图瓦片图片
DYNMAP_BASE=http://bgjq.simpfun.cn
# markers.json 所属世界名(对应 Dynmap 配置里的 world 目录名)
# 世界目录名(对应地图站点上的世界名)
DYNMAP_WORLD=minecraft_overworld
# ==================== SimpCore 开放 API(可选) ====================
# https://open.bgjq.top 申请
# 文档:https://open.bgjq.top/api-docs.html
# 注意:API 与 API-V2 是两套**独立、互不通用**的密钥体系,需要在密钥中心分别申请
#
# 第一套 API:邦国 / 领土 / 玩家 / 全服数据(https://bgjq.simpfun.cn/api/)
SIMP_API_KEY=<your-simp-api-key>
# 第二套 API-V2(ServerOpenAPI):在线玩家 / 装备 / 游戏时间 / 末影龙
# https://bgjq.simpfun.cn/api-v2/
# 配额很小(末影龙仅 1 次/分钟),程序只按需查询、不做轮询
SIMP_API_V2_KEY=<your-simp-api-v2-key>
# ==================== 人工介入提醒(可选) ====================
# 等待登录验证码 / 原点自救失败时推送提醒(默认按飞书、Lark 自定义机器人的文本消息格式)
# 用法:控制台「服务器 → 通知提醒」勾选「启用通知」并填地址;地址留空时回退本环境变量
# 注意:Webhook 地址等同于「任何人都能往群里发消息」的凭据,不要提交进仓库
NOTIFY_WEBHOOK_URL=
# ==================== 部署(可选,只在本地开发机用) ====================
# 只有 `npm run deploy` / `node scripts/ssh-helper.js` 会读这几个变量;
# 程序本体(src/)不读它们,所以服务器上的 .env 不需要填。
# 放在这里是为了不把 SSH 密码写进命令行(会留在 shell 历史与进程列表里)。
SSH_HOST=<your-server-ip>
SSH_PORT=<your-ssh-port>
SSH_USER=root
SSH_PASS=<your-ssh-password>
# 部署目录与健康检查端口(留空则用脚本默认值 /opt/autostore-bot 与 78)
DEPLOY_APP=
DEPLOY_PORT=
+626
查看文件
@@ -2,6 +2,632 @@
本项目遵循 [约定式提交](https://www.conventionalcommits.org/zh-hans/) 与 [语义化版本](https://semver.org/lang/zh-CN/)。
## [1.11.1] - 2026-10-01
给「寻路状态」进度日志加节流 —— 它一度是日志体积的主要来源。
### 修复
- **「寻路状态」进度日志刷屏**:mineflayer-pathfinder 每用满一个 tick 的计算预算
(`tickTimeout`,默认 40ms)就发一次 `path_update`,而原来的处理函数**每次都记一条**。
实测 4 分钟写了 **3893 条**(约 16 条/秒)—— 既淹没了真正有用的日志
(按 120 字节/行算一天能写上百 MB,而本服磁盘只剩 2.4G),
也让「寻路到底卡在哪」更难看清。
现在只在三种情况下记:**状态发生变化**(partial → success / noPath / timeout,
最要紧的信号)、**距上一条超过 2 秒**、**已探索节点数比上一条多出 1000 以上**
(长规划里仍能看出推进速度,不丢定位卡死所需的信息)。
### 测试
新增 1 个用例(480 → 481):300 次 `partial` 被节流到十几条以内且不为零,
状态变化时立刻记录。
## [1.11.0] - 2026-10-01
**修掉「机器人原地走之字形、净位移恒为 0」的朝向 bug** —— 这是扩地跑不完
那 763 格长途的直接原因。
### 修复
- **`bot.entity.yaw` 无界增长,导致发包朝向在两个方向间来回跳**。
mineflayer 收到服务端的 `position` 包时会直接 `bot.entity.yaw = conv.fromNotchianYaw(…)`
**不做取模**,而 `GrimCompat` 又在它基础上逐 tick 累加 —— 两者叠加让 yaw 一路涨到
**19254°(≈336 弧度)**。
抓包实测的后果:
```
yaw°: -1373 → 19254 → -1650 → -1407 → -1373 → 19254 → …
坐标: (6757.50, 11953.70) ⇄ (6757.89, 11954.12) 0.4 格内反复振荡
```
相邻两包的朝向折合相差约 **114°**,而机器人一直按着前进键 ——
于是它**原地走之字形、净位移恒为 0**,看起来像「卡在墙前反复起跳」
(物理录制里 `jump 74/240`、`水平碰撞 195/240`、`onGround` 只有 79/240)。
现在 `_onPhysicsTick` 每 tick 都调 `wrapAngle` 把 yaw 归一化到 `(-π, π]`,
`setLook` 也会归一化传入的目标角度。归一化只改变表示、不改变朝向
(`shortestAngleDelta(huge, wrapped) === 0`),所以对旋转逻辑完全透明。
### 测试
新增 2 个用例(478 → 480):`wrapAngle` 的归一化与边界(含 19254° 这个实测值、
NaN / Infinity),以及「从未归一化的巨大 yaw 出发、推进 40 tick 后仍归一化且朝向正确」。
## [1.10.1] - 2026-10-01
修掉 1.9.3 自动恢复里的一个会让恢复永久丢失的缺陷。
### 修复
- **`resumeConfig` 在尝试恢复之前就被清掉了**:`tick()` 里先取走待恢复的配置再调
`start()`,而 `start()` 在「已进入世界但还没完成登录」时会直接返回 false
(`isReady()` 要求 `loggedIn`)。本服掉线频繁、重连后常常短暂处于这个状态,
于是**一次失败的尝试就把这次恢复永久丢掉了**,任务再也不会自己接上。
现在:① 等 `bm.isReady()` 才尝试;② 只有 `start()` 真的返回 true 才清掉登记,
失败时保留并在下一 tick 继续试(日志打「自动恢复未成功(…),稍后重试」)。
### 测试
新增 1 个用例(477 → 478):未就绪时不启动也不丢登记,就绪后恢复并清登记。
## [1.10.0] - 2026-10-01
补上出站包一直漏报的 `hasHorizontalCollision`,针对「机器人一直在走、却只走出
物理速度的 1/5」这个现象。
### 新增
- **`grim.reportHorizontalCollision`(默认开启)**:把物理引擎算出的
`bot.entity.isCollidedHorizontally` 如实写进出站位置包的
`flags.hasHorizontalCollision`。
- mineflayer 在 `physics.js` 里把这个位标志写成 `undefined`(源码注释还标着
「1.21.3+」),序列化后**恒为 0** —— 而原版客户端在水平碰到方块时会上报 1;
- 机器人在崎岖地形上几乎一直在水平碰撞,却始终上报「没碰撞」,
正是反作弊 Prediction 最容易判错的形态:服务端按「该碰撞」预测、
客户端说「没碰撞」,于是被判异常并拉回;
- 实测(2026-10-01,王城外山区):机器人 `controls` 几乎每 tick 都是
`forward+sprint`,`velocity` 约 0.2~3 格/秒,但 2 秒粒度采样的**净位移只有
0.405 格/秒**(48.6 格 / 120 秒)—— 差额正是被服务端拉回的部分。
- 物理引擎早已算出真实值(`moveEntity` 里赋值),只是从没被发出去。
- 控制台「Grim 合规」配置面板新增对应开关;`packetStats.horizontalCollisionFixed`
暴露补写次数。
### 测试
新增 4 个用例(473 → 477):按真实值补写、已一致时不重复计数、开关可关闭、
物理引擎没给出状态时不瞎猜。
## [1.9.3] - 2026-10-01
**掉线重连后自动恢复扩地任务** —— 这是让扩地能真正连续跑起来的最后一环。
### 新增
- **`autoResumeAfterReconnect`(默认开启)**:本服会周期性把机器人踢下线
(实测约 1~6 分钟一次,踢出原因是 `An internal error occurred in your connection`),
每次都会走到「世界已退出,任务停止」。原来的行为是就此停住 ——
机器人虽然能被**原点自救**送回真实世界(实测 4/4 成功),却再也不会继续扩地,
于是永远停在原地。
现在世界退出时会把本次任务的配置记下来,重新进入世界后自动 `start()` 接上。
手动停止 / 到开垦时段外 / 耐久保护停机**不会**被自动恢复(只有因世界退出而停的那次会登记)。
- 状态文案改为「世界已退出,等待重连后自动恢复」,日志会打出
「已重新进入世界,自动恢复扩地任务」。
### 测试
新增 3 个用例(470 → 473):世界退出后暂停并在重连时恢复、开关可关闭、
手动停止不会被重连重新拉起。
## [1.9.2] - 2026-10-01
修正物理录制器里一个会误导排查的分类错误。
### 修复
- **`frozenTicks` 把「被服务端推回」误判成「输入没进物理」**:原来只看
「有输入 + tick 级无位移 + 物理没报告碰撞」。但服务端推回发生在 tick 之间,
tick 级位移同样是 0 —— 实测大厅里就是这个形态(`collidedHorizontally = false`、
`frozenTicks = 194`,而物理其实每 tick 都在动)。
现在再按**水平速度是否非零**细分:
- `pushedBackTicks`:速度非零 → 物理确实动了,位移被服务端位置包抹掉;
- `frozenTicks`:速度也是 0 → 这才是「输入根本没进物理」。
### 测试
新增 1 个用例(469 → 470)覆盖「被推回」形态。
## [1.9.1] - 2026-10-01
**修复「寻路器完全规划不出路径」** —— 这是自动扩地跑不起来的直接原因。
### 修复
- **高度偏好(`altitudePreference`)默认关闭**:它的启发值实现与
mineflayer-pathfinder 的内部约定冲突。该库每次 `compute()` 返回 `partial` 时,
用 **`h` 最小**的节点当作 partial 路径的终点
(`astar.js`: `if (neighborNode.h < this.bestNode.h) this.bestNode = neighborNode`),
而 `AltitudeGoal` 把 `h` 减去了 `权重 × y`,于是「又高又偏」的节点胜出,
partial 路径被引向高处而不是目标附近 —— 表现为 A* 一直在算、永不收敛,
**机器人一次移动指令都发不出来**。
- 实测(王城平台上,其余配置完全相同):开启 `inputTicks = 0`、任务永远停在
「寻路超时,重新寻路」;关闭 `inputTicks = 237`、立刻开始移动。
- `searchRadius` 从 -1 改到 128 对此**没有影响**,所以不是搜索空间无界导致的。
- 想重新启用,应把偏置改成**低处加移动代价**(改 `getNeighbors` 的 `cost`),
保持启发值只反映到目标的真实距离。`AltitudeGoal` 的注释里记了完整推导。
### 测试
新增 1 个回归用例(468 → 469):断言高度偏好默认关闭,并用启发值差值
把「为什么它必然出问题」固化下来。
## [1.9.0] - 2026-09-29
补上诊断「走不动」的最后一块拼图:抓包只能看到「客户端发了什么」,
看不到物理引擎内部读到了什么输入、算出了什么、有没有真的移动。
新增**物理 tick 录制器**,与抓包配合就能把三种完全不同的原因一刀切开。
### 新增
- **物理 tick 录制**(`src/core/physicsRecorder.js` + 控制台接口):
- `POST /api/bots/:id/physics/start`(可带 `seconds`)、`/physics/stop`、
`GET /api/bots/:id/physics`(支持 `?limit=`);
- 逐 tick 记录:位置增量、速度、`onGround`、**`isCollidedHorizontally`**、
控制键快照、脚下 / 脚部 / 头部方块名、`physicsEnabled`;
- 汇总给出三个判定用的计数:`inputTicks`(有移动输入的 tick 数)、
`inputMovedTicks`(其中真的产生了水平位移的)、
`blockedTicks`(有输入、没位移、物理报告碰撞 → **被方块挡住**)、
`frozenTicks`(有输入、没位移、物理没报告碰撞 → **输入根本没进物理**);
- 录制开始时先取一次当前位置作基线,避免第一个 tick 因「没有上一 tick 可比」
被误判成「有输入但没动」;
- 滚动缓冲只保留最近 240 个 tick(约 12 秒),只放内存、不写盘。
### 变更
- `FakeBot` 补上与 mineflayer 一致的 `controlState`(带 getter 的实时控制键代理)——
那正是 mineflayer 传给物理引擎的那个对象。
### 测试
新增 17 个用例(451 → 468):物理录制器的逐 tick 记录、三种故障判定
(被挡住 / 输入没生效 / 正常移动)、滚动缓冲、方块读不到时的容错,
以及控制台物理录制接口的 HTTP 用例。
## [1.8.0] - 2026-09-29
把「走不动」的排查手段补成可复用的诊断能力:能按需录下机器人**真正发出的协议包内容**,
与「能走」时的序列逐包对照(TODO.md 问题 1 · 假设 5)。
### 新增
- **出站包抓取**(`src/core/packetCapture.js` + 控制台接口):
- `POST /api/bots/:id/capture/start`(可带 `seconds` 到点自动停止)、
`POST /api/bots/:id/capture/stop`、`GET /api/bots/:id/capture`(支持 `?types=` 与 `?limit=`);
- **只在录制期间挂钩 `client.write`**,其余时间零开销 —— 这个钩子每个出站包都要过一遍;
- 录到的内容做降深 + 截断的 JSON 化处理,向量对象只留坐标(`move_minecart` 这类包
能带上万个浮点数,不处理会把缓冲区撑爆);
- 只放内存、有上限(默认 4000 包),不写盘。
### 修复
- **`scripts/check-protocol.js` 其实一直跑不通**:`minecraft-data` 的
`types.packet[1][1].type[1].fields[包名]` 给出的是**类型名字符串**(如 `packet_position_look`),
必须再去 `types[名字]` 取 `['container', [字段...]]` 才能展开字段;原实现直接对它 `.map()`,
必然抛 `def.map is not a function`。顺带修掉读 `node_modules` 时少了一层 `..` 的路径。
- 修完即可确认:1.21.4 上 `position` / `position_look` / `look` 的地面标志是
`flags: MovementFlags`(`u8` 位标志 `[onGround, hasHorizontalCollision]`),
没有独立的 `onGround` 字段;`_fixOutboundOnGround` 改的正是正确的 `flags.onGround`。
- **两条租约用例的时序余量过窄,会偶发失败**:`ttlMs: 40` 配两次 `setTimeout(25)`,
只剩 15ms 调度余量,全量并行跑测试时会被压垮。改为 `ttlMs: 400` 配 280ms 睡眠,
两侧各留 120ms 余量;断言内容不变(仍是「总耗时 > 原始 TTL、距续租 < TTL」)。
### 测试
新增 15 个用例(436 → 451):抓包器的录制 / 摘钩子 / 上限丢弃 / 按包名过滤 /
序列化边界,其中包含一条回归用例 —— `position_look` 的参数本身就是
`{x, y, z, yaw, pitch, onGround, flags}`,**不能**被「有 x/y/z 就当向量」的规则吞掉;
另有控制台抓包接口的 HTTP 用例。
## [1.7.0] - 2026-09-29
按 `TODO.md` 的优先级把待办全部落地:先是「走不动」的攻关与备选方案,
然后是自救 / 回城 / 大地图 / 控制台 / 多机器人 / 定时消息 / 登录提醒。
### 新增
- **地面状态修正(TODO 问题 1 · 假设 2 的方案 A / B)**:
- 方案 A(`grim.syncGroundToPhysics`):每个物理 tick 在「物理引擎算完、位置包发出之前」
用世界数据复核脚下是否有实体方块,是则强制把 `bot.entity.onGround` 同步为 true,
让移动回到地面分支(此前一直被服务端位置包置为 false,物理引擎长期按空中处理);
- 方案 B(`grim.trustSetbackOnGround` + `grim.setbackSyncEpsilon`):服务端下发的目标位置
与本地几乎一致(例行同步)时只采纳坐标、保留我们自己的 `onGround`,不采纳它的 false;
- 两者都与 `grim.enabled` 无关;控制台「Grim 合规」卡片显示
`物理对齐 / 保住 / 最近同步差` 三个计数,便于判断修正是否真的生效。
- **备选移动执行器 `manualMover.js`**(`pathing.executor: 'manual'`):
只借用 mineflayer-pathfinder 的 A* **算路径**,执行改为自己按键 ——
朝路径点转向、朝向对上才按前进、遇一格坎跳一下、长时间无位移重新规划。
控制键时序与「移动测试」的手动按键完全一致,便于逐 tick 对照排查。
- **出站包类型分布**(`packetStats.outboundTypes`):用于与旧模组抓包逐项对照(假设 4)。
- **原点自救升级**(TODO 问题 2):分两轮(左右 → 前后 + 跳),每轮结束后等传送落地再判定
「位置是否恢复」;统计 `attempts / successes / lastResult`(暴露在 `/api/status` 与控制台),
两轮都失败时升级为明确提示(点明可能是登录服,需要人工发验证码)。
- **回城改进**(TODO 问题 3):
- 回城前用 `pathing.diagnostics().neighborCount` 评估「原地是否真的更差」:
还能走就放弃回城、改为跳过目标(回城只会白白丢掉玩家摆好的位置);
- 回城完成后记录前后坐标与直线距离,日志与控制台展示「回城把机器人从 A 挪到了 B」,
位置几乎没变时明确提示回城没有帮助。
- **大地图后台预热 + 视野裁剪**(TODO 4.1):机器人移动超过阈值(或距上次超过 10 分钟)时,
用低并发把周边高层级瓦片补下来(已缓存只做一次 `statSync`,批量爬取在途时让路);
领土多边形按包围盒裁剪,全世界视角不再全量绘制。
- **控制台**(TODO 4.2):日志按帧批量写入(一次布局、一次滚动),
并用 `content-visibility` 让浏览器跳过视口外行的渲染;配置面板新增搜索框
(按名称 / 字段名 / 说明过滤,隐藏不删除,保存不受影响)。
- **目标区块租约表**(TODO 4.3,`src/core/targetLease.js`):
多机器人抢同一区块时谁先抢到谁去,其余机器人跳过;持有者持续续租,
掉线 / 卡死后租约到期自动失效;候选区块全被预定时等 15 秒再规划,而不是提前判「没有可用区块」。
- **定时消息支持 cron 与发送结果确认**(TODO 4.4):
5 段 cron 表达式(支持步长 / 区间 / 列表 / 星期范围,非法表达式会说明原因并按间隔发送);
可填「期望回复」(正则)确认发送是否真的生效,超时未回复记 `⚠ 未确认` 并写警告。
- **人工介入提醒(Webhook)**(TODO 4.5,`src/core/notifier.js`):
等待验证码(120 秒时限)与原点自救失败时推送到飞书 / Lark 自定义机器人;
支持 `NOTIFY_WEBHOOK_URL` 兜底、按问题去重(默认 60 秒)、快照只暴露域名不泄露完整地址。
### 变更
- `ExpansionController` 挑选目标、跳过目标、停止任务、完成任务时统一维护租约;
- `WebConsole` 增加低频(1 分钟)大地图预热检查,停止时一并清理定时器;
- 定时消息列表与配置面板同步支持 cron / 期望回复字段。
### 测试
新增 68 个用例(368 → 436),覆盖地面状态修正、手动执行器、原点自救判定、
回城评估与前后坐标、目标租约、cron 解析与确认、通知器、大地图预热。
## [1.6.0] - 2026-09-29
围绕「机器人为什么走不动」做了一轮系统排查,并给破坏性操作加上护栏。
### 新增
- **回城护栏**:`/u return` 是破坏性操作 —— 它把机器人拉回安全点,
会**清掉玩家在游戏客户端里手动摆好的位置**。项目里曾因此反复把机器人
从玩家摆好的位置拉走,排查了很久才定位。现在:
- `returnOnTimeout` **默认关闭**,不再自动回城
- 即便打开,`returnGraceSeconds`(默认 300 秒)内也不执行,
避免把刚摆好的位置清掉
- `returnCommand` 可配置(默认 `u return`),适配不同服务器
- 每次回城都明确记日志,说明「会丢失当前位置与玩家手动摆好的位置」
- 因未启用而跳过时也会记一条说明,避免用户以为「怎么不回城了」
- **区分「被传送到原点」的两种原因**(以前在日志里长得一样,常被误判成机器人坏了):
- **服务器要求输入验证码** → 被切到登录服,必须人工发验证码,给出明确提示
- **服务器没要求验证码** → 死亡后被卡住,**自动左右移动触发服务器重新传送**(`_escapeFromOrigin()`)
- ⚠️ 这两种情况**脚下都是基岩**,所以不能用「脚下是不是基岩」区分;
唯一可靠信号是服务器有没有发验证码提示。自救在两种情况下都无害,
因此即使判断错了也不会让情况变糟
- **移动执行诊断 `moveDiag`**:暴露当前按下的控制键、速度、寻路器内部状态
(`isMoving` / `isMining` / `goal` / 剩余路径点数)。这是排查「走不动」的关键手段 ——
能一刀切开「寻路器没执行」与「执行了但服务端不认」。
- **寻路诊断 `pathingDiag`**:起点可走邻居数、寻路器能否通过 `world.getBlock` 读到方块、
四向可见距离。
### 变更
- `_isStandingOnGround()` 增加「服务端是否正把我们按在原地」的判断
(`_serverPinsUs()`):短时间内反复收到几乎相同位置的位置包时,
说明在服务端眼里机器人是站着的,此时以服务端为准更安全
### 排查结论(已排除的方向,详见 `TODO.md`)
- ❌ **`GrimCompat` 的朝向限速不是原因**:A/B 实测关闭合规层后回弹频率没有变化
(6.0 vs 5.5 次/秒,位移都是 0)
- ❌ **疾跑不是原因**:关闭后仍是 4.8 次/秒
- ❌ **世界数据不是原因**:实测脚下 `stone`,`blockBelow` 正确
- 🎯 **关键线索**:寻路时 `forward`/`sprint` 确实按下、`isMoving=true`,
但**水平速度恒为 0**(`velocity.y = -0.078` 恰是自由落体第一 tick 的值),
说明速度每 tick 被清零;同时 `bot.entity.onGround` 被服务端位置包反复置为 false,
物理引擎一直按「空中」处理。下一步应从「让 `entity.onGround` 与出站包一致」入手。
## [1.5.1] - 2026-09-28
接入 API-V2 密钥后按实测响应修正字段名(之前是按文档猜的,跑起来全不对)。
### 修复
- **API-V2 `/players` 返回的是对象不是数组**:`{"online":8,"max":5000,"players":[...]}`,
原解析按裸数组处理会拿到空列表;且里面**没有坐标与延迟**,只有所在世界
- **API-V2 `/dragon` 的字段是 `dragonAlive`**,不是 `alive`;也没有 health / phase
- **API-V2 `/time` 直接给算好的时刻**:`{world, tick, phase, time12, time24}`,
原实现还在用 `world_time` / `day_time` / `is_day` 自己换算,全部取不到
- **API-V2 `/players/{name}/equipment` 的物品结构**:
`{type, key, amount, damage, maxDurability, unbreakable}`,装备在 `equipment` 子对象里。
`damage` 是**已损耗值**,原来会把「损耗 157」当成「剩余 157」显示;
现在换算成 `remainingDurability` 并在界面显示「剩余 / 上限」+ 百分比
- **限流提示**:V2 触发 429 时服务端带 `retryAfterSeconds`,现在直接告诉用户还要等多少秒
(末影龙接口只有 1 次/分钟,实测超限时提示「还需等待 16 秒」)
### 新增
- `SimpApiV2Client` 支持注入 `fetchImpl`,便于测试替换网络层
## [1.5.0] - 2026-09-28
### 新增
- **SimpCore 开放 API 数据浏览**:以前只用了「邦国领土」这一类接口,控制台也只能看到一行
「已启用 / 未启用」。现在把官方文档里的公开接口都接进来,并做成「查看 API 数据」弹层:
- **概览**:两套密钥的配置状态、请求数、缓存命中、最近 HTTP 状态与错误、健康检查按钮
- **邦国**:27 个邦国的君主 / 成员数 / 区块数 / 领土面积 / 特产 / 王城区块;
点开看成员(职位、职业)、已激活科技、捐献收支、领土统计(忠诚度、最早/最近占领)
- **玩家**:1600+ 名玩家可搜索(邦国 / 职位 / 职业 / 称号),点开看详情与所属邦国
- **全服**:生效加成 / 外交关系 / 封禁记录 / 上下线日志 / 公告历史
- **实时 V2**:在线玩家、游戏时间、末影龙、指定玩家的装备与手持物品
- **`SimpApiClient` 补齐到官方文档的全量接口**(原来只有 7 个,现在 16 个):
`/info/players`、`/info/unions/{key}/members`、`/tech`、`/contributions`、
`/info/buffs`、`/info/diplomacy`、`/info/bans`、`/info/logs`、`/info/announcements`
- **`SimpApiV2Client`**(新):`/players`、`/players/{name}/equipment`、`/time`、`/dragon`。
文档明确 **API 与 API-V2 是两套独立、互不通用的密钥体系**,因此单独一个实例与配置;
V2 配额很小(末影龙仅 1 次/分钟),一律按需查询、不做轮询
### 变更
- 快捷发送去掉 `/spawn` 与 `/list`,只留 `/u return` `/u claimmode` `/u restore confirm`
### 修复(解析字段名以实测响应为准)
- 捐献记录:响应里**只有 `player_uuid` 没有玩家名**,现在先用邦国成员表、查不到再退到全服
玩家表补全;时间字段是 `contributed_at` 不是 `created_at`;补充 `reason`(用途)列
- 公告:`message` 是 **Minecraft 聊天组件的 JSON 字符串**,新增 `componentToText()` 压平成纯文本
- 封禁:实际字段是 `banned_player_name` / `banned_by_player_name` / `banned_at` / `unbanned`
- 日志:实际字段是 `username` / `action` / `timestamp`
- 玩家表:上下线时间是 `last_join_time` / `last_quit_time`
- 王城坐标:服务端可能返回 `null`,原来会被 `Number(null)` 算成 0,
伪装成「王城在世界原点 (0,0)」;现在显式判空返回 null
## [1.4.1] - 2026-09-27
### 变更
- **控制台输入框不再区分聊天与指令**:两者本来就是同一个入口 ——
以 `/` 开头按游戏指令发送,否则按公屏消息发送。因此删掉了「公屏聊天 / 游戏指令」下拉框
和重复的「快捷公屏消息」输入框,只保留**一个输入框 + 快捷发送 + 移动测试**。
接口返回新增 `kind` 字段说明实际走的是哪条路,Toast 也会区分「消息已发送 / 指令已发送」
- 快捷发送按钮补上 `/u restore confirm`(配合定时消息常用)
## [1.4.0] - 2026-09-27
大地图改为跟随机器人、支持可调图幅;新增定时重复发送消息;控制台界面重做并适配移动端。
### 新增
- **定时重复发送消息 / 指令**(`src/bot/scheduledMessenger.js`)
- 每个机器人可配置多条定时任务,按固定间隔重复发送同一条内容,
例如每 300 秒发一次 `/u restore confirm` 维持疆土民心
- 以 `/` 开头的内容自动按游戏指令发送,否则按公屏消息发送
- 只在「已连接 + 已通过登录验证」时才发;未就绪的周期直接顺延、**不补发**,
避免重连后一次性连发多条
- 与指令节流队列共用,不会绕过 `server.commandCooldownMs` 刷屏
- 控制台新增「定时消息」卡片:新增 / 删除 / 启用停用,并显示距下次发送的剩余秒数
- 接口:`GET|POST /api/bots/:id/schedules`
- **大地图跟随机器人 + 可调图幅**
- 爬取与显示区域改为**以机器人当前位置为中心**(`bigMap.followBot`,默认开启),
不再傻傻地爬世界原点周边
- 界面新增「图幅」下拉框(±512 / ±1024 / ±2048 / ±4096 / ±8192 / 全世界),
切换后即时重新对焦并拉取对应范围
- 新增「爬取周边」按钮:只爬当前图幅内的瓦片。
层级 0 每张瓦片覆盖 4096 格,所以 ±4096 只需 **9 张**,而全世界要 100 张
- `POST /api/bigmap/crawl` 支持 `centerX` / `centerZ` / `rangeBlocks`;
`GET /api/bigmap` 支持 `rangeBlocks` 与 `bot` 查询参数
- 启动预爬取会先等机器人进入世界,以它的位置为中心;等不到才退回世界原点
- **领土地图以机器人所在邦国为中心**
- 之前初始视图用的是控制器的 `playerChunk`,而任务启动前它是 (0,0),
导致地图永远停在世界原点附近 —— 完全不是用户关心的位置
- 现在按「机器人所在区块 → 包含它的邦国 → 该邦国王城 / 外接矩形」定位
- **控制台界面重做 + 移动端适配**
- 重新设计的深色主题:统一圆角、阴影、焦点态与悬停态,卡片式分层
- 响应式三档:≥1200px 三栏;≤1200px 两栏(配置栏落到下方并自动分列);≤820px 单栏
- 移动端:底部导航(状态 / 地图 / 日志 / 配置)一键定位、触控目标放大到 ≥36px、
地图与日志高度按视口自适应、避让底部安全区、Toast 全宽
- 横屏手机压矮地图高度;尊重系统「减少动效」偏好
- 「添加玩家」折叠进 details,减少左栏长度
- 顶栏在窄屏下自动换行,按钮等分排布
### 变更
- 大地图默认图幅从「全世界 ±20480」改为「±4096 格」,默认预爬取瓦片数从 100 张降到 9 张
- 「回到机器人」保留并修正:没有坐标时给出明确提示,而不是静默无反应
### 修复
- `POST /api/bigmap/crawl` 的响应把中心与图幅字段传错(实际爬取是对的,但返回值显示成世界原点)
- 定时任务保存后接口返回的是原始列表而非归一化结果,导致前端拿不到「剩余秒数」
## [1.3.0] - 2026-09-27
按线上实测结果修复「机器人走不动」,并移植旧版机器人已验证的关键补包。
### 修复
- **控制台登录按钮点不动(致命)**:`bindEvents()` 只在 `startApp()` 里执行,而 `startApp()`
只在登录成功后才跑 —— 未登录时登录按钮根本没绑上事件,形成死锁。
现在登录相关按钮在 `main()` 里无条件绑定
- **机器人走不动(致命,根因已定位并修复)**:mineflayer **不发 `player_input` 包**。
1.21.2+ 服务端的移动预测依赖它,原版客户端每 tick 都发,而 mineflayer 只在
骑乘 / 跳下 / 潜行时发,地面移动的 forward / jump / sprint 完全不发。
带反作弊的服务器因此判定「没有任何输入却发生了位移」,**每 tick 把玩家拉回原位置**。
实测:修复前入站强制位置同步约 **17 次/秒**、按方向键 2 秒位移 0.00 格;
修复后入站降到 **0 次**、位移 0.53~1.40 格。
同时补发原版每 tick 末尾的 **`tick_end`** 包(反作弊用它判定 tick 边界,mineflayer 完全不发)
- **寻路插件从未加载**:项目从没调用 `bot.loadPlugin(pathfinder)`,`bot.pathfinder` 恒为
`undefined`、`isReady()` 恒为 false,扩地任务连启动都启动不了
- **寻路事件监听重复注册**:`configure()` 每次配置变更都会重跑并再挂一遍监听,
导致同一事件被处理多次(日志里同一秒出现多条相同事件)
- **资源包未自动接受**:服务器要求资源包时不接会卡在加载界面进不了世界
### 新增
- **长距离分段寻路**:mineflayer-pathfinder 只能规划**已加载区块**范围(实测约 8 区块 ≈ 130 格),
而扩地目标常在几十个区块之外,A* 会瞬间返回 noPath。现在会把旅程拆段:
走到已加载范围边缘附近 → 新区块加载 → 再规划下一段。
段长用 `bot.blockAt` 对未加载区块返回 null 的特性**二分探测**得到,不依赖世界内部结构
- **寻路预算可配置**:`pathing.thinkTimeoutMs` / `tickTimeoutMs` / `searchRadius`
(`searchRadius` 为负表示不限制剪枝)
- **位置同步诊断**:位置包收发计数、`player_input` / `tick_end` 计数、`onGround`、
游戏模式、维度、脚下方块、六向「脚部 / 头部 / 地面」与可通行判定
- **手动移动测试**:控制台按钮绕开寻路器直接按方向键并回报真实位移
- 配置界面新增协议补包与寻路预算相关字段
### 已知限制(重要)
- **当前所在位置无法寻路**:实测机器人站在 (6759, 72, 11926) 时,A* **恒定只展开 47 个节点**
就返回 noPath。已逐项排除:挖方块 / 放方块 / 跑酷 / 搭塔 / 开门 / 最大下落(试到 64 格)/
高度偏好 / 水域代价 / 搜索半径(试到不限制)/ 时间预算 / 依赖版本
(与能正常移动的旧机器人完全一致:mineflayer 4.39.0 + pathfinder 2.4.5 + prismarine-world 3.7.0)。
旧机器人日志证明寻路本身可用(2 次 `goal_reached`、2 秒走过 8.7 格),
但它当时在 **y=-59**,而本机器人被 `/spawn` 放到了 **y=72**,怀疑是新位置的地形让移动生成器
找不到任何可行走分支。**下一步需要把机器人挪到开阔地再试**
## [1.2.0] - 2026-09-27
首次真正连上线上服务器(Debian 12 + pm2 + 端口 78)后,暴露并修掉了一批
**只有实机运行才会发现**的缺陷。这些问题的共同点是:单元测试全绿,但机器人根本干不了活。
### 修复
- **寻路插件从未加载(致命)**:项目从没调用过 `bot.loadPlugin(pathfinder)`,
导致 `bot.pathfinder` 恒为 `undefined`、`isReady()` 恒为 false,
扩地任务连启动都启动不了,而界面上只显示一句含糊的「机器人尚未就绪」。
现在在 `createBot` 之后立刻加载,失败时明确报错
- **登录门禁死锁**:`/login`、`/register`、简幻通的 `/auth` 本身就是登录流程的一部分,
原先的门禁把它们一并拦下,形成「要先登录才能发登录指令」,账号永远卡在未验证状态。
新增 `server.preLoginCommands` 白名单(默认 `login,l,register,reg,auth`)
- **验证码发不出去**:部分服务器要求把验证码**直接当聊天内容**发出去(不是指令),
而 `sendChat` 同样被登录门禁挡住。现在服务器明确要求验证时会自动放行聊天,
另有 `server.allowChatBeforeLogin` 给无提示的服务器兜底
- **配置了登录指令却没密码时永久卡死**:`.env` 里没有密码时程序无法发送任何登录指令,
却把 `loggedIn` 永久置为 false,控制台连手动指令都发不出去。
现在走与「服务器不回提示」一致的兜底逻辑(超时后按无需登录放行),并在日志里说明
- **离线时指令被静默丢弃却回报「已发送」**:`bot.chat()` 在未连接时无处可发。
现在 `canSendCommand()` 要求已连接并进入世界,失败信息区分
「未连接」「登录被服务器拒绝」「未通过登录验证」三种情况
- **「被服务器传送」日志洪水**:账号未完成服务器验证时服务端每 tick 强推一次位置,
实测 3 分钟刷出 748 行日志,几十分钟就会冲光日志环形缓冲并把 WebSocket 打满。
现在首次立即记录、其余按 30 秒窗口聚合,断开连接时刷出未汇总计数
- **外站账号绑定流程无法识别**:原先只认 `/login` 与 `/register`,
遇到要求 `/auth <UID> <验证码>` 或扫码绑定的服务器时,表现只是静默的死循环。
现在命中相关提示会输出可操作的中文告警(每个连接只提示一次),
并新增 `server.authCommand` 模板供有固定验证码的服务器自动过验证
- **重连无退避**:连续被踢时每 10 秒硬撞服务器。现在按指数退避
(`server.reconnectMaxDelayMs`,默认上限 120 秒),成功进入世界后计数清零
- **控制台瓦片 URL 与后端路由不一致**:前端请求 `/api/bigmap/tile/:z/:x_y.png`,
后端路由为 `/api/bigmap/tile/:z/:x/:y.png`
### 新增
- **控制台添加 / 移除玩家**:不必改 `.env` 重启,直接在界面上填玩家名 + 密码即可挂新账号。
密码只写入服务器本地凭据文件 `data/bots.credentials.json`(权限强制 0600),
不进仓库、不出现在任何接口响应里;`.env` 与凭据文件同名时以 `.env` 为准。
`.env` 里配置的账号不允许从控制台移除(否则重启后会「复活」)
- **手动移动测试**:控制台可按前 / 后 / 左 / 右 / 跳 2 秒并显示真实位移,
用于区分「寻路器算不出路径」与「客户端根本走不动」这两种完全不同的故障
(绕开寻路器直接走控制键)
- **位置同步诊断**:`snapshot` 暴露位置包收发计数、onGround、游戏模式、维度、
脚下方块与六向实心情况,`/api/bots/:id/status` 可直接看到
- **按世界数据修正出站 onGround**(`grim.reportOnGroundFromWorld`,默认开启):
mineflayer 收到服务端 `position` 包时会把 `entity.onGround` 置为 false,
在频繁下发位置包的服务器上会导致客户端持续上报「站在地上却在空中」。
现在发包前用脚下方块复核,只有确实不一致时才改写
- `scripts/check-protocol.js`:核对 mineflayer 写出的包名 / 字段名与目标版本协议是否一致
### 已知限制(重要)
- **本服上 mineflayer 客户端无法移动**:实测(`DiLiDaLaDaLa`,已绑定简幻通、登录正常)
按方向键 2 秒位移 0.00~0.02 格;服务端每 tick 回弹一次(入站 ~17/s、出站 ~19/s)。
已系统性排除:区块数据缺失、onGround 不一致、Grim 合规层、游戏模式(survival)、
物理引擎未启用、寻路插件缺失、`teleport_confirm` 协议字段不匹配。
连接、登录、收发指令、收发聊天、公屏日志、大地图爬取均正常,**唯独移动被服务端拒绝**。
彻底定位需要把客户端的移动包与原生客户端逐字节对比,属于独立的排查工作
- 同机上原有的 `minecraft-bot`(端口 91)与该机器人不能同时使用同一账号,会互相踢下线
## [1.1.0] - 2026-09-27
新增多机器人挂机、开拓日志、游戏公屏完整日志与大地图。
### 新增
- **多机器人同时挂机**(`src/bot/botPool.js`、`src/config/botsConfig.js`)
- `.env` 里用 `MC_BOTS` 一次配置多个账号(支持 JSON 数组与 `用户名:密码` 逗号分隔两种写法),
未配置时回退到 `MC_USERNAME` / `MC_PASSWORD` 单机器人模式
- 每个机器人独立连接、独立登录、独立跑扩地状态机;共享同一份领土数据服务与运行日志
- **密码只从 `.env` 读取**:`data/bots.json` 只存启用状态、备注名、是否自动开始与各自的配置覆盖
- 每个机器人可有自己的扩地配置覆盖(策略、目标邦国、目标数量等),未覆盖字段回退全局默认
- 控制台新增机器人列表(连接状态 / 坐标 / 任务 / 已开拓数)、机器人切换、全部开始 / 全部停止
- **开拓日志**(`src/core/expansionLog.js`)
- 逐条记录**开拓玩家、邦国、区块、方块坐标、时间、判定来源**,追加写入 `data/expansion-log.jsonl`
- 支持按机器人 / 邦国 / 时间筛选、按机器人统计与今日计数、导出带 BOM 的 CSV
- 文件超过 16MB 自动轮转为 `.1`;进程被杀留下的半行在重启时被安全跳过
- 控制台新增「开拓日志」表格页签,支持筛选、清屏、导出
- **游戏公屏完整日志 + 聊天框**(`src/core/chatLog.js`)
- 每个机器人收到的**全部**公屏 / 系统 / 动作栏消息按机器人分文件落盘(`data/chat/<机器人>.jsonl`)
- 本机器人发出的聊天与指令同样留档,可对照「指令发出后服务器回了什么」
- 控制台新增「公屏聊天」页签(可按机器人或全部机器人查看、关键字筛选、导出)与公屏消息发送框
- **登录验证门禁**
- 游戏侧:未完成服务器登录验证时拒绝发送指令 / 聊天,且 `isReady()` 不放行扩地任务;
新增登录结果确认兜底(`server.loginConfirmTimeoutMs`),避免服务器不回提示导致机器人永远卡在未登录
- 控制台侧:新增 `WEB_PASSWORD` 与 `/api/login`、`/api/session`,页面弹出登录框,
验证通过后才允许操作各机器人的控制台、聊天框与扩地任务
- **大地图**(`src/core/bigMapService.js`)
- 爬取服务器地图站点(squaremap)的瓦片图片与领土标记,拼在一张图上查看**所有机器人的坐标**
- 坐标系已对源站实测标定:瓦片 512×512,层级 0 每张覆盖 4096 方块,层级 3 为 1 方块 1 像素
(用 27 个王城坐标交叉验证:正确公式下王城所在瓦片平均海洋占比 28.7%,
错误假设为 41.6% / 47.3% / 76.0%)
- 瓦片磁盘缓存 `data/mapcache/`,默认启动时预爬取层级 0(全世界 10×10=100 张),
更高层级按需穿透拉取并永久缓存;`/api/bigmap/tile/:z/:x/:y` 代理避免跨域
- 领土以 markers.json 的原始多边形绘制(预编译为 `Path2D`),叠加本次会话新占领区块、
王城与全部机器人坐标;支持滚轮缩放、拖拽平移、全世界 / 回到机器人一键定位
- **测试从 154 个增加到 242 个**:新增开拓日志、公屏日志、大地图坐标系与缓存、
机器人池与配置隔离、凭据解析、机器人登录门禁、多机器人 Web 接口与登录鉴权等用例
### 变更
- `TerritoryModel` 新增 `flyingChunksOf()` / `ownChunksOf()`,邦国归属改为**按名称显式传入**,
不再依赖全局 `selfStateName`(多机器人会互相覆盖);同时保留 markers.json 的原始多边形
- `ExpansionPlanner` 的己方邦国解析顺序改为「配置里的 `stateName` → 模型全局 `selfStateName`」
- 运行日志统一加机器人名前缀(`[游戏名] …`),多机器人共用一份日志也能分清来源
- Web 接口改为以 `/api/bots/:id/...` 为主,原有单机器人接口(`/api/task/*`、`/api/command`、
`/api/bot/reconnect`)保留为默认机器人的等价别名
- `/api/map` 支持 `?bot=<id>` 按机器人视角返回其邦国、目标与玩家区块
### 修复
- 修正控制台地图瓦片 URL 与后端路由不一致的问题(前端请求 `/api/bigmap/tile/:z/:x_y.png`,
后端路由为 `/api/bigmap/tile/:z/:x/:y.png`)
- **「被服务器传送」日志洪水**:账号未完成服务器验证时,服务端会每 tick 强推一次位置,
实测 3 分钟刷出 748 行日志,几十分钟就会把 2000 条的日志环形缓冲冲光、并把 WebSocket 打满。
现改为首次立即记录、其余按 30 秒窗口聚合成一条(`被服务器传送另有 N 次`),
并在连接断开时把未汇总的计数刷出去
- **外站账号绑定流程无法识别**:本项目原先只认 `/login` 与 `/register`,
遇到「简幻通」这类要求 `/auth <UID> <验证码>` 或扫码绑定的服务器时,
表现只是「连上 → 被传送 → 120 秒后被踢 → 重连」的静默死循环,失败原因对用户完全不可见。
现新增 `NEED_AUTH_PATTERN` 识别,命中后输出可操作的中文告警(每个连接只提示一次),
并新增 `server.authCommand` 模板供配置固定验证码的服务器自动过验证
- **重连无退避**:连续被踢时每 10 秒硬撞一次服务器。现按指数退避拉长间隔
(`server.reconnectMaxDelayMs`,默认上限 120 秒),成功进入世界后计数清零
- **登录门禁死锁**:有些服务器的登录流程本身就是指令(`/login`、`/register`,
简幻通体系还要先 `/auth`),原先的门禁把这些指令也一并拦下,导致
「要先登录才能发登录指令」,账号永远卡在未验证状态。
现新增 `server.preLoginCommands`(默认 `login,l,register,reg,auth`)白名单,
这些前缀在未验证时也放行;其余指令仍要求已完成验证
- **离线时指令被静默丢弃却回报「已发送」**:`bot.chat()` 在未连接时无处可发,
原先调用方只会收到「已发送」。现在 `canSendCommand()` 要求已连接并进入世界,
且失败信息会区分「未连接」「登录被拒」「未通过验证」三种情况并给出对应处理建议
### 已知限制
- **多个机器人共享一份领土模型**:`markers.json` 是全服数据,没必要每个机器人各拉一遍;
因此「会话新占领」区块是全局共享的,控制台地图的己方邦国显示的是最近启动的那个机器人的邦国
- **大地图层级 3 不预爬取**:全图 80×80=6400 张瓦片约 200MB,改为按需拉取 + 永久缓存
- **扫码类账号绑定无法自动化**:`/auth <UID> <验证码>` 的验证码是动态的、扫码更是需要人工,
程序只能明确报错提示;绑定是一次性的,绑定成功后同一账号即可自动登录
## [1.0.0] - 2026-02-14
由 Fabric 客户端模组 AutoStore v1.3.1 重构为 Node.js + mineflayer 机器人,UI 改为网页控制台,
+541 -41
查看文件
@@ -4,10 +4,11 @@ Minecraft 自动扩地机器人 —— 由原 Fabric 客户端模组 [AutoStore]
**Node.js + mineflayer 实现,UI 从游戏内 GUI 改为网页控制台,并针对装有 Grim 反作弊的服务器做合规适配。**
不需要开游戏客户端,一条命令启动,浏览器打开控制台即可开始自动扩地。
支持**同时挂多个玩家(机器人)**、**开拓日志**、**游戏公屏完整日志**与**爬取服务器地图的大地图**。
```
npm install
cp .env.example .env # 填入服务器地址与账号密码
cp .env.example .env # 填入服务器地址与账号密码(支持多个账号)
npm start # 打开 http://127.0.0.1:3081
```
@@ -35,6 +36,379 @@ npm start # 打开 http://127.0.0.1:3081
| 日志导出 | ✅ | 网页按钮导出到 `data/` |
| SimpCore 开放 API 第四数据源 | ✅ | 低频轮询 + 手动全服拉取 |
| Grim 反作弊适配 | ✅ 新增 | 见下方专章 |
| **多机器人同时挂机** | ✅ 新增 | 一个进程挂多个账号,各自独立连接 / 登录 / 扩地,配置可独立覆盖;**目标区块带租约**,不会撞在同一块上 |
| **开拓日志** | ✅ 新增 | 逐条记录开拓玩家、邦国、区块、方块坐标、时间、判定来源,落盘可导出 CSV |
| **游戏公屏完整日志 + 聊天框** | ✅ 新增 | 全部公屏 / 系统消息按机器人分文件留档,控制台可直接发公屏消息 |
| **大地图** | ✅ 新增 | 爬取服务器地图站点瓦片与领土标记,一张图上看全部机器人坐标;高层级瓦片后台预热 |
| **登录验证门禁** | ✅ 新增 | 游戏内登录 + 控制台登录双重校验,通过后才允许操作机器人与发送消息 |
| **定时消息(间隔 / cron)** | ✅ 新增 | 固定间隔或 cron 表达式定时发送,可选「期望回复」确认发送是否真的生效 |
| **人工介入提醒(Webhook)** | ✅ 新增 | 等待验证码 / 原点自救失败时推送到飞书等自定义机器人,不必一直盯控制台 |
| **出站包抓取(诊断)** | ✅ 新增 | 按需录下机器人真正发出的协议包(含内容),用于与「能走」时的序列逐包对照,定位反作弊拒绝移动的原因 |
| **物理 tick 录制(诊断)** | ✅ 新增 | 逐 tick 记录物理引擎读到的输入、算出的速度、有无位移与碰撞,把「输入没进物理 / 被方块挡住 / 服务端回弹」三种原因切开 |
---
## 多机器人同时挂机
### 配置
在 `.env` 里一次配好所有账号(**密码只从这里读,绝不落代码与配置文件**):
```bash
# 写法一:用户名:密码,逗号分隔
MC_BOTS=开拓一号:password1,开拓二号:password2
# 写法二:JSON 数组(用户名/密码含逗号冒号时用这个)
MC_BOTS=[{"username":"bot1","password":"p1"},{"username":"bot2","password":"p2","label":"二号机"}]
# 不设置 MC_BOTS 时回退到单机器人
MC_USERNAME=AutoStoreBot
MC_PASSWORD=xxxxxx
```
启动后控制台左栏会列出全部机器人(连接状态 / 坐标 / 任务状态 / 已开拓数),
顶栏下拉框切换当前操作的机器人。
### 每个机器人独立的配置
控制台右侧配置表单勾选「**仅对当前机器人生效**」后保存,即为该机器人写入专属配置覆盖
(存 `data/bots.json`,**不含密码**);不勾选则写入全局默认配置。未覆盖的字段一律回退到全局默认。
典型用法:给每个账号指定不同的 `目标邦国` 与 `扩地策略`,让它们分工扩张不同方向。
### 设计取舍
- **共享一份领土模型**:`markers.json` 是全服数据(约 160KB / 11.5 万区块),
没必要每个机器人各拉一遍,因此数据服务是单例、共享给全部控制器。
- **目标区块带租约**(`src/core/targetLease.js`):领土模型是全局共享的,但「谁要去占哪一块」
原先只存在各自的队列里,彼此看不见 —— 多机器人很容易撞在同一批候选区块上。
现在抢到的机器人独占该区块(其余机器人跳过),持有者持续续租,
掉线 / 卡死后租约到期自动失效,不会永久锁死区块;
候选区块全被预定时会等 15 秒再规划,而不是提前判「没有可用区块」。
- **邦国归属按机器人解析**:`ExpansionController` 在 `start()` 时把识别到的邦国固化到
`controller.stateName` 并写回本次任务配置,规划器不再读全局 `selfStateName`
(否则会被最后启动的机器人覆盖,导致 A 机器人按 B 的邦国规划)。
- **「会话新占领」区块是全局共享的**:控制台地图的「己方领土」显示的是最近启动的那个机器人的邦国。
---
## 开拓日志
`src/core/expansionLog.js` 逐条记录每一次成功开拓:
| 字段 | 说明 |
| --- | --- |
| `t` | 时间戳(毫秒),界面按 `YYYY-MM-DD HH:mm:ss` 展示 |
| `bot` / `botId` | 开拓玩家(机器人游戏名 / id) |
| `state` | 开拓归属的邦国 |
| `cx` / `cz` | 区块坐标 |
| `bx` / `bz` | 方块坐标(服务器回报的精确位置,通用成功文案时为空) |
| `source` | 判定来源:`chat`(带坐标的聊天消息)/ `chat-generic`(无坐标的通用文案) |
| `sessionCount` | 该机器人本轮任务的第几个 |
- 落盘:`data/expansion-log.jsonl`(追加写入,超过 16MB 轮转为 `.1`)
- 内存:保留最近 5000 条供实时查看,磁盘上的历史不受此限制
- 控制台「开拓日志」页签支持按机器人筛选、清屏、导出带 BOM 的 CSV(Excel 打开中文不乱码)
---
## 游戏公屏完整日志与聊天框
`src/core/chatLog.js` 把每个机器人收到的**全部**消息留档,不只留开垦相关的:
- 公屏聊天(其他玩家发言,含发言人)、系统公告、动作栏消息
- 本机器人发出的聊天与指令(便于对照「指令发出后服务器回了什么」)
- 落盘:`data/chat/<机器人名>.jsonl`(每机器人一份,超过 16MB 轮转)
- 控制台「公屏聊天」页签可按当前机器人或全部机器人查看、按关键字过滤、导出为纯文本
控制台右下角的「控制台 / 聊天框」可以选择**公屏聊天**或**游戏指令**模式发送内容。
---
## 大地图
`src/core/bigMapService.js` 爬取服务器地图站点(本项目部署的是
[squaremap](https://github.com/jpenilla/squaremap))的瓦片图片与领土标记,
把所有机器人画在同一张图上。
### 坐标系(已对源站实测标定)
squaremap 前端用 `L.CRS.Simple` + `tileSize: 512`,瓦片路径为
`tiles/<world>/<z>/<x>_<y>.png`,并且:
- 瓦片图片固定 **512×512** 像素;
- 最大原生层级 `zoom.max`(本站为 3)下 **1 方块 = 1 像素**;
- 因此层级 `z` 每张瓦片覆盖 `512 × 2^(maxZoom − z)` 个方块,
瓦片 `(x, y)` 覆盖方块区间 `[x·B, (x+1)·B) × [y·B, (y+1)·B)`。
实测标定:本站层级 0 每张瓦片覆盖 4096 方块,全世界(±20480)为 **10×10 = 100 张**。
用 27 个王城坐标交叉验证——按上式换算,王城所在瓦片的平均海洋像素占比为 **28.7%**,
而错误假设(256 / 1024 方块每瓦片、整体偏移半张瓦片)分别为 76.0% / 47.3% / 41.6%,
即只有正确公式能让王城稳定落在陆地上。
### 以机器人为中心 + 可调图幅
大地图的用途是看**自己机器人周边**,所以默认以机器人当前位置为中心(`bigMap.followBot`)。
界面上的「图幅」下拉框决定半径:
| 图幅 | 层级 0 需要的瓦片数 |
| --- | --- |
| ±512 格 | 1 |
| ±1024 格 | 1~4 |
| ±4096 格(默认) | 9 |
| ±8192 格 | 25 |
| 全世界(±20480) | 100 |
层级 0 每张瓦片覆盖 4096 格,所以「只看周边」比「拉全世界」省一到两个数量级。
点「爬取周边」只会爬当前图幅内的瓦片;「回到机器人」把视图拉回机器人所在位置。
### 缓存与爬取
| 层级 | 每瓦片方块数 | 全世界瓦片数 | 策略 |
| --- | --- | --- | --- |
| 0 | 4096 | 10×10 = 100 | 启动时自动预爬取(以机器人位置为中心,只爬当前图幅内) |
| 1 | 2048 | 20×20 = 400 | 按需拉取 + **后台预热** + 永久缓存 |
| 2 | 1024 | 40×40 = 1600 | 按需拉取 + **后台预热** + 永久缓存 |
| 3 | 512 | 80×80 = 6400 | 按需拉取 + **后台预热** + 永久缓存 |
- **后台预热**:机器人移动到新区域(默认超过 512 格)时,用低并发(批量爬取并发的一半)
顺带把周边高层级瓦片拉下来,避免首次缩放时干等;距上次预热超过 10 分钟也会补一次。
已缓存的瓦片只会做一次 `statSync`,不会重复下载;正在批量爬取时预热自动让路
- 磁盘缓存:`data/mapcache/<world>/<z>/<x>_<y>.png`,命中即不再回源
- 瓦片由后端代理(`/api/bigmap/tile/:z/:x/:y`),避免浏览器直连地图站点的跨域问题
- 领土多边形按**视野裁剪**绘制(预编译路径时顺带记包围盒,视野外的直接跳过),
全世界视角 + 全部邦国也不会掉帧
- 领土以 `markers.json` 的**原始多边形**绘制(预编译成 `Path2D`,按 even-odd 填充保留国中孔洞),
叠加本次会话新占领区块、王城与**全部机器人坐标(含名字标签)**
- 操作:滚轮缩放、拖拽平移、图幅切换、「爬取周边」/「回到机器人」一键定位
---
## SimpCore 开放 API
`src/core/simpApiClient.js` 对接 [SimpCore 开放 API](https://open.bgjq.top/api-docs.html)
(邦国崛起服务器的只读查询服务)。它既是占领判定的第四数据源,也把服务端的公开信息
完整暴露到控制台。
### 文档里的接口基本都接了
| 分类 | 接口 |
| --- | --- |
| 健康 | `GET /health`(免鉴权) |
| 玩家 | `/info/players`、`/info/players/{key}`、`/info/players/{key}/union` |
| 邦国 | `/info/unions`、`/info/unions/{key}`、`/members`、`/territory`、`/tech`、`/contributions` |
| 全服 | `/info/territory`、`/info/buffs`、`/info/diplomacy`、`/info/bans`、`/info/logs`、`/info/announcements` |
在左栏「领土数据源」点 **查看 API 数据** 打开浏览弹层,分五个页签:概览 / 邦国 / 玩家 / 全服 / 实时 V2。
限流按「Key × 接口」每分钟计算,所以**全部按需点击加载 + 会话内缓存 + 服务端 5 分钟缓存**,绝不做轮询。
### API 与 API-V2 是两套独立的密钥
文档明确说明两者**互不通用的**:
- **API**:`https://bgjq.simpfun.cn/api/` —— 邦国、领土、玩家、全服数据,配置 `apiKey`(或环境变量 `SIMP_API_KEY`)
- **API-V2**:`https://bgjq.simpfun.cn/api-v2/` —— 服务器实时状态(在线玩家 / 装备 / 游戏时间 / 末影龙),
需要在密钥中心**另外申请**,配置 `apiV2Key`(或环境变量 `SIMP_API_V2_KEY`)
V2 的接口级配额很小(末影龙只有 **1 次/分钟**),因此 V2 一律按需查询、不做自动轮询。
### 几个响应字段的坑(已按实测处理)
| 接口 | 坑 | 处理 |
| --- | --- | --- |
| 捐献 | 只有 `player_uuid`,**没有玩家名** | 先用邦国成员表补全,查不到再退到全服玩家表 |
| 捐献 | `amount` 可为负 | 负数是邦国支出(如「恢复民心」消耗金锭/钻石),UI 注明 |
| 公告 | `message` 是**聊天组件 JSON 字符串** | `componentToText()` 压平成纯文本 |
| 王城 | 服务端可能返回 `null` | 显式判空,避免 `Number(null)` 算成 0 伪装成 (0,0) |
| 封禁 / 日志 | 字段名是 `banned_player_name` / `timestamp` 等 | 按实测字段解析 |
| V2 `/players` | 返回**对象** `{online, max, players[]}` 而不是数组,且没有坐标 | 按对象解析 |
| V2 `/dragon` | 字段是 `dragonAlive`,没有 health / phase | 按实测字段解析 |
| V2 `/time` | 服务端已算好 `time24` / `time12` / `phase` | 直接用,不再自己用 tick 换算 |
| V2 `/equipment` | 物品的 `damage` 是**已损耗值** | 换算成 `remainingDurability` 再显示 |
| V2 限流 | 429 带 `retryAfterSeconds` | 提示里直接写出还要等多少秒 |
---
## 登录验证门禁
「登录验证后才允许操作」在两侧都做了强制:
**游戏侧**(`src/bot/botManager.js`)
- `canOperate()` = 已连接 + 已进入世界 + 已完成登录验证
- 未通过时 `sendCommand` / `sendChat` 直接拒绝并记警告,`isReady()` 也不放行扩地任务
- 登录 / 注册流程自身用 `{ internal: true }` 绕过门禁
- **登录确认兜底**:部分离线服登录成功后不回明确提示,`server.loginConfirmTimeoutMs`
(默认 10 秒)内既没收到成功也没收到失败提示时按「已登录」放行,避免机器人永远卡死;
收到「密码错误 / 登录失败」则标记 `loginFailed` 且不放行。设为 `0` 可禁用兜底
- **免登录指令白名单**:有些服务器的登录流程本身就是指令(`/login`、`/register`,
简幻通体系还要先 `/auth`),若把门禁一刀切会形成「要先登录才能发登录指令」的死锁。
`server.preLoginCommands`(默认 `login,l,register,reg,auth`)里列出的前缀在未验证时也放行
- **外站账号绑定(如简幻通)**:服务器要求 `/auth <UID> <验证码>` 或扫码绑定时,
密码无法自动完成,程序会输出明确的中文告警(每个连接只提示一次)而不是静默死循环。
若该服务器提供固定验证码,可在配置的「账号绑定 / 二次验证模板」(`server.authCommand`)里填写
- **失败信息可操作**:发送失败会区分「未连接」「登录被服务器拒绝」「未通过登录验证」三种情况,
而不是笼统报一句「发送失败」
- **人工介入提醒(Webhook)**:验证码只有 120 秒时限,错过就得等下次重登。
在控制台「服务器 → 通知提醒」勾选「启用通知」并填 Webhook 地址(地址也可走环境变量
`NOTIFY_WEBHOOK_URL`)后,服务器要求验证码时会推送一条提醒到飞书 / Lark 自定义机器人;
原文里带上服务器提示与处理步骤。同一问题默认 60 秒内只推一次,避免重连循环里反复轰炸;
接口里只暴露 Webhook 域名,不会把带凭证的完整地址打进响应
**控制台侧**(`src/web/server.js`)
- 配置 `WEB_PASSWORD`(或 `WEB_TOKEN`)后,页面弹出登录框,`POST /api/login` 校验通过才解锁
- 所有 `/api` 与 WebSocket 握手都要求凭证(`X-Auth-Token` 头 / `?token=` / Cookie)
- `/api/session` 与 `/api/login` 本身免鉴权,否则用户无法登录
- 控制台顶栏显示登录状态,可随时退出登录
---
## 部署到服务器
本项目已在 Debian 12 上以 pm2 常驻运行验证过,关键点:
### 0. 一键更新(已装好之后日常用这个)
```powershell
# 推荐:把 SSH 凭据写进 .env(已被 .gitignore 忽略),然后直接跑
npm run deploy
```
```bash
# 也可以临时用环境变量覆盖(.env 里的值仍作为兜底)
# Windows PowerShell
$env:SSH_HOST='<服务器 IP>'; $env:SSH_PORT='<端口>'
$env:SSH_USER='root'; $env:SSH_PASS='<服务器密码>'
npm run deploy
# Linux / macOS
SSH_HOST=... SSH_PORT=... SSH_USER=... SSH_PASS='<密码>' npm run deploy
```
> 推荐写进 `.env` 而不是命令行:命令行传密码会留在 shell 历史与进程列表里。
> 需要哪些变量见 `.env.example` 的「部署」一节(`SSH_HOST` / `SSH_PORT` / `SSH_USER` / `SSH_PASS`,
> 可选 `DEPLOY_APP` / `DEPLOY_PORT`)。**这些只在本机用,服务器上的 `.env` 不需要填。**
`scripts/deploy.js` 会依次做:打包 → 上传 → 远程执行 `update.sh` →
轮询确认控制台返回 200,任一步失败都会明确报错。
需要单条远程命令时用 `node scripts/ssh-helper.js exec "<命令>"`。
两个脚本用 `ssh2` 实现(Windows 自带 OpenSSH 但没有 sshpass,
无法非交互传密码);`ssh2` 是 devDependencies,未安装时脚本会提示而不是裸崩。
打包和更新都会**排除并保留**服务器上的 `.env`、`data/`、`node_modules/`、`runtime/`,
不会用你本机的空配置覆盖线上真实数据。
更多运维命令(回滚、日志路径、服务器上各文件的作用)见 `TODO.md` 的「部署方法」一节。
### 1. Node.js 版本要求 ≥ 22
`mineflayer 4.39` 的 `engines` 声明为 `>=22`,用 Node 18 会在运行时出问题。
若服务器上已有别的服务依赖旧版 Node,**不要升级系统 Node**,装一份项目私有运行时即可:
```bash
mkdir -p /opt/autostore-bot/runtime && cd /opt/autostore-bot/runtime
curl -fsSL -o node.tar.xz https://nodejs.org/dist/v24.16.0/node-v24.16.0-linux-x64.tar.xz
tar -xJf node.tar.xz && rm node.tar.xz
ln -sfn /opt/autostore-bot/runtime/node-v24.16.0-linux-x64 /opt/autostore-bot/runtime/current
```
### 2. 环境变量
对外提供控制台时,`WEB_HOST` 必须是 `0.0.0.0`,并且**必须**设置 `WEB_PASSWORD`
(否则任何人访问该端口都能操作你的游戏账号):
```bash
WEB_HOST=0.0.0.0
WEB_PORT=78
WEB_PASSWORD=<强密码>
```
### 3. pm2 常驻
```bash
export PATH=/opt/autostore-bot/runtime/current/bin:$PATH
npm install --omit=dev
pm2 start ecosystem.config.js # interpreter 指向项目私有 Node
pm2 save
pm2 startup systemd -u root --hp /root # 开机自启
```
`ecosystem.config.js` 里把 `interpreter` 显式指向私有运行时,避免 pm2 用系统 Node 启动。
### 4. 日志分两个文件
`data/pm2-out.log` 是 stdout(info 级),`data/pm2-error.log` 是 stderr
(**warn 与 error 级都在这里**)。排查问题时两个都要看;
控制台页面上的运行日志是内存环形缓冲,包含全部级别,不受这个拆分影响。
### 5. 排障经验
| 现象 | 原因 | 处理 |
| --- | --- | --- |
| 任务点了「开始扩地」却说「机器人尚未就绪」 | 寻路插件没加载,`bot.pathfinder` 恒为 undefined | 已在 `createBot` 后自动 `loadPlugin`;若仍出现看日志里的插件加载错误 |
| 日志被「被服务器传送」刷屏 | 账号未完成服务器验证,服务端每 tick 强推位置 | 已按 30 秒窗口聚合;看是否有「账号绑定」告警 |
| 连上 120 秒后被踢,反复循环 | 服务器要求外站账号绑定(如简幻通 `/auth`) | 人工完成一次绑定;绑定后同账号即可自动登录 |
| `mineflayer` 加载报错 / 语法不支持 | 系统 Node < 22 | 用项目私有运行时启动 |
| 能连上、能收指令,但**走不动** | 客户端移动包被服务端拒绝(回弹) | 用控制台的「移动测试」按钮确认位移;详见下方专章 |
### 6. 「机器人走不动」的排查方法
控制台右下角有**移动测试**按钮(前 / 后 / 左 / 右 / 跳),它绕开寻路器直接按控制键 2 秒并回报真实位移。
这能一眼区分两种完全不同的故障:
- **位移正常、但任务报「目标不可达」** → 是规划问题(距离太远、被地形挡住、挖/放方块被 Grim 约束禁用)
- **位移 ≈ 0** → 客户端移动被服务端拒绝,属于协议 / 反作弊交互问题,改规划参数没有意义
#### 最关键的一条:mineflayer 不发 `player_input`
这是本项目在真实服务器上「怎么按方向键都不动」的根因,**已在 `GrimCompat` 里自动补发**:
1.21.2+ 服务端的移动预测依赖 `player_input` 包,原版客户端每 tick 都发;
而 mineflayer 只在骑乘 / 跳下 / 潜行时发,地面移动的 forward / jump / sprint **完全不发**。
带反作弊的服务器因此判定「没有任何输入却发生了位移」,**每 tick 把玩家拉回原位置**。
实测对比(同一账号、同一位置、同一时段):
| | 入站强制位置同步 | 按方向键 2 秒的位移 |
| --- | --- | --- |
| 不补 `player_input` | ~17 次/秒 | 0.00 格 |
| 补 `player_input` + `tick_end` | **0 次** | 0.53 ~ 1.40 格 |
同时补发的还有 `tick_end`(原版 1.21.2+ 客户端每 tick 末尾发送,反作弊用它判定 tick 边界)。
两者可在配置里关掉(`grim.sendPlayerInput` / `grim.sendTickEnd`),但**线上服务器不要关**。
#### 长距离寻路会被自动拆段
mineflayer-pathfinder 只能规划**已加载区块**范围(实测本服约 8 区块 ≈ 130 格),
而扩地目标常在几十个区块之外,A* 会瞬间返回 noPath。
寻路服务会把旅程拆成若干段:走到已加载范围边缘附近 → 新区块加载 → 再规划下一段。
段长由 `bot.blockAt` 对未加载区块返回 null 的特性二分探测得出,不依赖世界内部结构。
相关配置:`pathing.maxSegmentBlocks`、`pathing.segmentMarginBlocks`。
#### 配套的诊断数据
在 `GET /api/bots/:id/status`:
| 字段 | 含义 |
| --- | --- |
| `packetStats.inboundPosition` | 服务端下发的强制位置同步次数(持续很高 = 被回弹) |
| `packetStats.outboundPosition` | 客户端上报的位置包次数 |
| `packetStats.playerInput` / `tickEnd` | 每 tick 补发的协议包次数(应约等于 20/秒) |
| `packetStats.onGroundFixed` | 出站包中 onGround 被世界数据纠正的次数 |
| `onGround` | 客户端当前认为自己在不在空中 |
| `gameMode` / `dimension` | 游戏模式与维度(adventure / spectator 会限制移动) |
| `world.blockBelow` / `world.surroundings.exits` | 脚下方块与各方向「脚部 / 头部 / 地面」方块名 + 可通行判定 |
| `grim.protocolPackets` | 协议补包是否生效及发送计数 |
判读方式:
- 入站高、出站为 0 → 客户端根本没上报位置
- 出入都高但位移为 0 → 客户端在报,服务端不认(回弹)
- `onGround` 恒为 false 而 `world.blockBelowSolid` 为 true → 开启 `grim.reportOnGroundFromWorld`
- **寻路日志里「已探索节点数」极小(如几十个)就 noPath** → 不是距离问题,是移动生成器
从起点就找不到分支:检查 `world.surroundings.exits` 的 `walkable`,或把机器人挪到开阔地
---
@@ -95,6 +469,23 @@ Grim 的 Prediction 检查正是逐 tick 复算玩家位置并与上报位置比
| `liquidCost` | 200(avoid 模式) | mineflayer 液体物理与原版差异最大,优先绕开水域 |
| `allowFreeMotion` | 关闭 | 禁止自由运动 |
### 6. 地面状态修正与手动执行器(「走不动」的两级应对)
服务端频繁下发 `position` 包时,mineflayer 会把 `entity.onGround` 置为 false 并清零速度,
物理引擎于是长期走「空中」分支 —— 表现就是「按着前进键、水平速度恒为 0」。为此有两级措施:
| 措施 | 配置 | 说明 |
| --- | --- | --- |
| 出站包复核 | `grim.reportOnGroundFromWorld` | 发包前用脚下方块复核 `onGround` |
| **方案 A**:物理引擎对齐 | `grim.syncGroundToPhysics` | 每个物理 tick 在「物理算完、发包之前」把 `entity.onGround` 与世界数据对齐,让移动回到地面分支 |
| **方案 B**:不信例行同步 | `grim.trustSetbackOnGround` + `grim.setbackSyncEpsilon` | 服务端位置与本地几乎一致(例行同步)时只采纳坐标、保留我们自己的 `onGround` |
| **备选执行器** | `pathing.executor: 'manual'` | 只用 A* 算路径,执行改为自己按键(转向 → 前进 → 卡坎跳),时序与「移动测试」的手动按键完全一致 |
方案 A / B 与 `grim.enabled` 无关(实测关闭合规层后回弹依旧)。
控制台「Grim 合规」卡片会显示 `物理对齐 / 保住 / 最近同步差` 三个计数,
用它们可以判断修正是否真的生效;`/api/status` 的 `packetStats` 里还有出站包类型分布,
便于与旧模组抓包逐项对照。
---
## 与原模组的行为差异(重要)
@@ -161,12 +552,13 @@ npm start
| --- | --- |
| `MC_HOST` / `MC_PORT` | 服务器地址与端口 |
| `MC_VERSION` | 协议版本,默认 `1.21.4` |
| `MC_USERNAME` | 机器人游戏名 |
| `MC_BOTS` | **多机器人**账号列表:`用户名:密码` 逗号分隔,或 JSON 数组写法 |
| `MC_USERNAME` / `MC_PASSWORD` | 单机器人兜底(未设置 `MC_BOTS` 时生效) |
| `MC_AUTH` | `offline`(离线服)或 `microsoft`(正版) |
| `MC_PASSWORD` | 服务器登录 / 注册密码,供 `{password}` 占位符使用 |
| `WEB_HOST` / `WEB_PORT` | 控制台监听地址与端口 |
| `WEB_TOKEN` | 控制台访问令牌;非本机访问**必须**设置(页面静态资源不校验,`/api` 与 WebSocket 握手校验) |
| `DYNMAP_BASE` / `DYNMAP_WORLD` | 地图数据源根地址与世界目录名 |
| `WEB_TOKEN` | 控制台令牌;非本机访问**必须**设置(页面静态资源不校验,`/api` 与 WebSocket 握手校验) |
| `WEB_PASSWORD` | 控制台登录密码:配置后页面弹出登录框,验证通过才允许操作机器人的控制台 / 聊天框 |
| `DYNMAP_BASE` / `DYNMAP_WORLD` | 地图站点根地址与世界目录名(领土数据 + 大地图瓦片) |
| `SIMP_API_KEY` | SimpCore 开放 API Key(可选) |
---
@@ -175,37 +567,110 @@ npm start
打开控制台后:
- **左栏**:连接状态、任务状态机、领土数据源、Grim 合规面板
- **中栏**:领土地图(Canvas,滚轮缩放 / 拖拽平移 / 图层开关)+ 实时运行日志
- **右栏**:分 7 个标签页的配置表单 + 手动指令输入
- **顶栏**:机器人下拉选择、连接 / 任务徽标、控制台登录状态、开始扩地 / 停止 / 重连
- **左栏**:机器人列表、连接、任务、**定时消息**、领土数据源、Grim 合规
- **中栏**:
- 地图页签 —— **大地图**(瓦片 + 领土多边形 + 全部机器人坐标)与 **领土地图**(区块级)
- 日志页签 —— **运行日志** / **公屏聊天** / **开拓日志**
- **右栏**:控制台(统一输入框 + 快捷发送 + 移动测试)+ 8 个标签页的配置表单
顶栏按钮:开始扩地 / 停止 / 重连。
### 控制台输入框:聊天与指令是同一个入口
地图图层:己方领土(蓝)、本次占领(绿)、飞地疏漏(红)、当前目标(黄)、机器人(白点)、王城(紫)。
不需要切换「聊天 / 指令」模式 —— **以 `/` 开头就按游戏指令发送,否则按公屏消息发送**。
服务端 `/api/bots/:id/chat` 就是这么分流的,返回里带 `kind` 字段说明实际走的是哪条路。
### REST / WebSocket 接口
另外提供:
- **快捷发送**:`/u return`、`/u claimmode`、`/u restore confirm`、`/spawn`、`/list` 一键发出
- **移动测试**:前 / 后 / 左 / 右 / 跳,绕开寻路器直接按控制键 2 秒并回报真实位移
### 响应式与移动端
| 宽度 | 布局 |
| --- | --- |
| ≥1200px | 三栏(状态 / 地图日志 / 配置) |
| ≤1200px | 两栏,配置栏落到下方并自动分列 |
| ≤820px | 单栏 + 底部导航(状态 / 地图 / 日志 / 配置一键定位) |
移动端额外处理:触控目标放大到 ≥36px、地图与日志高度按视口自适应、
避让底部安全区(`env(safe-area-inset-bottom)`)、横屏时压矮地图高度、
尊重系统「减少动效」偏好。
### 定时重复发送消息
挂机时常需要周期性发同一条指令(例如每 300 秒 `/u restore confirm` 维持疆土民心)。
左栏「定时消息」卡片里点「+ 新增」,填内容即可;以 `/` 开头按游戏指令发送,否则按公屏消息发送。
**两种调度方式**:
- **固定间隔**:每隔 N 秒发一次(下限 5 秒);
- **cron 表达式**:5 段「分 时 日 月 周」(本地时间),例如
`0 9 * * *` = 每天 09:00、`0/30 * * * *` = 每 30 分钟、`0 12 * * 1-5` = 工作日 12:00。
支持 `*`、步长、区间、列表;写错的表达式不会被静默吞掉,列表里会显示原因并按间隔发送。
**发送结果确认**(可选):填了「期望回复」(正则)后,发出消息会等待服务器的回复:
- 命中 → 任务状态显示 `✓ 已确认`,日志记一条「已确认生效」;
- 超时未回复 → 显示 `⚠ 未确认` 并写警告 —— 否则 `/u restore confirm` 到底成没成,只能靠人翻日志。
实现上刻意**不用**朴素的 `setInterval`:
- 只在「已连接 + 已通过登录验证」时才发送;
- 未就绪的周期直接顺延,**不补发** —— 否则重连后会把漏掉的几条一次性连发出去;
- 与指令节流队列共用,不会绕过 `server.commandCooldownMs` 变成刷屏;
- 间隔下限 5 秒、单个机器人最多 20 条,防止误配置把账号玩进黑名单。
---
---
## REST / WebSocket 接口
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/status` | 机器人 / 任务 / 数据源完整状态 |
| GET | `/api/config` | 当前配置 |
| POST | `/api/config` | 保存并立即应用配置 |
| GET | `/api/map?full=1` | 地图数据(`full=1` 附带全部邦国区块) |
| GET | `/api/session` | 控制台登录状态(免鉴权,用于决定是否弹登录框) |
| POST | `/api/login` | 控制台登录(免鉴权) |
| GET | `/api/status` | 机器人池 / 任务 / 数据源 / 日志 / 大地图完整状态 |
| GET | `/api/bots` | 全部机器人状态列表 |
| GET | `/api/bots/:id/status` | 单个机器人状态 |
| GET | `/api/bots/:id/settings` | 单个机器人的设置与生效配置 |
| POST | `/api/bots/:id/settings` | 保存备注名 / 启用 / 自动开始 / 配置覆盖 |
| GET | `/api/bots/:id/schedules` | 该机器人的定时消息列表(含距下次发送的秒数) |
| POST | `/api/bots/:id/schedules` | 整体替换该机器人的定时消息列表 |
| POST | `/api/bots/:id/task/start` | 启动该机器人的扩地(body 为配置覆盖) |
| POST | `/api/bots/:id/task/stop` | 停止该机器人的扩地 |
| POST | `/api/bots/:id/reconnect` | 重连该机器人 |
| POST | `/api/bots/:id/move` | 手动移动测试(`direction` / `seconds`),返回真实位移 |
| POST | `/api/bots` | 控制台新增玩家(`username` / `password` / `label`) |
| DELETE | `/api/bots/:id` | 移除控制台新增的玩家(`.env` 里的会返回 400) |
| POST | `/api/bots/:id/chat` | 发送内容:**以 `/` 开头按指令发送,否则按公屏消息发送**(要求已登录验证) |
| POST | `/api/bots/:id/command` | 同上,但强制按指令发送(等价于给内容加 `/` 前缀) |
| GET | `/api/bots/:id/chat` | 该机器人的公屏日志(`limit` / `since` / `kind` / `keyword`) |
| POST | `/api/bots/:id/chat/export` | 导出该机器人的公屏日志到 `data/` |
| GET | `/api/expansion-log` | 开拓日志(`limit` / `botId` / `state` / `since`)+ 统计 |
| POST | `/api/expansion-log/export` | 导出开拓日志 CSV 到 `data/` |
| POST | `/api/expansion-log/clear` | 清空内存中的开拓日志(磁盘保留) |
| GET | `/api/bigmap` | 大地图元信息(坐标系、图幅、瓦片范围、缓存进度、机器人坐标、领土标记);支持 `rangeBlocks` / `bot` 参数 |
| GET | `/api/bigmap/markers` | 大地图领土标记(多边形 + 王城) |
| GET | `/api/bigmap/tile/:z/:x/:y` | 瓦片代理(缓存优先,避免跨域) |
| POST | `/api/bigmap/crawl` | 爬取瓦片;默认爬机器人周边,支持 `centerX` / `centerZ` / `rangeBlocks` |
| POST | `/api/bigmap/cache/clear` | 清空瓦片缓存 |
| GET | `/api/config` | 全局配置 |
| POST | `/api/config` | 保存并立即应用全局配置 |
| GET | `/api/map?full=1&bot=<id>` | 领土地图数据(`full=1` 附带全部邦国区块,`bot` 指定视角机器人) |
| GET | `/api/logs` | 运行日志 |
| POST | `/api/logs/export` | 导出日志到 `data/` |
| POST | `/api/task/start` | 启动扩地(可带配置覆盖) |
| POST | `/api/task/stop` | 停止扩地 |
| POST | `/api/logs/export` | 导出运行日志到 `data/` |
| POST | `/api/map/refresh` | 手动刷新地图数据 |
| POST | `/api/api/fetch-all` | 手动拉取全服领土(重接口) |
| POST | `/api/command` | 发送游戏指令 |
| POST | `/api/bot/reconnect` | 重连服务器 |
| WS | `/ws` | 实时状态推送(1s)与日志流 |
| POST | `/api/task/start`、`/api/task/stop`、`/api/command`、`/api/bot/reconnect` | 默认机器人的等价别名(兼容单机器人用法) |
| WS | `/ws` | 实时推送:`status`(1s)、`log`、`chat`、`expansion` |
---
## 配置项
配置保存在 `data/autostore.json`,可通过控制台表单修改。完整字段见
配置保存在 `data/autostore.json`(全局默认),可通过控制台表单修改;勾选「仅对当前机器人生效」时
写入 `data/bots.json` 作为该机器人的配置覆盖。完整字段见
[`src/config/expansionConfig.js`](src/config/expansionConfig.js),分组如下:
| 分组 | 内容 |
@@ -215,8 +680,11 @@ npm start
| 寻路 | 疾跑 / 游泳 / 跳跃 / 挖方块 / 放方块、高度偏好、跑酷、搭塔、下落高度、液体代价 |
| 生存 | 自动进食、自动攻击、耐久保护、战斗筛选、水域策略、自动用船 |
| 数据 | 地图刷新间隔、SimpCore API |
| Grim | 合规层全部阈值(见上) |
| 服务器 | 开垦模式指令、登录 / 注册模板、指令节流、重连 |
| 大地图 | 开关、世界名、预爬取层级、最大层级、**图幅半径、是否跟随机器人**、启动预爬取、下载并发数、**后台预热(开关 / 半径 / 层级 / 移动阈值 / 最小间隔)** |
| 协调 | 目标区块租约时长(多机器人避免撞车) |
| Grim | 合规层全部阈值 + 协议补包开关 + **地面状态修正(方案 A / B)**(见上) |
| 服务器 | 开垦模式指令、登录 / 注册 / 绑定模板、登录确认超时、指令节流、重连 |
| 通知 | Webhook 开关与地址(`NOTIFY_WEBHOOK_URL` 兜底)、验证码提醒、自救失败提醒 |
---
@@ -230,49 +698,81 @@ npm run dev # 文件变更自动重启
### 测试覆盖
154 个测试用例,覆盖:
436 个测试用例,覆盖:
- 区块坐标编解码(含负数与边界)
- 领土模型:飞地识别、会话占领、API 数据合并
- 规划器:四种策略、S 形顺序、**接壤只算己方领土**(历史 bug 回归)、他国区块切断连通
- markers.json 解析:奇偶填充孔洞、王城换算、颜色解析
- 领土模型:飞地识别、会话占领、按邦国名取飞地与己方区块、API 数据合并、多边形保留
- 规划器:四种策略、S 形顺序、**接壤只算己方领土**(历史 bug 回归)、他国区块切断连通、
按 `config.stateName` 解析己方邦国
- markers.json 解析:奇偶填充孔洞、王城换算、颜色解析、多边形扁平化
- 字幕 / 聊天解析:进度、带坐标成功、通用成功、模糊匹配
- Grim 合规层:旋转限速、动作互斥、攻击冷却表、距离 / 视线校验、物理自检
- Grim 合规层:旋转限速、动作互斥、攻击冷却表、距离 / 视线校验、物理自检、
**地面状态修正(方案 A / B 的正反用例)**、出站包类型分布
- 战斗筛选:中立生物豁免、条件敌对、猪灵金甲判定、耐久保护
- 寻路服务:Grim 约束是否真正施加到 Movements、高度偏好目标
- 控制器状态机:完整扩地流程、超时重试、走两步、回城、耐久保护停止、完成
- Web 控制台:地图负载、REST 接口、令牌鉴权
- 寻路服务:Grim 约束是否真正施加到 Movements、高度偏好目标、**手动执行器(沿路点行进、跳坎、卡住终止、取消、分段重试)**
- 控制器状态机:完整扩地流程、超时重试、走两步、回城、耐久保护停止、完成、
多机器人邦国归属隔离、开拓日志写入与去重、**回城前「原地是否更差」评估与前后坐标**、
**目标租约的挑选 / 续租 / 归还**
- 多机器人:凭据解析(JSON / 逗号分隔 / 兜底)、机器人池组装、配置覆盖与持久化、
密码不落配置文件、未登录时拒绝发送、全局配置下发保留各自覆盖、**目标区块租约表**
- 机器人登录门禁:就绪判定、登录成功 / 失败 / 已登录提示、注册与登录自动补发、
确认兜底超时、公屏消息类型映射、**原点自救(判定成败 / 换方向重试 / 提前结束 / 通知提醒)**
- 开拓日志:字段记录、筛选、统计、JSONL 落盘与重启恢复、损坏行容错、环形缓冲、CSV 导出
- 公屏日志:按机器人隔离、类型 / 关键字过滤、分文件落盘、文件名安全化、文本导出
- 大地图:坐标系换算(含对源站实测标定的王城用例)、**以机器人为中心的图幅换算**、瓦片范围、缓存命中、批量爬取、缓存清理、**后台预热的节流与层级**
- 定时消息:内容归一化与间隔钳制、到点发送与按周期重复、未就绪时顺延不补发、多任务独立计时、整体替换重置计时、
**cron 解析与下次时刻计算(步长 / 区间 / 星期 / 稀缺表达式 / 非法回退)**、**发送结果确认(命中 / 超时 / 未配置)**
- 通知提醒:启用判定、飞书文本格式、去重节流、环境变量兜底、失败不抛异常、不泄露完整地址
- Web 控制台:地图负载、单 / 多机器人接口、聊天与开拓日志接口、定时任务接口、
图幅与中心计算、瓦片代理、令牌与登录鉴权、WebSocket 握手鉴权
### 目录结构
```
src/
├── index.js 入口:装配模块、启动 Web 控制台、驱动 tick
├── index.js 入口:装配模块、启动 Web 控制台、驱动多机器人 tick
├── config/
│ ├── expansionConfig.js 配置默认值 / 校验 / 归一化
│ └── configStore.js 配置 JSON 持久化
│ ├── configStore.js 全局配置 JSON 持久化
│ ├── botsConfig.js 从 .env 解析多机器人凭据(密码只在这里读)
│ └── botStore.js 机器人设置持久化(不含密码)
├── core/
│ ├── chunkPos.js 区块坐标编解码(整数键,零临时对象)
│ ├── territoryModel.js 领土模型:区块集合、飞地、会话占领、API 合并
│ ├── territoryDataService.js Dynmap markers.json 拉取与解析
│ ├── territoryModel.js 领土模型:区块集合、飞地、会话占领、API 合并、多边形
│ ├── territoryDataService.js markers.json 拉取与解析
│ ├── simpApiClient.js SimpCore 开放 API 客户端
│ ├── expansionPlanner.js 四种扩地策略
│ └── subtitleChatMonitor.js 字幕 / 聊天解析与模糊匹配
│ ├── subtitleChatMonitor.js 字幕 / 聊天解析与模糊匹配
│ ├── expansionLog.js ★ 开拓日志(玩家 / 邦国 / 区块 / 时间 / 来源)
│ ├── chatLog.js ★ 游戏公屏完整日志(按机器人分文件)
│ ├── bigMapService.js ★ 大地图瓦片爬取与缓存 / 后台预热
│ ├── targetLease.js ★ 目标区块租约表(多机器人避免撞车)
│ └── notifier.js ★ 人工介入提醒(飞书等 Webhook)
├── bot/
│ ├── botManager.js 连接、登录、重连、事件归一化
│ ├── grimCompat.js ★ Grim 反作弊合规层
│ ├── botPool.js ★ 机器人池:同时挂多个玩家
│ ├── botManager.js 连接、登录验证、公屏留档、指令 / 聊天发送、地面状态修正
│ ├── scheduledMessenger.js ★ 定时发送:固定间隔 / cron + 发送结果确认
│ ├── grimCompat.js ★ Grim 反作弊合规层 + 协议补包(player_input / tick_end)
│ ├── pathingService.js 寻路服务(替代 Baritone)
│ ├── manualMover.js ★ 手动执行器(只借用 A* 规划,自己按键执行)
│ ├── autoCombat.js 战斗筛选与耐久检查
│ ├── autoEat.js 自动进食
│ ├── autoBoat.js 自动用船(默认关闭)
│ └── durabilityGuard.js 耐久保护
├── controller/
│ └── expansionController.js 扩地任务状态机
│ └── expansionController.js 扩地任务状态机(按机器人实例化)
├── web/
│ ├── server.js Express + WebSocket
│ ├── server.js Express + WebSocket + 登录鉴权
│ └── public/ 控制台前端(原生 HTML/CSS/JS,无构建步骤)
└── util/
└── logger.js 环形日志缓冲
data/ (已 gitignore,运行时生成)
├── autostore.json 全局配置
├── bots.json 机器人设置(启用 / 备注 / 自动开始 / 配置覆盖,不含密码)
├── expansion-log.jsonl 开拓日志
├── chat/<机器人>.jsonl 游戏公屏日志
└── mapcache/<world>/<z>/ 大地图瓦片缓存
```
---
+576
查看文件
@@ -0,0 +1,576 @@
# TODO:留给后续开发者的待办与已知问题
> 本文档记录**尚未解决的大工程**、已经排查过的线索、以及推荐的验证方法。
> 小事已在代码里做完并提交,这里只写需要集中攻关的部分。
>
> 最后更新:2026-09-29
---
## 0. 先读这里:项目现状与部署
| 项 | 值 |
| --- | --- |
| 技术栈 | Node.js ≥ 22 + mineflayer 4.39.0 + mineflayer-pathfinder 2.4.5 |
| 测试 | `npm test`(436 个用例全绿) |
| 语法与前端 id 自检 | `npm run check` |
| 本地启动 | `npm start`(控制台 http://127.0.0.1:3080/) |
| 服务器 | `root@103.236.99.177:33851`,部署目录 `/opt/autostore-bot` |
| 控制台 | http://103.236.99.177:78/ ,密码在服务器 `.env` 的 `WEB_PASSWORD` |
| 进程管理 | pm2 应用 `autostore-bot`(端口 78);旧版 Fabric 机器人为 `minecraft-bot`(端口 91,已停) |
| 私有 Node | `/opt/autostore-bot/runtime/current/bin/node`(v24.16.0) |
**旧版 Fabric 模组源码**(重要参考,实机可用的实现):
`C:\Users\luoyi\AppData\Local\Temp\autostore-deploy\oldbot.js`(1356 行)。
它在这台服务器上是**真的能走路、能寻路、能扩地**的,所以遇到 mineflayer 行为诡异时,
第一件事应该是拿它的实现逐项对照,而不是自己猜。
> ⚠️ 上面的临时目录会被系统清理。若已丢失,它只作为历史参考 ——
> 不影响当前项目运行,但排查移动问题时很有价值,建议接手后先备份一份到仓库外。
> 📌 **本轮(1.7.0)已把本文档里的待办全部落地为代码** —— 详见 CHANGELOG 的 [1.7.0]。
> 下面每节都标注了「已实现 / 待实测」,未实测的部分需要部署到服务器后按验证方法确认。
---
## 0.1 🚀 部署方法(完整步骤)
### 方式一:一键部署(推荐)
```powershell
# 推荐:把 SSH_HOST / SSH_PORT / SSH_USER / SSH_PASS 写进 .env(已被 .gitignore 忽略),
# 然后直接跑 —— 命令行传密码会留在 shell 历史与进程列表里
npm run deploy
```
```bash
# 也可以临时用环境变量覆盖(.env 里的值仍作为兜底)
# Windows PowerShell
$env:SSH_HOST='103.236.99.177'; $env:SSH_PORT='33851'
$env:SSH_USER='root'; $env:SSH_PASS='<服务器密码>'
npm run deploy
# Linux / macOS
SSH_HOST=103.236.99.177 SSH_PORT=33851 SSH_USER=root SSH_PASS='<密码>' npm run deploy
```
`scripts/deploy.js` 会依次做:打包 → 上传 → 远程执行 `update.sh` →
轮询确认控制台返回 200。任一步失败都会明确报错。
可选环境变量:`DEPLOY_APP`(默认 `/opt/autostore-bot`)、`DEPLOY_PORT`(默认 `78`)。
`SSH_*` 与 `DEPLOY_*` **只在本机生效**,服务器上的 `.env` 不需要填。
### 方式二:手动部署
```bash
# 1. 打包(务必排除 data / .env / node_modules,否则会覆盖服务器上的真实数据)
tar -czf update.tar.gz \
--exclude=node_modules --exclude=.git --exclude=data --exclude='*.log' --exclude=.env \
-C . .
# 2. 上传
scp -P 33851 update.tar.gz root@103.236.99.177:/opt/autostore-bot/update.tar.gz
# 3. 远程应用(update.sh 会保留 .env / data / node_modules / runtime)
ssh -p 33851 root@103.236.99.177 'bash /opt/autostore-bot/update.sh'
```
### 辅助脚本
| 脚本 | 用途 |
| --- | --- |
| `scripts/deploy.js` | 一键部署(推荐) |
| `scripts/ssh-helper.js` | 单条远程命令 / 上传:`node scripts/ssh-helper.js exec "<命令>"`、`... put <本地> <远程>` |
Windows 自带 OpenSSH 但**没有 sshpass / plink**,无法非交互传密码,
所以这两个脚本用 `ssh2` 直接实现协议层(`ssh2` 是 devDependencies,
未安装时脚本会提示 `npm install --save-dev ssh2` 而不是裸崩)。
**凭据一律走环境变量,脚本里不写死任何密码。**
### update.sh 做了什么
`/opt/autostore-bot/update.sh` 的行为:
1. 解压 `update.tar.gz` 到 `/opt/autostore-bot`(**保留** `.env`、`data/`、`node_modules/`、`runtime/`)
2. 用私有 Node 重新安装/校验依赖
3. `pm2 restart autostore-bot --update-env`
4. 打印进程状态与端口监听情况
### 常用运维命令
```bash
# 进程状态
pm2 status
pm2 list
# 重启 / 停止
pm2 restart autostore-bot --update-env
pm2 stop autostore-bot
# 日志(实时)
tail -f /opt/autostore-bot/data/pm2-out.log # 运行日志
tail -f /opt/autostore-bot/data/pm2-error.log # 告警与错误
pm2 logs autostore-bot
# 端口
ss -lntp | grep ':78 '
# 回滚:把上一份备份目录改回来(update.sh 会先备份旧版本目录)
ls -1d /opt/autostore-bot/backup-* 2>/dev/null | tail -3
```
### 服务器上的关键文件
| 路径 | 说明 |
| --- | --- |
| `/opt/autostore-bot/.env` | **真实凭据**(`MC_USERNAME`、`MC_PASSWORD`、`WEB_PASSWORD`、`SIMP_API_KEY`、`SIMP_API_V2_KEY`、可选 `NOTIFY_WEBHOOK_URL`),权限 0600,**绝不进仓库** |
| `/opt/autostore-bot/data/autostore.json` | 运行时配置(控制台保存的),会被 `update.sh` 保留 |
| `/opt/autostore-bot/data/bots.json` | 机器人非敏感设置 |
| `/opt/autostore-bot/data/bots.credentials.json` | 界面添加的账号密码,权限 0600 |
| `/opt/autostore-bot/data/chat/<玩家名>.jsonl` | 公屏完整日志 |
| `/opt/autostore-bot/data/expansion-log.jsonl` | 开拓日志 |
| `/opt/autostore-bot/data/mapcache/` | 大地图瓦片缓存 |
| `/opt/autostore-bot/ecosystem.config.js` | pm2 配置 |
| `/opt/autostore-bot/runtime/current` | 私有 Node(服务器自带 Node 太旧时用) |
### 新增环境变量时
1. 在 `.env` 里加真实值(**只在服务器上加,不要提交**);
2. 在 `.env.example` 里加**占位符**(如 `<your-xxx-key>`),绝不含真实密钥;
3. 确认 `.gitignore` 已包含 `.env`(本项目已含,并有 `!.env.example` 例外)。
---
## 1. 🟢 「机器人走不动」:**已解决**(根因是自定义高度偏好,见下)
### 🚧 2026-10-01 当前进展与剩下的拦路虎
修复高度偏好后,机器人确实能朝目标走了(实测 20 分钟前进 95 格),
但要真正开垦出第一块地,还要跨过这几关:
**① 几何约束:目标天然就在 ~770 格外。**
规划器 `generateHolePriority` 是**按到玩家的曼哈顿距离排序**取最近的合法候选
(`expansionPlanner.js:109-130`),而机器人出生在**王城**——它在「养老院」这个
**33037 区块(约 180×180 区块)** 的大邦国正中间。实测最近的候选在 53 区块
(≈768 格)外,且 `targetCount: 1` 只返回一个候选。**所以长途跋涉是邦国几何决定的,绕不开。**
**② 服务器会踢它,三种原因都实测到过:**
- `You were kicked from main: An internal error occurred in your connection.`(最常见,约 1~6 分钟一次)
- `You were kicked from main: You are sending too many packets!`
- `You were kicked from main: Flying is not enabled on this server`
- 未完成验证时还有 `验证超时!您需要在 120 秒内完成身份验证`(120 秒内要人工发码)
实测出站包速率 **57.9 个/秒**,其中 `pong` 30.6(服务端在疯狂 ping)、
协议补包(`player_input` + `tick_end`)合计 23.9、位置包 1.35。
**注意:协议补包当初的立论依据(「走不动」的根因)已经被证伪**(真因是高度偏好),
它们是否仍然必要、是否就是「发包太多」的原因,**尚未验证**——这是下一步要测的。
**③ 半路会死**:实测死因是 `fell from a high place` 与 `was slain by Zombie`。
摔死那次白走了 100 多格。
- 摔死已处理:服务器配置里 `pathing.maxDropDown` 被改成了 **64**(钳制上限),
而原版摔落伤害超过 3 格后每格 1 点、**23 格必死**。已改回 4(代码默认本来就是 3)。
- 被僵尸杀还没处理:`autoAttack` 是开的,但夜里野外仍会被围死。
**④ 推进速度只有物理速度的 1/5**:实测机器人几乎每 tick 都按着 `forward+sprint`、
`velocity` 约 0.2~3 格/秒,但 2 秒粒度的**净位移只有 0.405 格/秒**
(48.6 格 / 120 秒)——差额是被服务端拉回的部分。为此已补上
`flags.hasHorizontalCollision`(mineflayer 恒写 `undefined`,见 [1.10.0]),
实测该标志确实一直在被漏报(补写计数很快涨到 1 万+),但**提速效果尚未量化验证**。
**⑤ 会卡在崎岖地形前「原地起跳」** —— ✅ **已定位并修复([1.11.0])**。
真正的根因不是地形,是**朝向**:`bot.entity.yaw` 无界增长(mineflayer 收到 `position` 包时
直接赋值、不取模,GrimCompat 又在它基础上累加),实测涨到 **19254°(≈336 弧度)**。
抓包证据:
```
yaw°: -1373 → 19254 → -1650 → -1407 → -1373 → 19254 → … 相邻两包折合差约 114°
坐标: (6756.57, 11953.6) ⇄ (6757.45, 11953.8) 0.9 格内「橡皮筋」
```
机器人一直按着前进键,但朝向每隔几个 tick 就被甩到另一个方向,于是**原地走之字形、
净位移恒为 0**。物理录制里的 `jump 74/240`、`水平碰撞 195/240`、`onGround 79/240`
都是这个 bug 的表象。
修复后实测朝向变成**平滑转向**(`0, -9, -17, -26, -34, -43 …`),速度从 0.35 提到 0.56 格/秒。
**⑥ 剩下的拦路虎:服务端在把它「钉」在固定坐标上。**
修复 yaw 之后,手动移动测试报告位移 **2.40~4.81 格**(说明客户端确实走了),
但一查状态,位置又回到同一个点(`6756.57` / `6750.3`)。`被服务器传送另有`
持续在 **16~194 次/30 秒**。也就是说**客户端走、服务端拉回**,净位移为 0 ——
这一层已经不在代码控制范围内,需要确认服务器侧的原因。
**⑦ 服务端提示过「邦国已激活科技: 疆土开拓者 I 剩余时间: 0时13分3秒」**
—— 开拓可能需要该邦国科技处于激活状态,且**有时间限制**。
这可能是「到了目标区块也未必能开垦成功」的一个前提条件,尚未验证。
### ✅ 2026-10-01 突破:修掉高度偏好后扩地真的跑起来了
**根因**:`AltitudeGoal` 把高度偏置加在**启发值**上
(`heuristic = max(0, 基础距离 - 权重 × y)`),而 `mineflayer-pathfinder`
每次 `compute()` 返回 `partial` 时用 **`h` 最小**的节点当 partial 路径的终点
(`astar.js`: `if (neighborNode.h < this.bestNode.h) this.bestNode = neighborNode`)。
于是「又高又偏」的节点 `h` 反而更小,partial 路径被引向高处而不是目标附近 ——
**A\* 一直在算、永不收敛,机器人一次移动指令都发不出来。**
**生产对照实测**(王城平台上,其余配置完全相同):
| `altitudePreference` | `inputTicks` | 结果 |
| --- | --- | --- |
| `true`(修复前) | **0** | 永远停在「寻路超时,重新寻路」,一步不动 |
| `false`(修复后) | **237** | 每 tick 都在走,`blockedTicks = 0`、`frozenTicks = 0` |
已把默认值改为 `false`([1.9.1]),并在 `AltitudeGoal` 的注释里写清推导与实测数据。
**想重新启用,必须把偏置改成「低处加移动代价」**(改 `getNeighbors` 的 `cost`),
不能改启发值。
**顺带修掉的第二个拦路虎**([1.9.3]):服务器每 1~6 分钟把机器人踢下线一次
(`An internal error occurred in your connection`),原来一掉线任务就永久停住。
现在 `autoResumeAfterReconnect`(默认开)会在世界退出时记下配置、重连后自动接上;
手动停止 / 时段外 / 耐久保护停机不会被误恢复。
**实测结果**(修复后连续 20 分钟):
```
起点 6753.6, 143, 11953.7 距目标 763 格
终点 6768.5, 116, 12045.7 距目标 668 格 ← 前进 95 格,全程未掉线
```
「原点自救」表现完美(4/4 成功):每次被踢到登录服大厅都能自己回到真实世界。
**仍需观察**:推进速度约 5 格/分钟(正常步行 4.3 格/秒),
因为每段路径都会走到「寻路重试耗尽 → 往边上走两步 → 跳过目标 → 重新规划同一目标」
的循环里。待优化项:`waitTimeoutSeconds`(60)偏长、以及「跳过目标后立刻重选同一个目标」。
### 历史记录:三种「走不动」的区分(结论仍然有效)
### 🔴 2026-10-01 实测:三种「走不动」要分开看(**先读这段**)
用新增的**物理 tick 录制**(`POST/GET /api/bots/:id/physics`,见 CHANGELOG [1.9.0])
逐 tick 抓了「输入 → 输出」,把三种完全不同的现象区分开了:
| 场景 | 物理有没有动 | 服务端在干什么 | 结论 |
| --- | --- | --- | --- |
| **登录服大厅** | ✅ 每 tick 走 0.116 格(正常速度) | 每 2 tick 推回一次(`被服务器传送另有` 240~260 次/30 秒) | **真回弹**,但这是未完成验证的惩罚,不是 bug |
| **真实世界、建筑内** | ✅ 加速到 0.21 格/tick(≈4.3 格/秒,正常步行) | 入站位置包 **0~1 个**,没插手 | **撞墙**:`collidedHorizontally=true`,`blockedTicks` 42~59/60 |
| **真实世界、卡在低处** | ❌ **一次都没动** | — | **寻路器 60 秒规划超时**,`inputTicks=0`,从未产生可执行路径 |
**关键结论:物理引擎、协议补包、Grim 合规层全都正常**(能加速到正常步行速度),
`frozenTicks` 在所有场景都是 0 —— 「输入没进物理」这种情况从未出现。
`searchRadius` 从 -1 改成 128 对结果**没有影响**,所以也不是搜索空间无界导致的。
**当前扩地的真正拦路虎(按优先级):**
1. **机器人被地形困在王城建筑里**:实测 `exits` 四向只有西面可走,东面头顶是 **torch**(火把)
—— 它被 `/u return` 传送进建筑后就出不来了。而此时 `mineflayer-pathfinder`
的「徒劳检测」会把每一段路径都清掉(见下),于是永远在「规划 → 卡住 → 清空 → 规划」里循环:
`blockedTicks` 230/237、净位移 ~0。
2. **装备耐久**:`lastReturn.reason = "装备耐久过低(主手 剩余耐久 15% ≤ 15%)"`
→ 一启动扩地就 `/u return` 回城并停机,`inputTicks=0`。**需要人工修装备**。
3. **⚠️ `/u return` 会造成摔落伤害**:实测 `health 3.79 → 1.24`、`7.8 → 5.6`,
更早一次直接 `fell from a high place` 摔死。回城前应确认落点高度;
而且它把机器人送进建筑内部(见第 1 条),等于「回城即被困」。
### 🎯 死循环的确切机制(2026-10-01,读 mineflayer-pathfinder 源码定位)
`mineflayer-pathfinder/index.js` 末尾的「徒劳检测」:
```js
// check for futility
if (performance.now() - lastNodeTime > 3500) {
resetPath('stuck') // 3.5 秒内到不了下一个路径点 → 清空路径
}
```
机器人被墙挡住时,3.5 秒必然到不了下一个路径点 → `resetPath('stuck')` → 重新规划 →
又卡住 → 又清空。**每 3.5 秒一个循环**,日志表现为 `寻路事件:路径已生成(代价 63.07)`
每 3 秒重复一次;`resetPath` 每次都会补一对 `diggingAborted`/`diggingCompleted` 监听器,
所以日志里还会出现 `MaxListenersExceededWarning: 11 diggingAborted listeners`
—— **这条警告就是「路径被反复清空」的可靠信号**。
另外两个也会清空路径的触发点(排查时注意):
- `blockUpdate`:路径附近的方块一变就 `resetPath('block_updated')`;
- `chunkColumnLoad`:新加载的区块与已访问区块相邻就 `resetPath('chunk_loaded')`
—— 服务器频繁传送机器人时,这个会被反复触发,A* 的 20 秒 `thinkTimeout`
因为每次都新建 AStar 而**永远不会到期**。
**结论:只要机器人被墙挡住,它就会无限循环「规划 → 卡住 → 清空」,
既不会放弃目标也不会自己脱困。** 需要先把它挪到开阔地;长期看应处理
`path_reset('stuck')` 的累计次数(连续 N 次就判定「此处出不去」并上报 / 换目标)。
### ⚠️ 线上实测补充(2026-09-29,**它推翻了下面原有的框架**)
用新增的**出站包抓取**(`POST/GET /api/bots/:id/capture`,见 CHANGELOG [1.8.0])做了对照,
结论是:**「走不动」不是服务端回弹造成的,是客户端物理引擎没有把按键积分成位移。**
手动按住一个方向键 3 秒的逐包实测(任务未启动、机器人空闲):
| 项 | 实测 |
| --- | --- |
| 入站位置包(服务端回弹) | **0 个** —— 服务端全程没插手 |
| 出站位置包 | 5 个,全是 1 秒一次的空闲包(**不是**每 tick 一个) |
| 客户端自己的坐标 | **完全没变**(`6752.3000, 146, 11922.3000` → 一字不差) |
| 朝向 | 完全没变(`-0.20876`) |
| `moveDiag.velocity` | 恒为 `(0, -0.078, 0)`,`speed = 0.078` |
| `player_input` 位标志 | `backward: true` ✅ —— 控制键确实按下去了 |
四方向各按 3 秒的位移:`forward 0.02 / back 0.00 / left 1.22 / right 1.40`(正常应约 12 格)。
**推论:**
1. `player_input` 如实上报了输入、`bot.getControlState()` 也是 true,
但 `prismarine-physics` 的 `applyHeading` 没有产生任何水平速度 ——
要么物理引擎拿到的 `control` 快照里 `forward/back` 是 0,
要么 `moveEntity` 把位移全吃掉了。**下一步要加 `physicsDiag` 直接看**:
`bot.physicsEnabled`、每 tick 的 `physicsTick` 计数、simulate 前后 `onGround`、
`world.getBlock(脚下)` 是否命中、以及 PlayerState 里读到的 `control` 快照。
2. **「服务端回弹」只在寻路任务运行时出现**(实测任务中入站 6/秒、出站 `position_look` 25/秒),
手动移动时入站为 **0** —— 回弹是「任务驱动移动」的伴生现象,**不是走不动的原因**。
这也解释了为什么下面两套针对回弹的修正(方案 A/B)开关对位移毫无影响
(实测:修正开 0.4~6.4 格 / 修正关 0.4~2.4 格,同量级)。
3. 原先记的「手动按键移动完全正常(forward 2 秒 4~8 格)」**已不成立**,
该基准需要重建 —— 基于它的推理(「规划问题 vs 协议问题」二分)也就全部偏了。
**顺带确认(1.21.4 协议):**
- `flags.hasHorizontalCollision` 恒为 `null`(= 0):mineflayer 在 `physics.js` 里
把它写成 `undefined`,而原版客户端会在水平碰撞时置位;实测 62/62 个位置包都是 0。
- `teleport_confirm.teleportId` 是大负数(如 `-1884468723`):该服务端用的是随机大整数,
不是原版的自增小整数,属于它自己的实现,不是 bug,但排查时别按原版预期去读。
- 包类型分布对照(5 秒):空闲 `player_input/tick_end/pong ≈ 100` 且 `position=5`;
手动按键与空闲同构(**没有** `position_look`);寻路中才有 `position_look=126`。
### 现象回顾(历史记录;其中「手动移动正常」已被上面的实测推翻)
- **手动按键移动完全正常**:通过控制台「移动测试」按 forward 2 秒,实测位移 4~8 格,位置保持。
- **寻路器驱动的移动被回弹**:一旦 `bot.pathfinder.setGoal()` 开始执行,
服务端以 **5~9 次/秒** 的频率强制同步位置(`forcedMove`),机器人几乎原地不动。
- 偶尔能挪动一点,但速度只有 **0.3 格/秒**(正常步行 4.3 格/秒)。
### 已确认正常的部分(不要再怀疑这些)
| 项 | 状态 | 证据 |
| --- | --- | --- |
| `player_input` 补包 | ✅ 每 physicsTick 都发(~20/s),bitflags 正确 | `packetStats.playerInput` 持续增长 |
| `tick_end` 补包 | ✅ 每帧发送 | `grim.protocolPackets.tickEndSent` |
| `onGround` 标志(出站包) | ✅ 已按世界数据复核 | `packetStats.onGroundFixed` |
| 世界数据可读性 | ✅ `world.getBlock` 正常,四向可见 115~125 格 | `pathingDiag.visibleDistance` |
| 寻路器本身 | ✅ 在开阔地能找到路径 | 实测「已探索 3633 节点 → success,代价 113.8」 |
| 服务端回弹是否持续 | ❌ **只在机器人尝试移动时出现** | 12 秒采样:空闲 +0,寻路中 +105 |
| 关闭疾跑 | 试过,**无改善** | `allowSprint=false` 后仍是 4.8 次/秒回弹 |
| 关闭合规层(`grim.enabled=false`) | 试过,**无改善** | A/B 实测 6.0 vs 5.5 次/秒,位移都是 0 |
| 世界数据是否错误 | ❌ **不是原因** | 实测脚下 `stone`,`blockBelow` 正确 |
### 🎯 关键线索(未变)
用 `/api/status` 的 `moveDiag` 采集到的实测数据:
- 寻路时 `forward`/`sprint` 都按下、`isMoving=true`,但**水平速度恒为 0**;
- `velocity.y = -0.078` 恰是自由落体第一 tick 的重力值 —— 说明速度每 tick 被清零;
- `bot.entity.onGround` 被服务端位置包反复置为 false(mineflayer 的 `physics.js`
在处理 `position` 包时无条件置 false),物理引擎因此一直走「空中」分支。
### ✅ 本轮已实现的代码(`1.7.0`)
| 手段 | 开关 | 位置 |
| --- | --- | --- |
| 方案 A:物理引擎 onGround 对齐 | `grim.syncGroundToPhysics`(默认开) | `botManager._installGroundStateFix` |
| 方案 B:例行同步不采纳 onGround=false | `grim.trustSetbackOnGround` + `grim.setbackSyncEpsilon`(默认 0.5 格) | 同上(`prependListener` 抢在 mineflayer 之前快照) |
| 出站包类型分布(假设 4 的对照依据) | 常开 | `packetStats.outboundTypes` |
| 备选执行器:只借 A* 算路径、自己按键执行 | `pathing.executor: 'manual'` | `src/bot/manualMover.js` |
**请按下面的顺序实测**:
1. 部署后先在控制台确认「Grim 合规」卡片里的 `地面修正` 一行出现非零计数
(`物理对齐 N · 保住 M · 最近同步差 X 格`);
2. 用「移动测试」确认手动移动仍然正常;
3. 开始扩地,观察 `moveDiag.speed` 是否 > 0.1、`moveDiag.onGround` 是否稳定为 true、
「被服务器传送另有」的计数是否下降、以及是否真的能走完一段路;
4. 若仍有回弹 → 在配置「寻路 → 寻路执行器」切到 **手动执行(备选方案)**,
再观察一次(它只借用 A* 规划,执行层完全是我们自己的按键时序);
5. 若两者都不行 → 回到下面「未完成的排查方向」,用 `packetStats.outboundTypes` +
旧模组抓包做逐项 diff(假设 5)。
### 未完成的排查方向(按优先级)
#### 假设 3:跳跃 / 跑酷的物理预测不匹配
Grim 的 Prediction 检查对**跳跃**最严格(起跳时机、跳跃高度、疾跑跳加成)。
mineflayer 的 `physics.js` 在这些细节上与原版有已知偏差。
**验证方法**:把路径限制成纯平地,禁用一切跳跃:
```json
{"allowSprint": false, "pathing": {"allowParkour": false, "allow1by1towers": false, "maxDropDown": 1}}
```
若回弹消失 → 需要在 `GrimCompat` 里拦下 `jump` 控制状态,改用更保守的移动策略。
#### 假设 5:直接抓包对照(最彻底)
用 `scripts/check-protocol.js` 的思路扩展一个抓包脚本,
分别在「旧模组移动」和「mineflayer 寻路移动」时记录 5 秒的**完整出站包序列**,diff 出差异。
`packetStats.outboundTypes` 已经给出包类型分布作为起点。
> 提示:`scripts/check-protocol.js` 里读取 `node_modules` 的路径用了
> `path.join(__dirname, 'node_modules', ...)`,在 `scripts/` 下是拿不到的 ——
> 顺手修一下(应为 `path.join(__dirname, '..', 'node_modules', ...)`)。
---
## 2. ✅ 原点自救(已实现判定与统计,实测成功率待观察)
服务器把玩家丢到世界原点附近有两种完全不同的原因,**以前在日志里长得一样**:
| 原因 | 判断依据 | 处理方式 | 现状 |
| --- | --- | --- | --- |
| 死亡后被卡住 | **服务器没有要求验证码** | 左右 / 前后移动即可触发服务器重新传送 | ✅ 两轮自救 + 成败判定 |
| 被切到登录服 | **服务器要求输入验证码** | **必须重新输入验证码**,只能人工完成 | ✅ 已识别并提示,支持 Webhook 提醒 |
> ⚠️ **不要用「脚下是不是基岩」来区分这两种情况 —— 实测两种情况下脚下都是基岩。**
> 唯一可靠的信号是**服务器有没有发「请直接输入验证码进行验证」这类提示**。
**本轮改进**:分两轮(左右 → 前后 + 跳),每轮结束后等 800ms 让传送落地再判定「位置是否恢复」;
统计 `attempts / successes / lastResult`(`/api/status` 与控制台「连接」卡片可见);
两轮都失败时升级为明确提示,并支持 Webhook 提醒。
**待观察**:线上实际成功率(看 `originRescue.successes / attempts`),
若长期为 0,说明该服务器对移动输入不再响应,应把重心放在验证码流程上。
---
## 3. ✅ 自动回城(已加「原地是否更差」评估与前后坐标)
回城是**破坏性**操作:它把机器人拉回安全点,会**清掉玩家在游戏客户端里手动摆好的位置**。
现状(1.7.0 之后):
- `returnOnTimeout` **默认关闭**;即便打开,`returnGraceSeconds`(默认 300 秒)内不执行;
- **回城前先评估**:`pathing.diagnostics().neighborCount` 为 0(确实无路可走)才回城,
否则记为「原地还能走」并跳过目标 —— 回城只会白白丢掉位置;
- **回城后会记录前后坐标与直线距离**,日志写「回城结果:机器人从 (A) 挪到了 (B)(直线 X 格)」,
控制台「任务」卡片显示 `最近回城`;位置几乎没变时明确提示回城没有帮助。
---
## 4. ✅ 其它已知问题(本轮都已落地)
### 4.1 大地图 ✅
- 层级 1~3 现在有**后台预热**:机器人移动超过 `bigMap.prewarmMinMoveBlocks`(默认 512 格)
或距上次超过 `prewarmIntervalMs`(默认 10 分钟)时,用一半并发把周边瓦片补下来;
- 领土多边形按**视野裁剪**绘制,全世界视角不再全量绘制。
### 4.2 控制台 ✅
- 日志改为**按帧批量写入**(一次布局、一次滚动),并用 `content-visibility: auto`
让浏览器跳过视口外行的渲染(等价于浏览器自带的虚拟列表,不改变内容与滚动位置);
- 配置面板新增**搜索框**:按名称 / 字段名 / 说明过滤,隐藏不删除(保存时仍能读到隐藏字段的值)。
### 4.3 多机器人 ✅
- 新增**目标区块租约表**(`src/core/targetLease.js`):谁先抢到谁去,其余机器人跳过;
- 持有者每个 tick 续租;停止任务 / 完成任务时归还;掉线 / 卡死后租约到期自动失效;
- 候选区块全被其他机器人预定时,等 15 秒再规划(而不是提前判「没有可用区块」)。
- **待观察**:多机器人同时扩地的实际撞车率(日志里出现「已被其他机器人预定,跳过」的次数)。
### 4.4 定时消息 ✅
- 支持 **cron 表达式**(5 段,本地时间,含步长 / 区间 / 列表 / 星期范围);
非法表达式不会被静默吞掉,列表里会显示原因并按间隔发送;
- 支持**发送结果确认**:填「期望回复」(正则)后,命中回复记「已确认」,
超时记「未确认」并写警告 —— 不再只靠人翻日志判断 `/u restore confirm` 是否生效。
### 4.5 登录验证 ✅
- 验证码仍只能人工输入(服务器设计如此),但等待验证码与原点自救失败时会通过
**Webhook 通知**(默认按飞书 / Lark 自定义机器人的文本格式)提醒,避免错过 120 秒时限;
- 配置位置:控制台「服务器 → 通知提醒(Webhook)」,或环境变量 `NOTIFY_WEBHOOK_URL`。
---
## 5. 常用排查命令
```bash
# 机器人完整状态(含寻路诊断 / 地面修正计数 / 自查统计)
curl -s -H "X-Auth-Token: $PW" http://103.236.99.177:78/api/status | jq '.data.bots[0]'
# 寻路诊断:邻居数、世界可读性、四向可见距离、当前执行器
curl -s -H "X-Auth-Token: $PW" http://103.236.99.177:78/api/status | jq '.data.bots[0].pathingDiag'
# 地面状态修正与出站包类型分布
curl -s -H "X-Auth-Token: $PW" http://103.236.99.177:78/api/status | jq '.data.bots[0].packetStats'
# 原点自救成功率 / 最近回城
curl -s -H "X-Auth-Token: $PW" http://103.236.99.177:78/api/status | jq '.data.bots[0].originRescue'
curl -s -H "X-Auth-Token: $PW" http://103.236.99.177:78/api/status | jq '.data.bots[0].task.lastReturn'
# 实时日志
tail -f /opt/autostore-bot/data/pm2-out.log
tail -f /opt/autostore-bot/data/pm2-error.log
# 公屏完整日志(含服务器消息,JSONL)
tail -f /opt/autostore-bot/data/chat/<玩家名>.jsonl
# 寻路状态(判断「探索了多少节点」是定位卡死的关键指标)
grep '寻路状态' /opt/autostore-bot/data/pm2-out.log | tail -20
# 被服务器回弹的频率(>100/30秒 说明移动被反作弊拒绝)
grep '被服务器传送另有' /opt/autostore-bot/data/pm2-error.log | tail -5
# 多机器人协调:谁在占哪块(日志)
grep '已被其他机器人预定' /opt/autostore-bot/data/pm2-out.log | tail -10
# 抓出站包(诊断「走不动」的主力工具,见问题 1 · 假设 5):
# 先在「能走」时抓 5 秒,再在「走不动」时抓 5 秒,然后逐包 diff
BOT=<机器人id>
curl -s -X POST -H "X-Auth-Token: $PW" -H 'Content-Type: application/json' \
-d '{"seconds":5}' http://103.236.99.177:78/api/bots/$BOT/capture/start
# 取包类型分布(先看「发了哪几种、各多少次」)
curl -s -H "X-Auth-Token: $PW" http://103.236.99.177:78/api/bots/$BOT/capture | jq '.data.byType'
# 只取移动相关的原始包内容(看 onGround / hasHorizontalCollision / player_input 位标志 / teleportId)
curl -s -H "X-Auth-Token: $PW" \
'http://103.236.99.177:78/api/bots/$BOT/capture?types=position_look,look,position,flying,player_input,teleport_confirm&limit=60' \
| jq '.data.packets'
# 手动停止(给 seconds 时可省略)
curl -s -X POST -H "X-Auth-Token: $PW" http://103.236.99.177:78/api/bots/$BOT/capture/stop
# 协议字段定义自检(核对 mineflayer 写出的字段名与 1.21.4 是否一致)
node scripts/check-protocol.js
# 录物理 tick(诊断「走不动」的另一半:抓包看「发了什么」,这里看「物理读到了什么、动没动」)
# 一边录一边按方向键,然后看三个计数:
# inputTicks>0 且 inputMovedTicks=0 且 frozenTicks>0 → 输入根本没进物理
# inputTicks>0 且 inputMovedTicks=0 且 blockedTicks>0 → 被方块挡住(地形)
# inputMovedTicks>0 但服务端把位置拉回 → 才是服务端回弹
curl -s -X POST -H "X-Auth-Token: $PW" -H 'Content-Type: application/json' \
-d '{"seconds":10}' http://103.236.99.177:78/api/bots/$BOT/physics/start
curl -s -X POST -H "X-Auth-Token: $PW" -H 'Content-Type: application/json' \
-d '{"direction":"forward","seconds":3}' http://103.236.99.177:78/api/bots/$BOT/move
curl -s -H "X-Auth-Token: $PW" http://103.236.99.177:78/api/bots/$BOT/physics \
| jq '.data | {inputTicks, inputMovedTicks, blockedTicks, frozenTicks, movedTicks, maxHorizDelta, last}'
# 逐 tick 明细(看速度、onGround、碰撞、脚下方块)
curl -s -H "X-Auth-Token: $PW" 'http://103.236.99.177:78/api/bots/$BOT/physics?limit=40' \
| jq '.data.ticks[] | {t, dx, dz, vx, vz, onGround, collidedHorizontally, blockBelow, control}'
```
### 判断「机器人是不是卡在登录服」
```bash
curl -s -H "X-Auth-Token: $PW" http://103.236.99.177:78/api/status \
| jq '.data.bots[0] | {position, chunk, needsVerification}'
```
- `needsVerification == true` → 服务器正在等待验证码,控制台会显示醒目提示(**这是可靠信号**)
- 位置靠近原点且 `needsVerification == false` → 多半是死亡后卡住,机器人会自动自救(并记录成败)
- ⚠️ `world.blockBelow == "bedrock"` **不能**用来判断 —— 实测两种情况脚下都是基岩
---
## 6. 代码约定(改动前请遵守)
- 提交信息用约定式提交(`feat:` / `fix:` / `docs:` / `refactor:` / `test:` / `chore:`),主题行说清做了什么。
- **所有密钥只走 `.env`**(已在 `.gitignore` 里),代码里绝不硬编码;
新增配置项要同时更新 `.env.example` 的占位符。
- 注释与文档用中文,解释「为什么」而不是「做了什么」。
- 每个 `fix` 都应补一个能复现原问题的测试;本项目测试在 `test/`,用 `node:test`。
- 改前端记得跑 `npm run check`,它会校验 `app.js` 引用的元素 id 在 `index.html` 里都存在。
- ⚠️ 写块注释时注意:**中文解释里不要出现 `*/` 这两个字符**(例如 cron 步长写法),
它会提前结束注释并导致语法错误(本项目已踩过一次)。
自动生成
+82 -3
查看文件
@@ -1,12 +1,12 @@
{
"name": "autostore-bot",
"version": "1.0.0",
"version": "1.11.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "autostore-bot",
"version": "1.0.0",
"version": "1.11.1",
"license": "MIT",
"dependencies": {
"chalk": "^4.1.2",
@@ -18,7 +18,8 @@
"ws": "^8.18.0"
},
"devDependencies": {
"minecraft-data": "^3.102.3"
"minecraft-data": "^3.102.3",
"ssh2": "^1.17.0"
},
"engines": {
"node": ">=18.0.0"
@@ -177,6 +178,16 @@
],
"license": "MIT"
},
"node_modules/bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tweetnacl": "^0.14.3"
}
},
"node_modules/body-parser": {
"version": "1.20.8",
"resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-1.20.8.tgz",
@@ -243,6 +254,16 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/buildcheck": {
"version": "0.0.7",
"resolved": "https://registry.npmmirror.com/buildcheck/-/buildcheck-0.0.7.tgz",
"integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==",
"dev": true,
"optional": true,
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz",
@@ -357,6 +378,21 @@
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/cpu-features": {
"version": "0.0.10",
"resolved": "https://registry.npmmirror.com/cpu-features/-/cpu-features-0.0.10.tgz",
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"dependencies": {
"buildcheck": "~0.0.6",
"nan": "^2.19.0"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz",
@@ -1077,6 +1113,14 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/nan": {
"version": "2.29.0",
"resolved": "https://registry.npmmirror.com/nan/-/nan-2.29.0.tgz",
"integrity": "sha512-GlGk3HIvitbvs+LT3g6XUP1kpirKNvmDFwF/bmo6XNWSb/eYEs/O4bfgIEIXCZ+lIOTS5xNwDvSGMw6FJdAhtA==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/nearley": {
"version": "2.20.1",
"resolved": "https://registry.npmmirror.com/nearley/-/nearley-2.20.1.tgz",
@@ -1748,6 +1792,34 @@
"npm": ">= 3.0.0"
}
},
"node_modules/ssh2": {
"version": "1.17.0",
"resolved": "https://registry.npmmirror.com/ssh2/-/ssh2-1.17.0.tgz",
"integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
"asn1": "^0.2.6",
"bcrypt-pbkdf": "^1.0.2"
},
"engines": {
"node": ">=10.16.0"
},
"optionalDependencies": {
"cpu-features": "~0.0.10",
"nan": "^2.23.0"
}
},
"node_modules/ssh2/node_modules/asn1": {
"version": "0.2.6",
"resolved": "https://registry.npmmirror.com/asn1/-/asn1-0.2.6.tgz",
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"safer-buffer": "~2.1.0"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz",
@@ -1800,6 +1872,13 @@
"license": "0BSD",
"optional": true
},
"node_modules/tweetnacl": {
"version": "0.14.5",
"resolved": "https://registry.npmmirror.com/tweetnacl/-/tweetnacl-0.14.5.tgz",
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
"dev": true,
"license": "Unlicense"
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmmirror.com/type-is/-/type-is-1.6.18.tgz",
+7 -4
查看文件
@@ -1,13 +1,15 @@
{
"name": "autostore-bot",
"version": "1.0.0",
"description": "AutoStore Bot —— Minecraft 自动扩地机器人(mineflayer 重写版,Web 控制台,Grim 反作弊适配)",
"version": "1.11.1",
"description": "AutoStore Bot —— Minecraft 自动扩地机器人(mineflayer 重写版,多机器人挂机,Web 控制台,开拓日志,大地图,Grim 反作弊适配)",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js",
"test": "node --test",
"check": "node scripts/check-syntax.js"
"check": "node scripts/check-syntax.js && node scripts/check-web-ids.js",
"deploy": "node scripts/deploy.js",
"ssh": "node scripts/ssh-helper.js"
},
"keywords": [
"minecraft",
@@ -31,6 +33,7 @@
"ws": "^8.18.0"
},
"devDependencies": {
"minecraft-data": "^3.102.3"
"minecraft-data": "^3.102.3",
"ssh2": "^1.17.0"
}
}
+92
查看文件
@@ -0,0 +1,92 @@
'use strict';
/**
* 协议自检:核对 mineflayer 写出的移动相关包在目标版本上的字段名与结构。
*
* 背景:部分服务端在收到位置包后会持续重发 `position`,直到客户端回一个
* `teleport_confirm` 确认;若 mineflayer 因为版本字段名不一致而写不出这个包,
* 就会形成「服务端一直传送、客户端一直走不动」的死循环。
*
* 注意 `minecraft-data` 的结构:`types.packet[1][1].type[1].fields[包名]` 拿到的是
* **类型名字符串**(如 `packet_position_look`),要再去 `types[名字]` 取
* `['container', [字段...]]` 才能看到字段。早先的版本直接对它 `.map()`,
* 所以这个脚本一直跑不通。
*/
const fs = require('fs');
const path = require('path');
const mcData = require('minecraft-data')('1.21.4');
const VERSION = '1.21.4';
const toServer = mcData.protocol.play.toServer;
const toClient = mcData.protocol.play.toClient;
/** 取某一方向「包名 → 类型名」的映射 */
function packetNameMap(direction) {
const paramsField = direction.types.packet[1][1];
return paramsField.type[1].fields;
}
/** 把类型名解析成字段列表;解析不到返回 null */
function resolveFields(direction, typeName) {
const type = direction.types[typeName];
if (!Array.isArray(type) || type[0] !== 'container') return null;
return type[1];
}
/** 打印一个包的字段定义 */
function dumpFields(direction, label, packetName) {
const typeName = packetNameMap(direction)[packetName];
if (!typeName) {
console.log(` ${packetName.padEnd(18)} (该版本没有这个包)`);
return;
}
const fields = resolveFields(direction, typeName);
if (!fields) {
console.log(` ${packetName.padEnd(18)} ${typeName}(不是 container,无法展开)`);
return;
}
const text = fields.map((f) => `${f.name}:${JSON.stringify(f.type)}`).join(', ');
console.log(` ${packetName.padEnd(18)} ${text}`);
}
console.log(`=== ${VERSION} 客户端 → 服务端:移动 / 传送相关包 ===`);
const serverNames = packetNameMap(toServer);
for (const name of Object.keys(serverNames).sort()) {
if (/teleport|position|input|flying|^look$/i.test(name)) console.log(` ${name}`);
}
console.log(`\n=== ${VERSION} 客户端 → 服务端:字段定义 ===`);
for (const name of ['position', 'position_look', 'look', 'flying', 'player_input', 'teleport_confirm', 'tick_end']) {
dumpFields(toServer, 'toServer', name);
}
console.log('\n=== 关键位标志类型的展开 ===');
for (const [name, def] of Object.entries(toServer.types)) {
if (Array.isArray(def) && def[0] === 'bitflags' && /MovementFlags|Input/i.test(name)) {
console.log(` ${name} = ${JSON.stringify(def)}`);
}
}
console.log(`\n=== ${VERSION} 服务端 → 客户端:position 包字段 ===`);
dumpFields(toClient, 'toClient', 'position');
for (const [name, def] of Object.entries(toClient.types)) {
if (Array.isArray(def) && def[0] === 'bitflags' && /PositionUpdateRelatives/i.test(name)) {
console.log(` ${name} = ${JSON.stringify(def)}`);
}
}
console.log('\n=== mineflayer 实际写出的包 / 用到的字段名 ===');
const physicsPath = path.join(__dirname, '..', 'node_modules', 'mineflayer', 'lib', 'plugins', 'physics.js');
try {
const lines = fs.readFileSync(physicsPath, 'utf8').split('\n');
lines.forEach((line, index) => {
if (/teleport_confirm|teleportId|_client\.write\(/.test(line)) {
console.log(` physics.js:${index + 1} ${line.trim()}`);
}
});
} catch (err) {
console.error(' 读不到 mineflayer/physics.js:' + (err && err.message ? err.message : err));
process.exitCode = 1;
}
+36
查看文件
@@ -0,0 +1,36 @@
'use strict';
/**
* 前端元素引用自检:核对 app.js 里 `$('id')` 引用的元素是否都存在于 index.html,
* 以及 index.html 里是否还有未被引用的 id(可能是改版后遗留的死元素)。
*
* 之所以放进 scripts/ 而不是测试里,是因为它检查的是静态资源一致性,
* 与运行时行为无关;出问题时直接跑 `node scripts/check-web-ids.js` 即可。
*/
const fs = require('fs');
const path = require('path');
const publicDir = path.join(__dirname, '..', 'src', 'web', 'public');
const app = fs.readFileSync(path.join(publicDir, 'app.js'), 'utf8');
const html = fs.readFileSync(path.join(publicDir, 'index.html'), 'utf8');
const referenced = new Set();
for (const match of app.matchAll(/\$\('([^']+)'\)/g)) referenced.add(match[1]);
const declared = new Set();
for (const match of html.matchAll(/id="([^"]+)"/g)) declared.add(match[1]);
const missing = [...referenced].filter((id) => !declared.has(id));
const unused = [...declared].filter((id) => !referenced.has(id));
console.log(`app.js 引用 ${referenced.size} 个元素 id,index.html 声明 ${declared.size} 个`);
if (missing.length > 0) {
console.error('引用了不存在的元素 id:' + missing.join(', '));
process.exit(1);
}
if (unused.length > 0) {
console.log('提示:index.html 中未被 app.js 直接引用的 id(可能由 CSS / 标签页逻辑使用):' + unused.join(', '));
}
console.log('前端元素引用自检通过');
+153
查看文件
@@ -0,0 +1,153 @@
'use strict';
/**
* 一键部署:打包 → 上传 → 远程执行 update.sh → 校验服务已起来。
*
* 用法(凭据全部走环境变量或 .env,脚本里不写死任何密钥):
*
* # 推荐:把 SSH_HOST / SSH_PORT / SSH_USER / SSH_PASS 写进 .env(已被 .gitignore 忽略)
* npm run deploy
*
* # 或者临时用环境变量覆盖(.env 里的值仍然作为兜底)
* # Windows PowerShell
* $env:SSH_HOST='<服务器 IP>'; $env:SSH_PORT='<端口>'
* $env:SSH_USER='root'; $env:SSH_PASS='<密码>'
* node scripts/deploy.js
*
* # Linux / macOS
* SSH_HOST=... SSH_PORT=... SSH_USER=... SSH_PASS=... node scripts/deploy.js
*
* 可选环境变量:
* DEPLOY_APP 部署目录,默认 /opt/autostore-bot
* DEPLOY_PORT 健康检查端口,默认 78
*
* 打包时**排除**这些,以免覆盖服务器上的真实数据与环境:
* node_modules / .git / data / *.log / .env
* 服务器上 `update.sh` 同样会保留 .env、data、node_modules、runtime。
*/
const { execFileSync, spawnSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
// 凭据优先从 .env 读:在命令行里传密码会留在 shell 历史与进程列表里,
// 写进已被忽略的 .env 更安全。dotenv 不覆盖已存在的环境变量,所以显式传参仍然优先。
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
const HOST = process.env.SSH_HOST;
const PASS = process.env.SSH_PASS;
const APP = process.env.DEPLOY_APP || '/opt/autostore-bot';
const PORT = process.env.DEPLOY_PORT || '78';
if (!HOST || !PASS) {
console.error('缺少 SSH_HOST / SSH_PASS。请写进项目根目录的 .env(参考 .env.example),'
+ '或用环境变量临时传入。');
process.exit(2);
}
const ROOT = path.resolve(__dirname, '..');
const HELPER = path.join(__dirname, 'ssh-helper.js');
const TARBALL = path.join(os.tmpdir(), `autostore-deploy-${Date.now()}.tar.gz`);
/** 通过 ssh-helper 执行远程命令,输出直接透传 */
function remote(command) {
const r = spawnSync(process.execPath, [HELPER, 'exec', command], {
stdio: ['ignore', 'pipe', 'pipe'],
env: process.env
});
const stdout = r.stdout ? r.stdout.toString('utf8') : '';
const stderr = r.stderr ? r.stderr.toString('utf8') : '';
if (stdout) process.stdout.write(stdout);
if (stderr) process.stderr.write(stderr);
return { code: r.status || 0, stdout, stderr };
}
/** 通过 ssh-helper 上传文件 */
function upload(localFile, remotePath) {
const r = spawnSync(process.execPath, [HELPER, 'put', localFile, remotePath], {
stdio: ['ignore', 'pipe', 'pipe'],
env: process.env
});
const stdout = r.stdout ? r.stdout.toString('utf8') : '';
const stderr = r.stderr ? r.stderr.toString('utf8') : '';
if (stdout) process.stdout.write(stdout);
if (stderr) process.stderr.write(stderr);
if (r.status !== 0) throw new Error('上传失败');
}
function pack() {
// tar 的 --exclude 必须放在 -C 之前才能生效
const args = [
'-czf', TARBALL,
'--exclude=node_modules',
'--exclude=.git',
'--exclude=data',
'--exclude=*.log',
'--exclude=.env',
'-C', ROOT,
'.'
];
try {
execFileSync('tar', args, { stdio: 'ignore' });
} catch {
console.error('打包失败。Windows 请确保 tar 可用(Win10 1803+ 自带 bsdtar);'
+ '或者手动压缩项目目录后通过 scripts/ssh-helper.js put 上传。');
process.exit(1);
}
return fs.statSync(TARBALL).size;
}
function main() {
console.log(`===== 1/4 打包 ${ROOT} =====`);
const size = pack();
console.log(` ${TARBALL} (${size} bytes)`);
console.log('\n===== 2/4 上传 =====');
upload(TARBALL, `${APP}/update.tar.gz`);
console.log('\n===== 3/4 远程应用更新 =====');
const applied = remote(`bash ${APP}/update.sh 2>&1 | tail -5`);
if (applied.code !== 0) {
console.error('update.sh 返回非零状态,请检查上面的输出');
}
console.log('\n===== 4/4 校验服务 =====');
// 等 pm2 把进程拉起来
atExit(() => {
const health = remote(
`for i in 1 2 3 4 5 6 7 8 9 10; do `
+ `if curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:${PORT}/ | grep -q 200; then `
+ `echo "控制台 200 OK"; break; fi; sleep 3; done; `
+ `ss -lntp | grep ':${PORT} ' || echo "警告:端口 ${PORT} 未监听"`
);
if (!health.stdout.includes('200 OK')) {
console.error(`\n服务未就绪,请登录服务器查看日志:\n`
+ ` tail -50 ${APP}/data/pm2-out.log\n`
+ ` tail -50 ${APP}/data/pm2-error.log`);
process.exitCode = 1;
}
});
}
/** 注册退出前执行(保证 1~3 步的输出先打完) */
const exitHooks = [];
function atExit(fn) {
exitHooks.push(fn);
}
process.on('exit', () => {
for (const fn of exitHooks) fn();
});
try {
main();
} catch (err) {
console.error('部署失败:' + (err && err.message ? err.message : err));
process.exitCode = 1;
} finally {
try {
fs.unlinkSync(TARBALL);
} catch {
// 临时文件删不掉无所谓
}
}
+135
查看文件
@@ -0,0 +1,135 @@
'use strict';
/**
* 部署辅助:用 ssh2 做密码登录的 SSH 执行与 SFTP 上传。
*
* 为什么要自己写一个:Windows 自带 OpenSSH 但**没有 sshpass / plink**,
* 无法在非交互模式下传密码,而部署脚本必须能无人值守地跑完。
* 用 ssh2 直接实现协议层就绕开了这个问题。
*
* **脚本本身不含任何凭据**,全部走环境变量,避免密钥进仓库。
*
* 用法:
* node scripts/ssh-helper.js exec "<命令>"
* node scripts/ssh-helper.js put <本地文件> <远程路径>
*
* 环境变量(也可以写进项目根目录的 .env,那里已被 .gitignore 忽略):
* SSH_HOST 必填
* SSH_PASS 必填
* SSH_PORT 可选,默认 22
* SSH_USER 可选,默认 root
*
* 若未安装 ssh2(devDependencies,可选),会给出安装提示而不是裸崩。
*/
const fs = require('fs');
const path = require('path');
// 与 deploy.js 一致:凭据可以从 .env 读;dotenv 不覆盖已存在的环境变量,显式传参仍然优先
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
let Client;
try {
({ Client } = require('ssh2'));
} catch {
console.error('缺少可选依赖 ssh2。部署相关脚本需要它:\n\n npm install --save-dev ssh2\n');
process.exit(2);
}
const HOST = process.env.SSH_HOST;
const PORT = Number(process.env.SSH_PORT || 22);
const USER = process.env.SSH_USER || 'root';
const PASS = process.env.SSH_PASS;
if (!HOST || !PASS) {
console.error('缺少 SSH_HOST / SSH_PASS。请写进项目根目录的 .env(参考 .env.example),'
+ '或用环境变量临时传入。');
process.exit(2);
}
function connect() {
return new Promise((resolve, reject) => {
const client = new Client();
client.on('ready', () => resolve(client));
client.on('error', reject);
client.connect({
host: HOST,
port: PORT,
username: USER,
password: PASS,
readyTimeout: 30000,
algorithms: {
// 老版本 OpenSSH 服务器可能只提供这些算法,放宽列表避免握手失败
kex: [
'curve25519-sha256', 'curve25519-sha256@libssh.org',
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
'diffie-hellman-group-exchange-sha256',
'diffie-hellman-group14-sha256', 'diffie-hellman-group14-sha1',
'diffie-hellman-group1-sha1'
],
serverHostKey: [
'ssh-ed25519', 'ecdsa-sha2-nistp256', 'rsa-sha2-512', 'rsa-sha2-256', 'ssh-rsa'
]
}
});
});
}
/** @returns {Promise<{code: number, stdout: string, stderr: string}>} */
function exec(client, command) {
return new Promise((resolve, reject) => {
client.exec(command, { pty: false }, (err, stream) => {
if (err) {
reject(err);
return;
}
let stdout = '';
let stderr = '';
stream.on('data', (chunk) => { stdout += chunk.toString('utf8'); });
stream.stderr.on('data', (chunk) => { stderr += chunk.toString('utf8'); });
stream.on('close', (code) => resolve({ code, stdout, stderr }));
});
});
}
function put(client, localFile, remotePath) {
return new Promise((resolve, reject) => {
client.sftp((err, sftp) => {
if (err) {
reject(err);
return;
}
sftp.fastPut(localFile, remotePath, (putErr) => {
if (putErr) reject(putErr);
else resolve(remotePath);
});
});
});
}
async function main() {
const [action, ...rest] = process.argv.slice(2);
const client = await connect();
try {
if (action === 'exec') {
const result = await exec(client, rest.join(' '));
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
process.exitCode = result.code || 0;
} else if (action === 'put') {
const local = path.resolve(rest[0]);
await put(client, local, rest[1]);
console.log(`uploaded ${path.basename(local)} -> ${rest[1]} (${fs.statSync(local).size} bytes)`);
} else {
console.error('未知动作:' + action);
process.exitCode = 2;
}
} finally {
client.end();
}
}
main().catch((err) => {
console.error('SSH 失败:' + (err && err.message ? err.message : err));
process.exit(1);
});
+1369 -25
查看文件
文件差异内容过多而无法显示 加载差异
+737
查看文件
@@ -0,0 +1,737 @@
'use strict';
const { EventEmitter } = require('events');
const { BotManager } = require('./botManager');
const { ScheduledMessenger } = require('./scheduledMessenger');
const { ExpansionController } = require('../controller/expansionController');
const { TargetLeaseTable } = require('../core/targetLease');
const { normalizeConfig } = require('../config/expansionConfig');
const { sanitizeId } = require('../config/botsConfig');
/**
* 机器人池:同时挂多个玩家(机器人)。
*
* 每个机器人由一对 `BotManager + ExpansionController` 组成,各自独立连接、独立登录、
* 独立跑扩地状态机;但**共享**同一份领土数据服务(markers.json 是全局的,
* 没必要每个机器人各拉一遍)与同一份运行日志。
*
* 账号来源有两个,同名时以 `.env` 为准(环境变量优先级更高,避免「改了 .env 不生效」):
* 1. `.env` 的 `MC_BOTS` / `MC_USERNAME`(`source: 'env'`);
* 2. 控制台「添加玩家」写入的 `data/bots.credentials.json`(`source: 'store'`)。
*
* 每个机器人可以有自己的一套扩地配置覆盖(存在 `data/bots.json`),
* 未覆盖的字段回退到全局配置。
*/
class BotPool extends EventEmitter {
/**
* @param {object} deps
* @param {Array<object>} deps.connections 连接参数列表(来自 .env,见 botsConfig)
* @param {object} deps.config 全局配置
* @param {import('../util/logger').Logger} [deps.logger]
* @param {import('../core/territoryDataService').TerritoryDataService} deps.dataService
* @param {import('../core/expansionLog').ExpansionLog} [deps.expansionLog]
* @param {import('../core/chatLog').ChatLog} [deps.chatLog]
* @param {{load: Function, save: Function}} [deps.botStore] 机器人设置持久化
* @param {{load: Function, save: Function}} [deps.credentialStore] 控制台新增账号的凭据持久化
*/
constructor(deps) {
super();
this.config = deps.config;
this.logger = deps.logger || null;
this.dataService = deps.dataService;
this.expansionLog = deps.expansionLog || null;
this.chatLog = deps.chatLog || null;
this.botStore = deps.botStore || null;
this.credentialStore = deps.credentialStore || null;
/** @type {import('../core/notifier').Notifier|null} 人工介入提醒(验证码 / 自救失败) */
this.notifier = deps.notifier || null;
/** @type {Map<string, object>} 机器人 id → 运行时条目 */
this.entries = new Map();
/** 已执行过「启动后自动开始扩地」的机器人(避免重连后把手动停止的任务又拉起来) */
this._autoStarted = new Set();
/** 机器人设置(非敏感部分) */
this.settings = this.botStore ? this.botStore.load().bots : {};
/** 控制台新增账号的凭据(含密码,落盘权限 0600) */
this.credentials = this.credentialStore ? this.credentialStore.load().bots : {};
/** 启动时间戳 */
this.startedAt = 0;
/**
* 目标区块租约表:多机器人共享,避免同时扩地时撞在同一批候选区块上
* (详见 core/targetLease.js)。
*/
this.targetLeases = new TargetLeaseTable({
ttlMs: (this.config.coordination ? this.config.coordination.targetLeaseTtlSeconds : 180) * 1000
});
for (const connection of this._mergeConnections(deps.connections || [])) {
this._addBot(connection);
}
this.defaultId = this.entries.size > 0 ? this.entries.keys().next().value : '';
}
/**
* 合并两个账号来源:`.env` 优先,控制台新增的账号补齐。
* @param {Array<object>} envConnections
* @returns {Array<object>}
*/
_mergeConnections(envConnections) {
const merged = [];
const seen = new Set();
for (const connection of envConnections) {
const id = connection.id || sanitizeId(connection.username);
if (!id || seen.has(id)) continue;
seen.add(id);
merged.push(Object.assign({}, connection, { id, source: 'env' }));
}
for (const [id, credential] of Object.entries(this.credentials)) {
if (seen.has(id) || !credential || !credential.username) continue;
seen.add(id);
merged.push(Object.assign({
auth: 'offline',
host: process.env.MC_HOST || 'bgjq.simpfun.cn',
port: Number.parseInt(process.env.MC_PORT || '25565', 10),
version: process.env.MC_VERSION || '1.21.4'
}, credential, { id, source: 'store' }));
}
return merged;
}
// ==================== 组装 ====================
/**
* 创建一个机器人条目:管理器 + 控制器,并接好事件。
* @param {object} connection
* @returns {object|null} 已存在同名机器人时返回 null
*/
_addBot(connection) {
const id = connection.id || sanitizeId(connection.username);
if (!id || this.entries.has(id)) return null;
const setting = this.settings[id] || {};
const manager = new BotManager(
Object.assign({}, connection, { label: setting.label || connection.label }),
this.getConfigFor(id),
this.logger,
{ chatLog: this.chatLog, notifier: this.notifier }
);
const controller = new ExpansionController({
botManager: manager,
dataService: this.dataService,
logger: this.logger,
botId: id,
botName: connection.username,
expansionLog: this.expansionLog,
targetLeases: this.targetLeases
});
// 寻路事件转发给控制器(与 Baritone 的 PathEvent 语义一致)
manager.on('modulesReady', () => {
if (manager.pathing) {
manager.pathing.onPathEvent = (event, detail) => controller.onPathEvent(event, detail);
}
// 进入世界并装配完成后按设置决定是否自动开始扩地。
// 只在本次进程内首次进入世界时触发:断线重连后不自动拉起,
// 否则用户在控制台手动停止的任务会被重连事件重新启动。
if (this._settingOf(id).autoStart && !this._autoStarted.has(id)) {
this._autoStarted.add(id);
this.startTask(id).catch((err) => {
if (this.logger) this.logger.log(`[${connection.username}] 自动开始扩地失败:${err.message}`, 'error');
});
}
});
manager.on('spawn', () => this.emit('botSpawn', id));
manager.on('end', () => this.emit('botEnd', id));
// 定时消息器:按固定间隔重复发送同一条内容(如每 300 秒 /u restore confirm)
const messenger = new ScheduledMessenger(manager, this.logger);
messenger.setTasks(setting.schedules || []);
this.entries.set(id, {
id,
connection,
manager,
controller,
messenger,
/** 账号来源:env(.env 配置)/ store(控制台新增) */
source: connection.source || 'env',
/** 该机器人实际生效的配置(全局 + 覆盖) */
config: manager.config
});
return this.entries.get(id);
}
/**
* 控制台新增一个玩家(机器人):落盘凭据 → 建立条目 → 立即连接。
*
* 密码只写进 `data/bots.credentials.json`(权限 0600,`data/` 已被 .gitignore 忽略),
* 不会进入代码仓库,也不会出现在任何接口响应里。
*
* @param {{username: string, password?: string, label?: string, auth?: string, host?: string, port?: number, version?: string}} input
* @param {{connect?: boolean}} [options] connect=false 时只登记不连接(测试或先配好再手动连接)
* @returns {object} 新建的机器人条目
*/
addBot(input, options) {
const opts = options || {};
const username = String((input && input.username) || '').trim();
if (!username) throw badRequest('缺少用户名');
if (!/^[A-Za-z0-9_]{1,16}$/.test(username)) {
throw badRequest('用户名只能是 1-16 位字母、数字或下划线(Minecraft 账号名规则)');
}
const id = sanitizeId(username);
if (this.entries.has(id)) throw badRequest('该玩家已在列表中:' + username);
if (!this.credentialStore) throw badRequest('当前部署未启用凭据存储,无法在控制台新增玩家');
const connection = {
id,
username,
password: input.password === undefined || input.password === null ? '' : String(input.password),
label: input.label ? String(input.label) : username,
auth: String(input.auth || process.env.MC_AUTH || 'offline'),
host: String(input.host || process.env.MC_HOST || 'bgjq.simpfun.cn'),
port: Number.isFinite(Number(input.port))
? Number(input.port)
: Number.parseInt(process.env.MC_PORT || '25565', 10),
version: String(input.version || process.env.MC_VERSION || '1.21.4'),
source: 'store'
};
this.credentials[id] = {
username: connection.username,
password: connection.password,
auth: connection.auth,
host: connection.host,
port: connection.port,
version: connection.version
};
this.credentialStore.save({ bots: this.credentials });
const entry = this._addBot(connection);
if (!this.defaultId) this.defaultId = id;
this._log(`已添加玩家 ${username}(密码保存在服务器本地凭据文件,权限 0600)`);
if (opts.connect !== false && this._settingOf(id).enabled) {
entry.messenger.start();
entry.manager.connect();
}
return entry;
}
/**
* 移除一个机器人。
*
* 只允许移除控制台新增的账号;`.env` 里配置的账号必须改 `.env`,
* 否则重启后它又会回来,容易让人误以为「删不掉」。
*
* @param {string} id
* @returns {object} 被移除的条目
*/
removeBot(id) {
const entry = this.require(id);
if (entry.source === 'env') {
throw badRequest(`${entry.connection.username} 是在 .env 的 MC_BOTS 里配置的,`
+ '请修改 .env 后重启;控制台只能移除在这里添加的玩家');
}
entry.messenger.stop();
entry.manager.disconnect();
this.entries.delete(id);
delete this.settings[id];
this._persistSettings();
if (this.credentials[id]) {
delete this.credentials[id];
if (this.credentialStore) this.credentialStore.save({ bots: this.credentials });
}
if (this.defaultId === id) {
this.defaultId = this.entries.size > 0 ? this.entries.keys().next().value : '';
}
this._log(`已移除玩家 ${entry.connection.username}`);
return entry;
}
// ==================== 生命周期 ====================
/** 连接所有已启用的机器人 */
start() {
this.startedAt = Date.now();
for (const entry of this.entries.values()) {
if (!this._settingOf(entry.id).enabled) {
this._log(`${entry.connection.username} 已在控制台禁用,跳过连接`);
continue;
}
entry.messenger.start();
entry.manager.connect();
}
}
/** 断开所有机器人 */
stop() {
for (const entry of this.entries.values()) {
try {
entry.controller.stop();
} catch {
// 忽略
}
entry.messenger.stop();
entry.manager.disconnect();
}
}
/** 驱动所有机器人的扩地状态机(由外部按 tick 调用) */
tick() {
for (const entry of this.entries.values()) {
try {
entry.controller.tick();
} catch (err) {
if (this.logger) {
this.logger.log(`[${entry.connection.username}] tick 异常:` + (err && err.stack ? err.stack : err), 'error');
}
}
}
}
// ==================== 访问 ====================
/** @returns {Array<string>} 全部机器人 id */
ids() {
return Array.from(this.entries.keys());
}
/** @returns {number} 机器人数量 */
get size() {
return this.entries.size;
}
/**
* @param {string} [id] 机器人 id;缺省取第一个
* @returns {object|null} 运行时条目
*/
get(id) {
if (!id) return this.entries.get(this.defaultId) || null;
return this.entries.get(id) || null;
}
/**
* 取机器人条目,找不到时抛错(供 REST 接口返回 404 用)。
* @param {string} id
* @returns {object}
*/
require(id) {
const entry = this.get(id);
if (!entry) {
const err = new Error('机器人不存在:' + id);
err.statusCode = 404;
throw err;
}
return entry;
}
// ==================== 配置 ====================
/** @returns {object} 机器人的非敏感设置(启用 / 备注 / 自动开始 / 定时任务 / 配置覆盖) */
_settingOf(id) {
const raw = this.settings[id] || {};
return {
enabled: raw.enabled !== false,
autoStart: raw.autoStart === true,
label: raw.label || '',
schedules: Array.isArray(raw.schedules) ? raw.schedules : [],
config: raw.config && typeof raw.config === 'object' ? raw.config : {}
};
}
/**
* 计算某机器人实际生效的配置:全局配置 + 该机器人的覆盖。
* @param {string} id
* @returns {object}
*/
getConfigFor(id) {
const setting = this._settingOf(id);
return normalizeConfig(setting.config, this.config);
}
/**
* 更新全局配置并下发给所有机器人(各自再叠加自己的覆盖)。
* @param {object} config 新的全局配置
*/
applyGlobalConfig(config) {
this.config = config;
// 租约时长可以热改(新配置对新租约生效;已发出的租约仍按原时长到期)
if (this.targetLeases && config.coordination) {
this.targetLeases.ttlMs = Math.max(1, Number(config.coordination.targetLeaseTtlSeconds) || 180) * 1000;
}
for (const entry of this.entries.values()) {
entry.config = this.getConfigFor(entry.id);
entry.manager.applyConfig(entry.config);
entry.controller.config = entry.config;
}
}
/**
* 保存某机器人的设置(启用 / 备注 / 自动开始 / 配置覆盖)并立即生效。
*
* @param {string} id
* @param {object} patch 部分设置;`config` 字段为配置覆盖(整体替换)
* @returns {object} 生效后的设置
*/
setBotSetting(id, patch) {
const entry = this.require(id);
const current = this.settings[id] || {};
const next = Object.assign({}, current, patch || {});
if (patch && patch.config !== undefined) {
// 配置覆盖只接受已知字段,避免脏数据污染运行状态
next.config = normalizeConfig(patch.config, this.config);
}
this.settings[id] = next;
this._persistSettings();
entry.config = this.getConfigFor(id);
entry.manager.applyConfig(entry.config);
entry.controller.config = entry.config;
// 定时任务列表整体替换:把归一化结果写回设置,
// 保证「接口返回值 / 落盘内容 / 运行时状态」三者一致
if (patch && patch.schedules !== undefined) {
const normalized = entry.messenger.setTasks(this._settingOf(id).schedules);
this.settings[id] = Object.assign({}, this.settings[id], { schedules: normalized });
this._persistSettings();
}
return this._settingOf(id);
}
/** @returns {object} 机器人的设置快照(供控制台表单回填) */
settingSnapshot(id) {
const setting = this._settingOf(id);
return {
id,
label: setting.label,
enabled: setting.enabled,
autoStart: setting.autoStart,
hasOverrides: Object.keys(setting.config).length > 0,
config: this.getConfigFor(id),
schedules: this.entry(id) ? this.entry(id).messenger.snapshot() : []
};
}
/**
* 取运行时条目(不存在时返回 null,不抛异常)。
* @param {string} id
* @returns {object|null}
*/
entry(id) {
return this.entries.get(id) || null;
}
_persistSettings() {
if (!this.botStore) return;
this.botStore.save({ bots: this.settings });
}
// ==================== 任务与控制 ====================
/**
* 启动某机器人的扩地任务。
* @param {string} id
* @param {object} [overrideConfig] 本次任务的配置覆盖(来自控制台表单)
* @returns {Promise<{ok: boolean, message: string, entry: object}>}
*/
async startTask(id, overrideConfig) {
const entry = this.require(id);
const config = overrideConfig
? normalizeConfig(overrideConfig, this.getConfigFor(id))
: this.getConfigFor(id);
if (overrideConfig) {
// 控制台点「开始扩地」且勾了「仅对当前机器人生效」时,顺带把这份配置存为该机器人的专属配置。
// 注意这是一份**完整快照**:存下之后全局配置的改动就不再影响它了,
// 所以这里明确记一条日志,避免以后排查时一头雾水。
const previous = this.settings[id] && this.settings[id].config ? '(覆盖已有)' : '(新建)';
this.settings[id] = Object.assign({}, this.settings[id] || {}, { config });
this._persistSettings();
entry.config = config;
entry.manager.applyConfig(config);
this._log(`${entry.connection.username} 已保存专属配置${previous}:`
+ `策略 ${config.strategy}、目标邦国「${config.stateName || '自动'}」、目标数量 ${config.targetCount};`
+ '此后全局配置的改动不会影响它,可在配置面板点「清除专属配置」恢复继承');
}
entry.controller.config = config;
const ok = await entry.controller.start(config);
return { ok, message: ok ? '任务已启动' : entry.controller.status, entry };
}
/**
* 清除某机器人的专属配置覆盖,恢复「继承全局默认配置」。
*
* 专属配置是一份完整快照,一旦存下,之后改全局配置就再也影响不到这个机器人 ——
* 很容易出现「我明明改了全局设置,它却还是老行为」的困惑,所以需要一条明确的退路。
*
* @param {string} id
* @returns {object} 生效后的设置快照
*/
clearBotConfigOverride(id) {
const entry = this.require(id);
if (this.settings[id]) delete this.settings[id].config;
this._persistSettings();
entry.config = this.getConfigFor(id);
entry.manager.applyConfig(entry.config);
entry.controller.config = entry.config;
this._log(`${entry.connection.username} 的专属配置已清除,恢复继承全局默认配置`);
return this.settingSnapshot(id);
}
/** 停止某机器人的扩地任务 */
stopTask(id) {
const entry = this.require(id);
entry.controller.stop();
return entry;
}
/** 重连某机器人 */
reconnect(id) {
const entry = this.require(id);
entry.manager.disconnect();
setTimeout(() => {
// 主动断开后 BotManager 不会自动重连,这里显式重建连接
entry.manager.connect();
}, 500).unref?.();
return entry;
}
/**
* 让机器人下线:断开连接、停止自动重连、停掉定时消息与扩地任务。
*
* 与「重连」的区别在于**下线后不会自己再连回来** ——
* 需要在游戏客户端里登同一个账号(同一账号只能有一个连接)、
* 或者手动进游戏处理点事情时,必须先让机器人下线。
*
* @param {string} id
* @returns {object} 机器人条目
*/
disconnectBot(id) {
const entry = this.require(id);
try {
entry.controller.stop();
} catch {
// 忽略
}
entry.messenger.stop();
entry.manager.disconnect();
this._log(`${entry.connection.username} 已下线(不会自动重连,点「上线」可恢复)`);
return entry;
}
/**
* 让机器人上线:恢复定时消息并重新连接。
* @param {string} id
* @returns {object} 机器人条目
*/
connectBot(id) {
const entry = this.require(id);
// 清掉「账号已在别处登录」的标记,否则重连会一直退避到最大间隔
entry.manager.alreadyConnected = false;
entry.manager.reconnectAttempts = 0;
entry.messenger.start();
entry.manager.connect();
this._log(`${entry.connection.username} 正在上线`);
return entry;
}
/**
* 向某机器人发送游戏指令。
*
* 登录门禁由 BotManager 判定:验证类指令(login / register / auth 等)即使在
* 未通过验证时也放行,否则会出现「要先登录才能发登录指令」的死锁。
*
* @param {string} id
* @param {string} command 不带斜杠的指令
* @returns {{ok: boolean, message: string}}
*/
sendCommand(id, command) {
const entry = this.require(id);
const text = String(command || '').replace(/^\//, '').trim();
if (!text) return { ok: false, message: '指令为空' };
if (!entry.manager.canSendCommand(text)) {
return { ok: false, message: describeSendFailure(entry.manager, text) };
}
const ok = entry.manager.sendCommand(text);
return { ok, message: ok ? '已发送:' + command : '发送失败' };
}
/**
* 向某机器人发送公屏聊天消息。
* @param {string} id
* @param {string} message 聊天正文(以 `/` 开头时按指令发送)
* @returns {{ok: boolean, message: string}}
*/
/**
* 向某机器人发送内容:聊天与指令是同一个入口,**以 `/` 开头按指令发送**,否则按公屏消息发送。
*
* @param {string} id
* @param {string} message 内容(可以带或不带前导 `/`)
* @returns {{ok: boolean, kind: string, message: string}}
*/
sendChat(id, message) {
const entry = this.require(id);
const text = String(message || '').trim();
if (!text) return { ok: false, kind: 'chat', message: '内容为空' };
const isCommand = text.startsWith('/');
const kind = isCommand ? 'command' : 'chat';
if (!entry.manager.canSendChat()) {
if (entry.manager.state !== 'online' || !entry.manager.bot) {
return { ok: false, kind, message: `${entry.connection.username} 当前未连接(状态:${entry.manager.state}),未发送` };
}
return { ok: false, kind, message: `${entry.connection.username} 未完成登录验证,已拒绝发送(服务器要求验证时会自动放行,用于发送验证码)` };
}
const ok = entry.manager.sendChat(text);
return {
ok,
kind,
message: ok ? (isCommand ? '指令已发送:' + text : '消息已发送') : '发送失败'
};
}
/**
* 手动移动测试:按住方向键若干秒,返回实际位移。
* @param {string} id
* @param {string} direction
* @param {number} [seconds]
* @returns {Promise<{ok: boolean, message: string, from: object|null, to: object|null}>}
*/
walkTest(id, direction, seconds) {
const entry = this.require(id);
return entry.manager.walkTest(direction, seconds);
}
/**
* 开始抓出站包(诊断用)。
* @param {string} id
* @param {number} [seconds] 录制时长(秒),省略则需手动停止
* @returns {{ok: boolean, message: string}}
*/
startCapture(id, seconds) {
return this.require(id).manager.startPacketCapture(seconds);
}
/**
* 停止抓包。
* @param {string} id
* @returns {{ok: boolean, message: string}}
*/
stopCapture(id) {
return this.require(id).manager.stopPacketCapture();
}
/**
* 取出抓包结果。
* @param {string} id
* @param {{types?: string|string[], limit?: number}} [options]
* @returns {object}
*/
captureSnapshot(id, options) {
return this.require(id).manager.packetCaptureSnapshot(options);
}
/**
* 开始录制物理 tick(诊断用)。
* @param {string} id
* @param {number} [seconds] 录制时长(秒),省略则需手动停止
* @returns {{ok: boolean, message: string}}
*/
startPhysicsRecorder(id, seconds) {
return this.require(id).manager.startPhysicsRecorder(seconds);
}
/**
* 停止物理 tick 录制。
* @param {string} id
* @returns {{ok: boolean, message: string}}
*/
stopPhysicsRecorder(id) {
return this.require(id).manager.stopPhysicsRecorder();
}
/**
* 取出物理 tick 录制结果。
* @param {string} id
* @param {{limit?: number}} [options]
* @returns {object}
*/
physicsSnapshot(id, options) {
return this.require(id).manager.physicsSnapshot(options);
}
// ==================== 状态快照 ====================
/**
* @returns {Array<object>} 所有机器人的状态快照(含连接、任务与位置)
*/
snapshot() {
const bots = [];
for (const entry of this.entries.values()) {
const setting = this._settingOf(entry.id);
bots.push(Object.assign({}, entry.manager.snapshot(), {
enabled: setting.enabled,
autoStart: setting.autoStart,
source: entry.source,
task: entry.controller.snapshot(),
schedules: entry.messenger.snapshot()
}));
}
return bots;
}
/**
* @returns {Array<object>} 所有机器人的位置(大地图打点用)
*/
positions() {
const result = [];
for (const entry of this.entries.values()) {
const snap = entry.manager.snapshot();
result.push({
id: entry.id,
label: entry.manager.label,
username: entry.connection.username,
state: snap.state,
loggedIn: snap.loggedIn,
position: snap.position,
chunk: snap.chunk,
stateName: entry.controller.stateName,
taskState: entry.controller.state,
completedClaims: entry.controller.completedClaims
});
}
return result;
}
_log(message, level) {
if (this.logger) this.logger.log(message, level);
}
}
/**
* 构造一个 400 错误(由 Web 层转成对应 HTTP 状态码)。
* @param {string} message
* @returns {Error}
*/
function badRequest(message) {
const err = new Error(message);
err.statusCode = 400;
return err;
}
/**
* 生成「为什么这条指令发不出去」的可操作说明。
*
* 区分「没连上」与「连上了但没过登录验证」——两者的处理方式完全不同,
* 笼统报一句「发送失败」会让用户无从下手。
*
* @param {import('./botManager').BotManager} manager
* @param {string} command 不带斜杠的指令
* @returns {string}
*/
function describeSendFailure(manager, command) { const name = manager.connection.username;
if (manager.state !== 'online' || !manager.bot) {
return `${name} 当前未连接(状态:${manager.state}),指令未发送;请先重连或等待自动重连`;
}
if (manager.loginFailed) {
return `${name} 登录被服务器拒绝,请检查 .env 中的账号密码`;
}
return `${name} 未完成登录验证,已拒绝发送 /${command};`
+ '若是验证类指令(login / register / auth),可在配置的「允许免登录发送的指令前缀」中放行';
}
module.exports = { BotPool, describeSendFailure, badRequest };
+117 -4
查看文件
@@ -108,6 +108,9 @@ class GrimCompat {
install() {
if (this._installed) return;
const bot = this.bot;
// 协议补包与「合规层开关」无关:缺了它们机器人根本无法移动,
// 属于「让客户端表现得像原版客户端」的必要动作,而不是可选的反作弊调优。
this._installProtocolPackets(bot);
if (!this.config.grim.enabled) {
this._installed = true;
return;
@@ -137,12 +140,83 @@ class GrimCompat {
if (!this._installed) return;
const bot = this.bot;
bot.removeListener('physicsTick', this._onPhysicsTick);
if (this._onProtocolTick) bot.removeListener('physicsTick', this._onProtocolTick);
if (this._originalLook) bot.look = this._originalLook;
if (this._originalLookAt) bot.lookAt = this._originalLookAt;
if (this._originalSetControlState) bot.setControlState = this._originalSetControlState;
this._installed = false;
}
// ==================== 协议补包 ====================
/**
* 补发原版客户端每 tick 都会发、而 mineflayer 不发的两个包。
*
* 这是本项目在真实服务器上「机器人完全走不动」的根因(由旧版机器人实测得出):
*
* 1. **`player_input`**:1.21.2+ 服务端的移动预测依赖它,原版客户端每 tick 都发;
* mineflayer 只在骑乘 / 跳下 / 潜行时发送,地面移动的 forward / jump / sprint 完全不发。
* 带反作弊的服务器因此判定「没有任何输入却发生了位移」,
* 每 tick 把玩家拉回原位置(setback)——表现就是原地抖动、位移恒为 0。
* 2. **`tick_end`**:原版 1.21.2+ 客户端在每 tick 末尾发送,反作弊用它判定 tick 边界;
* mineflayer 完全不发,会导致 PacketOrder / Timer 类判定异常。
*
* @param {import('mineflayer').Bot} bot
*/
_installProtocolPackets(bot) {
const grim = this.config.grim;
if (grim.sendPlayerInput === false && grim.sendTickEnd === false) return;
if (!bot || !bot._client) return;
// 版本能力探测:newPlayerInputPacket 自 1.21.3 起存在
try {
this.playerInputSupported = bot.supportFeature('newPlayerInputPacket');
} catch {
this.playerInputSupported = false;
}
this.playerInputSent = 0;
this.tickEndSent = 0;
this._onProtocolTick = () => {
if (grim.sendPlayerInput !== false && this.playerInputSupported) {
try {
bot._client.write('player_input', {
inputs: {
forward: bot.getControlState('forward'),
backward: bot.getControlState('back'),
left: bot.getControlState('left'),
right: bot.getControlState('right'),
jump: bot.getControlState('jump'),
shift: bot.getControlState('sneak'),
sprint: bot.getControlState('sprint')
}
});
this.playerInputSent++;
} catch {
// 发送失败不影响主流程
}
}
if (grim.sendTickEnd !== false) {
// 用 nextTick 保证 tick_end 排在本帧的 position 包之后
// (mineflayer 在 physicsTick 处理完才发位置)
process.nextTick(() => {
try {
if (bot._client && bot.entity) {
bot._client.write('tick_end', {});
this.tickEndSent++;
}
} catch {
// 忽略
}
});
}
};
bot.on('physicsTick', this._onProtocolTick);
this._log('协议补包已启用(player_input '
+ (this.playerInputSupported ? '已支持' : '当前版本不支持')
+ ',tick_end 每帧发送)');
}
// ==================== 旋转 ====================
/**
@@ -153,7 +227,7 @@ class GrimCompat {
*/
setLook(yaw, pitch) {
if (typeof yaw !== 'number' || Number.isNaN(yaw)) return Promise.resolve();
this.targetYaw = yaw;
this.targetYaw = wrapAngle(yaw);
this.targetPitch = typeof pitch === 'number' && !Number.isNaN(pitch)
? clamp(pitch, -Math.PI / 2, Math.PI / 2)
: this.targetPitch;
@@ -223,14 +297,25 @@ class GrimCompat {
});
}
/** physicsTick:把 entity.yaw/pitch 朝目标推进一小步(mineflayer 会在同一 tick 把该朝向发包) */
/**
* physicsTick:把 entity.yaw/pitch 朝目标推进一小步(mineflayer 会在同一 tick 把该朝向发包)。
*
* ⚠️ 每 tick 都要把 yaw **归一化到 (-π, π]**。
*
* mineflayer 收到服务端的 `position` 包时会直接 `bot.entity.yaw = conv.fromNotchianYaw(…)`,
* **不做取模**;本层又在它基础上累加。两者叠加会让 yaw 无界增长 ——
* 实测抓到过 **19254°(≈336 弧度)** 的 yaw:此时 mineflayer 发包时算出的
* `lastSentYaw` 与真实朝向差了整整若干圈,服务端收到的朝向在两个方向间来回跳
* (实测相邻两包折合相差约 **114°**),而机器人一直在按前进键,
* 于是它**原地走之字形、净位移恒为 0**(抓包里坐标在 0.4 格内反复振荡)。
*/
_onPhysicsTick() {
const bot = this.bot;
if (!bot.entity) return;
const step = this.maxStepRad;
const yawDelta = shortestAngleDelta(bot.entity.yaw, this.targetYaw);
if (Math.abs(yawDelta) > 1e-6) {
bot.entity.yaw += clamp(yawDelta, -step, step);
bot.entity.yaw = wrapAngle(bot.entity.yaw + clamp(yawDelta, -step, step));
} else {
bot.entity.yaw = this.targetYaw;
}
@@ -508,7 +593,15 @@ class GrimCompat {
attackCooldownMs: this.attackCooldownRemainingMs(),
blockedActions: this.blockedActions,
physicsOk: audit.ok,
issues: audit.issues
issues: audit.issues,
/** 协议补包状态:线上服务器上「走不动」时首先看这里 */
protocolPackets: {
playerInputSupported: this.playerInputSupported === true,
playerInputEnabled: this.config.grim.sendPlayerInput !== false,
playerInputSent: this.playerInputSent || 0,
tickEndEnabled: this.config.grim.sendTickEnd !== false,
tickEndSent: this.tickEndSent || 0
}
};
}
@@ -531,6 +624,25 @@ function shortestAngleDelta(from, to) {
return delta;
}
/**
* 把角度归一化到 (-π, π]。
*
* 必须做这一步:mineflayer 在 `position` 包里直接赋值 `entity.yaw`(不取模),
* 本层又在其上累加,两者叠加会让 yaw 无界增长 —— 实测抓到过 19254°(≈336 弧度),
* 后果是发包朝向在两个方向间来回跳、机器人原地走之字形。
*
* @param {number} angle 弧度
* @returns {number} 归一化后的弧度
*/
function wrapAngle(angle) {
if (!Number.isFinite(angle)) return 0;
const TWO_PI = Math.PI * 2;
let wrapped = angle % TWO_PI;
if (wrapped > Math.PI) wrapped -= TWO_PI;
else if (wrapped <= -Math.PI) wrapped += TWO_PI;
return wrapped;
}
/**
* 眼睛到实体碰撞箱的最近距离。
* @param {import('mineflayer').Bot} bot
@@ -576,6 +688,7 @@ module.exports = {
VANILLA_PHYSICS_BASELINE,
ATTACK_SPEED_RULES,
shortestAngleDelta,
wrapAngle,
eyeDistanceToEntity,
degToRad,
radToDeg
+304
查看文件
@@ -0,0 +1,304 @@
'use strict';
const { goals } = require('mineflayer-pathfinder');
const { shortestAngleDelta } = require('./grimCompat');
const { GoalNearXZ } = goals;
/**
* 手动执行器:只借用 mineflayer-pathfinder 的**规划**(A* 算路径),执行自己做。
*
* 背景(TODO.md 问题 1 的备选方案):
* 在邦国崛起这台服务器上,**手动按住方向键的移动完全正常**,而 pathfinder 的执行层
* 会被服务端反复回弹。因此留一条「只算路径、自己走」的路:
*
* 1. 用 `bot.pathfinder.getPathFromTo()` 拿到路径点数组(不执行);
* 2. 朝下一个路径点转(旋转仍走 Grim 合规层的逐 tick 限速);
* 3. 朝向对上后按住 `forward`,到位后切下一个点;
* 4. 遇到一格高的坎按一下 `jump`;长时间没有位移则重新规划。
*
* 控制键时序与「移动测试」的手动按键完全一致,因此出问题时可以逐 tick 对照排查。
* 默认不启用(`pathing.executor` 仍为 `pathfinder`),实测需要时再切换。
*/
/** 判定到达路径点的水平距离(格) */
const WAYPOINT_RADIUS = 0.6;
/**
* 判定到达路径点的垂直容差(格)。
*
* 必须小于 1:站在坎下(y 比路径点低 1 格)时不能算「已到达」,
* 否则会漏掉该跳的那一步,机器人会站在坎边「假装到了」。
*/
const WAYPOINT_Y_TOLERANCE = 0.5;
/** 朝向误差小于该值(弧度)才允许按住前进键,避免「斜着走」撞墙 */
const MAX_HEADING_ERROR = Math.PI / 6;
/** 判定需要跳的「目标高出脚下」阈值(格) */
const STEP_UP_THRESHOLD = 0.5;
/** 判定需要跳的水平距离上限(格):离坎太远就跳没有意义 */
const JUMP_RANGE = 1.6;
/** 跳一下按住多少 tick(与手动按键一致,避免长按连跳) */
const JUMP_HOLD_TICKS = 2;
/** 连续多少个物理 tick 没有位移判定为卡住(约 2 秒) */
const STUCK_TICKS = 40;
/** 单次 moveTo 最多重规划几次 */
const MAX_REPLANS = 3;
class ManualMover {
/**
* @param {import('mineflayer').Bot} bot
* @param {object} config 完整配置对象
* @param {import('../util/logger').Logger} [logger]
* @param {{plan?: Function, timeoutMs?: number}} [options]
* plan 可注入自定义规划函数(测试用),默认走 mineflayer-pathfinder 的 A*
*/
constructor(bot, config, logger, options) {
const opts = options || {};
this.bot = bot;
this.config = config;
this.logger = logger || null;
/** @type {(goalBlock: object) => Promise<Array<object>|null>} 规划函数 */
this.plan = opts.plan || ((goalBlock, runId) => this._planWithPathfinder(goalBlock, runId));
this.timeoutMs = opts.timeoutMs === undefined
? ((config.pathing && config.pathing.manualTimeoutMs) || 180000)
: opts.timeoutMs;
this._cancelled = true;
this._active = false;
this._jumpHoldTicks = 0;
/**
* 本次执行代次。
*
* stop() 与新一次 moveTo() 都会把它 +1,因此「上一代」的循环醒来后
* 会发现代次不匹配而立刻退出 —— 避免两次执行同时按键。
*/
this._runId = 0;
/** 最近一次规划的状态(success / noPath / timeout / noPathfinder) */
this._planStatus = null;
}
/** @returns {boolean} 是否正在执行 */
get active() {
return this._active;
}
/** @returns {string|null} 最近一次规划的状态 */
lastStatus() {
return this._planStatus;
}
/**
* 走到目标方块附近(水平 2 格内即可,与 GoalNearXZ 的到达判定一致)。
*
* @param {{x: number, z: number}} goalBlock 目标(方块坐标)
* @returns {Promise<{ok: boolean, reason: string, waypoints: number, ticks: number}>}
*/
async moveTo(goalBlock) {
const deadline = Date.now() + this.timeoutMs;
const runId = ++this._runId;
this._cancelled = false;
this._active = true;
let waypointCount = 0;
let ticks = 0;
this._log(`手动执行开始:目标 ${Math.round(goalBlock.x)},${Math.round(goalBlock.z)}`);
try {
for (let attempt = 0; attempt <= MAX_REPLANS; attempt++) {
const path = await this.plan(goalBlock, runId);
if (this._isStale(runId)) return { ok: false, reason: 'cancelled', waypoints: waypointCount, ticks };
if (!path || path.length === 0) {
const reason = this._planStatus || 'noPath';
this._log(`手动执行:规划失败(${reason})`);
return { ok: false, reason, waypoints: waypointCount, ticks };
}
waypointCount += path.length;
const walked = await this._follow(path, deadline, runId);
ticks += walked.ticks;
if (walked.ok) {
this._log(`手动执行完成:走过 ${waypointCount} 个路径点、${ticks} tick`);
return { ok: true, reason: 'arrived', waypoints: waypointCount, ticks };
}
if (walked.reason === 'cancelled' || walked.reason === 'timeout') {
return { ok: false, reason: walked.reason, waypoints: waypointCount, ticks };
}
this._log(`第 ${attempt + 1} 次执行中断(${walked.reason}),重新规划`);
}
return { ok: false, reason: 'stuck', waypoints: waypointCount, ticks };
} finally {
this._active = false;
this._releaseControls();
}
}
/** 取消当前执行(松开所有由本执行器按下的键) */
stop() {
this._cancelled = true;
// 代次 +1:让正在跑的循环在下一个 tick 立刻退出,避免与新执行抢控制键
this._runId++;
this._releaseControls();
}
/**
* 本次执行的循环是否应当退出(被取消,或已被更新的一代取代)。
* @param {number} runId
* @returns {boolean}
*/
_isStale(runId) {
return this._cancelled || runId !== this._runId;
}
/**
* 用 mineflayer-pathfinder 只算路径。
*
* `getPathFromTo` 是生成器:每次只算 `tickTimeout` 毫秒(与 pathfinder 自身一致),
* 因此这里逐个推进、在切片之间让出事件循环,避免长搜索卡住整个进程。
*
* @param {{x: number, z: number}} goalBlock
* @returns {Promise<Array<object>|null>} 路径点数组(失败返回 null)
*/
async _planWithPathfinder(goalBlock, runId) {
const bot = this.bot;
const pf = bot.pathfinder;
this._planStatus = null;
if (!pf || typeof pf.getPathFromTo !== 'function' || !bot.entity) {
this._planStatus = 'noPathfinder';
return null;
}
const goal = new GoalNearXZ(goalBlock.x, goalBlock.z, 2);
const generator = pf.getPathFromTo(pf.movements, bot.entity.position, goal, {
timeout: pf.thinkTimeout,
tickTimeout: pf.tickTimeout,
searchRadius: pf.searchRadius
});
let result = null;
for (;;) {
const next = generator.next();
if (next.done) break;
result = next.value.result;
if (result.status !== 'partial') break;
if (this._isStale(runId)) return null;
await bot.waitForTicks(1);
}
this._planStatus = result ? result.status : 'noPath';
if (!result || result.status !== 'success') return null;
return result.path;
}
/**
* 依次走完所有路径点。
* @param {Array<object>} path
* @param {number} deadline
* @param {number} runId
*/
async _follow(path, deadline, runId) {
let ticks = 0;
for (const node of path) {
const result = await this._walkToNode(node, deadline, runId);
ticks += result.ticks;
if (!result.ok) return { ok: false, reason: result.reason, ticks };
}
return { ok: true, reason: 'arrived', ticks };
}
/**
* 走向单个路径点:转朝向 → 按住前进 → 需要时跳一下。
* @param {{x: number, y: number, z: number}} node 路径点(方块坐标)
* @param {number} deadline
* @param {number} runId
*/
async _walkToNode(node, deadline, runId) {
const target = { x: node.x + 0.5, y: node.y, z: node.z + 0.5 };
let ticks = 0;
let stuckTicks = 0;
let lastX = this.bot.entity.position.x;
let lastZ = this.bot.entity.position.z;
while (!this._isStale(runId)) {
const pos = this.bot.entity.position;
const dx = target.x - pos.x;
const dz = target.z - pos.z;
const horizontal = Math.hypot(dx, dz);
if (horizontal <= WAYPOINT_RADIUS && Math.abs(target.y - pos.y) <= WAYPOINT_Y_TOLERANCE) {
return { ok: true, ticks };
}
if (Date.now() > deadline) return { ok: false, reason: 'timeout', ticks };
const yaw = Math.atan2(-dx, -dz);
this._look(yaw);
const headingError = Math.abs(shortestAngleDelta(this.bot.entity.yaw, yaw));
this._setControl('forward', headingError <= MAX_HEADING_ERROR);
// 目标比脚下高一格且已经靠近 → 跳一下(对应手动按键时的「卡坎就跳」)
const needJump = (target.y - pos.y) > STEP_UP_THRESHOLD && horizontal <= JUMP_RANGE;
if (needJump) {
this._setControl('jump', true);
this._jumpHoldTicks = JUMP_HOLD_TICKS;
} else if (this._jumpHoldTicks > 0) {
this._jumpHoldTicks--;
if (this._jumpHoldTicks === 0) this._setControl('jump', false);
}
// 卡住判定:水平位移长时间接近 0 就中断,交给上层重新规划
const moved = Math.hypot(pos.x - lastX, pos.z - lastZ);
if (moved < 0.02) {
stuckTicks++;
} else {
stuckTicks = 0;
lastX = pos.x;
lastZ = pos.z;
}
if (stuckTicks >= STUCK_TICKS) return { ok: false, reason: 'stuck', ticks };
ticks++;
await this.bot.waitForTicks(1);
}
return { ok: false, reason: 'cancelled', ticks };
}
/** 设置朝向(走 bot.look,即 Grim 合规层的逐 tick 限速路径) */
_look(yaw) {
const bot = this.bot;
try {
const pitch = bot.entity.pitch;
if (typeof bot.look === 'function') {
const result = bot.look(yaw, pitch);
// 限速推进的 Promise 不需要等待;避免未处理的 rejection
if (result && typeof result.catch === 'function') result.catch(() => {});
}
} catch {
// 朝向失败不影响主循环,下一 tick 会重试
}
}
/** 设置控制键(走 bot.setControlState,即 Grim 合规层的互斥规则) */
_setControl(control, state) {
try {
if (this.bot.getControlState && this.bot.getControlState(control) === state) return;
this.bot.setControlState(control, state);
} catch {
// 忽略:控制键设置在极端状态下可能失败(如骑乘),下一 tick 会重试
}
}
/** 松开本次执行按下的控制键 */
_releaseControls() {
this._jumpHoldTicks = 0;
for (const control of ['forward', 'jump']) {
try {
if (this.bot.getControlState && this.bot.getControlState(control) === true) {
this.bot.setControlState(control, false);
}
} catch {
// 忽略
}
}
}
_log(message) {
if (this.logger) this.logger.log(message);
}
}
module.exports = {
ManualMover,
WAYPOINT_RADIUS,
MAX_HEADING_ERROR,
STUCK_TICKS,
MAX_REPLANS
};
+388 -30
查看文件
@@ -2,9 +2,16 @@
const pathfinderPlugin = require('mineflayer-pathfinder');
const { ManualMover } = require('./manualMover');
const { GoalXZ, GoalNearXZ, Goal } = pathfinderPlugin.goals;
const { Movements } = pathfinderPlugin;
/** 单次寻路最多允许分成多少段(防止「不可达」目标导致无限重规划) */
const MAX_SEGMENTS = 200;
/** 分段路径算出 noPath 时最多收缩几次段长 */
const MAX_SEGMENT_SHRINK = 3;
/**
* 寻路服务:替代原 Java 版的 BaritoneBridge。
*
@@ -18,13 +25,24 @@ const { Movements } = pathfinderPlugin;
* - 大幅提高进入液体格的代价,优先绕开水域(mineflayer 液体物理与原版差异最大)
*/
/**
* 「寻路状态」日志的最小间隔(毫秒):进度日志最多这么频繁地记一条。
*
* mineflayer-pathfinder 每用满一个 tick 的计算预算就发一次 `path_update`,
* 不节流的话实测 4 分钟能写 3893 条,把真正有用的日志全淹了。
*/
const PATH_LOG_MIN_INTERVAL_MS = 2000;
/** 「寻路状态」日志的节点数增量阈值:探索推进这么多节点才值得再记一条 */
const PATH_LOG_NODE_STEP = 1000;
/** 路径事件枚举,语义对齐 Baritone 的 PathEvent */
const PathEvent = {
CALC_STARTED: 'CALC_STARTED',
const PathEvent = { CALC_STARTED: 'CALC_STARTED',
CALC_FINISHED_NOW_EXECUTING: 'CALC_FINISHED_NOW_EXECUTING',
CALC_FAILED: 'CALC_FAILED',
AT_GOAL: 'AT_GOAL',
CANCELED: 'CANCELED'
CANCELED: 'CANCELED',
/** 走完一段中途路径(长距离分段寻路时用,调用方应据此重置超时计时) */
SEGMENT_REACHED: 'SEGMENT_REACHED'
};
class PathingService {
@@ -48,6 +66,40 @@ class PathingService {
this._pathing = false;
this._lastStatus = null;
this._configured = false;
/** 「寻路状态」日志的节流状态:上次记的状态 / 时间 / 节点数 */
this._lastLoggedStatus = null;
this._lastPathLogAt = 0;
this._lastLoggedNodes = 0;
/** 事件监听是否已注册(configure 会被反复调用,不能重复注册) */
this._listenersBound = false;
/**
* 长距离分段寻路的状态。
*
* mineflayer-pathfinder 只能规划**已加载区块**范围内(实测本服约 8 区块 ≈ 130 格),
* 而扩地目标常常在几十个区块之外,于是 A* 瞬间返回 noPath(表现为「目标不可达」)。
* 解决办法是把整段旅程拆成若干段:先走到已加载范围边缘附近的中途点,
* 走完后新区块已加载,再规划下一段,直到抵达真正的目标。
*/
this.finalTarget = null;
/** 当前这一段的中途点(方块坐标) */
this.currentWaypoint = null;
/** 当前中途点是否就是最终目标 */
this._waypointIsFinal = false;
/** 本次 pathTo 已走的段数,用于防止无限循环 */
this._segmentCount = 0;
/** 段长收缩次数与系数(本段算出 noPath 时用来缩短段长重试) */
this._segmentShrink = 0;
this._segmentScale = 1;
/**
* 手动执行器(`pathing.executor === 'manual'` 时创建)。
*
* 只借用 pathfinder 的 A* 规划,执行改为自己按键(详见 manualMover.js 的说明);
* 默认不启用,实测确认寻路器执行层被服务端回弹时才切换。
* @type {ManualMover|null}
*/
this.mover = null;
/** 手动执行的代次:新一次执行会让旧的一次自行退出 */
this._manualRunId = 0;
}
/**
@@ -82,21 +134,32 @@ class PathingService {
movements.liquidCost = Math.max(movements.liquidCost, 200);
}
bot.pathfinder.setMovements(movements);
// 搜索超时与半径:默认值偏保守,山区大地图容易 noPath
bot.pathfinder.thinkTimeout = 10000;
bot.pathfinder.tickTimeout = 40;
bot.pathfinder.searchRadius = 256;
// 搜索预算:默认值偏保守,山区 / 建筑密集区容易在起点附近就剪枝掉所有分支
// (表现为 noPath 且「已探索节点数」极小)。searchRadius 为负表示不限制。
bot.pathfinder.thinkTimeout = this.config.pathing.thinkTimeoutMs;
bot.pathfinder.tickTimeout = this.config.pathing.tickTimeoutMs;
bot.pathfinder.searchRadius = this.config.pathing.searchRadius;
bot.on('goal_reached', () => {
this._pathing = false;
this._emit(PathEvent.AT_GOAL, { target: this.currentTarget });
});
// 事件监听只注册一次:configure() 在每次配置变更时都会重跑,
// 重复注册会让同一个事件被处理多次(日志里同一秒出现多条相同事件就是这么来的)
if (!this._listenersBound) {
this._listenersBound = true;
bot.on('goal_reached', () => this._onSegmentReached());
bot.on('path_update', (r) => {
this._lastStatus = r.status;
this._logPathProgress(r);
if (r.status === 'success') {
this.calcFailed = false;
this._emit(PathEvent.CALC_FINISHED_NOW_EXECUTING, { cost: r.cost, nodes: r.visitedNodes });
} else if (r.status === 'noPath') {
// 分段后仍不可达:可能是已加载范围的探测偏乐观,缩短段长再试一次
if (!this._waypointIsFinal && this.finalTarget && this._segmentShrink < MAX_SEGMENT_SHRINK) {
this._segmentShrink++;
this._segmentScale *= 0.6;
this._log(`本段不可达,缩短段长后重试(第 ${this._segmentShrink}/${MAX_SEGMENT_SHRINK} 次)`);
this._planSegment();
return;
}
this.calcFailed = true;
this._pathing = false;
this._emit(PathEvent.CALC_FAILED, { target: this.currentTarget });
@@ -106,13 +169,21 @@ class PathingService {
this._emit(PathEvent.CALC_FAILED, { target: this.currentTarget, timeout: true });
}
});
bot.on('path_reset', (reason) => {
if (reason !== 'chunk_loaded' && reason !== 'movement_update') {
this._emit(PathEvent.CANCELED, { reason });
}
});
bot.on('path_reset', (reason) => {
if (reason !== 'chunk_loaded' && reason !== 'movement_update') {
this._emit(PathEvent.CANCELED, { reason });
}
});
}
this.ready = true;
this._configured = true;
// 手动执行模式:只保留 A* 规划能力,执行交给 ManualMover(备选方案,默认不启用)
if (this.config.pathing.executor === 'manual') {
this.mover = new ManualMover(bot, this.config, this.logger);
this._log('寻路执行器:手动模式(只借用 A* 算路径,自己按键执行)');
} else {
this.mover = null;
}
this._log(`寻路器就绪(挖方块 ${this.config.allowBreak ? '开' : '关'},`
+ `放方块 ${this.config.allowPlace ? '开' : '关'},`
+ `跑酷 ${this.config.pathing.allowParkour ? '开' : '关'},`
@@ -120,6 +191,40 @@ class PathingService {
return true;
}
/**
* 记录寻路进度(**带节流**)。
*
* mineflayer-pathfinder 每用满一个 tick 的计算预算(`tickTimeout`,默认 40ms)
* 就发一次 `path_update`。长距离规划时这会以**每秒十几到几十条**的频率刷屏 ——
* 实测 4 分钟写了 **3893 条**「寻路状态」,既淹没了真正有用的日志
* (按 120 字节/行算,一天能写上百 MB,本服磁盘本就只剩 2.4G),
* 也让「寻路到底卡在哪」更难看清。
*
* 所以只在下面三种情况下记一条:
* 1. 状态发生变化(partial → success / noPath / timeout)—— 这是最要紧的;
* 2. 距上一条超过 {@link PATH_LOG_MIN_INTERVAL_MS};
* 3. 已探索节点数比上一条多出 {@link PATH_LOG_NODE_STEP} 以上
* —— 长规划里仍能看出「探索推进得有多快」,不会丢掉定位卡死所需的信息。
*
* @param {{status: string, visitedNodes?: number, cost?: number}} r path_update 事件
*/
_logPathProgress(r) {
const now = Date.now();
const nodes = Number.isFinite(r.visitedNodes) ? r.visitedNodes : 0;
const statusChanged = r.status !== this._lastLoggedStatus;
const stale = now - this._lastPathLogAt >= PATH_LOG_MIN_INTERVAL_MS;
const progressed = nodes - this._lastLoggedNodes >= PATH_LOG_NODE_STEP;
if (!statusChanged && !stale && !progressed) return;
this._lastLoggedStatus = r.status;
this._lastPathLogAt = now;
this._lastLoggedNodes = nodes;
this._log(`寻路状态:${r.status}`
+ (r.visitedNodes !== undefined ? `(已探索 ${r.visitedNodes} 节点)` : '')
+ (r.cost !== undefined ? `(代价 ${r.cost})` : '')
+ `,本段目标 ${this.currentWaypoint ? Math.round(this.currentWaypoint.x) + ',' + Math.round(this.currentWaypoint.z) : '无'}`
+ `,最终目标 ${this.finalTarget ? Math.round(this.finalTarget.x) + ',' + Math.round(this.finalTarget.z) : '无'}`);
}
/**
* 配置变更后重新应用(不重建事件监听)。
* @param {object} config 新配置
@@ -150,20 +255,40 @@ class PathingService {
const bot = this.bot;
if (!this.isAvailable()) return false;
const opts = options || {};
const blockX = chunk.x * 16 + 8;
const blockZ = chunk.z * 16 + 8;
const startY = Math.floor(bot.entity.position.y);
let goal = new GoalNearXZ(blockX, blockZ, 2);
if (opts.y !== undefined && opts.y !== null) {
goal = new GoalNearXZ(blockX, blockZ, 2);
}
if (this.config.altitudePreference && this.config.altitudeWeight > 0) {
goal = new AltitudeGoal(goal, this.config.altitudeWeight, startY);
}
this.currentTarget = { x: chunk.x, z: chunk.z };
this.finalTarget = { x: chunk.x * 16 + 8, z: chunk.z * 16 + 8 };
this._segmentCount = 0;
this._segmentShrink = 0;
this._segmentScale = 1;
this.calcFailed = false;
this._pathing = true;
this._emit(PathEvent.CALC_STARTED, { target: this.currentTarget });
return this._planSegment(opts);
}
/**
* 规划「当前这一段」的路径:把最终目标裁剪到已加载范围内。
*
* @param {{y?: number}} [options]
* @returns {boolean} 是否成功下发
*/
_planSegment(options) {
const bot = this.bot;
const opts = options || {};
const goalBlock = this._segmentGoal(this.finalTarget);
this.currentWaypoint = goalBlock;
this._waypointIsFinal = goalBlock.x === this.finalTarget.x && goalBlock.z === this.finalTarget.z;
this.calcFailed = false;
this._pathing = true;
// 手动执行模式:同样只规划「这一段」,执行交给 ManualMover(不阻塞调用方)
if (this.mover) return this._startManualRun(goalBlock);
let goal = new GoalNearXZ(goalBlock.x, goalBlock.z, 2);
if (this.config.altitudePreference && this.config.altitudeWeight > 0) {
const startY = Math.floor(bot.entity.position.y);
goal = new AltitudeGoal(goal, this.config.altitudeWeight, startY);
}
try {
bot.pathfinder.setGoal(goal);
} catch (err) {
@@ -176,11 +301,230 @@ class PathingService {
return true;
}
/**
* 启动一次手动执行(备选方案)。
*
* 与 pathfinder 的执行保持同一套对外语义:走完一段 → `_onSegmentReached()`(会继续
* 规划下一段或报到达);走不动 → 与 noPath 一样先缩短段长重试,再如实报失败。
*
* @param {{x: number, z: number}} goalBlock
* @returns {boolean}
*/
_startManualRun(goalBlock) {
if (!this.mover) return false;
const runId = ++this._manualRunId;
this.mover.stop();
this.mover.moveTo(goalBlock)
.then((result) => this._onManualOutcome(result, runId))
.catch((err) => {
if (runId !== this._manualRunId) return;
this.calcFailed = true;
this._pathing = false;
this._log('手动执行异常:' + (err && err.message ? err.message : String(err)));
this._emit(PathEvent.CALC_FAILED, { target: this.currentTarget, error: err && err.message });
});
return true;
}
/**
* 手动执行的结果处理。
* @param {{ok: boolean, reason: string, waypoints: number, ticks: number}} result
* @param {number} runId
*/
_onManualOutcome(result, runId) {
if (runId !== this._manualRunId) return; // 已被取消或被新的一段取代
if (result.ok) {
this._onSegmentReached();
return;
}
if (result.reason === 'cancelled') return;
if (!this._waypointIsFinal && this.finalTarget && this._segmentShrink < MAX_SEGMENT_SHRINK) {
this._segmentShrink++;
this._segmentScale *= 0.6;
this._log(`手动执行中断(${result.reason}),缩短段长后重试(第 ${this._segmentShrink}/${MAX_SEGMENT_SHRINK} 次)`);
this._planSegment();
return;
}
this.calcFailed = true;
this._pathing = false;
this._log(`手动执行失败:${result.reason}`
+ `(走过 ${result.waypoints} 个路径点、${result.ticks} tick)`);
this._emit(PathEvent.CALC_FAILED, { target: this.currentTarget, reason: result.reason });
}
/**
* 到达当前段的终点:是最终目标就报到达,否则继续规划下一段。
* pathfinder 的 `goal_reached` 与手动执行共用这一套逻辑。
*/
_onSegmentReached() {
this._pathing = false;
if (this._waypointIsFinal || !this.finalTarget) {
this._emit(PathEvent.AT_GOAL, { target: this.currentTarget });
return;
}
// 只走到了中途点:新区块已加载,继续朝最终目标规划下一段
this._segmentCount++;
if (this._segmentCount > MAX_SEGMENTS) {
this._log(`分段寻路超过 ${MAX_SEGMENTS} 段仍未到达目标,判定为不可达`);
this.calcFailed = true;
this._emit(PathEvent.CALC_FAILED, { target: this.currentTarget, reason: 'too-many-segments' });
return;
}
this._log(`已到达中途点 ${Math.round(this.currentWaypoint.x)},${Math.round(this.currentWaypoint.z)},`
+ `继续朝目标 ${this.finalTarget.x},${this.finalTarget.z} 前进(第 ${this._segmentCount} 段)`);
this._emit(PathEvent.SEGMENT_REACHED, { segment: this._segmentCount, target: this.currentTarget });
this._planSegment();
}
/**
* 把最终目标裁剪到「当前已加载的范围内」。
*
* 不做任何世界内部结构的猜测:沿目标方向直接探测最远的已加载位置
* (`bot.blockAt` 对未加载区块返回 null),再留出安全边距。
*
* @param {{x: number, z: number}} finalBlock 最终目标(方块坐标)
* @returns {{x: number, z: number}} 本段实际要去的点
*/
_segmentGoal(finalBlock) {
const bot = this.bot;
if (!bot.entity || !finalBlock) return finalBlock;
const pos = bot.entity.position;
const dx = finalBlock.x - pos.x;
const dz = finalBlock.z - pos.z;
const distance = Math.hypot(dx, dz);
if (distance <= 1) return finalBlock;
const pathing = this.config.pathing || {};
const maxSegment = Math.max(16, pathing.maxSegmentBlocks || 512);
const margin = Math.max(0, pathing.segmentMarginBlocks === undefined ? 24 : pathing.segmentMarginBlocks);
const unitX = dx / distance;
const unitZ = dz / distance;
// 只需要探测到「可能用到的最大段长」就够了
const loaded = this._probeLoadedDistance(unitX, unitZ, maxSegment + margin);
// 段长同时受「已加载范围 - 边距」和「单段上限」两个约束
const usable = Math.max(16, Math.min(loaded - margin, maxSegment) * this._segmentScale);
if (usable >= distance) return finalBlock;
return {
x: Math.round(pos.x + unitX * usable),
z: Math.round(pos.z + unitZ * usable)
};
}
/**
* 沿给定方向二分探测最远的「已加载」距离(方块)。
*
* 依据是已加载区块构成一个以玩家为中心的圆,因此「越远越可能未加载」是单调的。
* 同时在脚下与脚上一格取样,避免刚好落在空气层里被误判为未加载。
*
* @param {number} unitX 单位方向 X
* @param {number} unitZ 单位方向 Z
* @param {number} maxBlocks 探测上限
* @returns {number} 最远的已加载距离
*/
_probeLoadedDistance(unitX, unitZ, maxBlocks) {
const bot = this.bot;
if (!bot.blockAt || !bot.entity) return 0;
const pos = bot.entity.position;
const isLoaded = (d) => {
try {
const feet = bot.blockAt(pos.offset(unitX * d, 0, unitZ * d));
if (feet) return true;
return !!bot.blockAt(pos.offset(unitX * d, 1, unitZ * d));
} catch {
return false;
}
};
if (!isLoaded(0)) return 0;
let lo = 0;
let hi = maxBlocks;
// 迭代次数决定精度:maxBlocks / 2^14 ≈ 0.25 格,足够贴合区块加载边界
for (let i = 0; i < 14; i++) {
const mid = (lo + hi) / 2;
if (isLoaded(mid)) lo = mid;
else hi = mid;
}
return lo;
}
/**
* 寻路诊断:直接问寻路器「从当前位置能往哪些格子走」。
*
* 这是排查「A* 只探索几十个节点就 noPath」的关键手段:
* 如果 `neighbors` 为 0 或极少,说明不是目标太远,而是**寻路器读不到世界数据**
* (Movements 依赖 bot.world 的方块查询),此时调任何搜索参数都没用。
*
* @returns {object|null}
*/
diagnostics() {
const bot = this.bot;
if (!bot || !bot.entity || !bot.pathfinder) return null;
const pos = bot.entity.position;
const feet = { x: Math.floor(pos.x), y: Math.floor(pos.y), z: Math.floor(pos.z) };
let neighbors = [];
let error = '';
try {
const movements = bot.pathfinder.movements;
// 寻路器内部用的就是这种节点结构(x/y/z + 代价字段)
neighbors = movements.getNeighbors({
x: feet.x,
y: feet.y,
z: feet.z,
remainingBlocks: 0,
cost: 0,
hash: `${feet.x},${feet.y},${feet.z}`
}) || [];
} catch (err) {
error = err && err.message ? err.message : String(err);
}
const world = bot.world;
const worldApi = {
hasGetBlock: typeof world.getBlock === 'function',
hasGetBlockStateId: typeof world.getBlockStateId === 'function',
hasGetColumn: typeof world.getColumn === 'function'
};
// 用寻路器同款 API 采样脚下方块,看它到底能不能读到世界
let blockViaWorld = null;
try {
const b = world.getBlock ? world.getBlock(bot.entity.position.offset(0, -1, 0)) : null;
blockViaWorld = b ? b.name : null;
} catch (err) {
blockViaWorld = 'ERR:' + (err && err.message ? err.message : String(err));
}
return {
feet,
neighborCount: neighbors.length,
neighbors: neighbors.slice(0, 8).map((n) => ({ x: n.x, y: n.y, z: n.z, cost: n.cost })),
error,
worldApi,
blockViaWorld,
blockViaBlockAt: bot.blockAt ? (bot.blockAt(bot.entity.position.offset(0, -1, 0)) || {}).name : null,
/** 当前使用的执行器:pathfinder(默认)或 manual(备选方案) */
executor: this.mover ? 'manual' : 'pathfinder',
/** 手动执行器的最近一次规划状态(仅在 manual 模式下有意义) */
manualPlanStatus: this.mover ? this.mover.lastStatus() : null,
/** 四个方向能「看到」多远(方块):反映已加载区块范围 */
visibleDistance: {
north: Math.round(this._probeLoadedDistance(0, -1, 256)),
south: Math.round(this._probeLoadedDistance(0, 1, 256)),
east: Math.round(this._probeLoadedDistance(1, 0, 256)),
west: Math.round(this._probeLoadedDistance(-1, 0, 256))
}
};
}
/** 取消当前寻路 */
cancel() {
if (!this.isAvailable()) return;
cancel() { if (!this.isAvailable()) return;
this._pathing = false;
this.currentTarget = null;
this.finalTarget = null;
this.currentWaypoint = null;
this._waypointIsFinal = false;
this._segmentCount = 0;
this._segmentShrink = 0;
this._segmentScale = 1;
// 手动执行模式:停掉执行器并让在途的结果失效(否则它会继续按键)
this._manualRunId++;
if (this.mover) this.mover.stop();
try {
this.bot.pathfinder.setGoal(null);
} catch {
@@ -213,8 +557,22 @@ class PathingService {
* 对应原 Java 版的 GoalXZAltitude。
*
* heuristic = max(0, 基础启发值 - 权重 × 节点高度)
* 即 y 越大启发成本越低(A* 更倾向走高处),y 越小成本越高(对走低处施惩罚),
* clamp 到非负以免破坏 A* 的排序。到达判定与内层目标完全一致(不约束高度)。
*
* ⚠️ **这个实现会让寻路器完全规划不出路径,默认已关闭(见 `altitudePreference`)。**
*
* 原因是它与 mineflayer-pathfinder 的内部约定冲突:该库在每次 `compute()` 返回
* `partial` 时,用 **`h` 最小** 的节点当作 partial 路径的终点
* (`astar.js`: `if (neighborNode.h < this.bestNode.h) this.bestNode = neighborNode`)。
* 而这里把 `h` 减去了 `权重 × y`,于是「又高又偏」的节点 h 反而更小,
* partial 路径被引向高处而不是目标附近 —— 表现就是 A* 一直在算、永远不收敛,
* 机器人一次移动指令都发不出来。
*
* 实测(2026-10-01,王城平台上,其余配置完全相同):
* - 开启:`inputTicks = 0`,任务永远停在「寻路超时,重新寻路」;
* - 关闭:`inputTicks = 237`,立刻开始移动。
*
* 若要重新实现高度偏好,正确做法是给**低处邻居加移动代价**(改 `getNeighbors`
* 返回的 `cost`),保持启发值只反映到目标的真实距离。
*/
class AltitudeGoal extends Goal {
/**
+455
查看文件
@@ -0,0 +1,455 @@
'use strict';
/** 单个任务的间隔下限(秒):再短就近似刷屏,容易被服务器或反作弊盯上 */
const MIN_INTERVAL_SECONDS = 5;
/** 单个机器人最多允许的定时任务数 */
const MAX_TASKS = 20;
/** 调度器的心跳间隔(毫秒):只做「到点了吗」的判断,开销可忽略 */
const TICK_INTERVAL_MS = 1000;
/** 发送结果确认的默认超时(秒) */
const DEFAULT_CONFIRM_TIMEOUT_SECONDS = 10;
/**
* 定时消息器:按固定间隔或 cron 表达式重复发送同一条内容(公屏消息或游戏指令)。
*
* 典型用法是服务器要求周期性执行的指令,例如每 300 秒发一次 `/u restore confirm`
* 来维持疆土民心,或者每天 09:00 发一次公告。这类「心跳式」指令是挂机机器人的刚需,
* 但绝不能简单地 setInterval —— 断线、未登录、正在回城时都不该发,
* 否则要么白发(服务端丢弃),要么在错误的时机打断状态机。
*
* 因此这里做成:
* - **两种调度**:`intervalSeconds`(固定间隔)或 `cron`(5 段 cron 表达式,本地时间)。
* 例如「0 9 * * *」是每天 09:00,「0/30 * * * *」是每 30 分钟(步长写法见 parseCron);
* - **发送结果确认**(可选):配了 `expectPattern`(正则)的任务在发出后会等待服务器的
* 回复,超时未回复记为「未确认」并写警告 —— 否则 `/u restore confirm` 到底成没成,
* 只能靠人翻日志;
* - 只在 `canSendChat()`(已连接 + 已通过登录验证)时才发;
* - 未就绪时跳过本次并顺延,不补发(避免重连后一次性连发多条);
* - 与机器人共用指令节流队列,不会绕过 `server.commandCooldownMs` 刷屏。
*/
class ScheduledMessenger {
/**
* @param {import('./botManager').BotManager} manager
* @param {import('../util/logger').Logger} [logger]
*/
constructor(manager, logger) {
this.manager = manager;
this.logger = logger || null;
/** @type {Array<object>} 定时任务列表 */
this.tasks = [];
/** @type {Map<string, number>} 任务 id → 下次应发送的时间戳(cron 任务可能是 0=待计算) */
this._nextDueAt = new Map();
/** @type {Map<string, object>} 任务 id → 预编译的 cron 表达式 */
this._parsedCron = new Map();
/** @type {Map<string, object>} 任务 id → 待确认的发送(等服务器的回复) */
this._pending = new Map();
this._timer = null;
this._onChatBound = (message) => this._onChatMessage(message);
this._chatBound = false;
}
/**
* 设置任务列表(整体替换),并重置各任务的计时。
*
* @param {Array<object>} tasks 原始任务定义
* @param {number} [now] 当前时间戳(默认取系统时间;测试里注入固定值即可不必真等)
* @returns {Array<object>} 归一化后的任务列表
*/
setTasks(tasks, now) {
const current = Number.isFinite(now) ? now : Date.now();
this.tasks = normalizeTasks(tasks);
this._nextDueAt.clear();
this._parsedCron.clear();
this._pending.clear();
for (const task of this.tasks) {
if (task.mode === 'cron') {
const parsed = parseCron(task.cron);
this._parsedCron.set(task.id, parsed);
// cron 任务按「下一次匹配时刻」调度;表达式一年内无匹配时退回间隔模式
const next = parsed ? nextCronTime(parsed, current) : null;
if (next === null) {
task.mode = 'interval';
task.cronError = '表达式一年内没有匹配时刻,已按固定间隔发送';
this._nextDueAt.set(task.id, current + task.intervalSeconds * 1000);
} else {
this._nextDueAt.set(task.id, next);
}
} else {
// 首次不立即发送:给机器人留出进入世界与登录的时间
this._nextDueAt.set(task.id, current + task.intervalSeconds * 1000);
}
}
return this.tasks;
}
/** 启动调度心跳(并开始监听服务器回复,用于发送结果确认) */
start() {
if (!this._chatBound && typeof this.manager.on === 'function') {
this.manager.on('chat', this._onChatBound);
this._chatBound = true;
}
if (this._timer) return;
this._timer = setInterval(() => this.tick(), TICK_INTERVAL_MS);
if (this._timer.unref) this._timer.unref();
}
/** 停止调度心跳 */
stop() {
if (this._timer) clearInterval(this._timer);
this._timer = null;
if (this._chatBound && typeof this.manager.removeListener === 'function') {
this.manager.removeListener('chat', this._onChatBound);
this._chatBound = false;
}
}
/**
* 心跳:把所有到点的任务发出去,并检查待确认的发送是否超时。
*
* 抽成公开方法是为了可测 —— 测试里直接调 `tick()` 并注入当前时间,
* 不必真的等 300 秒。
*
* @param {number} [now] 当前时间戳(默认取系统时间)
* @returns {number} 本次实际发出的条数
*/
tick(now) {
const current = Number.isFinite(now) ? now : Date.now();
this._checkPendingConfirmations(current);
let sent = 0;
for (const task of this.tasks) {
if (!task.enabled) continue;
const due = this._nextDueAt.get(task.id);
if (due === undefined) {
this._reschedule(task, current);
continue;
}
if (current < due) continue;
// 未就绪就顺延到下一个周期,不补发
if (!this.manager.canSendChat()) {
this._reschedule(task, current);
continue;
}
const ok = this.manager.sendChat(task.content);
this._reschedule(task, current);
if (ok) {
sent++;
task.sentCount = (task.sentCount || 0) + 1;
this._armConfirmation(task, current);
this._log(`定时任务「${task.label || task.id}」已发送:${task.content}`);
}
}
return sent;
}
/**
* 排下一次发送时间:cron 任务取下一个匹配时刻,间隔任务取 now + interval。
* @param {object} task
* @param {number} now
*/
_reschedule(task, now) {
if (task.mode === 'cron') {
const parsed = this._parsedCron.get(task.id);
const next = parsed ? nextCronTime(parsed, now) : null;
if (next !== null) {
this._nextDueAt.set(task.id, next);
return;
}
// 理论上不会发生(setTasks 时已校验),兜底退回间隔模式
task.mode = 'interval';
}
this._nextDueAt.set(task.id, now + task.intervalSeconds * 1000);
}
/**
* 登记一条「待确认」:只有配了 expectPattern 的任务才需要确认服务器是否响应。
* @param {object} task
* @param {number} now
*/
_armConfirmation(task, now) {
if (!task.expectPattern) return;
this._pending.set(task.id, {
taskId: task.id,
pattern: buildPattern(task.expectPattern),
sentAt: now,
deadline: now + task.confirmTimeoutSeconds * 1000
});
}
/** 待确认超时的任务记为「未确认」并写警告 */
_checkPendingConfirmations(now) {
if (this._pending.size === 0) return;
for (const [taskId, pending] of Array.from(this._pending)) {
if (now < pending.deadline) continue;
this._pending.delete(taskId);
const task = this.tasks.find((t) => t.id === taskId);
if (!task) continue;
task.lastResult = {
at: pending.sentAt,
ok: false,
detail: `${task.confirmTimeoutSeconds} 秒内没有收到预期回复`
};
this._log(`定时任务「${task.label || task.id}」发送后 ${task.confirmTimeoutSeconds} 秒内`
+ `没有收到预期回复(${task.expectPattern})—— 指令可能没有生效`, 'warn');
}
}
/**
* 收到游戏消息:命中待确认任务的模式就记为「已确认生效」。
* @param {string} message
*/
_onChatMessage(message) {
if (!message || this._pending.size === 0) return;
const text = String(message);
for (const [taskId, pending] of Array.from(this._pending)) {
if (!pending.pattern.test(text)) continue;
this._pending.delete(taskId);
const task = this.tasks.find((t) => t.id === taskId);
if (task) {
task.lastResult = { at: Date.now(), ok: true, detail: text.slice(0, 120) };
this._log(`定时任务「${task.label || task.id}」已确认生效:${text.slice(0, 80)}`, 'success');
}
break;
}
}
/** @returns {object} 供控制台展示的状态快照 */
snapshot() {
const now = Date.now();
return this.tasks.map((task) => {
const due = this._nextDueAt.get(task.id);
return Object.assign({}, task, {
nextDueAt: due || 0,
nextInSeconds: due ? Math.max(0, Math.round((due - now) / 1000)) : null
});
});
}
_log(message, level) {
if (this.logger) this.logger.log(`[${this.manager.connection.username}] ${message}`, level);
}
}
// ==================== 任务归一化 ====================
/**
* 归一化任务列表:丢弃非法项、补齐默认值、限制数量。
*
* 调度方式二选一:
* - 写了合法的 `cron`(5 段表达式)→ cron 模式;
* - 否则按 `intervalSeconds` 固定间隔。
*
* @param {Array<object>} tasks
* @returns {Array<object>}
*/
function normalizeTasks(tasks) {
if (!Array.isArray(tasks)) return [];
const result = [];
const seen = new Set();
for (let i = 0; i < tasks.length && result.length < MAX_TASKS; i++) {
const raw = tasks[i];
if (!raw || typeof raw !== 'object') continue;
const content = String(raw.content === undefined || raw.content === null ? '' : raw.content).trim();
if (!content) continue;
let id = String(raw.id || '').trim();
if (!id || seen.has(id)) id = `task-${i + 1}`;
while (seen.has(id)) id += '_';
seen.add(id);
const cronText = String(raw.cron || '').trim();
const cronValid = cronText !== '' && parseCron(cronText) !== null;
const task = {
id,
label: String(raw.label || '').trim(),
content,
enabled: raw.enabled !== false,
/** 调度模式:cron(cron 表达式)| interval(固定间隔) */
mode: cronValid ? 'cron' : 'interval',
intervalSeconds: clampInterval(raw.intervalSeconds),
cron: cronValid ? cronText : '',
/** 期望的服务器回复(正则):用于确认发送结果,留空表示不确认 */
expectPattern: String(raw.expectPattern || '').trim(),
confirmTimeoutSeconds: clampConfirmTimeout(raw.confirmTimeoutSeconds),
/** 累计发送次数与最近一次发送结果(确认成功 / 超时未确认) */
sentCount: Number.isFinite(raw.sentCount) ? raw.sentCount : 0,
lastResult: raw.lastResult || null,
/** 以 `/` 开头的内容会被 sendChat 自动转成指令 */
kind: content.startsWith('/') ? 'command' : 'chat'
};
if (cronText !== '' && !cronValid) {
task.cronError = `cron 表达式无效:${cronText}(已按固定间隔发送)`;
}
result.push(task);
}
return result;
}
/**
* 把间隔钳制到合理范围。
* @param {*} value
* @returns {number}
*/
function clampInterval(value) {
const n = Number(value);
if (!Number.isFinite(n)) return 300;
return Math.min(86400, Math.max(MIN_INTERVAL_SECONDS, Math.trunc(n)));
}
/**
* 把确认超时钳制到合理范围(秒)。
* @param {*} value
* @returns {number}
*/
function clampConfirmTimeout(value) {
const n = Number(value);
if (!Number.isFinite(n)) return DEFAULT_CONFIRM_TIMEOUT_SECONDS;
return Math.min(120, Math.max(1, Math.trunc(n)));
}
/**
* 把「期望回复」编译成正则;非法正则退化为「包含该文本」。
* 用户在这里通常写的是服务器提示语片段,两种都能命中。
*
* @param {string} expect
* @returns {RegExp}
*/
function buildPattern(expect) {
try {
return new RegExp(expect);
} catch {
return new RegExp(escapeRegExp(expect));
}
}
function escapeRegExp(text) {
return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// ==================== cron 表达式 ====================
/**
* 解析 5 段 cron 表达式:`分 时 日 月 周`(本地时间)。
*
* 支持 `*`、`星号/步长`、`a-b`、`a,b,c` 与数字;周为 0-6(0 = 周日)。
* 刻意不支持月份 / 星期的英文名,也不支持秒级与 @daily 这类别名 ——
* 面板上的输入框写不下更多,够用即可。
*
* @param {string} expression
* @returns {{minutes: number[], hours: number[], daysOfMonth: number[], months: number[], daysOfWeek: number[]}|null}
*/
function parseCron(expression) {
const parts = String(expression || '').trim().split(/\s+/);
if (parts.length !== 5) return null;
const minutes = parseCronField(parts[0], 0, 59);
const hours = parseCronField(parts[1], 0, 23);
const daysOfMonth = parseCronField(parts[2], 1, 31);
const months = parseCronField(parts[3], 1, 12);
const daysOfWeek = parseCronField(parts[4], 0, 6);
if (!minutes || !hours || !daysOfMonth || !months || !daysOfWeek) return null;
return { minutes, hours, daysOfMonth, months, daysOfWeek };
}
/**
* 解析单个字段。
* @param {string} field
* @param {number} min
* @param {number} max
* @returns {number[]|null} 升序的取值列表;非法返回 null
*/
function parseCronField(field, min, max) {
const values = new Set();
for (const part of String(field).split(',')) {
const piece = part.trim();
if (!piece) return null;
let range = piece;
let step = 1;
const stepMatch = /^(.+)\/(\d+)$/.exec(piece);
if (stepMatch) {
range = stepMatch[1];
step = Number(stepMatch[2]);
if (!Number.isInteger(step) || step < 1) return null;
}
let from;
let to;
if (range === '*') {
from = min;
to = max;
} else if (range.includes('-')) {
const bounds = range.split('-').map((value) => Number(value));
from = bounds[0];
to = bounds[1];
if (!Number.isInteger(from) || !Number.isInteger(to) || from < min || to > max || from > to) return null;
} else {
const value = Number(range);
if (!Number.isInteger(value) || value < min || value > max) return null;
from = value;
// 「起点/步长」写法(如分钟位 0/30 = 每 30 分钟)按「从起点到字段上限」展开,
// 与主流 cron 实现一致;不带步长时就是个单值。
to = step > 1 ? max : value;
}
for (let value = from; value <= to; value += step) values.add(value);
}
return values.size > 0 ? Array.from(values).sort((a, b) => a - b) : null;
}
/**
* 某一天是否匹配「日 / 月 / 周」三个字段。
*
* 与标准 cron 一致:日与周都不是 `*` 时取**并集**(任一匹配即算匹配)。
*
* @param {object} parsed
* @param {Date} date
* @returns {boolean}
*/
function cronDayMatches(parsed, date) {
if (!parsed.months.includes(date.getMonth() + 1)) return false;
const domAll = parsed.daysOfMonth.length === 31;
const dowAll = parsed.daysOfWeek.length === 7;
const domMatch = parsed.daysOfMonth.includes(date.getDate());
const dowMatch = parsed.daysOfWeek.includes(date.getDay());
if (domAll && dowAll) return true;
if (domAll) return dowMatch;
if (dowAll) return domMatch;
return domMatch || dowMatch;
}
/**
* 计算下一次匹配时刻。
*
* 按天向前扫描(最多 4 年,覆盖 2 月 29 日这类稀疏表达式),
* 命中后再取当天第一个晚于 `fromMillis` 的「时:分」。
*
* @param {object} parsed parseCron 的结果
* @param {number} fromMillis
* @returns {number|null} 下一次匹配的时间戳;找不到返回 null
*/
function nextCronTime(parsed, fromMillis) {
if (!parsed) return null;
const start = new Date(fromMillis);
const day = new Date(start.getFullYear(), start.getMonth(), start.getDate(), 0, 0, 0, 0);
for (let i = 0; i < 366 * 4; i++) {
if (cronDayMatches(parsed, day)) {
for (const hour of parsed.hours) {
for (const minute of parsed.minutes) {
const candidate = new Date(day.getFullYear(), day.getMonth(), day.getDate(), hour, minute, 0, 0);
if (candidate.getTime() > fromMillis) return candidate.getTime();
}
}
}
day.setDate(day.getDate() + 1);
}
return null;
}
module.exports = {
ScheduledMessenger,
normalizeTasks,
clampInterval,
clampConfirmTimeout,
parseCron,
nextCronTime,
cronDayMatches,
buildPattern,
MIN_INTERVAL_SECONDS,
MAX_TASKS,
DEFAULT_CONFIRM_TIMEOUT_SECONDS
};
+109
查看文件
@@ -0,0 +1,109 @@
'use strict';
const fs = require('fs');
const path = require('path');
/**
* 机器人注册表持久化:保存「非敏感」的机器人设置。
*
* 存的是:是否启用、备注名、启动后是否自动开始扩地、以及每个机器人自己的扩地配置覆盖。
* **绝不存账号密码** —— 密码只从 `.env` 读取(见 {@link module:config/botsConfig})。
*
* 默认路径 `data/bots.json`(`data/` 已在 .gitignore 中忽略)。
*/
class BotStore {
/**
* @param {string} [filePath] 配置文件绝对路径
*/
constructor(filePath) {
this.filePath = filePath || path.join(__dirname, '..', '..', 'data', 'bots.json');
}
/**
* 读取注册表;文件不存在或损坏时返回空表。
* @returns {{bots: Record<string, object>}}
*/
load() {
try {
const parsed = JSON.parse(fs.readFileSync(this.filePath, 'utf8'));
if (parsed && typeof parsed === 'object' && parsed.bots && typeof parsed.bots === 'object') {
return { bots: parsed.bots };
}
} catch {
// 首次运行或文件损坏:回退到空表
}
return { bots: {} };
}
/**
* 写入注册表。
* @param {{bots: Record<string, object>}} data
* @returns {boolean} 是否写入成功
*/
save(data) {
try {
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
fs.writeFileSync(this.filePath, JSON.stringify({ bots: data.bots || {} }, null, 2), 'utf8');
return true;
} catch {
return false;
}
}
}
/**
* 控制台新增账号的凭据持久化。
*
* 与 {@link BotStore} 分开存是刻意的:
* - `data/bots.json` 只放非敏感设置,可以随手打开看;
* - 本文件含密码,落盘权限强制 0600,且 `data/` 已在 .gitignore 中忽略。
*
* `.env` 里的 `MC_BOTS` / `MC_USERNAME` 仍然是首选来源;两者同名时以 `.env` 为准
* (环境变量优先级更高,避免「改了半天 .env 不生效」)。
*/
class BotCredentialStore {
/**
* @param {string} [filePath] 凭据文件绝对路径
*/
constructor(filePath) {
this.filePath = filePath || path.join(__dirname, '..', '..', 'data', 'bots.credentials.json');
}
/**
* 读取凭据;文件不存在或损坏时返回空表。
* @returns {{bots: Record<string, object>}}
*/
load() {
try {
const parsed = JSON.parse(fs.readFileSync(this.filePath, 'utf8'));
if (parsed && typeof parsed === 'object' && parsed.bots && typeof parsed.bots === 'object') {
return { bots: parsed.bots };
}
} catch {
// 首次运行或文件损坏:回退到空表
}
return { bots: {} };
}
/**
* 写入凭据并强制 0600 权限(仅文件属主可读写)。
* @param {{bots: Record<string, object>}} data
* @returns {boolean} 是否写入成功
*/
save(data) {
try {
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
fs.writeFileSync(this.filePath, JSON.stringify({ bots: data.bots || {} }, null, 2), { encoding: 'utf8', mode: 0o600 });
// writeFileSync 的 mode 只在新文件时生效,已存在时需显式 chmod
try {
fs.chmodSync(this.filePath, 0o600);
} catch {
// Windows 等平台不支持 chmod,忽略
}
return true;
} catch {
return false;
}
}
}
module.exports = { BotStore, BotCredentialStore };
+113
查看文件
@@ -0,0 +1,113 @@
'use strict';
/**
* 机器人凭据解析。
*
* 设计原则(与项目其它部分一致):**账号密码只从环境变量读取,绝不落代码、绝不落配置文件**。
* 因此这里只负责把 `.env` 里的描述解析成内存中的连接参数列表,
* 机器人列表本身(哪个启用、备注名、各自的扩地配置)才存到 `data/bots.json`。
*
* 支持的写法(按优先级):
*
* 1. JSON 数组(推荐,用户名/密码含逗号冒号时也安全)
* ```
* MC_BOTS=[{"username":"BotA","password":"p1"},{"username":"BotB","password":"p2"}]
* ```
* 2. `用户名:密码` 逗号分隔
* ```
* MC_BOTS=BotA:p1,BotB:p2
* ```
* 3. 单机器人兜底:`MC_USERNAME` + `MC_PASSWORD`
*/
/**
* 从环境变量解析机器人连接参数列表。
*
* @param {NodeJS.ProcessEnv|object} [env] 环境变量(默认取 process.env,便于测试注入)
* @returns {Array<{id: string, username: string, password: string, auth: string, host: string, port: number, version: string, label: string}>}
*/
function parseBotsFromEnv(env) {
const source = env || process.env;
const defaults = {
host: source.MC_HOST || 'bgjq.simpfun.cn',
port: normalizePort(source.MC_PORT),
version: source.MC_VERSION || '1.21.4',
auth: source.MC_AUTH || 'offline'
};
const raw = typeof source.MC_BOTS === 'string' ? source.MC_BOTS.trim() : '';
let parsed = [];
if (raw) {
parsed = raw.startsWith('[') ? parseJsonList(raw) : parseCsvList(raw);
}
if (parsed.length === 0) {
const username = (source.MC_USERNAME || '').trim();
if (username) {
parsed = [{ username, password: source.MC_PASSWORD || '' }];
}
}
const seen = new Set();
const bots = [];
for (const item of parsed) {
const username = String(item.username || '').trim();
if (!username) continue;
const id = sanitizeId(username);
if (!id || seen.has(id)) continue;
seen.add(id);
bots.push({
id,
username,
password: item.password === undefined || item.password === null ? '' : String(item.password),
auth: String(item.auth || defaults.auth),
host: String(item.host || defaults.host),
port: normalizePort(item.port === undefined ? defaults.port : item.port),
version: String(item.version || defaults.version),
label: String(item.label || username)
});
}
return bots;
}
/** 解析 JSON 数组写法;格式错误时返回空数组(由兜底逻辑接管) */
function parseJsonList(raw) {
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.filter((item) => item && typeof item === 'object');
} catch {
return [];
}
}
/** 解析 `用户名:密码` 逗号分隔写法;没有冒号时视为「只有用户名」 */
function parseCsvList(raw) {
const result = [];
for (const part of raw.split(',')) {
const text = part.trim();
if (!text) continue;
const index = text.indexOf(':');
if (index < 0) {
result.push({ username: text, password: '' });
} else {
result.push({ username: text.slice(0, index).trim(), password: text.slice(index + 1).trim() });
}
}
return result;
}
/**
* 生成机器人 id:用于 URL、文件名与日志归属,必须稳定且不含特殊字符。
* @param {string} username
* @returns {string}
*/
function sanitizeId(username) {
return String(username).trim().replace(/[^\w\u4e00-\u9fa5.-]/g, '_').slice(0, 48);
}
function normalizePort(value) {
const n = Number.parseInt(value, 10);
return Number.isFinite(n) && n > 0 && n < 65536 ? n : 25565;
}
module.exports = { parseBotsFromEnv, sanitizeId, normalizePort };
+298 -6
查看文件
@@ -65,6 +65,19 @@ function defaultConfig() {
retries: 2,
/** 连续寻路超时累计达到该秒数后,发送回城指令并重新开始 */
returnThresholdSeconds: 60,
/**
* 是否允许「连续超时后自动回城」。
*
* **默认关闭**,因为回城是破坏性的:它会把机器人从当前位置传送回安全点,
* 而这个安全点往往并不比原地更好(可能同样是卡死点),
* 更麻烦的是**会把玩家在游戏客户端里手动摆好的位置一并清掉**。
* 需要用它来逃离卡死状态时再打开。
*/
returnOnTimeout: false,
/** 回城指令(不带前导斜杠),默认服务器自带的 `/u return` */
returnCommand: 'u return',
/** 建立连接后多久内不自动回城(秒):刚被玩家摆好位置的机器人不该被立刻拉走 */
returnGraceSeconds: 300,
/** 发送回城指令后等待传送完成的时间(秒) */
returnWaitSeconds: 12,
/** 单个目标最多触发「往边上走两步」自救的轮数 */
@@ -84,9 +97,20 @@ function defaultConfig() {
allowJump: true,
allowBreak: false,
allowPlace: false,
/** 寻路高度偏好:优先往高处走 */
altitudePreference: true,
/** 高度偏好权重:每上升 1 格折算的启发成本减少量 */
/**
* 寻路高度偏好:优先往高处走。
*
* ⚠️ **默认关闭,因为当前实现会让寻路器完全规划不出路径。**
* 实测(2026-10-01,王城平台上):打开时 `inputTicks = 0`(一次移动指令都不发)、
* 任务永远停在「寻路超时,重新寻路」;关掉后 `inputTicks = 237`,立刻开始移动。
*
* 原因见 `AltitudeGoal` 的注释:它把高度偏置加在启发值上,
* 而 mineflayer-pathfinder 用「h 最小」挑 partial 路径的终点(`bestNode`),
* 于是偏置会把 partial 路径引向「又高又偏」的节点,永远到不了目标附近。
* 想重新启用,应把偏置改成**移动代价**(低处加 cost),而不是改启发值。
*/
altitudePreference: false,
/** 高度偏好权重:每上升 1 格折算的启发成本减少量(当前实现下不生效) */
altitudeWeight: 0.1,
// ==================== 生存辅助(与 Java 版同名) ====================
@@ -98,6 +122,15 @@ function defaultConfig() {
durabilityGuard: true,
/** 耐久保护阈值:剩余耐久百分比 ≤ 该值时回城(1-99) */
durabilityThreshold: 15,
/**
* 掉线重连后是否自动恢复被中断的扩地任务。
*
* 本服会周期性把机器人踢下线(实测约 1~6 分钟一次),每次都会触发
* 「世界已退出,任务停止」。若不自动恢复,任务就永远停在那里 ——
* 机器人虽然能被原点自救送回真实世界,却再也不会继续扩地。
* 手动停止 / 到时段外 / 耐久保护停机都不会被自动恢复。
*/
autoResumeAfterReconnect: true,
/** 是否启用自动用船过水 */
autoBoat: false,
/** 触发自动用船的水面最小宽度(格) */
@@ -116,6 +149,16 @@ function defaultConfig() {
apiKey: '',
/** API 领土数据轮询间隔(秒),最小 60 */
apiRefreshIntervalSeconds: 180,
/**
* 是否启用 SimpCore API-V2(ServerOpenAPI 插件)。
*
* 注意:**API 与 API-V2 是两套独立、互不通用的密钥体系**,需要在密钥中心分别申请。
* V2 用于查询服务器实时状态(在线玩家 / 装备 / 游戏时间 / 末影龙),
* 接口级配额很小(末影龙仅 1 次/分钟),因此不做自动轮询,只在控制台按需查询。
*/
apiV2Enabled: false,
/** API-V2 Key(留空时回退读环境变量 SIMP_API_V2_KEY) */
apiV2Key: '',
// ==================== 寻路器专属(新增) ====================
pathing: {
@@ -128,7 +171,40 @@ function defaultConfig() {
/** 进入液体格的额外代价(越大越倾向绕开水域) */
liquidCost: 40,
/** 是否允许打开门/栅栏门 */
canOpenDoors: false
canOpenDoors: false,
/**
* 单段寻路的最长方块数(默认 512)。
*
* mineflayer-pathfinder 只能规划**已加载区块**范围内(实测本服约 8 区块 ≈ 130 格),
* 而扩地目标常在几十个区块之外,A* 会瞬间返回 noPath。
* 因此长距离会被拆成若干段:走到已加载范围边缘附近再重新规划下一段。
* 这个值只是单段的上限,实际段长由「已加载范围」动态决定。
*/
maxSegmentBlocks: 96,
/** 分段寻路时距已加载范围边缘留出的安全边距(方块),避免踩在未加载边界上 */
segmentMarginBlocks: 24,
/**
* A* 单次搜索的时间预算(毫秒)。
* 太小会在复杂地形里搜不完就放弃,表现为「目标不可达」。
*/
thinkTimeoutMs: 10000,
/** A* 每 tick 的时间片(毫秒),越大单帧越卡但搜得更快 */
tickTimeoutMs: 40,
/**
* A* 的代价搜索半径:节点代价超过「起点到目标的启发值 + 该值」就被剪枝。
* 负数表示不限制(复杂地形里最稳,但搜索更慢)。
*/
searchRadius: 256,
/**
* 寻路执行器:
* - `pathfinder`(默认):由 mineflayer-pathfinder 自己执行;
* - `manual`:**备选方案**(TODO 问题 1)。只用 A* 算路径,执行改为自己按键
* (朝路径点转 → 按住前进 → 遇坎跳),控制键时序与手动移动测试完全一致。
* 仅在实测确认 pathfinder 的执行层被服务端回弹时才需要切换。
*/
executor: 'pathfinder',
/** 手动执行器单段执行的超时(毫秒),防止卡死时无限按键 */
manualTimeoutMs: 180000
},
// ==================== 战斗辅助(新增) ====================
@@ -170,7 +246,144 @@ function defaultConfig() {
/** 是否在启动时校验物理参数未被篡改 */
checkPhysicsIntegrity: true,
/** 是否禁止在骑乘状态下攻击 / 进食(防 Vehicle 类检查) */
blockActionsWhileRiding: true
blockActionsWhileRiding: true,
/**
* 是否按世界数据修正出站位置包的 onGround 标志(默认开启)。
*
* mineflayer 收到服务端 `position` 包时会把 `entity.onGround` 置为 false,
* 在频繁下发位置包的服务器上会导致客户端持续上报「站在地上却在空中」,
* 被反作弊判定异常后每 tick 拉回一次,表现为机器人原地抖动、完全走不动。
* 开启后发包前会用脚下方块复核,只有确实不一致时才改写。
*/
reportOnGroundFromWorld: true,
/**
* 是否把物理引擎的 `isCollidedHorizontally` 如实写进出站包的
* `flags.hasHorizontalCollision`(默认开启)。
*
* mineflayer 在 `physics.js` 里把这个位标志写成 `undefined`,序列化后**恒为 0**
* —— 而原版客户端在水平碰到方块时会上报 1。机器人在崎岖地形上几乎一直在
* 水平碰撞,却始终上报「没碰撞」,正是反作弊 Prediction 最容易判错的形态:
* 服务端按「该碰撞」预测,客户端说「没碰撞」,于是被判异常并拉回。
* 实测(2026-10-01)机器人一直按着 forward+sprint,物理速度约 2 格/秒,
* 但净位移只有 0.4~0.5 格/秒,差额正是被服务端拉回的部分。
*
* 物理引擎已经算出了真实值(`bot.entity.isCollidedHorizontally`),只是没发出去。
*/
reportHorizontalCollision: true,
/**
* 【方案 A】是否把物理引擎的 onGround 与本地世界数据对齐(默认开启)。
*
* 服务端频繁下发 `position` 包时,mineflayer 会把 `entity.onGround` 置为 false,
* 物理引擎因此一直走「空中」分支(水平加速只有地面的 1/5 且每 tick 被清零),
* 表现就是「按着前进键、水平速度恒为 0」。开启后每个物理 tick 都会在
* 「物理引擎算完、位置包发出之前」用脚下方块复核,必要时强制同步 onGround,
* 让移动回到地面分支。与 `grim.enabled` 无关(实测关闭合规层后回弹依旧)。
*/
syncGroundToPhysics: true,
/**
* 【方案 B】是否忽略「例行同步」位置包带来的 onGround=false(默认开启)。
*
* 服务端下发的目标位置与本地位置几乎一致时,说明它只是例行同步坐标,
* 并不代表我们真的在空中;此时只采纳坐标、保留我们自己的 onGround,
* 避免物理引擎的地面状态被服务端反复打断。
* 位置差超过 `setbackSyncEpsilon` 时按真正的传送处理,不做任何干预。
*/
trustSetbackOnGround: true,
/**
* 方案 B 的判定阈值(格):服务端目标位置与本地位置之差不超过它时视为「例行同步」。
* 实测可在控制台观察 `packetStats.lastSetbackDelta.distance` 后再调整。
*/
setbackSyncEpsilon: 0.5,
/**
* 是否每 tick 补发 `player_input` 包(默认开启,**线上服务器必需**)。
*
* 1.21.2+ 服务端的移动预测依赖它,原版客户端每 tick 都发,而 mineflayer 只在
* 骑乘 / 跳下 / 潜行时发 —— 地面移动的 forward / jump / sprint 完全不发。
* 带反作弊的服务器会判定「无输入却位移」并每 tick 把玩家拉回,
* 表现就是机器人原地抖动、怎么按方向键都不动。
*/
sendPlayerInput: true,
/**
* 是否每 tick 补发 `tick_end` 包(默认开启)。
*
* 原版 1.21.2+ 客户端在每 tick 末尾发送,反作弊用它判定 tick 边界;
* mineflayer 完全不发,会导致 PacketOrder / Timer 类判定异常。
*/
sendTickEnd: true
},
// ==================== 大地图(新增) ====================
bigMap: {
/** 是否启用大地图(爬取服务器地图站点的瓦片图片与领土标记) */
enabled: true,
/** 地图站点上的世界目录名;留空则回退环境变量 DYNMAP_WORLD */
world: '',
/** 启动时预爬取的层级(0 = 全世界一张大图,本站为 10×10=100 张瓦片) */
zoom: 0,
/** 允许浏览的最大层级(本站 squaremap 的 zoom.max 为 3) */
maxZoom: 3,
/**
* 爬取半径(方块),**以机器人为中心**(见 followBot)。
*
* 层级 0 每张瓦片覆盖 4096 格,所以:
* 1024 格半径 = 1 张瓦片,4096 格 = 4 张,8192 格 = 9 张,20480 格 = 100 张。
* 只关心机器人周边时把半径调小,爬取和传输开销会差一个数量级。
*/
rangeBlocks: 4096,
/**
* 是否以机器人当前位置为中心爬取与显示大地图(默认开启)。
*
* 关闭时以世界原点 (0,0) 为中心 —— 只有在想看整张世界地图时才需要。
*/
followBot: true,
/** 启动时是否自动预爬取 */
crawlOnStart: true,
/** 瓦片下载并发数(对源站友好,默认 4) */
concurrency: 4,
/**
* 是否后台预热高层级瓦片(默认开启)。
*
* 只有「启动预爬取层级」(默认 0)会批量爬,层级 1~3 原本要靠用户缩放时按需拉取,
* 首次浏览要等好几秒。开启后机器人移动到新区域时会顺带把周边高层级瓦片拉下来。
*/
prewarm: true,
/** 预热覆盖半径(方块),以机器人为中心 */
prewarmRangeBlocks: 1024,
/** 从「启动预爬取层级」往上预热几层(受「最大可浏览层级」限制) */
prewarmLevels: 3,
/** 机器人移动超过该距离(方块)才重新预热,避免频繁请求源站 */
prewarmMinMoveBlocks: 512,
/** 距上次预热超过该时间(毫秒)也重新预热一次(默认 10 分钟) */
prewarmIntervalMs: 600000
},
// ==================== 多机器人协调(新增) ====================
coordination: {
/**
* 目标区块租约时长(秒)。
*
* 多个机器人同时扩地时,都用同一份领土模型挑候选区块,很容易撞在同一块上。
* 租约表让「谁要去占哪一块」互相可见:抢到的机器人独占该区块,其余机器人跳过;
* 持有者持续续租,机器人掉线 / 卡死后租约到期自动释放,不会永久锁死区块。
*/
targetLeaseTtlSeconds: 180
},
// ==================== 通知提醒(新增) ====================
notify: {
/**
* 是否启用 Webhook 通知(默认关闭)。
*
* 需要人工介入时(等待登录验证码、原点自救失败)把消息推到飞书 / Lark 自定义机器人,
* 不用一直盯着控制台 —— 验证码只有 120 秒时限,错过就得等下次重登。
*/
enabled: false,
/** Webhook 地址(留空时回退环境变量 NOTIFY_WEBHOOK_URL) */
webhookUrl: '',
/** 服务器要求输入验证码时提醒 */
onVerification: true,
/** 原点自救失败时提醒(说明可能其实是登录服,需要人工发验证码) */
onOriginRescueFailed: true
},
// ==================== 服务器交互(新增) ====================
@@ -181,12 +394,49 @@ function defaultConfig() {
loginCommand: 'login {password}',
/** 注册指令模板,`{password}` 会被替换;留空则不发送 */
registerCommand: '',
/**
* 账号绑定 / 二次验证指令模板(`{password}` 会被替换),留空则不发送。
*
* 用于「简幻通」这类外站账号体系:新账号必须先用 `/auth <UID> <验证码>` 或扫码绑定。
* 扫码类验证无法自动完成,此时保持留空即可——程序会在日志里明确提示需要人工验证一次;
* 若该服务器提供固定验证码,把 `auth {password}` 之类填进来就能自动过。
*/
authCommand: '',
/**
* 允许在「登录验证通过之前」发送的指令前缀。
*
* 存在先有鸡还是先有蛋的问题:有些服务器的登录流程本身就是一条指令
* (`/login`、`/register`,简幻通体系还要先 `/auth`),
* 若把登录门禁一刀切,这些指令就永远发不出去,账号会卡在未验证状态被反复踢出。
* 因此这里列出的前缀可以绕过门禁,其余指令仍要求已完成验证。
*/
preLoginCommands: ['login', 'l', 'register', 'reg', 'auth'],
/**
* 是否允许在登录验证通过之前发送公屏聊天。
*
* 默认关闭。服务器**明确要求验证**时会自动放行(验证码常常是直接当聊天内容发出去的),
* 这个开关用于兜底那些没有任何提示、却必须先说话才能完成登录的服务器。
*/
allowChatBeforeLogin: false,
/**
* 登录结果确认超时(毫秒)。
* 部分离线服登录成功后不回明确提示,超时后按「已登录」放行,
* 否则机器人会永远卡在未登录状态而无法接受控制台操作;设为 0 表示禁用兜底。
*/
loginConfirmTimeoutMs: 10000,
/** 聊天指令最小间隔(毫秒),防刷屏 */
commandCooldownMs: 1200,
/** 断线后是否自动重连 */
autoReconnect: true,
/** 重连间隔(毫秒) */
reconnectDelayMs: 10000
reconnectDelayMs: 10000,
/**
* 重连间隔上限(毫秒)。
*
* 连续重连失败(例如账号未完成服务器验证被反复踢出)时按指数退避拉长间隔,
* 避免每 10 秒一次地硬撞服务器;成功进入世界后计数清零、间隔回到 reconnectDelayMs。
*/
reconnectMaxDelayMs: 120000
}
};
}
@@ -257,12 +507,18 @@ function validateConfig(config) {
config.waitTimeoutSeconds = clampInt(config.waitTimeoutSeconds, 5, 3600, 60);
config.retries = clampInt(config.retries, 1, 20, 2);
config.returnThresholdSeconds = clampInt(config.returnThresholdSeconds, 0, 3600, 60);
config.returnOnTimeout = config.returnOnTimeout === true;
if (typeof config.returnCommand !== 'string') config.returnCommand = 'u return';
// 去掉玩家可能顺手写上的前导斜杠,发送时会统一补
config.returnCommand = config.returnCommand.trim().replace(/^\/+/, '');
config.returnGraceSeconds = clampInt(config.returnGraceSeconds, 0, 3600, 300);
config.returnWaitSeconds = clampInt(config.returnWaitSeconds, 1, 120, 12);
config.sideStepMaxRounds = clampInt(config.sideStepMaxRounds, 0, 10, 3);
config.claimStartHour = clampInt(config.claimStartHour, 0, 23, 11);
config.claimEndHour = clampInt(config.claimEndHour, 1, 24, 22);
config.eatThreshold = clampInt(config.eatThreshold, 0, 20, 14);
config.durabilityThreshold = clampInt(config.durabilityThreshold, 1, 99, 15);
config.autoResumeAfterReconnect = config.autoResumeAfterReconnect !== false;
config.autoBoatMinWidth = clampInt(config.autoBoatMinWidth, 1, 64, 4);
config.autoBoatMinLength = clampInt(config.autoBoatMinLength, 1, 128, 6);
config.refreshIntervalSeconds = clampInt(config.refreshIntervalSeconds, 10, 3600, 30);
@@ -271,14 +527,48 @@ function validateConfig(config) {
config.pathing.maxDropDown = clampInt(config.pathing.maxDropDown, 0, 64, 3);
config.pathing.liquidCost = clampInt(config.pathing.liquidCost, 1, 1000, 40);
config.pathing.maxSegmentBlocks = clampInt(config.pathing.maxSegmentBlocks, 32, 4096, 512);
config.pathing.segmentMarginBlocks = clampInt(config.pathing.segmentMarginBlocks, 0, 256, 24);
config.pathing.thinkTimeoutMs = clampInt(config.pathing.thinkTimeoutMs, 100, 300000, 10000);
config.pathing.tickTimeoutMs = clampInt(config.pathing.tickTimeoutMs, 5, 5000, 40);
// searchRadius 允许为负(不限制),因此单独钳制下限
config.pathing.searchRadius = clampInt(config.pathing.searchRadius, -1, 100000, 256);
if (config.pathing.executor !== 'manual') config.pathing.executor = 'pathfinder';
config.pathing.manualTimeoutMs = clampInt(config.pathing.manualTimeoutMs, 10000, 3600000, 180000);
config.combat.scanRange = clampNumber(config.combat.scanRange, 1, 32, 4.5);
config.coordination.targetLeaseTtlSeconds = clampInt(config.coordination.targetLeaseTtlSeconds, 30, 3600, 180);
config.grim.maxRotationPerTickDeg = clampNumber(config.grim.maxRotationPerTickDeg, 1, 180, 8.6);
config.grim.attackReach = clampNumber(config.grim.attackReach, 1, 6, 3.0);
config.grim.setbackSyncEpsilon = clampNumber(config.grim.setbackSyncEpsilon, 0, 5, 0.5);
config.bigMap.zoom = clampInt(config.bigMap.zoom, 0, 8, 0);
config.bigMap.maxZoom = clampInt(config.bigMap.maxZoom, 0, 8, 3);
// 预爬取层级不可能超过可浏览的最大层级,否则会爬到源站不存在的瓦片
if (config.bigMap.zoom > config.bigMap.maxZoom) config.bigMap.zoom = config.bigMap.maxZoom;
config.bigMap.rangeBlocks = clampInt(config.bigMap.rangeBlocks, 256, 200000, 4096);
config.bigMap.concurrency = clampInt(config.bigMap.concurrency, 1, 16, 4);
config.bigMap.prewarmRangeBlocks = clampInt(config.bigMap.prewarmRangeBlocks, 256, 200000, 1024);
config.bigMap.prewarmLevels = clampInt(config.bigMap.prewarmLevels, 0, 8, 3);
config.bigMap.prewarmMinMoveBlocks = clampInt(config.bigMap.prewarmMinMoveBlocks, 0, 100000, 512);
config.bigMap.prewarmIntervalMs = clampInt(config.bigMap.prewarmIntervalMs, 60000, 86400000, 600000);
if (typeof config.bigMap.world !== 'string') config.bigMap.world = '';
if (typeof config.bigMap.followBot !== 'boolean') config.bigMap.followBot = true;
config.server.commandCooldownMs = clampInt(config.server.commandCooldownMs, 0, 60000, 1200);
config.server.reconnectDelayMs = clampInt(config.server.reconnectDelayMs, 1000, 600000, 10000);
config.server.reconnectMaxDelayMs = clampInt(config.server.reconnectMaxDelayMs, config.server.reconnectDelayMs, 3600000, 120000);
config.server.loginConfirmTimeoutMs = clampInt(config.server.loginConfirmTimeoutMs, 0, 300000, 10000);
if (typeof config.server.authCommand !== 'string') config.server.authCommand = '';
if (typeof config.server.allowChatBeforeLogin !== 'boolean') config.server.allowChatBeforeLogin = false;
if (!Array.isArray(config.server.preLoginCommands)) {
config.server.preLoginCommands = defaultConfig().server.preLoginCommands;
} else {
config.server.preLoginCommands = config.server.preLoginCommands
.filter((item) => typeof item === 'string' && item.trim())
.map((item) => item.trim().toLowerCase().replace(/^\//, ''));
}
if (config.waterMode !== WATER_MODE.SWIM && config.waterMode !== WATER_MODE.BOAT) {
config.waterMode = WATER_MODE.AVOID;
@@ -288,6 +578,8 @@ function validateConfig(config) {
if (typeof config.server.claimCommand !== 'string') config.server.claimCommand = '';
if (typeof config.server.loginCommand !== 'string') config.server.loginCommand = '';
if (typeof config.server.registerCommand !== 'string') config.server.registerCommand = '';
if (typeof config.server.authCommand !== 'string') config.server.authCommand = '';
if (typeof config.notify.webhookUrl !== 'string') config.notify.webhookUrl = '';
return config;
}
+400 -33
查看文件
@@ -35,6 +35,21 @@ const State = {
/** 环形日志中「导出日志」用的状态摘要键名 */
const EXPORT_SUMMARY_KEYS = ['状态', '目标区块', '玩家区块', '队列', '已占领', '重试', '最近进度'];
/**
* 判定「寻路有进展」的最小距离变化(区块)。
*
* 只要与目标的曼哈顿距离缩短了这么多,就认为是在稳步接近而不是卡死,
* 从而重置超时计时。取 1 是因为矿区/建筑区里寻路速度可能远低于正常步行,
* 阈值再大就会把慢速推进误判成超时。
*/
const PATH_PROGRESS_MIN_CHUNKS = 1;
/**
* 候选区块全被其他机器人预定时,隔多久重新规划(毫秒)。
* 取 15 秒:既不会忙等,也不会在别人刚释放时错过太久。
*/
const LEASE_RETRY_DELAY_MS = 15000;
/**
* 自动扩地控制器:扩地任务的核心状态机。
*
@@ -50,11 +65,31 @@ class ExpansionController {
* @param {import('../bot/botManager').BotManager} deps.botManager
* @param {import('../core/territoryDataService').TerritoryDataService} deps.dataService
* @param {import('../util/logger').Logger} deps.logger
* @param {string} [deps.botId] 所属机器人 id(多机器人场景,用于日志归属)
* @param {string} [deps.botName] 所属机器人游戏名
* @param {import('../core/expansionLog').ExpansionLog} [deps.expansionLog] 开拓日志
* @param {import('../core/targetLease').TargetLeaseTable} [deps.targetLeases] 目标区块租约表(多机器人共享)
*/
constructor(deps) {
this.botManager = deps.botManager;
this.dataService = deps.dataService;
this.logger = deps.logger;
/** 所属机器人 id(默认取游戏名) */
this.botId = deps.botId || (deps.botManager && deps.botManager.connection
? deps.botManager.connection.username : 'bot');
/** 所属机器人游戏名 */
this.botName = deps.botName || (deps.botManager && deps.botManager.connection
? deps.botManager.connection.username : this.botId);
/** @type {import('../core/expansionLog').ExpansionLog|null} */
this.expansionLog = deps.expansionLog || null;
/**
* 目标区块租约表(多机器人共享,可空 —— 单机器人场景不需要协调)。
* 详见 core/targetLease.js:抢到的机器人独占区块,其余机器人跳过。
* @type {import('../core/targetLease').TargetLeaseTable|null}
*/
this.targetLeases = deps.targetLeases || null;
/** 候选区块全被其他机器人预定时,下一次重新规划的时间戳(避免忙等) */
this._nextReplanAt = 0;
this.planner = new ExpansionPlanner();
/** @type {import('../core/subtitleChatMonitor').SubtitleChatMonitor} */
this.monitor = new SubtitleChatMonitor({
@@ -79,6 +114,10 @@ class ExpansionController {
this.retryCount = 0;
/** 当前阶段(寻路 / 等待)的开始时间戳 */
this.stageStartedMillis = 0;
/** 本次连接/本次任务的开始时间戳,用于「刚连上时不许回城」的保护期 */
this.sessionStartedMillis = Date.now();
/** 上一次记录的「与目标的曼哈顿距离」,用于判断寻路是否在稳步推进 */
this._lastTargetDistance = null;
/** 最近一次开垦进度(0-100) */
this.lastProgress = 0;
@@ -89,6 +128,15 @@ class ExpansionController {
/** 等待开拓时段自动开始:不在时段时点击开始会暂存配置(null 表示无等待任务) */
this.pendingStart = null;
/**
* 被「世界退出」打断的任务配置:重连进入世界后用它自动恢复。
*
* 本服会周期性把机器人踢下线(实测约 1~6 分钟一次),而每次掉线都会走到
* 「世界已退出,任务停止」。没有这个字段的话,机器人虽然能被原点自救送回
* 真实世界,任务却再也不会继续 —— 扩地永远跑不长。
* 手动停止 / 到时段外 / 耐久保护停机都不会写入它。
*/
this.resumeConfig = null;
/** 连续寻路超时累计时长(毫秒) */
this.consecutiveTimeoutMillis = 0;
/** 发送回城指令的时间戳 */
@@ -107,10 +155,35 @@ class ExpansionController {
this.sideStepStartPos = null;
/** 本次回城是否由耐久保护触发:RETURNING 结束后停止任务而非重新规划 */
this.returnThenStop = false;
/**
* 最近一次回城的前后坐标与距离。
*
* 回城是破坏性操作(会把机器人拉回安全点、清掉玩家手动摆好的位置),
* 用户必须能在控制台上一眼看出「这次回城把机器人从哪挪到了哪」,
* 从而判断回城是否值得。
* @type {{at: number, reason: string, from: object|null, to: object|null, distance: number|null}|null}
*/
this.lastReturn = null;
/** 待回城时记录的「回城前坐标」(传送完成后与当前位置组成前后对比) */
this.pendingReturn = null;
/**
* 因「原地还能走」而放弃回城的次数。
*
* 连续超时达到回城阈值并不代表卡死 —— 位置还能走时回城毫无意义,
* 只会白白丢掉当前位置,因此这种情况下改为跳过目标并记入这里。
*/
this.returnSkippedNotStuck = 0;
/** 自动攻击当前目标的实体 ID(仅用于避免重复记录战斗日志) */
this.combatTargetEntityId = -1;
/** 当前是否正持续按住跳跃键(自动游泳用) */
this.holdingJump = false;
/**
* 本机器人实际开拓的邦国名(start() 时解析并固化)。
*
* 多机器人场景下每个机器人可能属于不同邦国,绝不能依赖领土模型的全局
* selfStateName(那会被最后启动的机器人覆盖)。
*/
this.stateName = '';
/** 是否已经发送过开垦模式指令 */
this._claimModeSent = false;
/** @type {((status: object) => void)|null} 状态变化回调(Web 控制台用) */
@@ -177,39 +250,44 @@ class ExpansionController {
this.playerChunk = this._playerChunk();
// ===== 识别己方邦国 =====
// 解析结果固化到 this.stateName,并写回本次任务配置,
// 使规划器不再依赖领土模型的全局 selfStateName(多机器人会互相覆盖)。
const model = this.dataService.getModel();
let resolvedName = '';
if (!this.config.stateName) {
const auto = model.findContainingState(this.playerChunk);
if (auto) {
model.setSelfStateName(auto.name);
this.dataService.setSelectedStateName(auto.name);
resolvedName = auto.name;
} else {
// 兜底:通过 SimpCore API 按玩家名识别
const detected = await this.dataService.detectStateByPlayer(bm.connection.username);
if (detected) {
this.dataService.getModel().setSelfStateName(detected);
this.dataService.setSelectedStateName(detected);
resolvedName = detected;
} else {
this.status = '无法自动识别邦国,请在控制台手动选择';
this.state = State.IDLE;
return false;
}
}
} else if (!model.state(this.config.stateName)) {
this.status = '所选邦国不存在:' + this.config.stateName;
this.state = State.IDLE;
return false;
} else {
const model2 = this.dataService.getModel();
if (!model2.state(this.config.stateName)) {
this.status = '所选邦国不存在:' + this.config.stateName;
this.state = State.IDLE;
return false;
}
model2.setSelfStateName(this.config.stateName);
this.dataService.setSelectedStateName(this.config.stateName);
resolvedName = this.config.stateName;
}
this.stateName = resolvedName;
this.config = Object.assign({}, requestedConfig, { stateName: resolvedName });
// 全局选中邦国跟随最近启动的机器人,供控制台地图与数据源展示使用
this.dataService.setSelectedStateName(resolvedName);
// ===== 复位任务状态 =====
this.completedClaims = 0;
this.retryCount = 0;
this.consecutiveTimeoutMillis = 0;
// 保护期从「本次任务开始」算起:玩家常趁机器人掉线时在客户端把它摆到开阔地,
// 刚启动就被回城拉走等于白摆
this.sessionStartedMillis = Date.now();
this.sideStepCount = 0;
this.sideStepping = false;
this.returnThenStop = false;
@@ -231,7 +309,7 @@ class ExpansionController {
}
this.setState(State.PLANNING, '正在生成扩张计划');
this._log('开始自动扩地,邦国:' + this.dataService.getModel().selfStateNameValue());
this._log(`开始自动扩地,邦国:` + this.stateName);
await this.plan();
return true;
}
@@ -239,6 +317,8 @@ class ExpansionController {
/** 手动停止任务 */
stop() {
const bm = this.botManager;
// 任何显式停止都作废「掉线待恢复」——只有 tick() 里因世界退出而停的那次会重新登记
this.resumeConfig = null;
if (this.pendingStart) {
this.pendingStart = null;
this.setState(State.IDLE, '已取消自动开始');
@@ -250,6 +330,9 @@ class ExpansionController {
if (this.sideStepping) this.stopSideStep();
this.releaseJump();
if (bm.boat) bm.boat.reset(true);
// 停止任务时归还全部目标租约,别的机器人可以立刻接手这些区块
if (this.targetLeases) this.targetLeases.releaseAll(this.botId);
this._nextReplanAt = 0;
this.setState(State.STOPPED, '已手动停止');
this._log('已手动停止');
}
@@ -263,12 +346,40 @@ class ExpansionController {
if (!this.config) return; // 尚未启动过任务,没有可驱动的状态
if (!bm.isSpawned()) {
if (this.isRunning()) {
// 先记下这次任务的配置:本服会周期性把机器人踢下线,重连回来时若没人管,
// 任务就永远停在这里。stop() 会清空 resumeConfig,所以要在它之后重新登记。
const interrupted = this.config;
this.stop();
this.status = '世界已退出,任务停止';
this.status = '世界已退出,等待重连后自动恢复';
if (interrupted && interrupted.autoResumeAfterReconnect !== false) {
this.resumeConfig = interrupted;
this._log('世界已退出,任务已暂停;重新进入世界后会自动恢复');
}
}
return;
}
// 重新进入世界:把被掉线打断的任务接上。
// 必须等 isReady()(含登录完成)才试,否则 start() 会直接返回 false;
// 而且只有在真的启动成功后才清掉 resumeConfig —— 否则一次失败的尝试
// 就会把这次恢复永久丢掉(实测本服掉线频繁,很容易踩到)。
if (this.resumeConfig && !this.isRunning()) {
if (!bm.isReady()) return; // 还没就绪,下一 tick 再试
const resume = this.resumeConfig;
this._log('已重新进入世界,自动恢复扩地任务');
Promise.resolve(this.start(resume))
.then((ok) => {
if (ok) {
this.resumeConfig = null;
} else {
this._log('自动恢复未成功(' + this.status + '),稍后重试', 'warn');
}
})
.catch((err) => this._log('自动恢复任务失败:' + err.message, 'error'));
return;
}
this.playerChunk = this._playerChunk();
// 续租当前目标:长距离寻路可能要走几分钟,不能让租约中途过期被别人抢走
if (this.isRunning()) this._renewTargetLease();
// 等待开垦时段自动开始:进入时段后自动启动已登记的任务
if (this.pendingStart && this.isWithinClaimTime()) {
@@ -324,6 +435,8 @@ class ExpansionController {
*/
async plan() {
const bm = this.botManager;
// 重新规划意味着目标可能变了(或被回城传送重置了位置),进度基准必须清空
this._lastTargetDistance = null;
if (this.config.targetCount > 0 && this.completedClaims >= this.config.targetCount) {
this.finish();
return;
@@ -331,21 +444,39 @@ class ExpansionController {
this.queue = [];
const model = this.dataService.getModel();
const planned = this.planner.generate(model, this.config, this.playerChunk);
let leasedByOthers = 0;
for (const chunk of planned) {
if (!this._isClaimedByOthers(chunk)) this.queue.push(chunk);
if (this._isClaimedByOthers(chunk)) continue;
// 被其他机器人预定(租约未到期)的区块先不排队,等下轮规划再看
if (this.targetLeases && this.targetLeases.isLeasedByOther(chunk, this.botId)) {
leasedByOthers++;
continue;
}
this.queue.push(chunk);
}
// 矩形区域扩地时不受扩圈上限限制(玩家圈选的矩形即为明确边界,需完整填充)
const rectangleMode = this.config.strategy === 2 && this.config.regionMaxX >= this.config.regionMinX;
if (this.config.maxRings > 0 && !rectangleMode) {
this.queue = this.queue.filter((chunk) => this._ringDistance(chunk, model) <= this.config.maxRings);
}
this._log(`规划完成,共 ${this.queue.length} 个候选区块`);
this._log(`规划完成,共 ${this.queue.length} 个候选区块`
+ (leasedByOthers > 0 ? `(另有 ${leasedByOthers} 个被其他机器人预定)` : ''));
if (this.queue.length === 0) {
if (leasedByOthers > 0) {
this._waitForLeaseRelease(leasedByOthers);
return;
}
this.setState(State.COMPLETE, '没有可用的相邻无主区块');
this._log('没有可用的相邻无主区块');
return;
}
this.target = this.queue.shift();
const picked = this._pickNextTarget();
if (!picked.target) {
this.setState(State.COMPLETE, '没有可用的相邻无主区块');
this._log('没有可用的相邻无主区块');
return;
}
this.target = picked.target;
this._startPathing();
}
@@ -416,6 +547,20 @@ class ExpansionController {
return;
}
const elapsedSeconds = Math.floor((Date.now() - this.stageStartedMillis) / 1000);
// 「有没有在推进」比「花了多久」更能说明问题:
// 目标常常在几百格外,而寻路速度受地形与反作弊回弹影响可能只有正常步行的十分之一,
// 只看墙钟时间会把「走得慢但在稳步接近」误判成卡死,然后回城传送把进度全部清零。
// 因此只要与目标的曼哈顿距离有实质缩短,就重置这一段计时。
const distance = chunkPos.manhattan(this.playerChunk, this.target);
if (this._lastTargetDistance === null) {
// 首次观察只记基准,不动计时 —— 否则每个目标的第一 tick 都会被无条件续期
this._lastTargetDistance = distance;
} else if (distance <= this._lastTargetDistance - PATH_PROGRESS_MIN_CHUNKS) {
this._lastTargetDistance = distance;
this.stageStartedMillis = Date.now();
this.consecutiveTimeoutMillis = 0;
return;
}
if (elapsedSeconds >= this.config.waitTimeoutSeconds) {
this.retryCount++;
this.dataService.refreshAsync();
@@ -433,26 +578,36 @@ class ExpansionController {
}
/**
* 发送回城指令:取消当前寻路,向服务器发送 `/u return`(约 10 秒后自动传送回安全位置),
* 随后进入 RETURNING 状态等待传送完成。
* 发送回城指令:取消当前寻路,向服务器发送回城指令(默认 `/u return`,
* 约 10 秒后自动传送回安全位置),随后进入 RETURNING 状态等待传送完成。
*
* 指令内容取自 `returnCommand`,方便不同服务器替换成自己的回城命令。
*
* @param {string} reason 触发回城的原因
*/
_sendReturnCommand(reason) {
const bm = this.botManager;
const command = (this.config.returnCommand || 'u return').replace(/^\/+/, '');
bm.pathing.cancel();
this._log(reason + ',发送 /u return 回城,10 秒后自动传送');
bm.sendCommand('u return');
this._log(`${reason},发送 /${command} 回城,约 10 秒后自动传送`
+ '(注意:这会把机器人拉回安全点,当前位置与玩家手动摆好的位置都会丢失)');
bm.sendCommand(command);
this.returnStartMillis = Date.now();
// 记下回城前坐标:传送完成后与当前位置组成「从 A 挪到了 B」的对比
this.pendingReturn = { reason, from: this._precisePos(), at: Date.now() };
this.setState(State.RETURNING, '等待回城传送(10 秒后自动传送)');
}
/**
* 回城传送等待阶段每 tick 逻辑:等待 returnWaitSeconds 秒后,
* 耐久保护触发的回城 → 停止任务;寻路超时触发的回城 → 重新规划扩地计划。
*
* 传送完成后记录回城前后坐标(控制台据此展示「回城把机器人从 A 挪到了 B」),
* 位置几乎没变时明确提示 —— 说明这个卡死点同时也是安全点,回城没有帮助。
*/
_tickReturning() {
if (Date.now() - this.returnStartMillis < this.config.returnWaitSeconds * 1000) return;
this._recordReturnResult();
if (this.returnThenStop) {
this.returnThenStop = false;
this._log('耐久保护回城完成,任务已停止;请修复 / 更换装备后重新开始扩地');
@@ -464,6 +619,28 @@ class ExpansionController {
this.plan();
}
/**
* 记录回城结果:前后坐标与直线距离,并写入明确日志。
* 没有待记录的回城(`pendingReturn` 为空)时什么也不做。
*/
_recordReturnResult() {
const pending = this.pendingReturn;
if (!pending) return;
this.pendingReturn = null;
const to = this._precisePos();
const from = pending.from;
const distance = from && to
? Math.round(Math.hypot(to.x - from.x, to.z - from.z) * 10) / 10
: null;
this.lastReturn = { at: Date.now(), reason: pending.reason, from, to, distance };
if (distance === null) return;
this._log(`回城结果:机器人从 ${formatPosition(from)} 挪到了 ${formatPosition(to)}(直线 ${distance} 格)`);
if (distance < 1) {
this._log('本次回城几乎没有改变位置 —— 说明卡死点同时也是安全点,回城没有帮助;'
+ '可考虑关闭「超时自动回城」,避免白白丢掉手动摆好的位置', 'warn');
}
}
/**
* 目标寻路重试耗尽时的脱困策略入口:取消当前寻路,改为朝「目标连线垂直方向」
* 走开约 2.5 格(左右各试一侧),走开后自动重新寻路原目标。
@@ -544,16 +721,80 @@ class ExpansionController {
/**
* 放弃当前目标(重试与走两步均已尝试)的统一出口:
* 连续寻路超时累计达到阈值则回城传送并重新规划;否则跳过当前目标。
*
* 回城是**破坏性**操作,因此默认关闭(`returnOnTimeout`),且即便打开也有两道护栏:
* - 刚建立连接的一段时间内不回城:玩家常常是趁机器人掉线时在客户端把它摆到开阔地,
* 一连上就被拉回安全点等于白摆;
* - 每次真正回城都明确记日志,避免事后只看到位置莫名其妙变了。
*/
_giveUpTarget(reason) {
if (this.consecutiveTimeoutMillis >= this.config.returnThresholdSeconds * 1000) {
this._sendReturnCommand(`连续寻路超时超过 ${this.config.returnThresholdSeconds} 秒`);
const config = this.config;
if (config.returnOnTimeout && this.consecutiveTimeoutMillis >= config.returnThresholdSeconds * 1000) {
const sessionSeconds = Math.floor((Date.now() - this.sessionStartedMillis) / 1000);
if (sessionSeconds < config.returnGraceSeconds) {
this._log(`连续超时达阈值,但本次连接仅 ${sessionSeconds} 秒(保护期 ${config.returnGraceSeconds} 秒),`
+ '不回城以免清掉刚摆好的位置;改为跳过当前目标');
this.setState(State.FAIL, reason + '(保护期内不回城)');
return;
}
// 回城前先判断「原地是否真的更差」:位置还能走时回城毫无意义,只会白白丢位置
const assess = this._assessReturnWorth();
if (!assess.worth) {
this.returnSkippedNotStuck++;
this._log(`连续超时达阈值,但${assess.reason} —— 放弃回城,改为跳过当前目标`
+ '(回城会把机器人拉回安全点,可能丢掉玩家摆好的位置)', 'warn');
this.setState(State.FAIL, reason + '(原地还能走,未回城)');
return;
}
this._log(`连续超时达阈值,${assess.reason}`, 'warn');
this._sendReturnCommand(`连续寻路超时超过 ${config.returnThresholdSeconds} 秒`);
return;
}
if (this.consecutiveTimeoutMillis >= config.returnThresholdSeconds * 1000 && !config.returnOnTimeout) {
this._log('连续寻路超时已达回城阈值,但「超时自动回城」未启用,跳过当前目标(如需自动逃离卡死状态请在配置里打开)');
}
this._log(this.target ? `${reason},跳过 ${this.target.x},${this.target.z}` : reason);
this.setState(State.FAIL, reason);
}
/**
* 回城前的「原地是否真的更差」评估。
*
* 回城是破坏性操作:它把机器人拉回安全点,会清掉玩家手动摆好的位置。
* 如果当前位置还能走(寻路器能从脚下节点生成邻居),说明只是走得慢 / 目标不可达,
* 回城不会带来任何好处 —— 这种情况应当跳过目标,而不是传送。
*
* 判据取自 `pathing.diagnostics().neighborCount`(寻路器从脚下节点能生成的可走方向数):
* - `0`:完全无路可走,确实卡死,回城值得一试;
* - `≥1`:还能走,回城没有意义;
* - 拿不到诊断(未连接 / 寻路器未就绪):保守起见仍按卡死处理。
*
* @returns {{worth: boolean, neighborCount: number|null, reason: string}}
*/
_assessReturnWorth() {
const bm = this.botManager;
let neighborCount = null;
try {
if (bm.pathing && typeof bm.pathing.diagnostics === 'function') {
const diag = bm.pathing.diagnostics();
if (diag && typeof diag.neighborCount === 'number') neighborCount = diag.neighborCount;
}
} catch {
neighborCount = null;
}
if (neighborCount === null) {
return { worth: true, neighborCount, reason: '拿不到寻路诊断(无法判断原地是否能走),按卡死处理' };
}
if (neighborCount >= 1) {
return {
worth: false,
neighborCount,
reason: `当前位置还有 ${neighborCount} 个可走方向(不是卡死),回城不会更好`
};
}
return { worth: true, neighborCount, reason: `当前位置 0 个可走方向(疑似卡死),回城值得一试` };
}
/** 等待开垦阶段每 tick 逻辑:玩家离开目标区块则重新寻路;等待超时则重试或跳过 */
_tickWaiting() {
if (!this.target) {
@@ -603,16 +844,26 @@ class ExpansionController {
/** 处理下一个目标:达成目标数量则完成;队列为空时重新规划,否则取队首开始寻路 */
_tickNext() {
this._releaseTargetLease();
this.target = null;
// 候选区块正被其他机器人预定:等一会儿再重新规划(避免忙等,见 _waitForLeaseRelease)
if (this._nextReplanAt && Date.now() < this._nextReplanAt) return;
this._nextReplanAt = 0;
if (this.config.targetCount > 0 && this.completedClaims >= this.config.targetCount) {
this.finish();
return;
}
if (this.queue.length === 0) {
const picked = this._pickNextTarget();
if (!picked.target) {
if (picked.skippedLease > 0) {
this._waitForLeaseRelease(picked.skippedLease);
return;
}
// 队列真的空了:重新规划
this.plan();
return;
}
this.target = this.queue.shift();
this.target = picked.target;
if (this._isClaimedByOthers(this.target)) {
this._log(`目标已被占领,跳过 ${this.target.x},${this.target.z}`);
this.setState(State.NEXT, '跳过已占领区块');
@@ -623,6 +874,7 @@ class ExpansionController {
/** 完成任务 */
finish() {
this._releaseTargetLease();
this.botManager.pathing.cancel();
this.setState(State.COMPLETE, this.config.targetCount > 0
? `已完成目标数量 ${this.completedClaims}/${this.config.targetCount}`
@@ -669,7 +921,7 @@ class ExpansionController {
*/
_isAdjacentToOwn(chunk) {
const model = this.dataService.getModel();
const self = model.selfState();
const self = model.state(this.stateName);
const ownChunks = self ? self.chunks : null;
const sessionClaims = model.sessionClaimsSet();
const x = chunk.x;
@@ -690,6 +942,55 @@ class ExpansionController {
return this.dataService.getModel().allClaimedChunks().has(chunkPos.key(chunk.x, chunk.z));
}
// ==================== 目标区块租约(多机器人协调) ====================
/**
* 从队列里取出下一个可用目标:
* 跳过已被占领的区块,以及被其他机器人租约占用的区块(谁先抢到谁去)。
*
* @returns {{target: {x: number, z: number}|null, skippedLease: number}}
*/
_pickNextTarget() {
let skippedLease = 0;
while (this.queue.length > 0) {
const chunk = this.queue.shift();
if (this._isClaimedByOthers(chunk)) continue;
if (this.targetLeases && !this.targetLeases.tryAcquire(chunk, this.botId)) {
skippedLease++;
this._log(`区块 ${chunk.x},${chunk.z} 已被其他机器人预定,跳过`);
continue;
}
return { target: chunk, skippedLease };
}
return { target: null, skippedLease };
}
/** 续租当前目标(每个 tick 调用,保证长时间寻路时租约不过期) */
_renewTargetLease() {
if (!this.targetLeases || !this.target) return;
this.targetLeases.renew(this.target, this.botId);
}
/** 释放当前目标的租约(幂等:没有目标或没有租约表时什么都不做) */
_releaseTargetLease() {
if (!this.targetLeases || !this.target) return;
this.targetLeases.release(this.target, this.botId);
}
/**
* 候选区块暂时都被其他机器人预定时的处理:等一会儿再重新规划。
*
* 直接判 COMPLETE 会让机器人在别人还在开垦时提前收工 —— 那些区块可能是
* 别人的目标失败了才释放的,稍后再看更合理。这里只设一个时间戳,
* 由 {@link _tickNext} 的时间闸门控制,不做定时器,避免并发状态。
*/
_waitForLeaseRelease(skippedLease) {
this._nextReplanAt = Date.now() + LEASE_RETRY_DELAY_MS;
this._log(`${skippedLease} 个候选区块正被其他机器人预定,`
+ `${Math.round(LEASE_RETRY_DELAY_MS / 1000)} 秒后重新规划`, 'warn');
this.setState(State.NEXT, '候选区块被其他机器人预定,等待释放');
}
/**
* 计算区块到己方领土的「圈数」(切比雪夫距离的最小值),用于过滤扩圈上限。
* @param {{x: number, z: number}} chunk
@@ -698,7 +999,7 @@ class ExpansionController {
*/
_ringDistance(chunk, model) {
let min = Infinity;
const self = model.selfState();
const self = model.state(this.stateName);
if (self) {
for (const k of self.chunks) {
const pos = chunkPos.decode(k);
@@ -727,6 +1028,10 @@ class ExpansionController {
this._log('寻路事件:AT_GOAL');
} else if (event === PathEvent.CALC_FINISHED_NOW_EXECUTING) {
this._log(`寻路事件:路径已生成(代价 ${detail ? detail.cost : '?'})`);
} else if (event === PathEvent.SEGMENT_REACHED) {
// 长距离分段寻路走完一段:这是「有进展」而不是卡住,
// 必须重置超时计时,否则走几十段的长途会被 waitTimeoutSeconds 误判为寻路超时
this.stageStartedMillis = Date.now();
}
}
@@ -740,8 +1045,10 @@ class ExpansionController {
/**
* 开垦成功(带具体坐标):把新占领区块加入模型与数据服务,累计完成数并进入 SUCCESS 状态。
* @param {{x: number, z: number}} chunk
* @param {number} [blockX] 方块坐标 X(服务器回报值)
* @param {number} [blockZ] 方块坐标 Z(服务器回报值)
*/
onSuccess(chunk) {
onSuccess(chunk, blockX, blockZ) {
if (!this.isRunning()) return;
const target = chunk || this.target;
if (!target) return;
@@ -749,8 +1056,8 @@ class ExpansionController {
// 去重:同一区块只计一次
if (this.claimedSet.has(key)) return;
this.claimedSet.add(key);
this.dataService.getModel().addSessionClaim(target);
this.dataService.addSessionClaim(target);
this.dataService.getModel().addSessionClaim(target, this.stateName);
this.dataService.addSessionClaim(target, this.stateName);
// 占领成功后立即触发一次地图刷新(请求侧已带缓存击穿),缩短「会话占领等待确认」窗口
this.dataService.refreshAsync();
this.completedClaims++;
@@ -758,6 +1065,7 @@ class ExpansionController {
this.target = target;
this.lastProgress = 100;
this._log(`占领成功 ${target.x},${target.z}`);
this._recordClaim(target, blockX, blockZ, 'chat');
this.setState(State.SUCCESS, `疆土已开拓 ${target.x},${target.z}`);
}
@@ -774,16 +1082,40 @@ class ExpansionController {
// 去重:同一区块只计一次
if (this.claimedSet.has(key)) return;
this.claimedSet.add(key);
this.dataService.getModel().addSessionClaim(this.target);
this.dataService.addSessionClaim(this.target);
this.dataService.getModel().addSessionClaim(this.target, this.stateName);
this.dataService.addSessionClaim(this.target, this.stateName);
this.dataService.refreshAsync();
this.completedClaims++;
this.consecutiveTimeoutMillis = 0;
this.lastProgress = 100;
this._log(`占领成功 ${this.target.x},${this.target.z}`);
this._recordClaim(this.target, null, null, 'chat-generic');
this.setState(State.SUCCESS, `疆土已开拓 ${this.target.x},${this.target.z}`);
}
/**
* 写入开拓日志:详细记录开拓玩家、区块、时间与来源。
*
* @param {{x: number, z: number}} chunk 区块坐标
* @param {number|null} blockX 方块坐标 X
* @param {number|null} blockZ 方块坐标 Z
* @param {string} source 判定来源
*/
_recordClaim(chunk, blockX, blockZ, source) {
if (!this.expansionLog) return;
this.expansionLog.record({
botId: this.botId,
bot: this.botName,
state: this.stateName,
chunk,
block: Number.isFinite(blockX) && Number.isFinite(blockZ) ? { x: blockX, z: blockZ } : null,
source,
progress: 100,
// 本轮任务已开拓区块数,便于在日志里看出第几个
sessionCount: this.completedClaims
});
}
// ==================== 生存辅助 ====================
/**
@@ -924,6 +1256,23 @@ class ExpansionController {
};
}
/**
* @returns {{x: number, y: number, z: number}|null} 机器人当前位置(两位小数)
*
* 与 `_blockPos` 的区别:这里是精确坐标,用于「回城前后挪了多少」这种对比展示,
* 取整会把几格的位移抹掉。
*/
_precisePos() {
const bot = this.botManager.bot;
if (!bot || !bot.entity) return null;
const pos = bot.entity.position;
return {
x: Math.round(pos.x * 100) / 100,
y: Math.round(pos.y * 100) / 100,
z: Math.round(pos.z * 100) / 100
};
}
_describeChunk(chunk) {
return chunk ? `${chunk.x},${chunk.z}` : '无';
}
@@ -943,7 +1292,9 @@ class ExpansionController {
}
_log(message, level) {
if (this.logger) this.logger.log(message, level);
if (!this.logger) return;
// 多机器人共用一份运行日志:统一加机器人名前缀,便于区分来源
this.logger.log(`[${this.botName}] ${message}`, level);
}
/**
@@ -951,6 +1302,9 @@ class ExpansionController {
*/
snapshot() {
return {
botId: this.botId,
botName: this.botName,
stateName: this.stateName,
state: this.state,
status: this.status,
running: this.isRunning(),
@@ -964,6 +1318,10 @@ class ExpansionController {
consecutiveTimeoutMs: this.consecutiveTimeoutMillis,
sideStepping: this.sideStepping,
sideStepCount: this.sideStepCount,
/** 因「原地还能走」而放弃回城的次数(回城前评估的结果) */
returnSkippedNotStuck: this.returnSkippedNotStuck,
/** 最近一次回城的前后坐标与直线距离(控制台展示「从 A 挪到了 B」) */
lastReturn: this.lastReturn,
autoBoatActive: !!(this.botManager.boat && this.botManager.boat.isActive()),
withinClaimTime: this.isWithinClaimTime(),
claimModeSent: this._claimModeSent,
@@ -989,6 +1347,15 @@ class ExpansionController {
}
}
/**
* 位置快照 → 日志里可读的文本。
* @param {{x: number, y: number, z: number}|null} pos
* @returns {string}
*/
function formatPosition(pos) {
return pos ? `(${pos.x}, ${pos.y}, ${pos.z})` : '(未知)';
}
/**
* 格式化为 HH:mm:ss。
* @param {Date} date
+514
查看文件
@@ -0,0 +1,514 @@
'use strict';
const fs = require('fs');
const path = require('path');
/**
* 大地图瓦片服务:爬取 squaremap 站点(bgjq.simpfun.cn)的地图图片并本地缓存,
* 供控制台在一张大地图上展示所有机器人的坐标与领土标记。
*
* ## 坐标系(已对 source 站点实测标定)
*
* squaremap 前端用 `L.CRS.Simple` + `tileSize: 512`,瓦片路径为
* `tiles/<world>/<z>/<x>_<y>.png`,并且:
*
* - 瓦片图片固定 512×512 像素;
* - 最大原生层级 `zoom.max`(本站为 3),在该层级 1 方块 = 1 像素;
* - 因此层级 z 的每张瓦片覆盖 `512 * 2^(maxZoom - z)` 个方块,
* 瓦片 (x, y) 覆盖方块区间 `[x*B, (x+1)*B) × [y*B, (y+1)*B)`(B 即上式)。
*
* 本站实测:王城「现代大陆」(10722, 1547) 落在 z=3 的瓦片 (20, 3) 内,
* 与上述换算一致(20*512=10240 ≤ 10722 < 10752,3*512=1536 ≤ 1547 < 2048)。
*
* ## 缓存策略
*
* - 磁盘缓存 `data/mapcache/<world>/<z>/<x>_<y>.png`,命中即直接返回,不再回源;
* - 低层级(默认 z=0,全世界 10×10=100 张)在启动时批量爬取,保证大地图秒开;
* - 高层级按需穿透拉取(浏览器缩放时才请求),拉过就永久缓存。
*/
/** 瓦片图片边长(像素) */
const TILE_PIXELS = 512;
/** 本站 squaremap 的最大原生层级(settings.json 的 zoom.max) */
const DEFAULT_MAX_ZOOM = 3;
/** 默认覆盖半径(方块):与服务器世界边界 ±20480 一致 */
const DEFAULT_RANGE_BLOCKS = 20480;
/** 单张瓦片下载超时(毫秒) */
const TILE_TIMEOUT_MS = 20000;
class BigMapService {
/**
* @param {object} config 完整配置对象
* @param {import('../util/logger').Logger} [logger]
* @param {{baseUrl?: string, world?: string, cacheDir?: string, fetchImpl?: Function}} [options]
*/
constructor(config, logger, options) {
const opts = options || {};
this.config = config;
this.logger = logger || null;
this.baseUrl = String(opts.baseUrl || process.env.DYNMAP_BASE || 'http://bgjq.simpfun.cn').replace(/\/+$/, '');
this.world = opts.world || (config && config.bigMap && config.bigMap.world) || process.env.DYNMAP_WORLD || 'minecraft_overworld';
this.cacheDir = opts.cacheDir || path.join(__dirname, '..', '..', 'data', 'mapcache');
this.fetchImpl = opts.fetchImpl || globalThis.fetch;
/** @type {Map<string, Buffer>} 内存 LRU(瓦片体积小,按层级分目录缓存到磁盘为主) */
this._memory = new Map();
this._memoryLimit = 256;
/** 爬取进度 */
this.crawl = {
running: false,
zoom: 0,
total: 0,
done: 0,
failed: 0,
startedAt: 0,
finishedAt: 0,
lastError: ''
};
this._crawlAbort = false;
/**
* 后台预热状态(机器人移动到新区域后顺带拉取周边高层级瓦片)。
*
* `lastCenterX/lastCenterZ` 用来判断「机器人是否移动够远」,
* `lastAt` 用来兜底「很久没预热就再预热一次」。
*/
this.prewarm = {
running: false,
lastAt: 0,
lastCenterX: null,
lastCenterZ: null,
/** 最近一次预热覆盖的层级范围(展示用) */
minZoom: 0,
maxZoom: 0,
total: 0,
done: 0,
failed: 0,
finishedAt: 0
};
this._prewarmAbort = false;
}
// ==================== 坐标系 ====================
/** @returns {number} 当前允许浏览的最大层级 */
get maxZoom() {
const configured = this.config && this.config.bigMap ? this.config.bigMap.maxZoom : undefined;
return Number.isFinite(configured) ? configured : DEFAULT_MAX_ZOOM;
}
/**
* 层级 z 下每张瓦片覆盖的方块数。
* @param {number} zoom
* @returns {number}
*/
blocksPerTile(zoom) {
return TILE_PIXELS * Math.pow(2, this.maxZoom - clampZoom(zoom, this.maxZoom));
}
/**
* 计算覆盖以 `center` 为中心、半径 `rangeBlocks` 的方形区域所需的瓦片下标范围(闭区间)。
*
* 默认中心是原点(全世界),但实际使用时通常只关心**机器人所在位置周边**的图幅 ——
* 全世界层级 0 是 100 张瓦片,而 1024 格半径只需 1 张、4096 格半径只需 4 张,
* 爬取和传输的开销差一个数量级。
*
* @param {number} zoom 层级
* @param {number} [rangeBlocks] 覆盖半径(方块),缺省取配置
* @param {number} [centerBlockX] 中心方块 X,缺省 0
* @param {number} [centerBlockZ] 中心方块 Z,缺省 0
* @returns {{minX: number, maxX: number, minZ: number, maxZ: number, count: number,
* centerBlockX: number, centerBlockZ: number, rangeBlocks: number}}
*/
tileRange(zoom, rangeBlocks, centerBlockX, centerBlockZ) {
const radius = Number.isFinite(rangeBlocks)
? Math.abs(rangeBlocks)
: (this.config && this.config.bigMap ? this.config.bigMap.rangeBlocks : DEFAULT_RANGE_BLOCKS);
const centerX = Number.isFinite(centerBlockX) ? centerBlockX : 0;
const centerZ = Number.isFinite(centerBlockZ) ? centerBlockZ : 0;
const perTile = this.blocksPerTile(zoom);
const minX = Math.floor((centerX - radius) / perTile);
const maxX = Math.ceil((centerX + radius) / perTile) - 1;
const minZ = Math.floor((centerZ - radius) / perTile);
const maxZ = Math.ceil((centerZ + radius) / perTile) - 1;
return {
minX,
maxX,
minZ,
maxZ,
count: (maxX - minX + 1) * (maxZ - minZ + 1),
centerBlockX: centerX,
centerBlockZ: centerZ,
rangeBlocks: radius
};
}
/**
* 瓦片覆盖的方块区间(闭开区间 `[min, max)`)。
* @param {number} zoom
* @param {number} tileX
* @param {number} tileZ
* @returns {{minBlockX: number, maxBlockX: number, minBlockZ: number, maxBlockZ: number}}
*/
tileBlockBounds(zoom, tileX, tileZ) {
const perTile = this.blocksPerTile(zoom);
return {
minBlockX: tileX * perTile,
maxBlockX: (tileX + 1) * perTile,
minBlockZ: tileZ * perTile,
maxBlockZ: (tileZ + 1) * perTile
};
}
/** @returns {string} 某个瓦片的回源地址 */
tileUrl(zoom, tileX, tileZ) {
return `${this.baseUrl}/tiles/${encodeURIComponent(this.world)}/${clampZoom(zoom, this.maxZoom)}/${tileX}_${tileZ}.png`;
}
/** @returns {string} 某个瓦片的磁盘缓存路径 */
tilePath(zoom, tileX, tileZ) {
return path.join(this.cacheDir, sanitize(this.world), String(clampZoom(zoom, this.maxZoom)), `${tileX}_${tileZ}.png`);
}
// ==================== 瓦片读取 ====================
/**
* 读取一张瓦片:内存 → 磁盘 → 回源。
*
* @param {number} zoom
* @param {number} tileX
* @param {number} tileZ
* @returns {Promise<Buffer|null>} 瓦片 PNG 数据;失败返回 null
*/
async getTile(zoom, tileX, tileZ) {
const z = clampZoom(zoom, this.maxZoom);
const key = `${z}/${tileX}_${tileZ}`;
const cached = this._memory.get(key);
if (cached) return cached;
const file = this.tilePath(z, tileX, tileZ);
try {
const data = fs.readFileSync(file);
if (data.length > 0) {
this._remember(key, data);
return data;
}
} catch {
// 未缓存,继续回源
}
return this._download(z, tileX, tileZ);
}
/** 从源站下载并落盘;失败返回 null(控制台会画占位色块) */
async _download(zoom, tileX, tileZ) {
const url = this.tileUrl(zoom, tileX, tileZ);
const controller = typeof AbortController === 'function' ? new AbortController() : null;
const timer = controller ? setTimeout(() => controller.abort(), TILE_TIMEOUT_MS) : null;
try {
const response = await this.fetchImpl(url, {
signal: controller ? controller.signal : undefined,
headers: {
'User-Agent': 'Mozilla/5.0 autostore-node/1.0',
Accept: 'image/png,image/*,*/*'
}
});
if (!response.ok) return null;
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.length === 0) return null;
this._remember(`${zoom}/${tileX}_${tileZ}`, buffer);
this._writeCache(zoom, tileX, tileZ, buffer);
return buffer;
} catch {
return null;
} finally {
if (timer) clearTimeout(timer);
}
}
/** 落盘缓存(失败静默:缓存只是加速手段,不影响功能) */
_writeCache(zoom, tileX, tileZ, buffer) {
try {
const file = this.tilePath(zoom, tileX, tileZ);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, buffer);
} catch {
// 忽略
}
}
/** 写入内存 LRU(简单实现:超限时丢掉最早插入的若干项) */
_remember(key, buffer) {
this._memory.set(key, buffer);
while (this._memory.size > this._memoryLimit) {
const oldest = this._memory.keys().next();
if (oldest.done) break;
this._memory.delete(oldest.value);
}
}
// ==================== 批量爬取 ====================
/**
* 批量爬取某一层级、某一区域内的全部瓦片(已缓存则跳过)。
*
* 区域默认以原点为中心;实际使用时通常传机器人所在坐标,只爬它周边那几平方公里,
* 这样图幅可以自由调整而不用把整个世界都拉下来。
*
* 重复调用时若已有爬取在途则直接返回当前进度,不会并发起多轮。
*
* @param {number} [zoom] 层级,缺省取配置里的 bigMap.zoom
* @param {{force?: boolean, centerBlockX?: number, centerBlockZ?: number, rangeBlocks?: number}} [options]
* @returns {Promise<object>} 爬取结果统计
*/
async crawlAll(zoom, options) {
if (this.crawl.running) return this.snapshot().crawl;
const opts = options || {};
const z = clampZoom(Number.isFinite(zoom) ? zoom : (this.config.bigMap.zoom || 0), this.maxZoom);
const range = this.tileRange(z, opts.rangeBlocks, opts.centerBlockX, opts.centerBlockZ);
this._crawlAbort = false;
this.crawl = {
running: true,
zoom: z,
total: range.count,
done: 0,
failed: 0,
startedAt: Date.now(),
finishedAt: 0,
lastError: '',
centerBlockX: range.centerBlockX,
centerBlockZ: range.centerBlockZ,
rangeBlocks: range.rangeBlocks
};
if (this.logger) {
this.logger.log(`开始爬取大地图瓦片:层级 ${z},中心 ${range.centerBlockX},${range.centerBlockZ},`
+ `半径 ${range.rangeBlocks} 格,瓦片 ${range.minX}..${range.maxX} × ${range.minZ}..${range.maxZ},共 ${range.count} 张`);
}
const tasks = [];
for (let x = range.minX; x <= range.maxX; x++) {
for (let y = range.minZ; y <= range.maxZ; y++) tasks.push([x, y]);
}
const concurrency = Math.max(1, Math.min(16, (this.config.bigMap && this.config.bigMap.concurrency) || 4));
let cursor = 0;
const worker = async () => {
while (cursor < tasks.length && !this._crawlAbort) {
const task = tasks[cursor++];
const buffer = await this._fetchForCrawl(z, task[0], task[1], opts.force === true);
if (buffer) this.crawl.done++;
else this.crawl.failed++;
}
};
const workers = [];
for (let i = 0; i < concurrency; i++) workers.push(worker());
await Promise.all(workers);
this.crawl.running = false;
this.crawl.finishedAt = Date.now();
if (this.logger) {
const seconds = ((this.crawl.finishedAt - this.crawl.startedAt) / 1000).toFixed(1);
this.logger.log(`大地图瓦片爬取完成:成功 ${this.crawl.done} / 失败 ${this.crawl.failed},用时 ${seconds} 秒`, 'success');
}
return this.snapshot().crawl;
}
/** 爬取单张瓦片:非强制模式下已缓存则直接计入成功 */
async _fetchForCrawl(zoom, tileX, tileZ, force) {
if (!force) {
try {
if (fs.statSync(this.tilePath(zoom, tileX, tileZ)).size > 0) return true;
} catch {
// 未缓存
}
}
return (await this._download(zoom, tileX, tileZ)) !== null;
}
/** 中断进行中的爬取 */
abortCrawl() {
this._crawlAbort = true;
}
// ==================== 后台预热 ====================
/**
* 后台低优先级预热:机器人移动到新区域后,顺带把周边的高层级瓦片拉下来。
*
* 只有「启动预爬取层级」(默认 0,全世界 10×10 张)会批量爬,层级 1~3 原本
* 要靠用户缩放时按需拉取 —— 首次浏览某个区域要等好几秒。预热就是用空闲带宽
* 把「以机器人为中心、半径 prewarmRangeBlocks」的较高层级补下来。
*
* 节流规则(避免频繁骚扰源站):
* - 已有批量爬取或预热在途 → 直接跳过;
* - 机器人移动不足 `prewarmMinMoveBlocks` 且距上次预热不足 `prewarmIntervalMs` → 跳过;
* - 并发取批量爬取的一半(低优先级),已缓存的瓦片只会做一次 statSync。
*
* @param {number} centerBlockX
* @param {number} centerBlockZ
* @returns {Promise<object>} 预热结果(跳过关时返回 `{skipped: 原因}`)
*/
async prewarmAround(centerBlockX, centerBlockZ) {
const mapConfig = (this.config && this.config.bigMap) || {};
if (mapConfig.prewarm === false) return { skipped: 'disabled' };
if (!Number.isFinite(centerBlockX) || !Number.isFinite(centerBlockZ)) return { skipped: 'no-position' };
if (this.crawl.running || this.prewarm.running) return { skipped: 'busy' };
const now = Date.now();
const minMove = Number.isFinite(mapConfig.prewarmMinMoveBlocks) ? mapConfig.prewarmMinMoveBlocks : 512;
const interval = Number.isFinite(mapConfig.prewarmIntervalMs) ? mapConfig.prewarmIntervalMs : 600000;
const moved = this.prewarm.lastCenterX === null
? Infinity
: Math.hypot(centerBlockX - this.prewarm.lastCenterX, centerBlockZ - this.prewarm.lastCenterZ);
if (moved < minMove && now - this.prewarm.lastAt < interval) return { skipped: 'recent' };
const baseZoom = clampZoom(mapConfig.zoom || 0, this.maxZoom);
const levels = Number.isFinite(mapConfig.prewarmLevels) ? Math.max(0, mapConfig.prewarmLevels) : 3;
const lastZoom = Math.min(this.maxZoom, baseZoom + levels);
if (lastZoom <= baseZoom) return { skipped: 'no-levels' };
const radius = Number.isFinite(mapConfig.prewarmRangeBlocks) ? mapConfig.prewarmRangeBlocks : 1024;
const batchConcurrency = Math.max(1, Math.min(16, mapConfig.concurrency || 4));
const concurrency = Math.max(1, Math.floor(batchConcurrency / 2));
this.prewarm.running = true;
this.prewarm.lastAt = now;
this.prewarm.lastCenterX = centerBlockX;
this.prewarm.lastCenterZ = centerBlockZ;
this.prewarm.minZoom = baseZoom + 1;
this.prewarm.maxZoom = lastZoom;
this._prewarmAbort = false;
let done = 0;
let failed = 0;
let total = 0;
const startedAt = Date.now();
try {
for (let z = baseZoom + 1; z <= lastZoom; z++) {
const range = this.tileRange(z, radius, centerBlockX, centerBlockZ);
total += range.count;
const tasks = [];
for (let x = range.minX; x <= range.maxX; x++) {
for (let y = range.minZ; y <= range.maxZ; y++) tasks.push([x, y]);
}
let cursor = 0;
const worker = async () => {
while (cursor < tasks.length && !this._prewarmAbort) {
const task = tasks[cursor++];
if (await this._fetchForCrawl(z, task[0], task[1], false)) done++;
else failed++;
}
};
const workers = [];
for (let i = 0; i < concurrency; i++) workers.push(worker());
await Promise.all(workers);
}
} finally {
this.prewarm.running = false;
this.prewarm.total = total;
this.prewarm.done = done;
this.prewarm.failed = failed;
this.prewarm.finishedAt = Date.now();
}
if (this.logger && total > 0) {
const seconds = ((this.prewarm.finishedAt - startedAt) / 1000).toFixed(1);
this.logger.log(`大地图预热完成:层级 ${baseZoom + 1}~${lastZoom} 周边 ${total} 张,`
+ `成功 ${done} / 失败 ${failed},用时 ${seconds} 秒`);
}
return Object.assign({}, this.prewarm);
}
/** 中断进行中的预热 */
abortPrewarm() {
this._prewarmAbort = true;
}
// ==================== 状态 ====================
/**
* @param {{centerBlockX?: number, centerBlockZ?: number, rangeBlocks?: number}} [options]
* @returns {object} 大地图状态快照(供控制台与前端渲染使用)
*/
snapshot(options) {
const opts = options || {};
const zoom = this.config && this.config.bigMap ? this.config.bigMap.zoom : 0;
const centerX = Number.isFinite(opts.centerBlockX) ? opts.centerBlockX : 0;
const centerZ = Number.isFinite(opts.centerBlockZ) ? opts.centerBlockZ : 0;
const range = this.tileRange(zoom, opts.rangeBlocks, centerX, centerZ);
return {
enabled: !!(this.config && this.config.bigMap && this.config.bigMap.enabled),
baseUrl: this.baseUrl,
world: this.world,
tilePixels: TILE_PIXELS,
maxZoom: this.maxZoom,
zoom,
blocksPerTile: this.blocksPerTile(zoom),
rangeBlocks: range.rangeBlocks,
followBot: !!(this.config && this.config.bigMap && this.config.bigMap.followBot),
tiles: range,
cached: this.countCached(zoom),
source: {
tileUrlTemplate: `${this.baseUrl}/tiles/${this.world}/{z}/{x}_{y}.png`,
markersUrl: `${this.baseUrl}/tiles/${this.world}/markers.json`
},
crawl: Object.assign({}, this.crawl),
/** 后台预热状态:最近一次覆盖的层级、成功 / 失败数与上次位置 */
prewarm: Object.assign({}, this.prewarm)
};
}
/**
* 统计某层级已缓存的瓦片数量。
* @param {number} zoom
* @returns {number}
*/
countCached(zoom) {
const dir = path.join(this.cacheDir, sanitize(this.world), String(clampZoom(zoom, this.maxZoom)));
try {
return fs.readdirSync(dir).filter((name) => name.endsWith('.png')).length;
} catch {
return 0;
}
}
/**
* 清空磁盘与内存缓存。
* @returns {number} 删除的瓦片数
*/
clearCache() {
let removed = 0;
const root = path.join(this.cacheDir, sanitize(this.world));
try {
removed = countFiles(root);
fs.rmSync(root, { recursive: true, force: true });
} catch {
// 忽略
}
this._memory.clear();
return removed;
}
}
/** 递归统计目录下的文件数 */
function countFiles(dir) {
let total = 0;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.isDirectory()) total += countFiles(path.join(dir, entry.name));
else total++;
}
return total;
}
/** 世界名可能含路径分隔符,落盘前统一安全化 */
function sanitize(name) {
return String(name).replace(/[\\/:*?"<>|]/g, '_') || 'world';
}
function clampZoom(zoom, max) {
const n = Number(zoom);
if (!Number.isFinite(n)) return 0;
return Math.min(max, Math.max(0, Math.trunc(n)));
}
module.exports = {
BigMapService,
TILE_PIXELS,
DEFAULT_MAX_ZOOM,
DEFAULT_RANGE_BLOCKS,
TILE_TIMEOUT_MS
};
+225
查看文件
@@ -0,0 +1,225 @@
'use strict';
const fs = require('fs');
const path = require('path');
/** 每个机器人在内存中保留的公屏消息条数上限 */
const MAX_ENTRIES_PER_BOT = 2000;
/** 单个机器人聊天日志文件大小上限:超过后轮转为 `.1` */
const MAX_FILE_BYTES = 16 * 1024 * 1024;
/** 消息类型 */
const ChatKind = {
/** 公屏聊天(其他玩家发言) */
CHAT: 'chat',
/** 系统消息 / 服务器广播 */
SYSTEM: 'system',
/** 动作栏、Boss 条等游戏信息 */
ACTION: 'action',
/** 本机器人发出的聊天内容 */
OUT: 'out',
/** 本机器人发出的指令 */
COMMAND: 'command'
};
/**
* 游戏公屏日志:把每个机器人收到的**全部**公屏 / 系统消息按机器人分文件留档,
* 同时保留内存环形缓冲供控制台实时查看。
*
* 与原 Java 版只留「开垦相关」消息不同,这里要求「完整日志」:
* 所有 messagestr / message 事件都落盘,便于事后核对谁在什么时候说了什么、
* 服务器在什么时候发了什么公告。
*/
class ChatLog {
/**
* @param {{dir?: string, logger?: import('../util/logger').Logger, maxEntriesPerBot?: number}} [options]
*/
constructor(options) {
const opts = options || {};
this.dir = opts.dir || path.join(__dirname, '..', '..', 'data', 'chat');
this.logger = opts.logger || null;
this.maxEntriesPerBot = opts.maxEntriesPerBot || MAX_ENTRIES_PER_BOT;
/** @type {Map<string, Array<object>>} 机器人 id → 消息环形缓冲 */
this.buffers = new Map();
/** @type {Map<string, number>} 机器人 id → 累计消息数 */
this.counters = new Map();
/** @type {((entry: object) => void)|null} 新消息回调(WebSocket 实时推送用) */
this.onEntry = null;
}
/**
* 追加一条公屏消息。
*
* @param {object} event
* @param {string} event.botId 机器人 id
* @param {string} [event.bot] 机器人游戏名
* @param {string} event.message 消息正文
* @param {string} [event.kind] 消息类型,见 {@link ChatKind}
* @param {string} [event.username] 发言人(公屏消息)
* @param {number} [event.time] 时间戳(缺省取当前时间)
* @returns {object|null} 写入的记录;消息为空时返回 null
*/
append(event) {
const text = event.message === undefined || event.message === null ? '' : String(event.message);
if (!text) return null;
const botId = String(event.botId || event.bot || 'bot');
const entry = {
t: Number.isFinite(event.time) ? event.time : Date.now(),
botId,
bot: String(event.bot || botId),
kind: event.kind || ChatKind.CHAT,
username: event.username ? String(event.username) : '',
message: text
};
let buffer = this.buffers.get(botId);
if (!buffer) {
buffer = [];
this.buffers.set(botId, buffer);
}
buffer.push(entry);
while (buffer.length > this.maxEntriesPerBot) buffer.shift();
this.counters.set(botId, (this.counters.get(botId) || 0) + 1);
this._appendToFile(entry);
if (this.onEntry) {
try {
this.onEntry(entry);
} catch {
// 订阅者异常不得影响主流程
}
}
return entry;
}
/**
* 查询某机器人的公屏消息(时间升序,最新在最后)。
*
* @param {string} botId
* @param {{limit?: number, since?: number, kind?: string, keyword?: string}} [filter]
* @returns {Array<object>}
*/
list(botId, filter) {
const opts = filter || {};
const buffer = this.buffers.get(String(botId)) || [];
let result = buffer;
if (Number.isFinite(opts.since)) result = result.filter((e) => e.t >= opts.since);
if (opts.kind) result = result.filter((e) => e.kind === opts.kind);
if (opts.keyword) {
const needle = String(opts.keyword).toLowerCase();
result = result.filter((e) => e.message.toLowerCase().includes(needle));
}
const limit = Number.isFinite(opts.limit) && opts.limit > 0 ? Math.trunc(opts.limit) : 300;
return result.length > limit ? result.slice(result.length - limit) : result.slice();
}
/** @returns {object} 公屏日志状态快照(供控制台展示) */
snapshot() {
const bots = [];
for (const [botId, buffer] of this.buffers) {
bots.push({
botId,
count: this.counters.get(botId) || 0,
buffered: buffer.length,
lastAt: buffer.length > 0 ? buffer[buffer.length - 1].t : 0
});
}
bots.sort((a, b) => b.count - a.count);
return { dir: this.dir, bots, total: sumCounters(this.counters) };
}
/**
* 导出某机器人的公屏日志为纯文本文件。
*
* @param {string} botId
* @param {string} filePath 目标路径
* @returns {string|null} 导出文件路径;失败返回 null
*/
exportTo(botId, filePath) {
try {
const rows = this.list(botId, { limit: Number.MAX_SAFE_INTEGER });
const lines = [`==== ${botId} 游戏公屏日志(${rows.length} 条)====`];
for (const e of rows) {
lines.push(`[${formatDateTime(e.t)}] ${kindLabel(e.kind)}${e.username ? e.username + ': ' : ''}${e.message}`);
}
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, lines.join('\n'), 'utf8');
return filePath;
} catch {
return null;
}
}
/** 机器人对应的落盘文件名(把不安全字符替换为下划线) */
filePathOf(botId) {
return path.join(this.dir, `${sanitize(String(botId))}.jsonl`);
}
/** 追加一行到磁盘(失败只记日志,绝不影响机器人运行) */
_appendToFile(entry) {
const file = this.filePathOf(entry.botId);
try {
fs.mkdirSync(this.dir, { recursive: true });
rotateIfNeeded(file);
fs.appendFileSync(file, JSON.stringify(entry) + '\n', 'utf8');
} catch (err) {
if (this.logger) {
this.logger.log('公屏日志写入失败:' + (err && err.message ? err.message : String(err)), 'warn');
}
}
}
}
/** 文件名安全化:去掉路径分隔符与 Windows 保留字符 */
function sanitize(name) {
return name.replace(/[\\/:*?"<>|]/g, '_') || 'bot';
}
/** 文件超过上限时轮转为 `.1` */
function rotateIfNeeded(file) {
let size = 0;
try {
size = fs.statSync(file).size;
} catch {
return;
}
if (size < MAX_FILE_BYTES) return;
try {
fs.renameSync(file, file + '.1');
} catch {
// 轮转失败时继续追加,不阻塞
}
}
function kindLabel(kind) {
if (kind === ChatKind.OUT) return '【发言】';
if (kind === ChatKind.COMMAND) return '【指令】';
if (kind === ChatKind.SYSTEM) return '【系统】';
if (kind === ChatKind.ACTION) return '【动作栏】';
return '';
}
function sumCounters(counters) {
let total = 0;
for (const value of counters.values()) total += value;
return total;
}
/**
* 格式化为 `YYYY-MM-DD HH:mm:ss`。
* @param {number} millis
* @returns {string}
*/
function formatDateTime(millis) {
const d = new Date(millis);
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} `
+ `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
module.exports = {
ChatLog,
ChatKind,
sanitize,
formatDateTime,
MAX_ENTRIES_PER_BOT,
MAX_FILE_BYTES
};
+264
查看文件
@@ -0,0 +1,264 @@
'use strict';
const fs = require('fs');
const path = require('path');
/** 内存中保留的开拓日志条数上限(磁盘上的 JSONL 不受此限制) */
const MAX_ENTRIES = 5000;
/** 单文件大小上限:超过后轮转为 `.1`,避免长期挂机把磁盘写满 */
const MAX_FILE_BYTES = 16 * 1024 * 1024;
/**
* 开拓日志:逐条记录「哪个机器人、在什么时间、开拓了哪个区块」。
*
* 之所以单独成模块而不是复用 {@link module:util/logger}:
* - 运行日志是给人看的文本,会被环形缓冲滚掉;
* - 开拓日志是结构化数据,需要按机器人 / 时间 / 邦国筛选、统计与导出,
* 并且必须落盘(挂机数天后仍能查到第一个区块是谁开的)。
*
* 落盘格式为 JSONL(每行一条 JSON),追加写入,崩溃时最多丢最后一行。
*/
class ExpansionLog {
/**
* @param {{filePath?: string, logger?: import('../util/logger').Logger, maxEntries?: number}} [options]
*/
constructor(options) {
const opts = options || {};
this.filePath = opts.filePath || path.join(__dirname, '..', '..', 'data', 'expansion-log.jsonl');
this.logger = opts.logger || null;
this.maxEntries = opts.maxEntries || MAX_ENTRIES;
/** @type {Array<object>} 最近的开拓记录(时间升序) */
this.entries = [];
/** @type {((entry: object) => void)|null} 新记录回调(WebSocket 实时推送用) */
this.onEntry = null;
/** 累计写入条数(含被环形缓冲滚掉与进程重启前写入的) */
this.totalRecorded = 0;
/** 按机器人统计的开拓数 */
this.byBot = new Map();
this._load();
}
/**
* 记录一次开拓。
*
* @param {object} event
* @param {string} event.botId 机器人 id
* @param {string} event.bot 机器人游戏名
* @param {string} [event.state] 邦国名
* @param {{x: number, z: number}} event.chunk 区块坐标
* @param {{x: number, z: number}|null} [event.block] 方块坐标(服务器回报)
* @param {string} [event.source] 判定来源:chat / chat-generic / subtitle
* @param {number} [event.sessionCount] 本轮任务已开拓数
* @returns {object} 写入的记录
*/
record(event) {
const chunk = event.chunk || { x: 0, z: 0 };
const entry = {
t: Date.now(),
botId: String(event.botId || event.bot || 'bot'),
bot: String(event.bot || event.botId || 'bot'),
state: event.state || '',
cx: toInt(chunk.x),
cz: toInt(chunk.z),
bx: event.block ? toInt(event.block.x) : null,
bz: event.block ? toInt(event.block.z) : null,
source: event.source || 'chat',
sessionCount: Number.isFinite(event.sessionCount) ? event.sessionCount : null
};
this.entries.push(entry);
while (this.entries.length > this.maxEntries) this.entries.shift();
this.totalRecorded++;
this.byBot.set(entry.botId, (this.byBot.get(entry.botId) || 0) + 1);
this._appendToFile(entry);
if (this.onEntry) {
try {
this.onEntry(entry);
} catch {
// 订阅者异常不得影响主流程
}
}
return entry;
}
/**
* 查询开拓日志(时间升序返回,最新在最后)。
*
* @param {{limit?: number, botId?: string, state?: string, since?: number, until?: number}} [filter]
* @returns {Array<object>}
*/
list(filter) {
const opts = filter || {};
let result = this.entries;
if (opts.botId) result = result.filter((e) => e.botId === opts.botId);
if (opts.state) result = result.filter((e) => e.state === opts.state);
if (Number.isFinite(opts.since)) result = result.filter((e) => e.t >= opts.since);
if (Number.isFinite(opts.until)) result = result.filter((e) => e.t <= opts.until);
const limit = Number.isFinite(opts.limit) && opts.limit > 0 ? Math.trunc(opts.limit) : 200;
// 取最新的 limit 条,但仍按时间升序返回,方便前端直接追加渲染
return result.length > limit ? result.slice(result.length - limit) : result.slice();
}
/**
* 统计信息:总数、按机器人分布、时间跨度、今日开拓数。
* @returns {object}
*/
stats() {
const bots = [];
for (const [botId, count] of this.byBot) {
let name = botId;
for (let i = this.entries.length - 1; i >= 0; i--) {
if (this.entries[i].botId === botId) {
name = this.entries[i].bot;
break;
}
}
bots.push({ botId, bot: name, count });
}
bots.sort((a, b) => b.count - a.count);
const first = this.entries.length > 0 ? this.entries[0].t : 0;
const last = this.entries.length > 0 ? this.entries[this.entries.length - 1].t : 0;
return {
total: this.totalRecorded,
buffered: this.entries.length,
byBot: bots,
firstAt: first,
lastAt: last,
today: this._countToday()
};
}
/** @returns {number} 本地时区「今天」的开拓条数 */
_countToday() {
const start = new Date();
start.setHours(0, 0, 0, 0);
const since = start.getTime();
let count = 0;
for (let i = this.entries.length - 1; i >= 0; i--) {
if (this.entries[i].t < since) break;
count++;
}
return count;
}
/** 清空内存缓冲(磁盘文件保留,便于事后追溯) */
clearBuffer() {
this.entries.length = 0;
}
/**
* 导出为 CSV 文件。
*
* @param {string} filePath 目标路径
* @param {{botId?: string, since?: number}} [filter]
* @returns {string|null} 导出文件路径;失败返回 null
*/
exportCsv(filePath, filter) {
try {
const rows = this.list(Object.assign({ limit: Number.MAX_SAFE_INTEGER }, filter || {}));
const lines = ['时间,时间戳,机器人,邦国,区块X,区块Z,方块X,方块Z,来源,本轮第几个'];
for (const e of rows) {
lines.push([
formatDateTime(e.t),
e.t,
csvCell(e.bot),
csvCell(e.state),
e.cx,
e.cz,
e.bx === null ? '' : e.bx,
e.bz === null ? '' : e.bz,
e.source,
e.sessionCount === null ? '' : e.sessionCount
].join(','));
}
fs.mkdirSync(path.dirname(filePath), { recursive: true });
// 加 BOM,Excel 打开中文不乱码
fs.writeFileSync(filePath, '\ufeff' + lines.join('\r\n'), 'utf8');
return filePath;
} catch {
return null;
}
}
// ==================== 持久化 ====================
/** 启动时把磁盘上的历史记录读回内存(只保留最后 maxEntries 条) */
_load() {
let raw;
try {
raw = fs.readFileSync(this.filePath, 'utf8');
} catch {
return;
}
const lines = raw.split('\n');
for (const line of lines) {
const text = line.trim();
if (!text) continue;
let parsed;
try {
parsed = JSON.parse(text);
} catch {
// 进程被杀时最后一行可能不完整:跳过即可
continue;
}
if (!parsed || typeof parsed !== 'object') continue;
this.entries.push(parsed);
this.totalRecorded++;
this.byBot.set(parsed.botId, (this.byBot.get(parsed.botId) || 0) + 1);
}
while (this.entries.length > this.maxEntries) this.entries.shift();
}
/** 追加一行到磁盘(失败只记日志,绝不影响扩地主流程) */
_appendToFile(entry) {
try {
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
this._rotateIfNeeded();
fs.appendFileSync(this.filePath, JSON.stringify(entry) + '\n', 'utf8');
} catch (err) {
if (this.logger) {
this.logger.log('开拓日志写入失败:' + (err && err.message ? err.message : String(err)), 'warn');
}
}
}
/** 文件超过上限时轮转为 `.1`(只保留一份历史,避免无限增长) */
_rotateIfNeeded() {
let size = 0;
try {
size = fs.statSync(this.filePath).size;
} catch {
return;
}
if (size < MAX_FILE_BYTES) return;
try {
fs.renameSync(this.filePath, this.filePath + '.1');
} catch {
// 轮转失败时继续追加,不阻塞
}
}
}
/**
* 格式化为 `YYYY-MM-DD HH:mm:ss`。
* @param {number} millis
* @returns {string}
*/
function formatDateTime(millis) {
const d = new Date(millis);
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} `
+ `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
/** CSV 字段转义:含逗号 / 引号 / 换行时用双引号包裹 */
function csvCell(value) {
const text = value === undefined || value === null ? '' : String(value);
return /[",\r\n]/.test(text) ? '"' + text.replace(/"/g, '""') + '"' : text;
}
function toInt(value) {
const n = Number(value);
return Number.isFinite(n) ? Math.trunc(n) : 0;
}
module.exports = { ExpansionLog, formatDateTime, MAX_ENTRIES, MAX_FILE_BYTES };
+43 -32
查看文件
@@ -21,33 +21,37 @@ class ExpansionPlanner {
/**
* 根据配置的策略分发到对应的扩地算法。
*
* 己方邦国的解析顺序为「配置里的 stateName → 模型全局 selfStateName」。
* 多机器人同时挂机时,每个机器人可能属于不同邦国,因此绝不能只读全局值。
*
* @param {import('./territoryModel').TerritoryModel} model 当前邦国领土模型
* @param {object} config 扩地配置
* @param {{x: number, z: number}} playerChunk 玩家当前所在区块
* @returns {Array<{x: number, z: number}>} 建议占领的区块列表(按优先级排序)
*/
generate(model, config, playerChunk) {
const stateName = resolveStateName(model, config);
let result;
if (config.strategy === 1) {
result = this.generateHolePriority(model, config, playerChunk);
result = this.generateHolePriority(model, config, playerChunk, stateName);
} else if (config.strategy === 4) {
result = this.generatePerimeter(model, config, playerChunk);
result = this.generatePerimeter(model, config, playerChunk, stateName);
} else if (config.strategy === 3) {
result = this.generateAroundPlayer(model, config, playerChunk);
result = this.generateAroundPlayer(model, config, playerChunk, stateName);
} else if (config.strategy === 2 && config.regionMaxX >= config.regionMinX) {
result = this.generateRectangle(model, config, playerChunk);
result = this.generateRectangle(model, config, playerChunk, stateName);
} else {
result = this.generateLine(model, config, playerChunk);
result = this.generateLine(model, config, playerChunk, stateName);
}
return this.prefixFlyingChunks(model, config, playerChunk, result);
return this.prefixFlyingChunks(model, config, playerChunk, result, stateName);
}
/**
* 将己方领土内的「飞地(疏漏区块)」提到队列最前,优先填补。
* @returns {Array<{x: number, z: number}>}
*/
prefixFlyingChunks(model, config, playerChunk, planned) {
const flying = model.selfFlyingChunks();
prefixFlyingChunks(model, config, playerChunk, planned, stateName) {
const flying = model.flyingChunksOf(stateName || model.selfStateNameValue());
if (!flying || flying.size === 0) return planned;
const limit = config.targetCount > 0
? config.targetCount
@@ -79,8 +83,8 @@ class ExpansionPlanner {
* 候选必须未被占领、与己方领土相邻、且在配置区域内。
* 先排矩形内部的空洞,再排外部边界,最后按「离玩家近、己方邻居多」排序。
*/
generateHolePriority(model, config, playerChunk) {
const actualOwn = this.ownChunks(model);
generateHolePriority(model, config, playerChunk, stateName) {
const actualOwn = this.ownChunks(model, stateName);
const allClaimed = model.allClaimedChunks();
if (actualOwn.size === 0) return [];
const limit = config.targetCount > 0 ? config.targetCount : 256;
@@ -135,12 +139,12 @@ class ExpansionPlanner {
* 收集所有与己方领土相邻的合法区块,以领土质心(或王城)为中心按方位角排序,
* 使扩张点均匀分布在领土四周。支持多圈扩张。
*/
generatePerimeter(model, config, playerChunk) {
const actualOwn = this.ownChunks(model);
generatePerimeter(model, config, playerChunk, stateName) {
const actualOwn = this.ownChunks(model, stateName);
const allClaimed = model.allClaimedChunks();
const plannedOwn = new Set(actualOwn);
const candidates = new Set();
const center = this.capital(model, this.centroid(actualOwn, playerChunk));
const center = this.capital(model, stateName, this.centroid(actualOwn, playerChunk));
const limit = config.targetCount > 0 ? config.targetCount : 128;
const rings = config.maxRings > 0 ? config.maxRings : 1;
const result = [];
@@ -169,8 +173,8 @@ class ExpansionPlanner {
* 收集所有与己方领土相邻的合法区块,以玩家当前位置为中心,
* 按与玩家的曼哈顿距离由近到远排序(距离相同者优先己方邻居多的),围绕玩家一圈圈向外铺开。
*/
generateAroundPlayer(model, config, playerChunk) {
const actualOwn = this.ownChunks(model);
generateAroundPlayer(model, config, playerChunk, stateName) {
const actualOwn = this.ownChunks(model, stateName);
const allClaimed = model.allClaimedChunks();
if (actualOwn.size === 0) return [];
const limit = config.targetCount > 0 ? config.targetCount : 64;
@@ -195,8 +199,8 @@ class ExpansionPlanner {
* 每步挑选评分最优的区块:距离近、方向一致、邻居多、形状规整者优先。
* 当扩张形状长宽比超过 2:1 时切换方向补短边,避免领土过于细长。
*/
generateLine(model, config, playerChunk) {
const actualOwn = this.ownChunks(model);
generateLine(model, config, playerChunk, stateName) {
const actualOwn = this.ownChunks(model, stateName);
const allClaimed = model.allClaimedChunks();
const plannedOwn = new Set(actualOwn);
const candidates = new Set();
@@ -247,8 +251,8 @@ class ExpansionPlanner {
* - 首条扫描线的行内起始端同样选取离王城更近的一端;
* - 每个入选区块必须与「己方领土(含本次已选)」四正方向接壤,保证服务器接受开拓。
*/
generateRectangle(model, config, playerChunk) {
const actualOwn = this.ownChunks(model);
generateRectangle(model, config, playerChunk, stateName) {
const actualOwn = this.ownChunks(model, stateName);
const allClaimed = model.allClaimedChunks();
if (actualOwn.size === 0) return [];
const minX = config.regionMinX;
@@ -256,7 +260,7 @@ class ExpansionPlanner {
const minZ = config.regionMinZ;
const maxZ = config.regionMaxZ;
const limit = config.targetCount > 0 ? config.targetCount : Infinity;
const capital = this.capital(model, playerChunk);
const capital = this.capital(model, stateName, playerChunk);
// ===== 1. 四条边到王城的距离(点到线段距离的平方),选最近者为起始边 =====
const dxEdge = Math.max(0, Math.max(minX - capital.x, capital.x - maxX));
@@ -525,21 +529,28 @@ class ExpansionPlanner {
return { x: Math.trunc(x / chunkKeys.size), z: Math.trunc(z / chunkKeys.size) };
}
/** 汇总「己方」区块:本次会话新占领的区块 + 己方邦国已拥有的区块 */
ownChunks(model) {
const result = new Set(model.sessionClaimsSet());
const self = model.selfState();
if (self) {
for (const k of self.chunks) result.add(k);
}
return result;
/** 汇总「己方」区块:本次会话新占领的区块 + 指定邦国已拥有的区块 */
ownChunks(model, stateName) {
return model.ownChunksOf(stateName || model.selfStateNameValue());
}
/** 获取己方邦国的王城位置;未识别邦国时回退到 fallback */
capital(model, fallback) {
const self = model.selfState();
/** 获取指定邦国的王城位置;未识别邦国时回退到 fallback */
capital(model, stateName, fallback) {
const self = model.state(stateName || model.selfStateNameValue());
return self && self.capital ? self.capital : fallback;
}
}
module.exports = { ExpansionPlanner, WORLD_BORDER_CHUNK_RADIUS };
/**
* 解析本次规划使用的邦国名:配置里显式指定的优先,其次回退到模型的全局己方邦国。
*
* @param {import('./territoryModel').TerritoryModel} model
* @param {object} config
* @returns {string}
*/
function resolveStateName(model, config) {
if (config && typeof config.stateName === 'string' && config.stateName) return config.stateName;
return model.selfStateNameValue();
}
module.exports = { ExpansionPlanner, WORLD_BORDER_CHUNK_RADIUS, resolveStateName };
+140
查看文件
@@ -0,0 +1,140 @@
'use strict';
/**
* 通知提醒(Webhook):需要人工介入时把消息推到飞书机器人等渠道。
*
* 服务器只给 120 秒输入登录验证码,错过就要等下一次重登 —— 这是整个机器人流程里
* 几乎唯一必须人工介入的环节(另一个是「原点自救失败」说明可能被切到了登录服),
* 因此值得在手机上也收到提醒,而不是只写在日志里等人翻。
*
* 实现刻意做到最小:POST 一个 JSON,默认按飞书 / Lark 自定义机器人的文本消息格式
* (`{"msgtype":"text","content":{"text":"…"}}`)—— 在群聊里加一个自定义机器人
* 就能拿到 Webhook 地址,零依赖。
*/
/** 同一 key 的最小提醒间隔(毫秒):重连循环里同一个问题只提醒一次 */
const DEFAULT_MIN_INTERVAL_MS = 60000;
/** 单次请求超时(毫秒) */
const REQUEST_TIMEOUT_MS = 10000;
class Notifier {
/**
* @param {object} config 完整配置对象
* @param {import('../util/logger').Logger} [logger]
* @param {{fetchImpl?: Function, minIntervalMs?: number}} [options]
*/
constructor(config, logger, options) {
const opts = options || {};
this.config = config;
this.logger = logger || null;
this.fetchImpl = opts.fetchImpl || globalThis.fetch;
this.minIntervalMs = Number.isFinite(opts.minIntervalMs) ? opts.minIntervalMs : DEFAULT_MIN_INTERVAL_MS;
/** @type {Map<string, number>} 去重 key → 上次发送时间 */
this._lastSentAt = new Map();
this.sent = 0;
this.failed = 0;
this.lastError = '';
this.lastSentAt = 0;
}
/** @returns {string} Webhook 地址(配置优先,其次环境变量 NOTIFY_WEBHOOK_URL) */
get webhookUrl() {
const cfg = (this.config && this.config.notify) || {};
const configured = typeof cfg.webhookUrl === 'string' ? cfg.webhookUrl.trim() : '';
if (configured) return configured;
return String(process.env.NOTIFY_WEBHOOK_URL || '').trim();
}
/** @returns {boolean} 是否已开启且配了地址 */
isEnabled() {
const cfg = (this.config && this.config.notify) || {};
return cfg.enabled === true && this.webhookUrl !== '';
}
/**
* 发送一条提醒。
*
* @param {string} text 正文
* @param {{key?: string, force?: boolean}} [options]
* key 用于去重:同一个 key 在 `minIntervalMs` 内只会发一次(force 可跳过去重)
* @returns {Promise<boolean>} 是否成功发出
*/
async notify(text, options) {
const opts = options || {};
if (!this.isEnabled()) return false;
const key = opts.key || 'default';
const now = Date.now();
const last = this._lastSentAt.get(key);
if (opts.force !== true && Number.isFinite(last) && now - last < this.minIntervalMs) {
return false; // 刚提醒过同一个问题,不重复打扰
}
this._lastSentAt.set(key, now);
const payload = {
msgtype: 'text',
content: { text: String(text) }
};
const controller = typeof AbortController === 'function' ? new AbortController() : null;
const timer = controller ? setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS) : null;
try {
const response = await this.fetchImpl(this.webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: controller ? controller.signal : undefined
});
if (!response || response.ok !== true) {
this.failed++;
this.lastError = `HTTP ${response ? response.status : '???'}`;
this._log('通知发送失败:' + this.lastError, 'warn');
return false;
}
this.sent++;
this.lastSentAt = Date.now();
this.lastError = '';
this._log('已发送通知提醒:' + firstLine(text));
return true;
} catch (err) {
this.failed++;
this.lastError = err && err.message ? err.message : String(err);
this._log('通知发送异常:' + this.lastError, 'warn');
return false;
} finally {
if (timer) clearTimeout(timer);
}
}
/** @returns {object} 供控制台展示的状态(不暴露完整 Webhook 地址) */
snapshot() {
const url = this.webhookUrl;
return {
enabled: this.isEnabled(),
configured: url !== '',
/** 只显示域名部分,避免把带凭证的完整 Webhook 打进接口响应 */
host: url ? safeHost(url) : '',
sent: this.sent,
failed: this.failed,
lastError: this.lastError,
lastSentAt: this.lastSentAt
};
}
_log(message, level) {
if (this.logger) this.logger.log(message, level);
}
}
/** 取 URL 的域名(拿不到就返回空串) */
function safeHost(url) {
try {
return new URL(url).host;
} catch {
return '';
}
}
/** 取多行文本的第一行(日志里只记一行摘要) */
function firstLine(text) {
return String(text).split('\n')[0].slice(0, 120);
}
module.exports = { Notifier, DEFAULT_MIN_INTERVAL_MS };
+267
查看文件
@@ -0,0 +1,267 @@
'use strict';
/**
* 出站包抓取:把机器人真正写出的协议包按时间顺序录下来,供逐包对照。
*
* 为什么需要它(TODO.md 问题 1 · 假设 5):
* 现有的 `packetStats.outboundTypes` 只能回答「发了哪几种包、各多少次」,
* 回答不了「发出去的内容对不对」—— 而反作弊拒绝移动的典型原因恰恰在内容里:
* 上报的 `onGround` / `hasHorizontalCollision` 与服务端预测不一致、
* `player_input` 的位标志不对、位置与朝向没放在同一次更新里……
* 所以需要一个能录下原始出站序列、并能在「能走」与「走不动」两种状态下 diff 的工具。
*
* 设计取舍:
* - **只在录制期间挂钩 `client.write`**:这个钩子每个出站包都要过一遍,
* 常驻会平白给正常发包加开销,所以录制结束立刻摘掉;
* - 录到的内容做**降深 + 截断**的 JSON 化处理,向量对象只留坐标 ——
* 否则一个 `move_minecart` 包就能把缓冲区撑爆(实测该包能带上万个浮点数);
* - 只放内存、有上限(默认 4000 包),不写盘,进程重启即清空。
*/
/** 默认最多录多少个包(超出后丢弃并计数,避免长时间录制吃内存) */
const DEFAULT_MAX_PACKETS = 4000;
/** 单个字符串字段截断长度 */
const MAX_STRING_LENGTH = 120;
/** 数组字段最多保留多少项 */
const MAX_ARRAY_ITEMS = 40;
/** 对象展开的最大深度 */
const MAX_DEPTH = 4;
/** 浮点保留位数:够 diff,又不至于把接口响应撑大 */
const ROUND_DIGITS = 5;
/** 单次录制时长上限(秒),防止忘了停 */
const MAX_SECONDS = 60;
class PacketCapture {
/**
* @param {{maxPackets?: number}} [options]
*/
constructor(options) {
const opts = options || {};
this.maxPackets = Number.isFinite(opts.maxPackets) && opts.maxPackets > 0
? Math.floor(opts.maxPackets)
: DEFAULT_MAX_PACKETS;
/** @type {object|null} 当前要录制的 mineflayer client(重连后会换成新连接) */
this._client = null;
/** @type {Function|null} 被替换掉的原始 write */
this._originalWrite = null;
/** @type {Array<{t: number, name: string, params: object}>} 按时间顺序录到的出站包 */
this.packets = [];
this.running = false;
this.startedAt = 0;
this.stoppedAt = 0;
/** 因超出上限被丢弃的包数(>0 说明这段录制不完整) */
this.dropped = 0;
this._timer = null;
}
/**
* 记住当前连接的 client。
*
* 每次装配事件时都会调用,重连后自动换成新连接;
* 正在录制时换连接说明旧连接已经没了,先把录制收尾。
*
* @param {object|null} client
*/
setClient(client) {
if (this._client === client) return;
if (this.running) this.stop();
this._client = client || null;
}
/**
* 开始录制。
*
* @param {number} [seconds] 录制时长(秒);给定时到点自动停止
* @returns {{ok: boolean, message: string}}
*/
start(seconds) {
if (!this._client || typeof this._client.write !== 'function') {
return { ok: false, message: '当前没有可录制的连接' };
}
if (this.running) this.stop();
this.packets = [];
this.dropped = 0;
this.startedAt = Date.now();
this.stoppedAt = 0;
this.running = true;
this._patch();
const duration = Number(seconds);
if (Number.isFinite(duration) && duration > 0) {
const bounded = Math.min(MAX_SECONDS, duration);
this._timer = setTimeout(() => this.stop(), bounded * 1000);
if (this._timer.unref) this._timer.unref();
return { ok: true, message: `已开始抓包(${bounded} 秒后自动停止)` };
}
return { ok: true, message: '已开始抓包(需手动停止)' };
}
/**
* 停止录制并摘掉钩子。已录到的内容保留,供 `snapshot()` 读取。
*
* @returns {{ok: boolean, message: string}}
*/
stop() {
if (this._timer) {
clearTimeout(this._timer);
this._timer = null;
}
if (!this.running) return { ok: true, message: '当前没有在抓包' };
this.running = false;
this.stoppedAt = Date.now();
this._unpatch();
return { ok: true, message: `抓包已停止,共录到 ${this.packets.length} 个包` };
}
/** 清空已录内容(不影响是否正在录制) */
clear() {
this.packets = [];
this.dropped = 0;
this.startedAt = this.running ? Date.now() : 0;
this.stoppedAt = 0;
}
/**
* 取出录制结果。
*
* @param {{types?: string|string[], limit?: number}} [options]
* types 只看这些包(逗号分隔或数组);limit 最多返回多少个包
* @returns {object}
*/
snapshot(options) {
const opts = options || {};
const byType = {};
for (const packet of this.packets) {
byType[packet.name] = (byType[packet.name] || 0) + 1;
}
const wanted = normalizeTypes(opts.types);
let packets = wanted ? this.packets.filter((p) => wanted.includes(p.name)) : this.packets;
const limit = Number.isFinite(opts.limit) && opts.limit > 0 ? Math.floor(opts.limit) : packets.length;
if (packets.length > limit) packets = packets.slice(0, limit);
return {
running: this.running,
startedAt: this.startedAt,
stoppedAt: this.stoppedAt,
elapsedMs: this.startedAt ? (this.stoppedAt || Date.now()) - this.startedAt : 0,
/** 录到的总包数(未按 types 过滤前) */
count: this.packets.length,
dropped: this.dropped,
byType,
/** 本次实际返回的包数 */
returned: packets.length,
packets
};
}
// ==================== 内部 ====================
/** 替换 client.write:先记录,再原样发出去 */
_patch() {
const client = this._client;
const original = client.write;
this._originalWrite = original;
const capture = this;
client.write = function patchedWrite(name, params) {
capture._record(name, params);
return original.apply(this, arguments);
};
}
/** 还原 client.write */
_unpatch() {
const client = this._client;
if (client && this._originalWrite && client.write !== this._originalWrite) {
client.write = this._originalWrite;
}
this._originalWrite = null;
}
/**
* 记录一个出站包。
*
* 整个过程包在 try 里:抓包是诊断手段,绝不能因为它自己的异常把发包搞挂。
*
* @param {string} name 包名
* @param {object} params 包内容
*/
_record(name, params) {
if (!this.running) return;
try {
if (this.packets.length >= this.maxPackets) {
this.dropped++;
return;
}
this.packets.push({
t: Date.now() - this.startedAt,
name: String(name),
params: toPlain(params, MAX_DEPTH)
});
} catch {
// 忽略:不能让诊断影响正常发包
}
}
}
/**
* 把包内容转成「能进 JSON、又保留可比对信息」的普通对象。
*
* @param {*} value
* @param {number} depth 还能往下展开几层
* @returns {*}
*/
function toPlain(value, depth) {
// undefined 显式转成 null 而不是删掉键:字段「没传」本身就是重要线索
// (例如 mineflayer 把 hasHorizontalCollision 置为 undefined)
if (value === undefined || value === null) return null;
const type = typeof value;
if (type === 'number') return roundNumber(value);
if (type === 'boolean') return value;
if (type === 'string') {
return value.length > MAX_STRING_LENGTH ? value.slice(0, MAX_STRING_LENGTH) + '…' : value;
}
if (type === 'bigint') return Number(value);
if (type === 'function') return '[function]';
if (type !== 'object') return String(value);
if (depth <= 0) return '[depth]';
if (Array.isArray(value)) {
return value.slice(0, MAX_ARRAY_ITEMS).map((item) => toPlain(item, depth - 1));
}
// 向量对象(Vec3 之类的类实例)只留坐标。
// 注意这里必须排除「普通对象」:`position_look` 的参数本身就是
// `{x, y, z, yaw, pitch, onGround, flags}`,若按向量处理会把
// yaw / pitch / onGround / flags 全部丢掉 —— 而那正是最需要对照的字段。
if (!isPlainObject(value)
&& typeof value.x === 'number' && typeof value.y === 'number' && typeof value.z === 'number') {
return { x: roundNumber(value.x), y: roundNumber(value.y), z: roundNumber(value.z) };
}
const out = {};
for (const [key, item] of Object.entries(value)) {
out[key] = toPlain(item, depth - 1);
}
return out;
}
/** 是否是「普通对象」(对象字面量 / Object.create(null)),而不是类实例 */
function isPlainObject(value) {
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}
/** 浮点取整;NaN / Infinity 转成字符串,免得 JSON 里变成 null 看不出区别 */
function roundNumber(value) {
if (!Number.isFinite(value)) return String(value);
const factor = 10 ** ROUND_DIGITS;
return Math.round(value * factor) / factor;
}
/** 把 types 参数统一成数组;没给就返回 null(表示不过滤) */
function normalizeTypes(types) {
if (types === undefined || types === null || types === '') return null;
const list = Array.isArray(types) ? types : String(types).split(',');
const cleaned = list.map((item) => String(item).trim()).filter(Boolean);
return cleaned.length > 0 ? cleaned : null;
}
module.exports = { PacketCapture, DEFAULT_MAX_PACKETS, MAX_SECONDS };
+300
查看文件
@@ -0,0 +1,300 @@
'use strict';
/**
* 物理 tick 录制器:逐 tick 记录「输入 → 输出」,用来定位「走不动」到底卡在哪一层。
*
* 为什么需要它:出站包抓取(`packetCapture`)已经证明 —— 手动按住方向键 3 秒时
* 客户端自己的坐标与朝向一个字节都没变、服务端也没有下发任何位置包(入站 0 个),
* 而 `player_input` 如实上报了按键。也就是说问题在 prismarine-physics 这一层,
* 但抓包只能看到「发出去的包」,看不到物理引擎内部。
*
* 本录制器补上的正是这一段。逐 tick 记录下面这些量,就能把两种完全不同的故障切开:
*
* | 观察 | 结论 |
* | --- | --- |
* | `control.forward = true` 但速度恒为 0、位置不动、`collidedHorizontally = false` | **输入没进物理**(applyHeading 没被调用 / control 快照是空的) |
* | 速度非 0 但位置不动、`collidedHorizontally = true` | **被方块挡住**(地形问题,或脚下/身旁方块读错) |
* | 速度非 0、位置不动、`collidedHorizontally = false` | **被服务端推回**(推回发生在 tick 之间,所以 tick 级位移为 0) |
*
* 所以 `isCollidedHorizontally` 与「速度是否非零」是这里最关键的两个字段:
* 前者把「被挡住」与其余情况分开,后者把「输入没生效」与「被推回」分开。
*/
/** 默认保留多少个 tick(20 TPS 下约 12 秒) */
const DEFAULT_MAX_TICKS = 240;
/** 判定「这一 tick 真的动了」的阈值(格) */
const MOVE_EPSILON = 1e-6;
/** 单次录制时长上限(秒) */
const MAX_SECONDS = 120;
/** 浮点保留位数 */
const ROUND_DIGITS = 5;
class PhysicsRecorder {
/**
* @param {{maxTicks?: number}} [options]
*/
constructor(options) {
const opts = options || {};
this.maxTicks = Number.isFinite(opts.maxTicks) && opts.maxTicks > 0
? Math.floor(opts.maxTicks)
: DEFAULT_MAX_TICKS;
/** @type {object|null} 当前要录制的 bot(重连后会换新的) */
this._bot = null;
/** @type {Function|null} 挂在 bot 上的 physicsTick 处理器 */
this._handler = null;
/** @type {Array<object>} 滚动缓冲:只保留最近 maxTicks 个 tick */
this.ticks = [];
this.running = false;
this.startedAt = 0;
this.stoppedAt = 0;
this._timer = null;
/** 上一 tick 的位置,用来算增量 */
this._prev = null;
}
/**
* 记住当前连接的 bot。
*
* 每次装配事件时都会调用,重连后自动换成新 bot;
* 正在录制时换连接说明旧连接已经没了,先把录制收尾。
*
* @param {object|null} bot
*/
setBot(bot) {
if (this._bot === bot) return;
if (this.running) this.stop();
this._bot = bot || null;
}
/**
* 开始录制。
*
* @param {number} [seconds] 录制时长(秒);给定时到点自动停止
* @returns {{ok: boolean, message: string}}
*/
start(seconds) {
const bot = this._bot;
if (!bot || typeof bot.on !== 'function') {
return { ok: false, message: '当前没有可录制的连接' };
}
if (this.running) this.stop();
this.ticks = [];
this.startedAt = Date.now();
this.stoppedAt = 0;
this.running = true;
this._handler = () => this._record();
bot.on('physicsTick', this._handler);
// 先取一次当前位置当基线:否则第一个 tick 没有「上一 tick」可比,
// 位移会被记成 0,在统计里白白多出一个「有输入但没动」的假样本。
const pos = bot.entity && bot.entity.position;
this._prev = pos && Number.isFinite(pos.x) ? { x: pos.x, y: pos.y, z: pos.z } : null;
const duration = Number(seconds);
if (Number.isFinite(duration) && duration > 0) {
const bounded = Math.min(MAX_SECONDS, duration);
this._timer = setTimeout(() => this.stop(), bounded * 1000);
if (this._timer.unref) this._timer.unref();
return { ok: true, message: `已开始录制物理 tick(${bounded} 秒后自动停止)` };
}
return { ok: true, message: '已开始录制物理 tick(需手动停止)' };
}
/**
* 停止录制并摘掉监听。已录到的内容保留,供 `snapshot()` 读取。
*
* @returns {{ok: boolean, message: string}}
*/
stop() {
if (this._timer) {
clearTimeout(this._timer);
this._timer = null;
}
if (!this.running) return { ok: true, message: '当前没有在录制物理 tick' };
this.running = false;
this.stoppedAt = Date.now();
if (this._bot && this._handler) {
try {
this._bot.removeListener('physicsTick', this._handler);
} catch {
// 忽略:连接可能已经断开
}
}
this._handler = null;
return { ok: true, message: `物理 tick 录制已停止,共 ${this.ticks.length} 个 tick` };
}
/** 清空已录内容(不影响是否正在录制) */
clear() {
this.ticks = [];
this._prev = null;
this.startedAt = this.running ? Date.now() : 0;
this.stoppedAt = 0;
}
/**
* 取出录制结果。
*
* @param {{limit?: number}} [options] limit 最多返回最后多少个 tick(默认全给)
* @returns {object}
*/
snapshot(options) {
const opts = options || {};
const all = this.ticks;
const hasInput = (t) => t.control.forward || t.control.back || t.control.left || t.control.right;
const hasMoved = (t) => Math.abs(t.dx) > MOVE_EPSILON || Math.abs(t.dz) > MOVE_EPSILON;
const hasHorizVelocity = (t) => Math.hypot(t.vx, t.vz) > MOVE_EPSILON;
const inputTicks = all.filter(hasInput);
const inputMoved = inputTicks.filter(hasMoved);
const blocked = inputTicks.filter((t) => !hasMoved(t) && t.collidedHorizontally);
// 有输入、无位移、物理也没报告碰撞时,还要看速度:
// 速度非零说明物理确实动了,只是位移被服务端的位置包抹掉了(推回发生在 tick 之间);
// 速度也是 0 才是「输入根本没进物理」。
const pushedBack = inputTicks.filter((t) => !hasMoved(t) && !t.collidedHorizontally && hasHorizVelocity(t));
const frozen = inputTicks.filter((t) => !hasMoved(t) && !t.collidedHorizontally && !hasHorizVelocity(t));
let ticks = all;
const limit = Number.isFinite(opts.limit) && opts.limit > 0 ? Math.floor(opts.limit) : all.length;
if (ticks.length > limit) ticks = ticks.slice(ticks.length - limit);
const maxHoriz = all.reduce((max, t) => Math.max(max, Math.hypot(t.dx, t.dz)), 0);
return {
running: this.running,
startedAt: this.startedAt,
stoppedAt: this.stoppedAt,
elapsedMs: this.startedAt ? (this.stoppedAt || Date.now()) - this.startedAt : 0,
count: all.length,
/**
* 有移动输入的 tick 数,与其中「真的产生了水平位移」的 tick 数。
* 前者 > 0 而后者 = 0,就是「按了键但物理没动」—— 一眼看出输入没进物理。
*/
inputTicks: inputTicks.length,
inputMovedTicks: inputMoved.length,
/** 有输入、没位移、且物理报告水平碰撞:被方块挡住 */
blockedTicks: blocked.length,
/**
* 有输入、没位移、物理没报告碰撞、但**水平速度非零**:
* 物理确实在动,位移却被服务端的位置包抹掉了(实测大厅里就是这个形态)。
*/
pushedBackTicks: pushedBack.length,
/**
* 有输入、没位移、物理没报告碰撞、**水平速度也是 0**:
* 这才是「输入根本没进物理」(applyHeading 没被调用 / control 快照为空)。
*/
frozenTicks: frozen.length,
movedTicks: all.filter(hasMoved).length,
maxHorizDelta: Number(maxHoriz.toFixed(ROUND_DIGITS)),
physicsEnabled: all.length > 0 ? all[all.length - 1].physicsEnabled : null,
last: all.length > 0 ? all[all.length - 1] : null,
returned: ticks.length,
ticks
};
}
// ==================== 内部 ====================
/**
* 记录一个物理 tick。
*
* 挂在 `physicsTick` 上,因此这里读到的是**物理引擎算完之后**的状态
* (mineflayer 的顺序是 simulatePlayer → emit('physicsTick') → updatePosition)。
* 输入侧读 `bot.controlState`(mineflayer 用 getter 暴露的实时控制键)。
*
* 整个过程包在 try 里:录制是诊断手段,绝不能因为它自己的异常影响物理循环。
*/
_record() {
if (!this.running) return;
try {
const bot = this._bot;
const entity = bot && bot.entity;
const pos = entity && entity.position;
if (!pos || !Number.isFinite(pos.x)) return;
const vel = entity.velocity || { x: 0, y: 0, z: 0 };
const prev = this._prev;
const row = {
t: Date.now() - this.startedAt,
x: round(pos.x),
y: round(pos.y),
z: round(pos.z),
dx: prev ? round(pos.x - prev.x) : 0,
dy: prev ? round(pos.y - prev.y) : 0,
dz: prev ? round(pos.z - prev.z) : 0,
vx: round(vel.x),
vy: round(vel.y),
vz: round(vel.z),
onGround: entity.onGround === true,
/** 物理引擎是否认为撞到了东西 —— 区分「输入没生效」与「被方块挡住」的关键 */
collidedHorizontally: entity.isCollidedHorizontally === true,
collidedVertically: entity.isCollidedVertically === true,
physicsEnabled: bot.physicsEnabled !== false,
inWater: entity.isInWater === true,
inWeb: entity.isInWeb === true,
control: {
forward: controlOf(bot, 'forward'),
back: controlOf(bot, 'back'),
left: controlOf(bot, 'left'),
right: controlOf(bot, 'right'),
jump: controlOf(bot, 'jump'),
sprint: controlOf(bot, 'sprint'),
sneak: controlOf(bot, 'sneak')
},
/** 物理引擎脚下/脚下所读到的方块(null = 该区块未加载) */
blockBelow: blockName(bot, pos, 0, -1, 0),
blockAtFeet: blockName(bot, pos, 0, 0, 0),
blockAtHead: blockName(bot, pos, 0, 1, 0)
};
this._prev = { x: pos.x, y: pos.y, z: pos.z };
if (this.ticks.length >= this.maxTicks) this.ticks.shift();
this.ticks.push(row);
} catch {
// 忽略:不能让诊断影响物理循环
}
}
}
/** 读某个偏移处的方块名;区块未加载返回 null(与物理引擎看到的一致) */
function blockName(bot, pos, dx, dy, dz) {
try {
const block = bot.blockAt(pos.offset(dx, dy, dz), false);
return block ? block.name : null;
} catch {
return null;
}
}
/**
* 读某个控制键是否按下。
*
* mineflayer 用带 getter 的 `bot.controlState` 暴露实时控制键(见其 physics.js),
* 那正是传给物理引擎的那个对象,所以优先读它;测试替身只有 `getControlState` 时回退。
*
* @param {object} bot
* @param {string} name
* @returns {boolean}
*/
function controlOf(bot, name) {
const state = bot.controlState;
if (state && state[name] !== undefined) return state[name] === true;
if (typeof bot.getControlState === 'function') {
try {
return bot.getControlState(name) === true;
} catch {
return false;
}
}
return false;
}
/** 浮点取整;NaN / Infinity 转字符串,免得 JSON 里变成 null 看不出区别 */
function round(value) {
if (!Number.isFinite(value)) return String(value);
const factor = 10 ** ROUND_DIGITS;
return Math.round(value * factor) / factor;
}
module.exports = { PhysicsRecorder, DEFAULT_MAX_TICKS, MAX_SECONDS };
+618 -12
查看文件
@@ -6,24 +6,48 @@ const chunkPos = require('./chunkPos');
* SimpCore 开放 API 客户端(文档:https://open.bgjq.top/api-docs.html)。
*
* 邦国崛起服务器的只读查询服务,作为占领判定的第四数据源
* (游戏字幕、游戏消息、Dynmap JSON 之外的补充),提供:
* - 己方邦国领土区块明细(区块坐标、忠诚度、占领时间)——修正本地领土模型
* - 全服领土区块(手动触发)——交叉修正他人区块归属
* - 玩家所属邦国查询——辅助识别玩家邦国
* - 邦国列表(含王城坐标)——王城数据兜底
* (游戏字幕、游戏消息、Dynmap JSON 之外的补充),同时把服务端的公开信息
* 完整暴露到控制台供人工查阅。
*
* 鉴权:`Authorization: Bearer <KEY>`;限流:按 Key × 接口每分钟限流。
* 已实现的接口(与官方文档一一对应):
*
* | 分类 | 接口 | 用途 |
* | --- | --- | --- |
* | 健康 | `GET /health` | 连通性测试(免鉴权) |
* | 玩家 | `GET /info/players` | 全部玩家(含邦国、职位、职业、称号) |
* | 玩家 | `GET /info/players/{key}` | 单个玩家详情 |
* | 玩家 | `GET /info/players/{key}/union` | 玩家所属邦国 |
* | 邦国 | `GET /info/unions` | 全部邦国(君主、成员数、区块数、面积、特产) |
* | 邦国 | `GET /info/unions/{key}` | 单个邦国详情 |
* | 邦国 | `GET /info/unions/{key}/members` | 成员列表(职位 / 职业) |
* | 邦国 | `GET /info/unions/{key}/territory` | 领土区块明细(忠诚度、占领时间) |
* | 邦国 | `GET /info/unions/{key}/tech` | 已激活科技 |
* | 邦国 | `GET /info/unions/{key}/contributions` | 捐献记录 |
* | 全服 | `GET /info/territory` | 全服领土(可按 union_id 过滤) |
* | 全服 | `GET /info/buffs` | 全服生效加成 |
* | 全服 | `GET /info/diplomacy` | 邦国外交关系 |
* | 全服 | `GET /info/bans` | 封禁记录 |
* | 全服 | `GET /info/logs` | 玩家上下线日志 |
* | 全服 | `GET /info/announcements` | 公告历史 |
*
* 鉴权:`Authorization: Bearer <KEY>`(也支持 `X-API-Key`);限流:按 Key × 接口每分钟限流。
* 全服数据类接口限流较严且数据量大(实测全服 territory 单次请求可超 2 分钟),
* 因此本客户端**只适合低频调用**:自动轮询仅用单邦国轻接口,全服接口仅手动触发。
* 因此本客户端**只适合低频调用**:自动轮询仅用单邦国轻接口,其余全部按需手动触发。
* 服务端响应带缓存,数据最多有 1 小时延迟。
*/
/** API 服务基础地址 */
const BASE_URL = 'https://bgjq.simpfun.cn/api/';
/** API-V2(ServerOpenAPI 插件,独立密钥体系)服务基础地址 */
const BASE_URL_V2 = 'https://bgjq.simpfun.cn/api-v2/';
/** 默认缓存 TTL:5 分钟。服务端数据本身最多延迟 1 小时,更短的 TTL 意义不大 */
const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000;
/** API-V2 的接口级配额很小(最低 1 QPM),缓存给长一点 */
const DEFAULT_CACHE_TTL_V2_MS = 30 * 1000;
class SimpApiClient {
/**
* @param {import('../util/logger').Logger} [logger]
@@ -42,6 +66,10 @@ class SimpApiClient {
this._lastHttpStatus = 0;
/** 最近一次请求失败的错误描述(空串表示无错误) */
this._lastError = '';
/** 累计请求数(含缓存命中统计,供控制台展示用量) */
this._requestCount = 0;
/** 缓存命中次数 */
this._cacheHits = 0;
}
/**
@@ -187,6 +215,265 @@ class SimpApiClient {
};
}
// ==================== 查询接口(补充:官方文档里的其余接口) ====================
/**
* 全部玩家:`GET /info/players`。
* 实测字段:`uuid, username, union_id, union_name, role, profession, profession_level,
* title, manual_claiming, first_join_time, last_join_time, last_quit_time`
*
* @returns {Promise<Array<object>>}
*/
async getPlayers() {
const body = await this._getCachedOrFetch('info/players?limit=1000');
if (body === null) return [];
return parseArray(body).map((element) => ({
name: stringOr(element, 'username', ''),
uuid: stringOr(element, 'uuid', ''),
unionId: intOr(element, 'union_id', -1),
unionName: stringOr(element, 'union_name', ''),
role: stringOr(element, 'role', ''),
job: stringOr(element, 'profession', ''),
jobLevel: intOr(element, 'profession_level', 0),
title: stringOr(element, 'title', ''),
firstJoinAt: stringOr(element, 'first_join_time', ''),
lastJoinAt: stringOr(element, 'last_join_time', ''),
lastQuitAt: stringOr(element, 'last_quit_time', '')
})).filter((player) => player.name);
}
/**
* 邦国成员列表:`GET /info/unions/{key}/members`。
*
* 实测字段:`uuid, username, union_id, union_name, role, profession, profession_level,
* title, manual_claiming, union_action_cooldown, first_join_time, last_join_time, last_quit_time`
*
* @param {string|number} key
* @returns {Promise<Array<object>>}
*/
async getUnionMembers(key) {
const body = await this._getCachedOrFetch(`info/unions/${encodeURIComponent(String(key))}/members`);
if (body === null) return [];
return parseArray(body).map((element) => ({
name: stringOr(element, 'username', ''),
uuid: stringOr(element, 'uuid', ''),
role: stringOr(element, 'role', ''),
job: stringOr(element, 'profession', ''),
jobLevel: intOr(element, 'profession_level', 0),
title: stringOr(element, 'title', ''),
firstJoinAt: stringOr(element, 'first_join_time', ''),
lastJoinAt: stringOr(element, 'last_join_time', ''),
lastQuitAt: stringOr(element, 'last_quit_time', '')
})).filter((member) => member.name);
}
/**
* 邦国已激活(未过期)科技:`GET /info/unions/{key}/tech`。
* 实测多数邦国返回空数组(当前没有生效中的科技)。
* @param {string|number} key
* @returns {Promise<Array<object>>}
*/
async getUnionTech(key) {
const body = await this._getCachedOrFetch(`info/unions/${encodeURIComponent(String(key))}/tech`);
if (body === null) return [];
return parseArray(body).map((element) => ({
name: stringOr(element, 'name', stringOr(element, 'buff_name', stringOr(element, 'tech_name', ''))),
effect: stringOr(element, 'effect', stringOr(element, 'description', '')),
level: intOr(element, 'level', intOr(element, 'buff_level', 0)),
expiresAt: stringOr(element, 'expires_at', stringOr(element, 'expire_at', ''))
}));
}
/**
* 邦国捐献记录:`GET /info/unions/{key}/contributions?limit=N`。
*
* **实测响应里只有 `player_uuid`,没有玩家名**;数量可以为负(负数代表支出,
* 例如「恢复民心」消耗金锭 / 钻石),并带 `reason` 说明用途。
*
* @param {string|number} key
* @param {number} [limit]
* @returns {Promise<Array<object>>}
*/
async getUnionContributions(key, limit) {
const path = `info/unions/${encodeURIComponent(String(key))}/contributions?limit=${normalizeLimit(limit)}`;
const body = await this._getCachedOrFetch(path);
if (body === null) return [];
return parseArray(body).map((element) => ({
uuid: stringOr(element, 'player_uuid', stringOr(element, 'uuid', '')),
material: stringOr(element, 'material', ''),
amount: round4(doubleOr(element, 'amount', 0)),
reason: stringOr(element, 'reason', ''),
createdAt: stringOr(element, 'contributed_at', stringOr(element, 'created_at', ''))
}));
}
/**
* 全服玩家表(uuid → 玩家名)。
*
* 捐献记录只有 UUID,要显示玩家名得查这张表;它数据量大(实测 1600+ 行)但有 5 分钟缓存,
* 因此只在确实有未知 UUID 时才懒加载一次。
*
* @returns {Promise<Map<string, string>>}
*/
async getPlayerNameMap() {
if (this._nameMap) return this._nameMap;
const body = await this._getCachedOrFetch('info/players?limit=1000');
const map = new Map();
if (body !== null) {
for (const element of parseArray(body)) {
const uuid = stringOr(element, 'uuid', '');
const name = stringOr(element, 'username', '');
if (uuid && name) map.set(uuid, name);
}
}
this._nameMap = map;
return map;
}
/**
* 全服生效加成:`GET /info/buffs`。
* @returns {Promise<Array<object>>}
*/
async getBuffs() {
const body = await this._getCachedOrFetch('info/buffs');
if (body === null) return [];
return parseArray(body).map((element) => ({
unionId: intOr(element, 'union_id', -1),
unionName: stringOr(element, 'union_name', ''),
name: stringOr(element, 'name', stringOr(element, 'buff_name', '')),
effect: stringOr(element, 'effect', ''),
level: intOr(element, 'level', 0),
expiresAt: stringOr(element, 'expires_at', '')
}));
}
/**
* 邦国外交关系:`GET /info/diplomacy`。
* 实测字段:`grantor_union_id, grantor_name, grantee_union_id, grantee_name, permission_level`
*
* @returns {Promise<Array<object>>}
*/
async getDiplomacy() {
const body = await this._getCachedOrFetch('info/diplomacy');
if (body === null) return [];
return parseArray(body).map((element) => ({
grantor: stringOr(element, 'grantor_name', ''),
grantee: stringOr(element, 'grantee_name', ''),
level: stringOr(element, 'permission_level', stringOr(element, 'level', ''))
}));
}
/**
* 封禁记录:`GET /info/bans?limit=N`。
* 实测字段:`banned_player_name, banned_by_player_name, reason, banned_at, unbanned, unbanned_at`
*
* @param {number} [limit]
* @returns {Promise<Array<object>>}
*/
async getBans(limit) {
const body = await this._getCachedOrFetch(`info/bans?limit=${normalizeLimit(limit)}`);
if (body === null) return [];
return parseArray(body).map((element) => ({
name: stringOr(element, 'banned_player_name', ''),
reason: stringOr(element, 'reason', ''),
operator: stringOr(element, 'banned_by_player_name', ''),
createdAt: stringOr(element, 'banned_at', ''),
unbanned: element.unbanned === true,
unbannedAt: stringOr(element, 'unbanned_at', '')
}));
}
/**
* 玩家上下线等日志:`GET /info/logs?limit=N`。
* 实测字段:`username, action(JOIN/QUIT), timestamp`
*
* @param {number} [limit]
* @returns {Promise<Array<object>>}
*/
async getLogs(limit) {
const body = await this._getCachedOrFetch(`info/logs?limit=${normalizeLimit(limit)}`);
if (body === null) return [];
return parseArray(body).map((element) => ({
name: stringOr(element, 'username', ''),
action: stringOr(element, 'action', ''),
detail: stringOr(element, 'detail', ''),
createdAt: stringOr(element, 'timestamp', stringOr(element, 'created_at', ''))
}));
}
/**
* 公告历史:`GET /info/announcements?limit=N`。
*
* **实测 `message` 是 Minecraft 聊天组件的 JSON 字符串**(带 color / extra 等),
* 这里统一提取成纯文本再返回,免得前端拿到一串乱码。
*
* @param {number} [limit]
* @returns {Promise<Array<object>>}
*/
async getAnnouncements(limit) {
const body = await this._getCachedOrFetch(`info/announcements?limit=${normalizeLimit(limit)}`);
if (body === null) return [];
return parseArray(body).map((element) => ({
content: componentToText(stringOr(element, 'message', stringOr(element, 'content', ''))),
author: stringOr(element, 'author_name', ''),
createdAt: stringOr(element, 'created_at', '')
}));
}
/**
* 邦国领土的统计摘要(不把几万个区块全丢给前端)。
* @param {string|number} key
* @returns {Promise<object|null>}
*/
async getUnionTerritoryStats(key) {
const chunks = await this.getUnionTerritory(key);
if (chunks.size === 0) return null;
let minX = Infinity;
let maxX = -Infinity;
let minZ = Infinity;
let maxZ = -Infinity;
let loyaltySum = 0;
let loyaltyCount = 0;
let earliest = '';
let latest = '';
for (const chunk of chunks.values()) {
if (chunk.x < minX) minX = chunk.x;
if (chunk.x > maxX) maxX = chunk.x;
if (chunk.z < minZ) minZ = chunk.z;
if (chunk.z > maxZ) maxZ = chunk.z;
if (chunk.loyalty >= 0) {
loyaltySum += chunk.loyalty;
loyaltyCount++;
}
if (chunk.lastClaimedAt) {
if (!earliest || chunk.lastClaimedAt < earliest) earliest = chunk.lastClaimedAt;
if (!latest || chunk.lastClaimedAt > latest) latest = chunk.lastClaimedAt;
}
}
return {
chunkCount: chunks.size,
blockArea: chunks.size * 256,
bounds: { minX, maxX, minZ, maxZ },
avgLoyalty: loyaltyCount > 0 ? Math.round((loyaltySum / loyaltyCount) * 10) / 10 : null,
earliestClaimAt: earliest,
latestClaimAt: latest
};
}
/** @returns {object} 客户端状态快照(供控制台「API 数据」面板展示) */
snapshot() {
return {
baseUrl: this.baseUrl,
hasKey: this.hasApiKey(),
lastHttpStatus: this._lastHttpStatus,
lastError: this._lastError,
requestCount: this._requestCount,
cacheHits: this._cacheHits,
cachedPaths: this.cache.size,
cacheTtlMs: this.cacheTtlMs
};
}
// ==================== 网络与缓存底层 ====================
/**
@@ -201,12 +488,16 @@ class SimpApiClient {
const opts = options || {};
const entry = this.cache.get(path);
const now = Date.now();
if (entry && entry.expiresAt > now) return entry.body;
if (entry && entry.expiresAt > now) {
this._cacheHits++;
return entry.body;
}
if (!this.hasApiKey()) {
this._lastError = '未配置 API Key';
return null;
}
try {
this._requestCount++;
const response = await this._request(path, { auth: true, timeoutMs: opts.timeoutMs || 30000 });
this._lastHttpStatus = response.status;
if (response.status === 200) {
@@ -252,6 +543,248 @@ class SimpApiClient {
}
}
// ==================== API-V2(ServerOpenAPI 插件,独立密钥体系) ====================
/**
* SimpCore API-V2 客户端。
*
* 官方文档明确说明:**API 与 API-V2 是两套相互独立、互不通用的接口系统**,
* 需要在密钥中心分别申请。V2 由 ServerOpenAPI 插件提供,用于查询服务器**实时**状态:
*
* | 接口 | 说明 | 接口级限速 |
* | --- | --- | --- |
* | `GET /players` | 在线玩家列表 | 10 QPM |
* | `GET /players/{name}/equipment` | 玩家装备与手持物品 | 30 QPM |
* | `GET /time` | 游戏内时间 | 10 QPM |
* | `GET /dragon` | 末影龙是否存活 | **1 QPM** |
*
* 因为配额很小(末影龙接口每分钟只能问一次),这里**一律不做自动轮询**,
* 只在控制台里按需手动查询,并带 30 秒本地缓存兜底。
*/
class SimpApiV2Client {
/**
* @param {import('../util/logger').Logger} [logger]
* @param {{baseUrl?: string, cacheTtlMs?: number, fetchImpl?: Function}} [options]
*/
constructor(logger, options) {
const opts = options || {};
this.logger = logger || null;
this.baseUrl = opts.baseUrl || BASE_URL_V2;
this.cacheTtlMs = opts.cacheTtlMs || DEFAULT_CACHE_TTL_V2_MS;
/** 可注入的 fetch,便于测试替换 */
this.fetchImpl = opts.fetchImpl || globalThis.fetch;
this.apiKey = '';
this.cache = new Map();
this._lastHttpStatus = 0;
this._lastError = '';
this._requestCount = 0;
this._cacheHits = 0;
}
/** @param {string} key */
setApiKey(key) {
const fresh = key ? key.trim() : '';
if (fresh !== this.apiKey) this.cache.clear();
this.apiKey = fresh;
}
/** @returns {boolean} */
hasApiKey() {
return this.apiKey.length > 0;
}
/** @returns {number} */
lastHttpStatus() {
return this._lastHttpStatus;
}
/** @returns {string} */
lastError() {
return this._lastError;
}
/**
* 在线玩家:`GET /players`。
*
* 实测响应是**对象**而不是数组:
* `{"online":8,"max":5000,"players":[{"name","displayName","uuid","world"}]}`
* 注意里面**没有坐标与延迟**,只有所在世界。
*
* @returns {Promise<{online: number, max: number, players: Array<object>}>}
*/
async getOnlinePlayers() {
const body = await this._getCachedOrFetch('players');
const object = body === null ? null : parseObject(body);
if (!object) return { online: 0, max: 0, players: [] };
const list = Array.isArray(object.players) ? object.players : [];
return {
online: intOr(object, 'online', list.length),
max: intOr(object, 'max', 0),
players: list.map((element) => ({
name: stringOr(element, 'name', stringOr(element, 'displayName', '')),
displayName: stringOr(element, 'displayName', ''),
uuid: stringOr(element, 'uuid', ''),
world: stringOr(element, 'world', '')
})).filter((player) => player.name)
};
}
/**
* 玩家装备与手持物品:`GET /players/{name}/equipment`。
*
* 实测响应:
* `{"name","displayName","uuid","equipment":{"mainHand":{...},"offHand":null,"helmet":{...},...}}`
* 物品结构为 `{type, key, amount, damage, maxDurability, unbreakable}` ——
* 注意 `damage` 是**已损耗值**,剩余耐久要自己算 `maxDurability - damage`。
*
* @param {string} name
* @returns {Promise<object|null>}
*/
async getPlayerEquipment(name) {
const body = await this._getCachedOrFetch(`players/${encodeURIComponent(String(name))}/equipment`);
const object = body === null ? null : parseObject(body);
if (!object) return null;
const equipment = object.equipment || {};
return {
player: stringOr(object, 'name', String(name)),
displayName: stringOr(object, 'displayName', ''),
uuid: stringOr(object, 'uuid', ''),
mainHand: v2ItemOf(equipment.mainHand),
offHand: v2ItemOf(equipment.offHand),
helmet: v2ItemOf(equipment.helmet),
chestplate: v2ItemOf(equipment.chestplate),
leggings: v2ItemOf(equipment.leggings),
boots: v2ItemOf(equipment.boots)
};
}
/**
* 游戏内时间:`GET /time`。
*
* 实测响应:`{"world":"world","tick":10085,"phase":"DAY","time12":"04:05 PM","time24":"16:05"}`
* —— 服务端已经把时刻算好了,不需要客户端再用 tick 换算。
*
* @returns {Promise<object|null>}
*/
async getGameTime() {
const body = await this._getCachedOrFetch('time');
const object = body === null ? null : parseObject(body);
if (!object) return null;
return {
world: stringOr(object, 'world', ''),
tick: longOr(object, 'tick', 0),
phase: stringOr(object, 'phase', ''),
time12: stringOr(object, 'time12', ''),
time24: stringOr(object, 'time24', '')
};
}
/**
* 末影龙状态:`GET /dragon`。
*
* 实测响应:`{"dragonAlive":false,"world":"world_the_end"}`。
* **该接口限速 1 次/分钟**,超限返回 429 并带 `retryAfterSeconds`。
*
* @returns {Promise<object|null>}
*/
async getDragon() {
const body = await this._getCachedOrFetch('dragon');
const object = body === null ? null : parseObject(body);
if (!object) return null;
return {
alive: object.dragonAlive === true,
world: stringOr(object, 'world', '')
};
}
/** @returns {object} 状态快照 */
snapshot() {
return {
baseUrl: this.baseUrl,
hasKey: this.hasApiKey(),
lastHttpStatus: this._lastHttpStatus,
lastError: this._lastError,
requestCount: this._requestCount,
cacheHits: this._cacheHits,
cachedPaths: this.cache.size,
cacheTtlMs: this.cacheTtlMs
};
}
/** 与 v1 相同的「缓存优先 + 鉴权 + 错误归类」逻辑 */
async _getCachedOrFetch(path) {
const entry = this.cache.get(path);
const now = Date.now();
if (entry && entry.expiresAt > now) {
this._cacheHits++;
return entry.body;
}
if (!this.hasApiKey()) {
this._lastError = '未配置 API-V2 Key';
return null;
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 20000);
try {
this._requestCount++;
const response = await this.fetchImpl(this.baseUrl + path, {
headers: { 'User-Agent': 'AutoStore-Node/1.0', Authorization: 'Bearer ' + this.apiKey },
signal: controller.signal
});
const text = await response.text();
this._lastHttpStatus = response.status;
if (response.status === 200) {
this.cache.set(path, { body: text, expiresAt: now + this.cacheTtlMs });
this._lastError = '';
return text;
}
if (response.status === 401) {
this._lastError = 'API-V2 Key 无效(401)——注意 V2 需要单独申请密钥';
this.cache.clear();
} else if (response.status === 429) {
// 服务端会带 retryAfterSeconds,直接告诉用户还要等多久,比「稍后重试」有用得多
const wait = intOr(parseObject(text) || {}, 'retryAfterSeconds', -1);
this._lastError = '触发限流(429)'
+ (wait > 0 ? `:还需等待 ${wait} 秒` : '')
+ '。V2 接口配额很小(末影龙仅 1 次/分钟)';
} else {
const detail = parseObject(text);
this._lastError = detail && detail.error ? String(detail.error) : 'HTTP ' + response.status;
}
} catch (err) {
this._lastError = err && err.message ? err.message : String(err);
} finally {
clearTimeout(timer);
}
return null;
}
}
/**
* 解析 API-V2 的物品对象。
*
* 实测结构:`{type, key, amount, damage, maxDurability, unbreakable}`。
* `damage` 是**已损耗值**而不是剩余耐久,所以这里同时给出两者,
* 免得界面上把「损耗 157」显示成「剩余 157」。
*
* @param {object|null} item
* @returns {object|null}
*/
function v2ItemOf(item) {
if (!item || typeof item !== 'object') return null;
const maxDurability = intOr(item, 'maxDurability', 0);
const damage = intOr(item, 'damage', 0);
return {
type: stringOr(item, 'type', ''),
key: stringOr(item, 'key', ''),
amount: intOr(item, 'amount', 1),
damage,
maxDurability,
remainingDurability: maxDurability > 0 ? Math.max(0, maxDurability - damage) : -1,
unbreakable: item.unbreakable === true
};
}
// ==================== JSON 解析(字段名以实测响应为准) ====================
/**
@@ -263,8 +796,8 @@ function parseUnionSummary(element) {
if (!element || typeof element !== 'object') return null;
const name = stringOr(element, 'name', '');
if (!name) return null;
const capitalX = doubleOr(element, 'capital_x', Number.NaN);
const capitalZ = doubleOr(element, 'capital_z', Number.NaN);
const capitalX = optionalNumber(element, 'capital_x');
const capitalZ = optionalNumber(element, 'capital_z');
return {
id: intOr(element, 'id', -1),
name,
@@ -273,12 +806,30 @@ function parseUnionSummary(element) {
chunkCount: intOr(element, 'territory_chunk_count', 0),
blockArea: longOr(element, 'territory_block_area', 0),
specialOutput: stringOr(element, 'special_output_material', ''),
capital: Number.isNaN(capitalX) || Number.isNaN(capitalZ)
// 王城坐标可能为 null(文档明确「空值输出 null」),此时不能换算成 (0,0) 假装有效
capital: capitalX === null || capitalZ === null
? null
: chunkPos.fromBlock(Math.floor(capitalX), Math.floor(capitalZ))
};
}
/**
* 取一个可能为 null 的数值字段。
*
* 直接用 `Number(null)` 会得到 0,把「没有王城」误判成「王城在 (0,0)」——
* 而 (0,0) 正好是世界原点,看起来完全像个合法坐标,排查起来很坑。
*
* @param {object} object
* @param {string} key
* @returns {number|null}
*/
function optionalNumber(object, key) {
const value = object[key];
if (value === undefined || value === null || value === '') return null;
const n = Number(value);
return Number.isFinite(n) ? n : null;
}
/**
* 解析领土区块。实测字段:`id, x, z, union_id, union_name, last_claimed_at, loyalty`
*/
@@ -338,10 +889,65 @@ function doubleOr(object, key, fallback) {
return Number.isFinite(value) ? value : fallback;
}
/**
* 把 Minecraft 聊天组件提取成纯文本。
*
* 公告接口返回的 `message` 是组件 JSON(形如 `{"color":"gold","extra":[{"text":"公告: "}, ...]}`),
* 直接显示会是一串带转义符的 JSON,必须先压平。
*
* @param {string} raw
* @returns {string}
*/
function componentToText(raw) {
const text = String(raw || '');
if (!text) return '';
const walk = (value) => {
if (typeof value === 'string') return value;
if (Array.isArray(value)) return value.map(walk).join('');
if (value && typeof value === 'object') {
const parts = [];
if (typeof value.text === 'string') parts.push(value.text);
if (value.translate && typeof value.translate === 'string') parts.push(value.translate);
for (const key of ['extra', 'with', 'children']) {
if (value[key] !== undefined) parts.push(walk(value[key]));
}
return parts.join('');
}
return '';
};
// 先尝试按 JSON 解析(公告字段是 JSON 字符串,不是对象)
try {
return walk(JSON.parse(text));
} catch {
return text;
}
}
/**
* 把 limit 参数钳制到 1..1000(文档默认 100)。
* @param {*} value
* @returns {number}
*/
function normalizeLimit(value) {
const n = Number(value);
if (!Number.isFinite(n)) return 100;
return Math.min(1000, Math.max(1, Math.trunc(n)));
}
/** 保留 4 位小数,避免捐献数量出现 -0.5520000000000004 这种浮点尾巴 */
function round4(value) {
return Math.round(value * 10000) / 10000;
}
module.exports = {
SimpApiClient,
SimpApiV2Client,
BASE_URL,
BASE_URL_V2,
DEFAULT_CACHE_TTL_MS,
DEFAULT_CACHE_TTL_V2_MS,
parseUnionSummary,
parseTerritoryChunk
parseTerritoryChunk,
normalizeLimit,
componentToText
};
+160
查看文件
@@ -0,0 +1,160 @@
'use strict';
const chunkPos = require('./chunkPos');
/**
* 目标区块租约表(多机器人协调用)。
*
* 背景(TODO.md 4.3):领土模型是全局共享的,但「谁要去占哪一块」只存在各机器人
* 自己的队列里,彼此看不见 —— 多个机器人同时扩地时很容易撞在同一批候选区块上:
* 两个机器人同时跑到同一区块、只有一个能开垦成功,另一半时间全白花。
*
* 这里给「目标区块」加一张带租约的占用表:
* - **抢占**:`tryAcquire` 是「检查 + 写入」的原子操作,谁先抢到谁去;
* - **租约**:占用有有效期(默认 3 分钟),持有者持续续租;机器人掉线 / 卡死时
* 租约到期自动失效,区块不会被永久锁死;
* - **释放**:开垦成功 / 放弃目标 / 停止任务时主动释放,其他机器人立刻可用。
*
* 同一 Node 进程内管理所有机器人,因此不需要真正的分布式锁,
* 一张内存表就足以覆盖「同进程多机器人」这个实际场景。
*/
/** 默认租约时长(毫秒) */
const DEFAULT_TTL_MS = 180000;
class TargetLeaseTable {
/**
* @param {{ttlMs?: number}} [options]
*/
constructor(options) {
const opts = options || {};
this.ttlMs = Number.isFinite(opts.ttlMs) && opts.ttlMs > 0 ? opts.ttlMs : DEFAULT_TTL_MS;
/** @type {Map<string, {botId: string, expiresAt: number, acquiredAt: number}>} */
this._leases = new Map();
}
/**
* 尝试占用一个区块(同一个机器人可重复占用 / 续租)。
*
* @param {{x: number, z: number}} chunk
* @param {string} botId
* @param {number} [ttlMs] 本次租约时长,缺省取表上的 ttlMs
* @returns {boolean} 是否占用成功(已被其他机器人占用时返回 false)
*/
tryAcquire(chunk, botId, ttlMs) {
if (!chunk) return false;
const key = chunkPos.key(chunk.x, chunk.z);
const now = Date.now();
const existing = this._leases.get(key);
if (existing && existing.expiresAt > now && existing.botId !== botId) return false;
const ttl = Number.isFinite(ttlMs) && ttlMs > 0 ? ttlMs : this.ttlMs;
this._leases.set(key, {
botId,
expiresAt: now + ttl,
acquiredAt: existing && existing.botId === botId ? existing.acquiredAt : now
});
return true;
}
/**
* 续租(只有当前持有者能续)。
* @param {{x: number, z: number}} chunk
* @param {string} botId
* @param {number} [ttlMs]
* @returns {boolean} 是否续租成功
*/
renew(chunk, botId, ttlMs) {
if (!chunk) return false;
const key = chunkPos.key(chunk.x, chunk.z);
const existing = this._leases.get(key);
if (!existing || existing.botId !== botId) return false;
const ttl = Number.isFinite(ttlMs) && ttlMs > 0 ? ttlMs : this.ttlMs;
existing.expiresAt = Date.now() + ttl;
return true;
}
/**
* 释放占用(只有当前持有者能释放;已过期的占用直接清掉)。
* @param {{x: number, z: number}} chunk
* @param {string} botId
* @returns {boolean} 是否释放了一个占用
*/
release(chunk, botId) {
if (!chunk) return false;
const key = chunkPos.key(chunk.x, chunk.z);
const existing = this._leases.get(key);
if (!existing) return false;
if (existing.botId !== botId) return false;
this._leases.delete(key);
return true;
}
/**
* 释放某个机器人的全部占用(停止任务 / 断线 / 退出时调用)。
* @param {string} botId
* @returns {number} 释放的数量
*/
releaseAll(botId) {
let released = 0;
for (const [key, lease] of this._leases) {
if (lease.botId === botId) {
this._leases.delete(key);
released++;
}
}
return released;
}
/**
* 区块是否被**其他**机器人占用(已过期的占用视为空闲,顺手清理)。
* @param {{x: number, z: number}} chunk
* @param {string} botId 自己
* @returns {boolean}
*/
isLeasedByOther(chunk, botId) {
if (!chunk) return false;
const key = chunkPos.key(chunk.x, chunk.z);
const lease = this._leases.get(key);
if (!lease) return false;
if (lease.expiresAt <= Date.now()) {
this._leases.delete(key);
return false;
}
return lease.botId !== botId;
}
/**
* 清理过期租约。
* @returns {number} 清理掉的数量
*/
sweep() {
const now = Date.now();
let removed = 0;
for (const [key, lease] of this._leases) {
if (lease.expiresAt <= now) {
this._leases.delete(key);
removed++;
}
}
return removed;
}
/** @returns {number} 当前有效占用数量 */
get size() {
this.sweep();
return this._leases.size;
}
/** @returns {Array<{chunk: string, botId: string, remainingMs: number}>} 供控制台展示 */
snapshot() {
const now = Date.now();
const rows = [];
for (const [key, lease] of this._leases) {
if (lease.expiresAt <= now) continue;
rows.push({ chunk: key, botId: lease.botId, remainingMs: lease.expiresAt - now });
}
return rows;
}
}
module.exports = { TargetLeaseTable, DEFAULT_TTL_MS };
+95 -8
查看文件
@@ -4,7 +4,7 @@ const crypto = require('crypto');
const chunkPos = require('./chunkPos');
const { TerritoryModel } = require('./territoryModel');
const { SimpApiClient } = require('./simpApiClient');
const { SimpApiClient, SimpApiV2Client } = require('./simpApiClient');
/**
* 领土数据服务:定时从远程地图服务拉取邦国领土数据(Dynmap markers.json),
@@ -71,6 +71,13 @@ class TerritoryDataService {
/** @type {SimpApiClient} SimpCore 开放 API 客户端(第四占领判定数据源) */
this.apiClient = new SimpApiClient(logger);
/**
* @type {SimpApiV2Client} SimpCore API-V2 客户端(实时状态查询)
*
* 与上面那套是**互相独立的两套密钥体系**,所以单独一个实例、单独一个 Key。
* 只在控制台按需查询,不参与自动轮询。
*/
this.apiV2Client = new SimpApiV2Client(logger);
/** API 最近一次成功拉取的 邦国名 → 区块键集合 */
this.apiTerritory = new Map();
/** API 最近一次成功拉取的 邦国名 → 王城区块 */
@@ -79,6 +86,8 @@ class TerritoryDataService {
this.lastApiSuccessMillis = 0;
/** API 轮询间隔(秒);0 表示未启用 */
this._apiPollIntervalSeconds = 0;
/** API-V2 是否启用 */
this.apiV2Enabled = false;
this._refreshTimer = null;
this._apiTimer = null;
@@ -134,6 +143,25 @@ class TerritoryDataService {
this._rescheduleApi();
}
/**
* 配置 API-V2(ServerOpenAPI)。
*
* V2 的接口级配额极小(末影龙 1 次/分钟),因此**只设置 Key,不做任何自动轮询**,
* 全部由控制台按需触发。
*
* @param {boolean} enabled
* @param {string} apiKey
*/
applyApiV2Config(enabled, apiKey) {
this.apiV2Enabled = !!enabled && !!(apiKey && apiKey.trim());
this.apiV2Client.setApiKey(this.apiV2Enabled ? apiKey : '');
}
/** @returns {boolean} API-V2 是否已启用(开关打开且配了 Key) */
isApiV2Enabled() {
return this.apiV2Enabled === true && this.apiV2Client.hasApiKey();
}
/** 按 _apiPollIntervalSeconds 重新调度 API 轮询(≤0 表示停用) */
_rescheduleApi() {
if (this._apiTimer) clearInterval(this._apiTimer);
@@ -367,10 +395,11 @@ class TerritoryDataService {
/**
* 记录一次会话内新占领的区块。
* @param {{x: number, z: number}} chunk
* @param {string} [stateName] 归属邦国名(多机器人各自记录时使用)
*/
addSessionClaim(chunk) {
addSessionClaim(chunk, stateName) {
this.sessionClaimTimes.set(chunkPos.key(chunk.x, chunk.z), Date.now());
this.model.addSessionClaim(chunk);
this.model.addSessionClaim(chunk, stateName);
}
/** @returns {object} 数据源状态快照(供 Web 控制台展示) */
@@ -389,7 +418,29 @@ class TerritoryDataService {
apiLastSuccessMillis: this.lastApiSuccessMillis,
apiError: this.apiClient.lastError(),
apiHttpStatus: this.apiClient.lastHttpStatus(),
apiTerritoryCount: this.apiTerritory.size
apiTerritoryCount: this.apiTerritory.size,
apiV2Enabled: this.isApiV2Enabled(),
apiV2Error: this.apiV2Client.lastError(),
apiV2HttpStatus: this.apiV2Client.lastHttpStatus()
};
}
/**
* API 相关状态快照(供控制台「API 数据」面板展示用量与配额情况)。
* @returns {object}
*/
apiSnapshot() {
return {
v1: Object.assign(this.apiClient.snapshot(), {
enabled: this.isApiEnabled(),
pollIntervalSeconds: this._apiPollIntervalSeconds,
lastSuccessMillis: this.lastApiSuccessMillis,
territoryUnions: this.apiTerritory.size,
capitalUnions: this.apiCapitals.size
}),
v2: Object.assign(this.apiV2Client.snapshot(), { enabled: this.isApiV2Enabled() }),
/** 官方文档说明:API 与 API-V2 是两套独立、互不通用的密钥体系 */
note: 'API 与 API-V2 是两套独立密钥体系,需在密钥中心分别申请;列表类接口支持 limit,响应带最多 1 小时缓存延迟'
};
}
}
@@ -428,13 +479,15 @@ function parseMarkers(json) {
const claimColors = new Map();
/** @type {Map<string, {x: number, z: number}>} */
const capitals = new Map();
/** @type {Map<string, number[][]>} 邦国名 → 扁平多边形列表(大地图直接绘制用) */
const claimPolygons = new Map();
for (const layer of root) {
if (!layer || typeof layer !== 'object') continue;
const markers = layer.markers;
if (!Array.isArray(markers)) continue;
if (layer.id === 'union_claims') {
for (const marker of markers) parseClaimMarker(marker, claims, claimColors);
for (const marker of markers) parseClaimMarker(marker, claims, claimColors, claimPolygons);
} else if (layer.id === 'union_capitals') {
for (const marker of markers) parseCapitalMarker(marker, capitals);
}
@@ -448,7 +501,13 @@ function parseMarkers(json) {
capital = first.done ? { x: 0, z: 0 } : chunkPos.decode(first.value);
}
const color = claimColors.has(name) ? claimColors.get(name) : DEFAULT_COLOR;
states.set(name, { name, capital, chunks: chunkKeys, color });
states.set(name, {
name,
capital,
chunks: chunkKeys,
color,
polygons: claimPolygons.get(name) || []
});
}
return new TerritoryModel(states);
}
@@ -456,8 +515,11 @@ function parseMarkers(json) {
/**
* 解析一个领土多边形 marker:从弹窗文本提取邦国名,
* 将多边形覆盖的区块按 even-odd 规则填充进该邦国的区块集合,并记录颜色。
*
* 同时把原始多边形(方块坐标,扁平为 `[x1, z1, x2, z2, ...]`)保留下来,
* 供大地图按几何直接绘制——低缩放级别下逐个区块画矩形既慢又糊。
*/
function parseClaimMarker(marker, claims, colors) {
function parseClaimMarker(marker, claims, colors, polygons) {
if (!marker || typeof marker !== 'object') return;
const popup = typeof marker.popup === 'string' ? marker.popup : '';
const matcher = CLAIM_PATTERN.exec(popup);
@@ -471,12 +533,36 @@ function parseClaimMarker(marker, claims, colors) {
}
// 一个 marker 的多个多边形遵循 dynmap 的 even-odd 填充规则:
// 外圈为正,内圈多边形(国中飞地的边界)作为孔洞被减去,而非作为独立领土并集填充。
fillPolygonEvenOdd(chunks, extractPolygons(marker.points));
const extracted = extractPolygons(marker.points);
fillPolygonEvenOdd(chunks, extracted);
if (polygons) {
let list = polygons.get(name);
if (!list) {
list = [];
polygons.set(name, list);
}
for (const polygon of extracted) list.push(flattenPolygon(polygon));
}
if (!colors.has(name)) {
colors.set(name, parseColor(typeof marker.color === 'string' ? marker.color : '#75bd20'));
}
}
/**
* 把多边形压平成 `[x1, z1, x2, z2, ...]`,减少 JSON 体积与对象开销。
* @param {Array<[number, number]>} polygon
* @returns {number[]}
*/
function flattenPolygon(polygon) {
const flat = new Array(polygon.length * 2);
let i = 0;
for (const point of polygon) {
flat[i++] = point[0];
flat[i++] = point[1];
}
return flat;
}
/** 解析一个王城 marker:从弹窗文本提取邦国名,记录王城的区块坐标 */
function parseCapitalMarker(marker, capitals) {
if (!marker || typeof marker !== 'object') return;
@@ -613,6 +699,7 @@ module.exports = {
fillPolygonEvenOdd,
pointInPolygon,
extractPolygons,
flattenPolygon,
parseColor,
buildMarkersUrl,
CLAIM_CONFIRM_GRACE_MS,
+41 -7
查看文件
@@ -19,10 +19,12 @@ const API_DEFAULT_COLOR = 0xff43de;
class TerritoryModel {
/**
* @param {Map<string, {name: string, capital: {x: number, z: number}|null, chunks: Set<number>, color: number}>} [states]
* @param {Map<string, {name: string, capital: {x: number, z: number}|null, chunks: Set<number>, color: number, polygons?: number[][]}>} [states]
*/
constructor(states) {
/** @type {Map<string, {name: string, capital: {x: number, z: number}|null, chunks: Set<number>, color: number}>} */
/**
* @type {Map<string, {name: string, capital: {x: number, z: number}|null, chunks: Set<number>, color: number, polygons?: number[][]}>}
*/
this.states = new Map(states || []);
/** @type {Set<number>} 所有邦国已占领区块的并集 */
this.allClaimed = new Set();
@@ -126,7 +128,36 @@ class TerritoryModel {
/** @returns {Set<number>} 己方邦国的飞地(疏漏区块);未选择邦国时为空集 */
selfFlyingChunks() {
return this._flyingByStateMap().get(this.selfStateName) || EMPTY_SET;
return this.flyingChunksOf(this.selfStateName);
}
/**
* 指定邦国的飞地(疏漏区块)。
*
* 多机器人场景下每个机器人可能在不同邦国,因此不能只依赖全局的 selfStateName,
* 必须支持按邦国名查询。
*
* @param {string} name 邦国名
* @returns {Set<number>}
*/
flyingChunksOf(name) {
if (!name) return EMPTY_SET;
return this._flyingByStateMap().get(name) || EMPTY_SET;
}
/**
* 取「己方」区块集合:指定邦国领土 ∪ 会话新占领区块。
*
* @param {string} name 邦国名
* @returns {Set<number>} 新区块集合(调用方可安全修改)
*/
ownChunksOf(name) {
const result = new Set(this.sessionClaims);
const state = name ? this.states.get(name) : null;
if (state) {
for (const k of state.chunks) result.add(k);
}
return result;
}
/** @returns {Map<string, Set<number>>} 各邦国飞地区块(懒计算并缓存) */
@@ -176,14 +207,16 @@ class TerritoryModel {
/**
* 记录一次会话内新占领的区块:加入会话集合与全局已占领集合,
* 并同步更新己方邦国的区块集合。
*
* @param {{x: number, z: number}} chunk
* @param {string} [stateName] 归属邦国名;缺省时使用全局 selfStateName
*/
addSessionClaim(chunk) {
addSessionClaim(chunk, stateName) {
const k = chunkPos.key(chunk.x, chunk.z);
this.sessionClaims.add(k);
this.allClaimed.add(k);
this._invalidateFlying();
const self = this.states.get(this.selfStateName);
const self = this.states.get(stateName || this.selfStateName);
if (self) {
self.chunks.add(k);
}
@@ -224,7 +257,7 @@ class TerritoryModel {
const first = chunks.values().next();
capital = first.done ? { x: 0, z: 0 } : chunkPos.decode(first.value);
}
rebuilt.set(name, { name, capital, chunks, color });
rebuilt.set(name, { name, capital, chunks, color, polygons: [] });
} else {
const chunks = new Set(existing.chunks);
for (const k of chunkKeys) chunks.add(k);
@@ -232,7 +265,8 @@ class TerritoryModel {
if (apiCapital && (!capital || (capital.x === 0 && capital.z === 0))) {
capital = apiCapital;
}
rebuilt.set(name, { name, capital, chunks, color: existing.color });
// 多边形是 Dynmap 独有的原始几何,API 合并时原样保留(大地图绘制用)
rebuilt.set(name, { name, capital, chunks, color: existing.color, polygons: existing.polygons || [] });
}
}
// 未被 API 覆盖的邦国原样保留
+106 -31
查看文件
@@ -7,30 +7,31 @@ require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
const chalk = require('chalk');
const { ConfigStore } = require('./config/configStore');
const { BotStore, BotCredentialStore } = require('./config/botStore');
const { parseBotsFromEnv } = require('./config/botsConfig');
const { normalizeConfig } = require('./config/expansionConfig');
const { Logger } = require('./util/logger');
const { BotManager } = require('./bot/botManager');
const { BotPool } = require('./bot/botPool');
const { TerritoryDataService } = require('./core/territoryDataService');
const { ExpansionController } = require('./controller/expansionController');
const { ExpansionLog } = require('./core/expansionLog');
const { ChatLog } = require('./core/chatLog');
const { BigMapService } = require('./core/bigMapService');
const { Notifier } = require('./core/notifier');
const { WebConsole } = require('./web/server');
/** 控制器 tick 间隔(毫秒),50ms ≈ 20 TPS */
const TICK_INTERVAL_MS = 50;
/** 启动时等待机器人进入世界的上限(毫秒),超时后大地图退回以世界原点为中心 */
const WAIT_FOR_BOT_MS = 60000;
async function main() {
const logger = new Logger({ echo: true });
logger.log(chalk.bold('==== AutoStore Bot 启动 ===='));
// ==================== 连接参数(全部来自环境变量,密钥不落代码) ====================
const connection = {
host: process.env.MC_HOST || 'bgjq.simpfun.cn',
port: Number.parseInt(process.env.MC_PORT || '25565', 10),
username: process.env.MC_USERNAME || 'AutoStoreBot',
version: process.env.MC_VERSION || '1.21.4',
auth: process.env.MC_AUTH || 'offline',
password: process.env.MC_PASSWORD || ''
};
if (!Number.isFinite(connection.port) || connection.port <= 0) connection.port = 25565;
const dataDir = path.join(__dirname, '..', 'data');
// ==================== 机器人凭据(全部来自环境变量,密钥不落代码 / 不落配置文件) ====================
const connections = parseBotsFromEnv(process.env);
// ==================== 配置 ====================
const configStore = new ConfigStore();
@@ -38,29 +39,58 @@ async function main() {
config = normalizeConfig(config, config);
// 环境变量里的 API Key 作为兜底(配置文件为空时)
if (!config.apiKey && process.env.SIMP_API_KEY) config.apiKey = process.env.SIMP_API_KEY.trim();
// API-V2 是独立的一套密钥体系,单独兜底
if (!config.apiV2Key && process.env.SIMP_API_V2_KEY) {
config.apiV2Key = process.env.SIMP_API_V2_KEY.trim();
config.apiV2Enabled = true;
}
logger.log(`配置文件:${configStore.filePath}`);
logger.log(`服务器:${connection.host}:${connection.port} / ${connection.username} / ${connection.version} / ${connection.auth}`);
// ==================== 各模块 ====================
const botManager = new BotManager(connection, config, logger);
const expansionLog = new ExpansionLog({ filePath: path.join(dataDir, 'expansion-log.jsonl'), logger });
const chatLog = new ChatLog({ dir: path.join(dataDir, 'chat'), logger });
const dataService = new TerritoryDataService(config, logger);
const controller = new ExpansionController({ botManager, dataService, logger });
// 寻路事件转发给控制器(与 Baritone 的 PathEvent 语义一致)
botManager.on('modulesReady', () => {
if (botManager.pathing) {
botManager.pathing.onPathEvent = (event, detail) => controller.onPathEvent(event, detail);
}
const bigMap = new BigMapService(config, logger, {
baseUrl: process.env.DYNMAP_BASE,
world: config.bigMap.world || process.env.DYNMAP_WORLD,
cacheDir: path.join(dataDir, 'mapcache')
});
const botStore = new BotStore();
// 控制台新增玩家的凭据单独落盘(0600),与 .env 互为补充、.env 优先
const credentialStore = new BotCredentialStore();
// 通知提醒(Webhook):等待验证码 / 原点自救失败时把消息推到飞书等渠道
const notifier = new Notifier(config, logger);
const pool = new BotPool({
connections,
config,
logger,
dataService,
expansionLog,
chatLog,
botStore,
credentialStore,
notifier
});
// 账号来源有两个:.env 的 MC_BOTS / MC_USERNAME,以及控制台「添加玩家」写入的本地凭据文件
const botNames = pool.snapshot().map((b) => `${b.username}(${b.source === 'env' ? '.env' : '控制台'})`);
logger.log(`机器人数量:${pool.size}`
+ (botNames.length > 0 ? '(' + botNames.join('、') + ')' : ''));
if (pool.size === 0) {
logger.log('未配置任何机器人:请在 .env 设置 MC_BOTS,或启动后到控制台用「添加玩家」新增', 'error');
}
// ==================== Web 控制台 ====================
const webConsole = new WebConsole(
{
botManager,
controller,
pool,
dataService,
logger,
expansionLog,
chatLog,
bigMap,
notifier,
getConfig: () => config,
applyConfig: (next) => applyConfig(next)
},
@@ -68,7 +98,8 @@ async function main() {
host: process.env.WEB_HOST || '127.0.0.1',
port: Number.parseInt(process.env.WEB_PORT || '3081', 10),
token: process.env.WEB_TOKEN || '',
exportDir: path.join(__dirname, '..', 'data')
password: process.env.WEB_PASSWORD || '',
exportDir: dataDir
}
);
@@ -80,23 +111,35 @@ async function main() {
function applyConfig(next) {
config = normalizeConfig(next, config);
configStore.save(config);
botManager.applyConfig(config);
controller.config = config;
pool.applyGlobalConfig(config);
dataService.config = config;
dataService.applyApiConfig(config.apiEnabled, config.apiKey, config.apiRefreshIntervalSeconds);
dataService.applyApiV2Config(config.apiV2Enabled, config.apiV2Key || process.env.SIMP_API_V2_KEY);
dataService.reschedule(config.refreshIntervalSeconds);
if (bigMap) {
bigMap.config = config;
bigMap.world = config.bigMap.world || process.env.DYNMAP_WORLD || bigMap.world;
}
notifier.config = config;
logger.log('配置已更新并应用');
return config;
}
// ==================== 启动 ====================
dataService.applyApiConfig(config.apiEnabled, config.apiKey, config.apiRefreshIntervalSeconds);
dataService.applyApiV2Config(config.apiV2Enabled, config.apiV2Key || process.env.SIMP_API_V2_KEY);
dataService.start(config.refreshIntervalSeconds);
botManager.connect();
pool.start();
// 大地图:启动时预爬取「机器人所在位置周边」的瓦片。
// 机器人还没进世界时先等一会儿 —— 以世界原点为中心爬是没意义的。
if (config.bigMap.enabled && config.bigMap.crawlOnStart) {
scheduleInitialCrawl(pool, bigMap, config, logger);
}
const tickTimer = setInterval(() => {
try {
controller.tick();
pool.tick();
} catch (err) {
logger.error('tick 异常:' + (err && err.stack ? err.stack : err));
}
@@ -114,8 +157,7 @@ async function main() {
shuttingDown = true;
logger.log(`收到 ${signal},正在退出…`);
clearInterval(tickTimer);
controller.stop();
botManager.disconnect();
pool.stop();
dataService.stop();
try {
await webConsole.stop();
@@ -131,6 +173,39 @@ async function main() {
});
}
/**
* 启动时预爬取大地图瓦片:优先等机器人进入世界,以它的位置为中心。
*
* 大地图的用途是看自己机器人周边,所以「以世界原点为中心爬」几乎总是爬错地方。
* 这里最多等 `WAIT_FOR_BOT_MS`,等到任意一个机器人有坐标就用它当中心;
* 等不到(比如机器人被禁用)再退回世界原点,保证控制台至少不是空白。
*
* @param {import('./bot/botPool').BotPool} pool
* @param {import('./core/bigMapService').BigMapService} bigMap
* @param {object} config
* @param {import('./util/logger').Logger} logger
*/
function scheduleInitialCrawl(pool, bigMap, config, logger) {
const deadline = Date.now() + WAIT_FOR_BOT_MS;
const attempt = () => {
const position = pool.positions().find((bot) => bot.position) || null;
if (!position && Date.now() < deadline) {
setTimeout(attempt, 3000).unref?.();
return;
}
const center = position
? { centerBlockX: position.position.x, centerBlockZ: position.position.z }
: { centerBlockX: 0, centerBlockZ: 0 };
if (!position) {
logger.log('等待机器人进入世界超时,大地图改以世界原点为中心预爬取', 'warn');
}
bigMap.crawlAll(config.bigMap.zoom, center).catch((err) => {
logger.log('大地图预爬取失败:' + (err && err.message ? err.message : String(err)), 'warn');
});
};
setTimeout(attempt, 3000).unref?.();
}
// 仅在作为入口运行时启动(被 require 时只导出工厂,便于测试与语法自检)
if (require.main === module) {
main().catch((err) => {
@@ -139,4 +214,4 @@ if (require.main === module) {
});
}
module.exports = { main };
module.exports = { main, scheduleInitialCrawl };
+2379 -140
查看文件
文件差异内容过多而无法显示 加载差异
+274 -48
查看文件
@@ -2,7 +2,8 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#0d1117">
<title>AutoStore Bot 控制台</title>
<link rel="stylesheet" href="style.css">
</head>
@@ -10,23 +11,65 @@
<header class="topbar">
<div class="brand">
<span class="logo">AS</span>
<div>
<div class="brand-text">
<h1>AutoStore Bot</h1>
<p>Minecraft 自动扩地机器人 · mineflayer · Grim 合规</p>
<p>多机器人自动扩地 · mineflayer · Grim 合规 · 大地图</p>
</div>
</div>
<div class="topbar-right">
<span id="connBadge" class="badge badge-offline">未连接</span>
<span id="taskBadge" class="badge badge-idle">空闲</span>
<button id="btnStart" class="btn btn-primary">开始扩地</button>
<button id="btnStop" class="btn btn-danger">停止</button>
<button id="btnReconnect" class="btn">重连</button>
<label class="field-inline">
<span class="field-label">当前机器人</span>
<select id="botSelect" class="input input-sm"></select>
</label>
<div class="badges">
<span id="connBadge" class="badge badge-offline">未连接</span>
<span id="taskBadge" class="badge badge-idle">空闲</span>
<span id="authBadge" class="badge badge-idle" title="控制台登录状态">未登录</span>
</div>
<div class="topbar-actions">
<button id="btnStart" class="btn btn-primary">开始扩地</button>
<button id="btnStop" class="btn btn-danger">停止</button>
<button id="btnReconnect" class="btn">重连</button>
<button id="btnOffline" class="btn" title="让机器人断开且不再自动重连(需要在游戏客户端登同一账号时用)">下线</button>
</div>
</div>
</header>
<nav class="mobile-nav" id="mobileNav">
<button class="mobile-nav-item active" data-scroll="panelStatus">状态</button>
<button class="mobile-nav-item" data-scroll="panelMap">地图</button>
<button class="mobile-nav-item" data-scroll="panelLogs">日志</button>
<button class="mobile-nav-item" data-scroll="panelConfig">配置</button>
</nav>
<main class="layout">
<!-- ==================== 左栏:状态面板 ==================== -->
<section class="column col-left">
<section class="column col-left" id="panelStatus">
<div class="card">
<div class="card-head">
<h2>机器人(<span id="botCount">0</span>)</h2>
<div class="row row-compact">
<button id="btnStartAll" class="btn btn-sm">全部开始</button>
<button id="btnStopAll" class="btn btn-sm">全部停止</button>
</div>
</div>
<div id="botList" class="bot-list"></div>
<details class="add-bot">
<summary>+ 添加玩家</summary>
<div class="field">
<input id="newBotName" class="input" type="text" placeholder="玩家名(1-16 位字母/数字/下划线)" maxlength="16">
</div>
<div class="field">
<input id="newBotPass" class="input" type="password" placeholder="服务器密码(可留空)" autocomplete="new-password">
</div>
<div class="field">
<input id="newBotLabel" class="input" type="text" placeholder="备注(可留空)">
</div>
<button id="btnAddBot" class="btn btn-sm btn-primary btn-block">添加</button>
<p class="hint">密码只保存在服务器本地凭据文件(权限 0600),不会进仓库、也不会出现在任何接口响应里。.env 里配置的账号需改 .env 才能移除。</p>
</details>
</div>
<div class="card">
<h2>连接</h2>
<dl class="kv">
@@ -37,8 +80,15 @@
<dt>所在区块</dt><dd id="kChunk">-</dd>
<dt>生命 / 饥饿</dt><dd id="kHealth">-</dd>
<dt>状态</dt><dd id="kBotState">-</dd>
<dt>登录验证</dt><dd id="kLogin">-</dd>
<dt>原点自救</dt><dd id="kOriginRescue">-</dd>
<dt>重连次数</dt><dd id="kReconnect">0</dd>
</dl>
<div class="row">
<label class="chk"><input type="checkbox" id="chkEnabled"> 启用该机器人</label>
<label class="chk"><input type="checkbox" id="chkAutoStart"> 启动后自动扩地</label>
<button id="btnSaveBotSetting" class="btn btn-sm">保存</button>
</div>
</div>
<div class="card">
@@ -46,17 +96,70 @@
<dl class="kv">
<dt>状态机</dt><dd id="kTaskState">IDLE</dd>
<dt>描述</dt><dd id="kTaskStatus">未启动</dd>
<dt>开拓邦国</dt><dd id="kTaskStateName">-</dd>
<dt>当前目标</dt><dd id="kTarget">-</dd>
<dt>队列</dt><dd id="kQueue">0</dd>
<dt>已完成</dt><dd id="kCompleted">0</dd>
<dt>重试</dt><dd id="kRetry">0</dd>
<dt>开垦进度</dt><dd id="kProgress">0%</dd>
<dt>连续寻路超时</dt><dd id="kTimeout">0s</dd>
<dt>最近回城</dt><dd id="kLastReturn">-</dd>
<dt>开垦时段</dt><dd id="kClaimTime">-</dd>
</dl>
<div class="progress"><div id="progressBar" class="progress-fill"></div></div>
</div>
<div class="card">
<div class="card-head">
<h2>定时消息</h2>
<button id="btnAddSchedule" class="btn btn-sm">+ 新增</button>
</div>
<div id="scheduleList" class="schedule-list"></div>
<div id="scheduleEditor" class="schedule-editor hidden">
<div class="field">
<label>内容(以 / 开头按游戏指令发送)</label>
<input id="schedContent" class="input" type="text" placeholder="/u restore confirm">
</div>
<div class="field-row">
<div class="field">
<label>调度方式</label>
<select id="schedMode" class="input">
<option value="interval">固定间隔</option>
<option value="cron">cron 表达式</option>
</select>
</div>
<div class="field">
<label>间隔(秒)</label>
<input id="schedInterval" class="input" type="number" min="5" value="300">
</div>
</div>
<div class="field hidden" id="schedCronField">
<label>cron 表达式(分 时 日 月 周,本地时间)</label>
<input id="schedCron" class="input" type="text" placeholder="0 9 * * *">
<div class="hint">例:<code>*/30 * * * *</code> 每 30 分钟 · <code>0 9 * * *</code> 每天 09:00 · <code>0 12 * * 1-5</code> 工作日 12:00</div>
</div>
<div class="field-row">
<div class="field">
<label>期望回复(正则,可留空)</label>
<input id="schedExpect" class="input" type="text" placeholder="民心.*恢复">
</div>
<div class="field">
<label>确认超时(秒)</label>
<input id="schedConfirm" class="input" type="number" min="1" max="120" value="10">
</div>
</div>
<div class="field">
<label>备注</label>
<input id="schedLabel" class="input" type="text" placeholder="恢复民心">
</div>
<div class="row row-compact">
<button id="btnSaveSchedule" class="btn btn-sm btn-primary">保存</button>
<button id="btnCancelSchedule" class="btn btn-sm">取消</button>
</div>
<p class="hint">只会在机器人已连接且通过登录验证时发送;未就绪的周期会跳过,不会补发。填了「期望回复」时,发出后会等待服务器回复,超时未回复会在日志里给出警告。</p>
</div>
</div>
<div class="card">
<h2>领土数据源</h2>
<dl class="kv">
@@ -69,9 +172,10 @@
<dt>己方飞地</dt><dd id="kFlying">0</dd>
<dt>SimpCore API</dt><dd id="kApi">未启用</dd>
</dl>
<div class="row">
<div class="row row-compact">
<button id="btnRefreshMap" class="btn btn-sm">刷新地图</button>
<button id="btnFetchAll" class="btn btn-sm">拉取全服领土</button>
<button id="btnOpenSimp" class="btn btn-sm btn-primary">查看 API 数据</button>
</div>
</div>
@@ -83,6 +187,8 @@
<dt>朝向已同步</dt><dd id="kGrimSettled">-</dd>
<dt>攻击距离</dt><dd id="kGrimReach">-</dd>
<dt>攻击冷却</dt><dd id="kGrimCooldown">-</dd>
<dt>协议补包</dt><dd id="kGrimPackets">-</dd>
<dt>地面修正</dt><dd id="kGrimGround">-</dd>
<dt>已拦下动作</dt><dd id="kGrimBlocked">0</dd>
<dt>物理自检</dt><dd id="kGrimAudit">-</dd>
</dl>
@@ -90,82 +196,202 @@
</div>
</section>
<!-- ==================== 中栏:地图 ==================== -->
<section class="column col-center">
<!-- ==================== 中栏:地图与日志 ==================== -->
<section class="column col-center" id="panelMap">
<div class="card card-map">
<div class="card-head">
<h2>领土地图</h2>
<div class="row">
<label class="chk"><input type="checkbox" id="chkFull" checked> 显示全部邦国</label>
<label class="chk"><input type="checkbox" id="chkFlying" checked> 标出飞地</label>
<div class="card-head card-head-wrap">
<div class="tabs tabs-inline" id="mapTabs">
<button class="tab active" data-view="big">大地图</button>
<button class="tab" data-view="territory">领土地图</button>
</div>
<div class="row row-compact" id="bigMapControls">
<label class="field-inline">
<span class="field-label">图幅</span>
<select id="bigMapRange" class="input input-sm">
<option value="512">±512 格</option>
<option value="1024">±1024 格</option>
<option value="2048">±2048 格</option>
<option value="4096" selected>±4096 格</option>
<option value="8192">±8192 格</option>
<option value="20480">全世界</option>
</select>
</label>
<label class="chk"><input type="checkbox" id="chkTerritory" checked> 领土标记</label>
<label class="chk"><input type="checkbox" id="chkBotLabels" checked> 名字</label>
<button id="btnCrawlAround" class="btn btn-sm btn-primary">爬取周边</button>
<button id="btnMapCenterBots" class="btn btn-sm">回到机器人</button>
</div>
<div class="row row-compact hidden" id="territoryMapControls">
<label class="chk"><input type="checkbox" id="chkFull" checked> 全部邦国</label>
<label class="chk"><input type="checkbox" id="chkFlying" checked> 飞地</label>
<label class="chk"><input type="checkbox" id="chkGrid" checked> 网格</label>
<button id="btnMapCenter" class="btn btn-sm">回到玩家</button>
<button id="btnMapFit" class="btn btn-sm">全图</button>
<button id="btnMapCenter" class="btn btn-sm">回到机器人</button>
<button id="btnMapFit" class="btn btn-sm">看己方全境</button>
</div>
</div>
<canvas id="mapCanvas"></canvas>
<div class="map-legend">
<span><i class="sw sw-self"></i>己方领土</span>
<span><i class="sw sw-session"></i>本次占领</span>
<span><i class="sw sw-flying"></i>飞地(疏漏)</span>
<span><i class="sw sw-target"></i>当前目标</span>
<span><i class="sw sw-player"></i>机器人</span>
<span><i class="sw sw-capital"></i>王城</span>
<span id="legendBig">
<i class="sw sw-self"></i>己方领土
<i class="sw sw-session"></i>本次占领
<i class="sw sw-capital"></i>王城
<i class="sw sw-bot"></i>机器人
</span>
<span id="legendTerritory" class="hidden">
<i class="sw sw-self"></i>己方领土
<i class="sw sw-session"></i>本次占领
<i class="sw sw-flying"></i>飞地(疏漏)
<i class="sw sw-target"></i>当前目标
<i class="sw sw-player"></i>机器人
<i class="sw sw-capital"></i>王城
</span>
<span id="mapInfo" class="mono small"></span>
</div>
</div>
<div class="card card-log">
<div class="card-head">
<h2>运行日志</h2>
<div class="row">
<div class="card card-log" id="panelLogs">
<div class="card-head card-head-wrap">
<div class="tabs tabs-inline" id="logTabs">
<button class="tab active" data-pane="log">运行日志</button>
<button class="tab" data-pane="chat">公屏聊天</button>
<button class="tab" data-pane="exp">开拓日志</button>
</div>
<div class="row row-compact" id="logControls">
<label class="chk"><input type="checkbox" id="chkAutoScroll" checked> 自动滚动</label>
<button id="btnClearLog" class="btn btn-sm">清屏</button>
<button id="btnExportLog" class="btn btn-sm">导出日志</button>
<button id="btnExportLog" class="btn btn-sm">导出</button>
</div>
<div class="row row-compact hidden" id="chatControls">
<label class="chk"><input type="checkbox" id="chkChatAutoScroll" checked> 自动滚动</label>
<label class="chk"><input type="checkbox" id="chkChatAllBots"> 全部机器人</label>
<button id="btnClearChat" class="btn btn-sm">清屏</button>
<button id="btnExportChat" class="btn btn-sm">导出</button>
</div>
<div class="row row-compact hidden" id="expControls">
<label class="field-inline">
<span class="field-label">机器人</span>
<select id="expFilterBot" class="input input-sm"><option value="">全部</option></select>
</label>
<label class="chk"><input type="checkbox" id="chkExpAutoScroll" checked> 自动滚动</label>
<button id="btnExportExp" class="btn btn-sm">导出 CSV</button>
<button id="btnClearExp" class="btn btn-sm">清屏</button>
</div>
</div>
<div id="logView" class="log-view"></div>
<div id="chatView" class="log-view hidden"></div>
<div id="expView" class="log-view log-table hidden">
<table class="exp-table">
<thead>
<tr><th>时间</th><th>机器人</th><th>邦国</th><th>区块</th><th>方块坐标</th><th>来源</th></tr>
</thead>
<tbody id="expBody"></tbody>
</table>
</div>
</div>
</section>
<!-- ==================== 右栏:配置 ==================== -->
<section class="column col-right">
<!-- ==================== 右栏:配置与控制 ==================== -->
<section class="column col-right" id="panelConfig">
<div class="card">
<div class="card-head">
<h2>控制台</h2>
<span class="mono small" id="consoleTarget">-</span>
</div>
<p class="hint" id="consoleHint">以 / 开头按游戏指令发送,否则按公屏消息发送。登录验证通过后才允许发送。</p>
<div id="verifyBanner" class="verify-banner hidden">
<strong>服务器正在等待验证码</strong>
<p>把验证码填进下面的输入框直接发送即可(当聊天内容发出去,不用加斜杠)。账号绑定过的服务器可能每次登录都要输一次。</p>
</div>
<div class="row row-compact">
<input id="cmdInput" class="input input-grow" type="text" placeholder="输入内容或 /指令,回车发送">
<button id="btnSendCmd" class="btn btn-sm btn-primary">发送</button>
</div>
<div class="row row-compact">
<span class="field-label">快捷发送</span>
<button class="btn btn-sm" data-cmd="/u return">/u return</button>
<button class="btn btn-sm" data-cmd="/u claimmode">/u claimmode</button>
<button class="btn btn-sm" data-cmd="/u restore confirm">/u restore confirm</button>
</div>
<div class="row row-compact">
<span class="field-label">移动测试</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>
<div class="card card-config">
<div class="card-head">
<h2>配置</h2>
<div class="row">
<button id="btnSaveConfig" class="btn btn-sm btn-primary">保存配置</button>
<div class="row row-compact">
<button id="btnSaveConfig" class="btn btn-sm btn-primary">保存</button>
<button id="btnReloadConfig" class="btn btn-sm">重新载入</button>
</div>
</div>
<label class="chk config-scope">
<input type="checkbox" id="chkBotOverride">
仅对当前机器人生效(不勾选则写入全局默认配置)
</label>
<p class="hint hidden" id="overrideHint">
<span id="overrideHintText"></span>
<button id="btnClearOverride" class="btn btn-sm">清除专属配置</button>
</p>
<div class="tabs" id="configTabs">
<button class="tab active" data-tab="strategy">策略</button>
<button class="tab" data-tab="timing">超时</button>
<button class="tab" data-tab="pathing">寻路</button>
<button class="tab" data-tab="survival">生存</button>
<button class="tab" data-tab="data">数据</button>
<button class="tab" data-tab="bigmap">大地图</button>
<button class="tab" data-tab="grim">Grim</button>
<button class="tab" data-tab="server">服务器</button>
</div>
<div class="field config-search">
<input id="configSearch" class="input" type="search" placeholder="搜索配置项(名称 / 字段名 / 说明)" autocomplete="off">
</div>
<div id="configBody" class="config-body"></div>
</div>
<div class="card">
<h2>手动指令</h2>
<div class="row">
<input id="cmdInput" class="input" type="text" placeholder="u claimmode(不带斜杠)">
<button id="btnSendCmd" class="btn btn-sm">发送</button>
</div>
<div class="row">
<button class="btn btn-sm" data-cmd="u return">/u return</button>
<button class="btn btn-sm" data-cmd="u claimmode">/u claimmode</button>
<button class="btn btn-sm" data-cmd="spawn">/spawn</button>
<button class="btn btn-sm" data-cmd="list">/list</button>
</div>
</div>
</section>
</main>
<!-- SimpCore 开放 API 数据浏览:数据量大且限流严格,做成按需打开的弹层而不是常驻面板 -->
<div id="simpOverlay" class="overlay hidden">
<div class="overlay-box overlay-box-lg">
<div class="card-head">
<h2>SimpCore 开放 API</h2>
<div class="row row-compact">
<span id="simpStatus" class="mono small"></span>
<button id="btnCloseSimp" class="btn btn-sm">关闭</button>
</div>
</div>
<div class="tabs" id="simpTabs">
<button class="tab active" data-simp="overview">概览</button>
<button class="tab" data-simp="unions">邦国</button>
<button class="tab" data-simp="players">玩家</button>
<button class="tab" data-simp="global">全服</button>
<button class="tab" data-simp="v2">实时 V2</button>
</div>
<div id="simpBody" class="simp-body"></div>
<div id="simpV2Result" class="simp-detail hidden"></div>
</div>
</div>
<!-- 登录遮罩:控制台配置了 WEB_TOKEN / WEB_PASSWORD 时显示 -->
<div id="loginOverlay" class="overlay hidden">
<div class="overlay-box">
<h2>控制台登录</h2>
<p class="hint">验证通过后才允许操作各机器人的控制台、聊天框与扩地任务。</p>
<input id="loginPassword" class="input" type="password" placeholder="控制台密码 / 令牌" autocomplete="current-password">
<div class="row">
<button id="btnLogin" class="btn btn-primary btn-grow">登录</button>
<button id="btnLogout" class="btn">退出登录</button>
</div>
<p id="loginError" class="hint error-text"></p>
</div>
</div>
<div id="toast" class="toast"></div>
<script src="app.js"></script>
</body>
+453 -91
查看文件
@@ -1,16 +1,38 @@
/* ==========================================================================
AutoStore Bot 控制台样式
设计要点:
- 深色主题,卡片式布局,三层栅格(左状态 / 中地图日志 / 右配置)
- 响应式:≤1200px 收成两栏,≤820px 收成单栏并启用底部导航
- 移动端:触控目标 ≥ 36px、地图自适应高度、底部安全区避让
========================================================================== */
:root {
--bg: #0d1117;
--panel: #161b22;
--panel-2: #1c2330;
--border: #30363d;
--text: #c9d1d9;
--muted: #8b949e;
--bg: #0b0f16;
--bg-soft: #10151d;
--panel: #151b25;
--panel-2: #1b2230;
--panel-3: #212a3a;
--border: #2b3444;
--border-soft: #222a37;
--text: #d7dee8;
--text-dim: #9aa7b8;
--muted: #6b7787;
--accent: #e94560;
--accent-2: #2f81f7;
--accent-hover: #f2586f;
--accent-2: #3b8ef7;
--accent-2-hover: #5aa0f9;
--green: #3fb950;
--yellow: #d29922;
--yellow: #d6a01f;
--red: #f85149;
--purple: #bc8cff;
--radius-sm: 6px;
--radius: 10px;
--radius-lg: 14px;
--shadow: 0 1px 2px rgba(0, 0, 0, .3), 0 8px 24px -12px rgba(0, 0, 0, .5);
--gap: 12px;
--topbar-h: 58px;
}
* { box-sizing: border-box; }
@@ -18,114 +40,141 @@
html, body {
margin: 0;
padding: 0;
height: 100%;
background: var(--bg);
min-height: 100%;
background: radial-gradient(1200px 600px at 20% -10%, #16202e 0%, var(--bg) 55%);
color: var(--text);
font-family: "Segoe UI", "Microsoft YaHei", system-ui, -apple-system, sans-serif;
font-family: "Segoe UI", "Microsoft YaHei", system-ui, -apple-system, "Helvetica Neue", sans-serif;
font-size: 14px;
line-height: 1.5;
-webkit-text-size-adjust: 100%;
}
.mono { font-family: Consolas, "Cascadia Mono", monospace; }
.mono { font-family: Consolas, "Cascadia Mono", ui-monospace, monospace; }
.small { font-size: 12px; }
.hidden { display: none !important; }
.error-text { color: var(--red); }
/* ==================== 顶栏 ==================== */
.topbar {
position: sticky;
top: 0;
z-index: 30;
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 18px;
background: linear-gradient(90deg, #161b22, #1b2230);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 10;
gap: 12px;
flex-wrap: wrap;
padding: 10px 16px;
background: rgba(16, 21, 29, .92);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--border-soft);
}
.brand { display: flex; align-items: center; gap: 12px; }
.brand { display: flex; align-items: center; gap: 11px; min-width: 0; }
.logo {
width: 38px; height: 38px;
width: 36px; height: 36px;
display: grid; place-items: center;
border-radius: 9px;
background: var(--accent);
color: #fff; font-weight: 700; letter-spacing: 1px;
border-radius: var(--radius);
background: linear-gradient(135deg, var(--accent), #b5324a);
color: #fff; font-weight: 700; letter-spacing: .5px;
box-shadow: 0 4px 14px -6px rgba(233, 69, 96, .9);
flex: 0 0 auto;
}
.brand h1 { font-size: 16px; margin: 0; }
.brand p { font-size: 12px; margin: 2px 0 0; color: var(--muted); }
.topbar-right { display: flex; align-items: center; gap: 8px; }
.brand-text { min-width: 0; }
.brand h1 { font-size: 15px; margin: 0; white-space: nowrap; }
.brand p { font-size: 11.5px; margin: 1px 0 0; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.topbar-right { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.badges { display: flex; gap: 6px; flex-wrap: wrap; }
.topbar-actions { display: flex; gap: 6px; flex-wrap: wrap; }
.badge {
padding: 4px 10px;
padding: 3px 10px;
border-radius: 999px;
font-size: 12px;
font-size: 11.5px;
border: 1px solid var(--border);
background: var(--panel-2);
white-space: nowrap;
}
.badge-online { color: var(--green); border-color: #1f6f34; }
.badge-online { color: var(--green); border-color: #1f6f34; background: rgba(63, 185, 80, .1); }
.badge-offline { color: var(--muted); }
.badge-running { color: var(--accent-2); border-color: #1f4f8f; }
.badge-running { color: var(--accent-2); border-color: #1f4f8f; background: rgba(59, 142, 247, .12); }
.badge-idle { color: var(--muted); }
.badge-error { color: var(--red); border-color: #8f2b26; }
/* ==================== 按钮 ==================== */
.btn {
padding: 6px 12px;
border-radius: 6px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--panel-2);
color: var(--text);
cursor: pointer;
font-size: 13px;
transition: background .15s, border-color .15s;
font-family: inherit;
white-space: nowrap;
transition: background .15s, border-color .15s, transform .06s, opacity .15s;
}
.btn:hover { background: #232b39; border-color: #48505e; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn-primary:hover { background: #d63851; border-color: #d63851; }
.btn-danger { background: #3a1d22; border-color: #7a2b34; color: #ff8b98; }
.btn-danger:hover { background: #4a2329; }
.btn:hover:not(:disabled) { background: var(--panel-3); border-color: #3c4759; }
.btn:active:not(:disabled) { transform: translateY(1px); }
.btn:disabled { opacity: .55; cursor: not-allowed; }
.btn-primary { background: var(--accent-2); border-color: var(--accent-2); color: #fff; }
.btn-primary:hover:not(:disabled) { background: var(--accent-2-hover); border-color: var(--accent-2-hover); }
.btn-danger { background: rgba(248, 81, 73, .12); border-color: #7a2b34; color: #ff9aa2; }
.btn-danger:hover:not(:disabled) { background: rgba(248, 81, 73, .2); }
.btn-sm { padding: 4px 9px; font-size: 12px; }
.btn-block { width: 100%; }
.btn-grow { flex: 1 1 auto; }
/* ==================== 布局 ==================== */
.layout {
display: grid;
grid-template-columns: 300px minmax(420px, 1fr) 360px;
gap: 12px;
padding: 12px;
grid-template-columns: 320px minmax(420px, 1fr) 380px;
gap: var(--gap);
padding: var(--gap);
align-items: start;
}
.column { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.column { display: flex; flex-direction: column; gap: var(--gap); min-width: 0; }
/* ==================== 卡片 ==================== */
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px;
background: linear-gradient(180deg, var(--panel) 0%, var(--bg-soft) 100%);
border: 1px solid var(--border-soft);
border-radius: var(--radius-lg);
padding: 13px;
box-shadow: var(--shadow);
}
.card h2 {
font-size: 13px;
margin: 0 0 8px;
color: var(--muted);
font-size: 12px;
margin: 0 0 9px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: .6px;
letter-spacing: .7px;
font-weight: 600;
}
.card-head {
display: flex; align-items: center; justify-content: space-between;
gap: 8px; margin-bottom: 8px;
gap: 8px; margin-bottom: 9px;
}
.card-head h2 { margin: 0; }
.card-head-wrap { flex-wrap: wrap; }
.row { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-top: 8px; }
.row-compact { margin-top: 6px; }
/* ==================== 键值表 ==================== */
.kv {
display: grid;
grid-template-columns: 88px 1fr;
grid-template-columns: 92px 1fr;
gap: 4px 8px;
margin: 0;
font-size: 13px;
}
.kv dt { color: var(--muted); }
.kv dd { margin: 0; word-break: break-all; }
.kv dd { margin: 0; word-break: break-word; }
.progress {
margin-top: 10px; height: 6px; border-radius: 3px;
background: #0b0f14; overflow: hidden;
background: #080b11; overflow: hidden;
}
.progress-fill {
height: 100%; width: 0%;
@@ -136,104 +185,417 @@ html, body {
.issues { margin: 8px 0 0; padding-left: 18px; color: var(--yellow); font-size: 12px; }
.issues li { margin-bottom: 3px; }
/* ==================== 机器人列表 ==================== */
.bot-list { display: flex; flex-direction: column; gap: 6px; }
.bot-item {
display: grid;
grid-template-columns: 10px minmax(0, 1fr) auto;
gap: 9px; align-items: center;
padding: 8px 10px;
border: 1px solid var(--border-soft);
border-radius: var(--radius);
background: var(--panel-2);
cursor: pointer;
transition: border-color .15s, background .15s;
}
.bot-item:hover { border-color: #3c4759; }
.bot-item.active { border-color: var(--accent-2); background: rgba(59, 142, 247, .08); }
.bot-item .dot { width: 9px; height: 9px; border-radius: 50%; background: var(--muted); }
.bot-item .dot.online { background: var(--green); box-shadow: 0 0 8px rgba(63, 185, 80, .7); }
.bot-item .dot.connecting { background: var(--yellow); }
.bot-item .dot.offline { background: var(--red); }
.bot-item .dot.disabled { background: #454e5e; }
.bot-item .name { font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.bot-item .sub { font-size: 11px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.bot-item .meta { font-size: 11px; color: var(--muted); text-align: right; white-space: nowrap; }
.bot-item .remove {
margin-left: 6px; padding: 0 6px;
border: 1px solid var(--border); border-radius: 4px;
background: transparent; color: var(--muted); cursor: pointer;
font-size: 13px; line-height: 18px;
}
.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 summary {
cursor: pointer; font-size: 12.5px; color: var(--accent-2);
list-style: none; user-select: none;
}
.add-bot summary::-webkit-details-marker { display: none; }
.add-bot summary::before { content: '▸ '; }
.add-bot[open] summary::before { content: '▾ '; }
.add-bot .field { margin-top: 8px; }
.add-bot .hint { font-size: 11px; color: var(--muted); margin: 8px 0 0; }
/* ==================== 定时消息 ==================== */
.schedule-list { display: flex; flex-direction: column; gap: 6px; }
.schedule-item {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
gap: 8px; align-items: center;
padding: 7px 9px;
border: 1px solid var(--border-soft);
border-radius: var(--radius);
background: var(--panel-2);
font-size: 12.5px;
}
.schedule-item.off { opacity: .55; }
.schedule-item .content { min-width: 0; }
.schedule-item .content code {
display: block;
font-family: Consolas, ui-monospace, monospace;
color: var(--text);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.schedule-item .sub { font-size: 11px; color: var(--muted); }
.schedule-item .remove {
padding: 0 6px; border: 1px solid var(--border); border-radius: 4px;
background: transparent; color: var(--muted); cursor: pointer; line-height: 18px;
}
.schedule-item .remove:hover { color: var(--red); border-color: #7a2b34; }
.schedule-empty { font-size: 12px; color: var(--muted); padding: 4px 0; }
.schedule-editor {
margin-top: 10px; padding-top: 10px;
border-top: 1px dashed var(--border);
}
.schedule-editor .hint { font-size: 11px; color: var(--muted); margin: 6px 0 0; }
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
/* ==================== 地图 ==================== */
.card-map { display: flex; flex-direction: column; }
#mapCanvas {
width: 100%;
height: 380px;
height: clamp(300px, 46vh, 520px);
background: #05070a;
border: 1px solid var(--border);
border-radius: 8px;
border: 1px solid var(--border-soft);
border-radius: var(--radius);
display: block;
cursor: grab;
touch-action: none;
}
#mapCanvas:active { cursor: grabbing; }
.map-legend {
display: flex; gap: 14px; flex-wrap: wrap;
margin-top: 8px; font-size: 12px; color: var(--muted);
display: flex; gap: 12px; flex-wrap: wrap;
margin-top: 8px; font-size: 11.5px; color: var(--muted);
align-items: center;
}
.sw {
display: inline-block; width: 10px; height: 10px;
border-radius: 2px; margin-right: 4px; vertical-align: -1px;
}
.sw-self { background: #2f81f7; }
.sw-self { background: #3b8ef7; }
.sw-session { background: #3fb950; }
.sw-flying { background: #f85149; }
.sw-target { background: #d29922; }
.sw-target { background: #d6a01f; }
.sw-player { background: #ffffff; }
.sw-capital { background: #bc8cff; }
.sw-bot { background: #ffd33d; }
.chk { display: flex; align-items: center; gap: 4px; font-size: 12px; color: var(--muted); cursor: pointer; }
/* ==================== 日志 ==================== */
/* ==================== 日志 / 聊天 / 表格 ==================== */
.card-log { display: flex; flex-direction: column; }
.log-view {
height: 260px;
height: clamp(220px, 34vh, 380px);
overflow-y: auto;
background: #05070a;
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px;
font-family: Consolas, "Cascadia Mono", monospace;
border: 1px solid var(--border-soft);
border-radius: var(--radius);
padding: 8px 10px;
font-family: Consolas, "Cascadia Mono", ui-monospace, monospace;
font-size: 12px;
line-height: 1.55;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
word-break: break-word;
overscroll-behavior: contain;
}
.log-line { display: block; }
.log-time { color: #586069; margin-right: 6px; }
.log-time { color: #55606f; margin-right: 6px; }
.log-info { color: var(--text); }
.log-warn { color: var(--yellow); }
.log-error { color: var(--red); }
.log-success { color: var(--green); }
/* ==================== 配置 ==================== */
/*
* 日志 / 公屏行很多时(移动端尤其明显):让浏览器跳过「视口外」行的渲染与布局。
* 相当于浏览器自带的虚拟列表 —— 不改变内容与滚动位置,只是不再为看不见的行做绘制。
* 不支持的浏览器会忽略这两行,行为与原来一致。
*/
.log-line,
.chat-line {
content-visibility: auto;
contain-intrinsic-size: auto 20px;
}
.chat-line { display: block; }
.chat-time { color: #55606f; margin-right: 6px; }
.chat-bot { color: var(--accent-2); margin-right: 4px; }
.chat-user { color: var(--purple); margin-right: 4px; }
.chat-out { color: var(--green); }
.chat-command { color: var(--yellow); }
.chat-system { color: var(--text-dim); }
.chat-action { color: var(--muted); }
.log-table { white-space: normal; padding: 0; }
.exp-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.exp-table th {
position: sticky; top: 0; z-index: 1;
text-align: left; color: var(--text-dim); font-weight: 600;
background: #05070a; padding: 5px 7px;
border-bottom: 1px solid var(--border);
}
.exp-table td { padding: 3px 7px; border-bottom: 1px solid #10151d; white-space: nowrap; }
.exp-table tbody tr:hover { background: #0d1219; }
.exp-empty { color: var(--muted); padding: 8px; }
/* ==================== 表单 ==================== */
.card-config { display: flex; flex-direction: column; }
.tabs { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 8px; }
.config-scope { margin-bottom: 8px; }
/* 需要验证码时的醒目提示 */
.verify-banner {
margin: 8px 0;
padding: 10px 12px;
border: 1px solid #7a5a18;
border-left: 3px solid var(--yellow);
border-radius: var(--radius);
background: rgba(214, 160, 31, .12);
font-size: 12.5px;
}
.verify-banner strong { color: var(--yellow); display: block; margin-bottom: 3px; }
.verify-banner p { margin: 0; color: var(--text-dim); line-height: 1.5; }
.tabs { display: flex; gap: 5px; flex-wrap: wrap; margin-bottom: 9px; }
.tabs-inline { margin-bottom: 0; }
.tab {
padding: 4px 10px; font-size: 12px; cursor: pointer;
background: var(--panel-2); border: 1px solid var(--border);
border-radius: 6px; color: var(--muted);
background: var(--panel-2); border: 1px solid var(--border-soft);
border-radius: 999px; color: var(--muted);
font-family: inherit;
transition: background .15s, color .15s, border-color .15s;
}
.tab:hover { color: var(--text); }
.tab.active { color: #fff; background: var(--accent-2); border-color: var(--accent-2); }
.config-body { max-height: 520px; overflow-y: auto; }
.config-body { max-height: min(58vh, 560px); overflow-y: auto; padding-right: 2px; }
.field { margin-bottom: 9px; }
.field label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 3px; }
.field .hint { font-size: 11px; color: #5d6672; margin-top: 2px; }
.field .hint { font-size: 11px; color: var(--muted); margin-top: 3px; line-height: 1.45; }
.input, select.input {
width: 100%; padding: 5px 8px;
background: #0b0f14; border: 1px solid var(--border);
border-radius: 6px; color: var(--text); font-size: 13px;
width: 100%; padding: 6px 9px;
background: #080b11; border: 1px solid var(--border);
border-radius: var(--radius-sm); color: var(--text); font-size: 13px;
font-family: inherit;
outline: none;
transition: border-color .15s, box-shadow .15s;
}
.input:focus { border-color: var(--accent-2); }
.field-inline { display: flex; align-items: center; gap: 8px; }
.field-inline input[type=checkbox] { width: 15px; height: 15px; }
.input:focus { border-color: var(--accent-2); box-shadow: 0 0 0 3px rgba(59, 142, 247, .15); }
.field-inline { display: flex; align-items: center; gap: 6px; }
.field-inline input[type=checkbox] { width: 15px; height: 15px; accent-color: var(--accent-2); }
.field-label { font-size: 12px; color: var(--muted); white-space: nowrap; }
.input-sm { width: auto; min-width: 104px; padding: 4px 8px; font-size: 12px; }
.input-grow { flex: 1 1 120px; min-width: 0; width: auto; }
.group-title {
font-size: 12px; color: var(--accent-2);
margin: 12px 0 6px; padding-bottom: 4px;
margin: 13px 0 7px; padding-bottom: 4px;
border-bottom: 1px dashed var(--border);
font-weight: 600;
}
.group-title:first-child { margin-top: 0; }
.chk { display: flex; align-items: center; gap: 5px; font-size: 12px; color: var(--text-dim); cursor: pointer; }
.chk input[type=checkbox] { accent-color: var(--accent-2); }
/* ==================== 提示 ==================== */
.toast {
position: fixed; right: 18px; bottom: 18px;
background: var(--panel-2); border: 1px solid var(--border);
position: fixed; right: 16px; bottom: 16px;
background: var(--panel-3); border: 1px solid var(--border);
border-left: 3px solid var(--accent-2);
padding: 10px 14px; border-radius: 8px;
font-size: 13px; max-width: 380px;
padding: 10px 14px; border-radius: var(--radius);
font-size: 13px; max-width: min(380px, calc(100vw - 32px));
opacity: 0; transform: translateY(8px);
transition: opacity .2s, transform .2s;
pointer-events: none;
z-index: 100;
box-shadow: var(--shadow);
}
.toast.show { opacity: 1; transform: translateY(0); }
.toast.error { border-left-color: var(--red); }
@media (max-width: 1280px) {
.layout { grid-template-columns: 1fr; }
.config-body { max-height: none; }
/* ==================== 登录遮罩 ==================== */
.overlay {
position: fixed; inset: 0; z-index: 200;
display: grid; place-items: center;
padding: 16px;
background: rgba(5, 7, 10, .8);
backdrop-filter: blur(3px);
}
.overlay-box {
width: min(380px, 100%);
background: linear-gradient(180deg, var(--panel) 0%, var(--bg-soft) 100%);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 20px;
box-shadow: var(--shadow);
}
.overlay-box h2 { font-size: 16px; margin: 0 0 6px; color: var(--text); text-transform: none; letter-spacing: 0; }
.overlay-box .input { margin: 12px 0; }
/* ==================== SimpCore API 数据弹层 ==================== */
.overlay-box-lg {
width: min(1100px, 100%);
max-height: min(86vh, 900px);
display: flex;
flex-direction: column;
padding: 16px;
}
.overlay-box-lg .card-head h2 { text-transform: none; letter-spacing: 0; color: var(--text); font-size: 15px; }
.simp-body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
margin-top: 10px;
padding-right: 2px;
}
.simp-section { margin-bottom: 14px; }
.simp-section h3 {
font-size: 12.5px;
color: var(--accent-2);
margin: 0 0 7px;
padding-bottom: 4px;
border-bottom: 1px dashed var(--border);
}
.simp-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 8px;
}
.simp-stat {
padding: 9px 11px;
border: 1px solid var(--border-soft);
border-radius: var(--radius);
background: var(--panel-2);
}
.simp-stat .label { font-size: 11px; color: var(--muted); }
.simp-stat .value { font-size: 15px; margin-top: 2px; word-break: break-word; }
.simp-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
.simp-table th {
position: sticky; top: 0; z-index: 1;
text-align: left; color: var(--text-dim); font-weight: 600;
background: var(--panel-2); padding: 6px 8px;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.simp-table td { padding: 5px 8px; border-bottom: 1px solid #10151d; }
.simp-table tbody tr:hover { background: #0d1219; }
.simp-table tbody tr.clickable { cursor: pointer; }
.simp-table .num { text-align: right; font-variant-numeric: tabular-nums; }
.simp-table .mono { font-size: 11.5px; }
.simp-empty { color: var(--muted); padding: 10px 2px; font-size: 12.5px; }
.simp-error { color: var(--red); font-size: 12.5px; padding: 8px 2px; }
.simp-detail {
margin-top: 10px; padding: 10px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: #0d1219;
}
.simp-detail h4 { margin: 0 0 8px; font-size: 13px; }
.simp-detail .close-row { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
.simp-sub { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 9px; }
.tag {
display: inline-block; padding: 1px 7px; border-radius: 999px;
font-size: 11px; border: 1px solid var(--border); color: var(--text-dim);
}
.tag-owner { color: var(--yellow); border-color: #6b5518; }
.tag-admin { color: var(--accent-2); border-color: #1f4f8f; }
/* ==================== 移动端底部导航 ==================== */
.mobile-nav { display: none; }
/* ==================== 响应式 ==================== */
/* 中等屏幕:配置栏移到下方,两栏布局 */
@media (max-width: 1200px) {
.layout { grid-template-columns: minmax(280px, 320px) minmax(0, 1fr); }
.col-right { grid-column: 1 / -1; }
.col-right { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); align-items: start; }
.config-body { max-height: min(46vh, 460px); }
}
/* 平板 / 手机:单栏 + 底部导航 */
@media (max-width: 820px) {
:root { --gap: 10px; }
.topbar {
padding: 8px 12px;
gap: 8px;
align-items: center;
}
.brand p { display: none; }
.topbar-right { width: 100%; justify-content: space-between; }
.topbar-right .field-inline { flex: 1 1 100%; }
.topbar-right .field-inline select { flex: 1 1 auto; width: auto; }
.topbar-actions { width: 100%; }
.topbar-actions .btn { flex: 1 1 0; text-align: center; }
.badges { width: 100%; }
.layout {
grid-template-columns: 1fr;
padding: 10px 10px calc(64px + env(safe-area-inset-bottom, 0px));
}
.col-right { grid-column: auto; display: flex; }
/* 底部导航:把「状态 / 地图 / 日志 / 配置」四块一键定位 */
.mobile-nav {
display: flex;
position: fixed;
left: 0; right: 0; bottom: 0;
z-index: 40;
background: rgba(16, 21, 29, .96);
backdrop-filter: blur(10px);
border-top: 1px solid var(--border-soft);
padding-bottom: env(safe-area-inset-bottom, 0px);
}
.mobile-nav-item {
flex: 1 1 0;
padding: 11px 4px;
background: transparent;
border: none;
color: var(--muted);
font-size: 12px;
font-family: inherit;
cursor: pointer;
border-top: 2px solid transparent;
}
.mobile-nav-item.active { color: var(--accent-2); border-top-color: var(--accent-2); }
/* 触控目标放大 */
.btn { padding: 8px 13px; font-size: 13px; }
.btn-sm { padding: 6px 11px; font-size: 12.5px; }
.input, select.input { padding: 8px 10px; font-size: 14px; }
.input-sm { padding: 6px 9px; font-size: 12.5px; }
.tab { padding: 6px 12px; font-size: 12.5px; }
#mapCanvas { height: clamp(260px, 52vh, 420px); }
.log-view { height: clamp(240px, 46vh, 400px); font-size: 12.5px; }
.config-body { max-height: none; }
.card { padding: 12px; }
.kv { grid-template-columns: 84px 1fr; }
.toast { left: 12px; right: 12px; bottom: calc(64px + env(safe-area-inset-bottom, 0px)); max-width: none; }
}
@media (max-width: 420px) {
.brand h1 { font-size: 14px; }
.schedule-item { grid-template-columns: auto minmax(0, 1fr) auto; }
.schedule-item .next { display: none; }
.field-row { grid-template-columns: 1fr; }
}
/* 横屏手机:地图压矮一点,避免看不到下面的日志 */
@media (max-height: 520px) and (orientation: landscape) {
#mapCanvas { height: 46vh; }
.log-view { height: 40vh; }
}
/* 尊重「减少动效」偏好 */
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; animation: none !important; }
}
+1141 -55
查看文件
文件差异内容过多而无法显示 加载差异
+282
查看文件
@@ -0,0 +1,282 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { BigMapService, TILE_PIXELS, DEFAULT_MAX_ZOOM } = require('../src/core/bigMapService');
const { defaultConfig } = require('../src/config/expansionConfig');
/** 造一张假的 PNG 内容(只需非空即可,服务端只做字节透传) */
function fakePng(size) {
const buffer = Buffer.alloc(size || 64);
buffer[0] = 0x89;
buffer[1] = 0x50;
return buffer;
}
/** 建一个使用临时缓存目录、带假 fetch 的大地图服务 */
function setup(options) {
const opts = options || {};
const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-bigmap-'));
const config = defaultConfig();
Object.assign(config.bigMap, opts.bigMap || {});
const calls = [];
const fetchImpl = opts.fetchImpl || (async (url) => {
calls.push(url);
if (opts.failAll) return { ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) };
const data = fakePng(opts.tileSize || 64);
return { ok: true, status: 200, arrayBuffer: async () => data.buffer.slice(data.byteOffset, data.byteOffset + data.length) };
});
const service = new BigMapService(config, null, {
baseUrl: 'http://bgjq.simpfun.cn',
world: 'minecraft_overworld',
cacheDir,
fetchImpl
});
return { service, config, cacheDir, calls };
}
test('坐标系:最大层级 1 方块 = 1 像素,逐级翻倍', () => {
const { service } = setup();
assert.strictEqual(service.maxZoom, DEFAULT_MAX_ZOOM);
assert.strictEqual(service.blocksPerTile(3), TILE_PIXELS, '最大层级每张瓦片覆盖 512 方块');
assert.strictEqual(service.blocksPerTile(2), TILE_PIXELS * 2);
assert.strictEqual(service.blocksPerTile(1), TILE_PIXELS * 4);
assert.strictEqual(service.blocksPerTile(0), TILE_PIXELS * 8, '层级 0 每张瓦片覆盖 4096 方块');
});
test('坐标系:王城 (10722, 1547) 落在 z=3 的瓦片 (20, 3) 内(对源站实测标定)', () => {
const { service } = setup();
const bounds = service.tileBlockBounds(3, 20, 3);
assert.deepStrictEqual(bounds, { minBlockX: 10240, maxBlockX: 10752, minBlockZ: 1536, maxBlockZ: 2048 });
assert.ok(10722 >= bounds.minBlockX && 10722 < bounds.maxBlockX);
assert.ok(1547 >= bounds.minBlockZ && 1547 < bounds.maxBlockZ);
});
test('tileRange 覆盖 ±rangeBlocks:层级 0 全世界为 10×10=100 张', () => {
const { service } = setup();
const range = service.tileRange(0, 20480, 0, 0);
assert.strictEqual(range.minX, -5);
assert.strictEqual(range.maxX, 4);
assert.strictEqual(range.minZ, -5);
assert.strictEqual(range.maxZ, 4);
assert.strictEqual(range.count, 100);
// 瓦片范围必须完整覆盖 -20480..20480
assert.strictEqual(range.minX * service.blocksPerTile(0), -20480);
assert.strictEqual((range.maxX + 1) * service.blocksPerTile(0), 20480);
});
test('tileRange 支持以机器人位置为中心:图幅越小爬的瓦片越少', () => {
const { service } = setup();
// 层级 0 每张瓦片 4096 格:半径 20480 → 100 张,4096 → 4 张
assert.strictEqual(service.tileRange(0, 20480, 0, 0).count, 100);
assert.strictEqual(service.tileRange(0, 4096, 0, 0).count, 4);
// 半径 1024 且中心落在某张瓦片正中(瓦片 4 覆盖 16384..20480,中心 18432)→ 只要 1 张
const centered = service.tileRange(0, 1024, 18432, 18432);
assert.strictEqual(centered.count, 1);
assert.strictEqual(centered.minX, 4);
assert.strictEqual(centered.minZ, 4);
assert.strictEqual(centered.centerBlockX, 18432);
// 该瓦片必须真的覆盖机器人坐标
const bounds = service.tileBlockBounds(0, centered.minX, centered.minZ);
assert.ok(18432 >= bounds.minBlockX && 18432 < bounds.maxBlockX);
assert.ok(18432 >= bounds.minBlockZ && 18432 < bounds.maxBlockZ);
});
test('tileRange 中心移到远处时瓦片下标整体平移,不再盯着世界原点', () => {
const { service } = setup();
const atOrigin = service.tileRange(0, 4096, 0, 0);
const atBot = service.tileRange(0, 4096, 20000, -20000);
assert.notStrictEqual(atOrigin.minX, atBot.minX);
// 机器人所在的瓦片(正 X、负 Z 象限)必须落在范围内
const tileX = Math.floor(20000 / 4096);
const tileZ = Math.floor(-20000 / 4096);
assert.ok(tileX >= atBot.minX && tileX <= atBot.maxX, '瓦片 X 下标应覆盖机器人');
assert.ok(tileZ >= atBot.minZ && tileZ <= atBot.maxZ, '瓦片 Z 下标应覆盖机器人');
assert.ok(tileX > 0 && tileZ < 0, '机器人位于正 X、负 Z 象限');
const bounds = service.tileBlockBounds(0, tileX, tileZ);
assert.ok(20000 >= bounds.minBlockX && 20000 < bounds.maxBlockX);
});
test('tileRange 缺省中心为世界原点', () => {
const { service } = setup();
const range = service.tileRange(0, 4096);
assert.strictEqual(range.centerBlockX, 0);
assert.strictEqual(range.centerBlockZ, 0);
});
test('tileUrl / tilePath 拼接源站地址与磁盘缓存路径', () => {
const { service } = setup();
assert.strictEqual(
service.tileUrl(2, -3, 7),
'http://bgjq.simpfun.cn/tiles/minecraft_overworld/2/-3_7.png'
);
assert.ok(service.tilePath(2, -3, 7).endsWith(path.join('minecraft_overworld', '2', '-3_7.png')));
});
test('getTile 首次回源并落盘,第二次命中磁盘不再回源', async () => {
const { service, calls } = setup();
const first = await service.getTile(1, 2, 3);
assert.ok(first && first.length > 0);
assert.strictEqual(calls.length, 1);
// 清掉内存缓存,验证磁盘缓存生效
service._memory.clear();
const second = await service.getTile(1, 2, 3);
assert.ok(second && second.length > 0);
assert.strictEqual(calls.length, 1, '第二次不应再回源');
});
test('getTile 回源失败返回 null(控制台画占位色块)', async () => {
const { service } = setup({ failAll: true });
assert.strictEqual(await service.getTile(0, 0, 0), null);
});
test('crawlAll 批量爬取范围内全部瓦片并统计进度', async () => {
const { service, calls } = setup({ bigMap: { rangeBlocks: 4096 } });
const result = await service.crawlAll(0);
// rangeBlocks=4096、层级 0 每张 4096 方块 → 2×2=4 张
assert.strictEqual(result.total, 4);
assert.strictEqual(result.done, 4);
assert.strictEqual(result.failed, 0);
assert.strictEqual(result.running, false);
assert.strictEqual(calls.length, 4);
assert.strictEqual(service.countCached(0), 4);
});
test('crawlAll 已缓存的瓦片不重复下载', async () => {
const { service, calls } = setup({ bigMap: { rangeBlocks: 4096 } });
await service.crawlAll(0);
assert.strictEqual(calls.length, 4);
await service.crawlAll(0);
assert.strictEqual(calls.length, 4, '第二次爬取应全部命中缓存');
});
test('crawlAll 统计失败张数', async () => {
const { service } = setup({ failAll: true, bigMap: { rangeBlocks: 4096 } });
const result = await service.crawlAll(0);
assert.strictEqual(result.total, 4);
assert.strictEqual(result.done, 0);
assert.strictEqual(result.failed, 4);
});
test('crawlAll 支持以指定中心爬取(只拉机器人周边那几张)', async () => {
const { service, calls } = setup();
const result = await service.crawlAll(0, {
centerBlockX: 18432,
centerBlockZ: 18432,
rangeBlocks: 1024
});
assert.strictEqual(result.total, 1, '中心落在瓦片正中且半径 1024 时只需 1 张瓦片');
assert.strictEqual(result.done, 1);
assert.strictEqual(result.centerBlockX, 18432);
assert.strictEqual(result.rangeBlocks, 1024);
// 只请求了机器人所在的那一张,而不是世界原点附近
assert.strictEqual(calls.length, 1);
assert.ok(calls[0].includes('/4_4.png'), '实际请求的瓦片 URL:' + calls[0]);
});
test('snapshot 暴露坐标系、瓦片范围与缓存数量', () => {
const { service } = setup({ bigMap: { rangeBlocks: 20480, zoom: 0 } });
const snap = service.snapshot();
assert.strictEqual(snap.world, 'minecraft_overworld');
assert.strictEqual(snap.tilePixels, TILE_PIXELS);
assert.strictEqual(snap.blocksPerTile, 4096);
assert.strictEqual(snap.tiles.count, 100);
assert.strictEqual(snap.cached, 0);
assert.ok(snap.source.tileUrlTemplate.includes('{z}/{x}_{y}.png'));
});
test('snapshot 接受中心参数,瓦片范围随机器人位置变化', () => {
const { service } = setup({ bigMap: { rangeBlocks: 1024, zoom: 0 } });
const atOrigin = service.snapshot({ centerBlockX: 0, centerBlockZ: 0 });
const atBot = service.snapshot({ centerBlockX: 18432, centerBlockZ: 18432 });
assert.strictEqual(atBot.tiles.count, 1, '中心落在瓦片正中时只要 1 张');
assert.notStrictEqual(atOrigin.tiles.minX, atBot.tiles.minX, '中心不同,瓦片下标必须不同');
assert.strictEqual(atBot.tiles.centerBlockX, 18432);
assert.strictEqual(atBot.tiles.minX, 4);
});
test('clearCache 清空磁盘与内存缓存', async () => {
const { service } = setup({ bigMap: { rangeBlocks: 4096 } });
await service.crawlAll(0);
assert.strictEqual(service.countCached(0), 4);
const removed = service.clearCache();
assert.strictEqual(removed, 4);
assert.strictEqual(service.countCached(0), 0);
assert.strictEqual(service._memory.size, 0);
});
test('配置里 zoom 大于 maxZoom 时被钳制(避免爬到源站不存在的瓦片)', () => {
const { service, config } = setup({ bigMap: { zoom: 7, maxZoom: 3 } });
// 直接走一次配置归一化,验证钳制逻辑
const { normalizeConfig } = require('../src/config/expansionConfig');
const normalized = normalizeConfig(config);
assert.strictEqual(normalized.bigMap.zoom, 3);
assert.strictEqual(service.maxZoom, 3);
});
// ==================== 后台预热(机器人移动后顺带补高层级瓦片)====================
test('prewarmAround 只预热比「启动预爬取层级」更高的层级', async () => {
const { service, calls } = setup({ bigMap: { zoom: 0, prewarmLevels: 2, prewarmRangeBlocks: 512 } });
const result = await service.prewarmAround(0, 0);
assert.strictEqual(result.skipped, undefined, '首次调用应真正预热');
assert.strictEqual(result.minZoom, 1);
assert.strictEqual(result.maxZoom, 2, '默认只预热上面两层');
assert.ok(result.done > 0);
for (const url of calls) {
assert.ok(/\/tiles\/minecraft_overworld\/[12]\//.test(url), '不应预热层级 0(批量爬取负责):' + url);
}
});
test('prewarmAround 在机器人没怎么移动时跳过(避免频繁请求源站)', async () => {
const { service, calls } = setup({ bigMap: { prewarmMinMoveBlocks: 512, prewarmIntervalMs: 600000 } });
await service.prewarmAround(1000, 1000);
const firstCalls = calls.length;
const again = await service.prewarmAround(1000, 1000);
assert.strictEqual(again.skipped, 'recent');
assert.strictEqual(calls.length, firstCalls, '跳过时不应再发起任何下载');
// 移动够远之后应该重新预热
const moved = await service.prewarmAround(2000, 2000);
assert.strictEqual(moved.skipped, undefined);
assert.ok(calls.length > firstCalls);
});
test('prewarmAround 可关闭,且批量爬取在途时让路', async () => {
const { service } = setup({ bigMap: { prewarm: false } });
assert.strictEqual((await service.prewarmAround(0, 0)).skipped, 'disabled');
const second = setup().service;
second.crawl.running = true;
assert.strictEqual((await second.prewarmAround(0, 0)).skipped, 'busy');
});
test('prewarmAround 覆盖半径越大、层级越多,需要的瓦片越多', async () => {
const { service: small } = setup({ bigMap: { prewarmLevels: 1, prewarmRangeBlocks: 256 } });
const smallResult = await small.prewarmAround(0, 0);
const { service: big } = setup({ bigMap: { prewarmLevels: 2, prewarmRangeBlocks: 2048 } });
const bigResult = await big.prewarmAround(0, 0);
assert.ok(bigResult.total > smallResult.total, `大图幅应预热更多瓦片:${bigResult.total} vs ${smallResult.total}`);
});
test('snapshot 暴露预热状态供控制台展示', async () => {
const { service } = setup();
await service.prewarmAround(0, 0);
const snapshot = service.snapshot();
assert.ok(snapshot.prewarm);
assert.strictEqual(snapshot.prewarm.running, false);
assert.ok(snapshot.prewarm.done > 0);
assert.strictEqual(snapshot.prewarm.lastCenterX, 0);
});
test('prewarmLevels 为 0 时不预热(只做启动批量爬取)', async () => {
const { service, calls } = setup({ bigMap: { prewarmLevels: 0 } });
const result = await service.prewarmAround(0, 0);
assert.strictEqual(result.skipped, 'no-levels');
assert.strictEqual(calls.length, 0);
});
文件差异内容过多而无法显示 加载差异
+494
查看文件
@@ -0,0 +1,494 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { BotPool } = require('../src/bot/botPool');
const { BotStore, BotCredentialStore } = require('../src/config/botStore');
const { defaultConfig, normalizeConfig } = require('../src/config/expansionConfig');
const { Logger } = require('../src/util/logger');
const { TerritoryModel } = require('../src/core/territoryModel');
const chunkPos = require('../src/core/chunkPos');
/** 造一个只提供 getModel 的数据服务桩 */
function fakeDataService() {
const map = new Map([[
'A',
{ name: 'A', capital: { x: 0, z: 0 }, chunks: new Set([chunkPos.key(0, 0)]), color: 0x75bd20 }
]]);
const model = new TerritoryModel(map);
model.setSelfStateName('A');
return {
model,
getModel: () => model,
setSelectedStateName: (name) => model.setSelfStateName(name),
addSessionClaim: () => {},
refreshAsync: () => {},
detectStateByPlayer: async () => null,
snapshot: () => ({ stateCount: 1 })
};
}
function connection(username) {
return {
id: username,
username,
password: 'pw',
auth: 'offline',
host: 'mc.example.com',
port: 25565,
version: '1.21.4',
label: username
};
}
/** 建一个含 2 个机器人、使用临时 bots.json 的池 */
function setup(options) {
const opts = options || {};
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-pool-'));
const storePath = path.join(dir, 'bots.json');
const credPath = path.join(dir, 'bots.credentials.json');
if (opts.settings) fs.writeFileSync(storePath, JSON.stringify({ bots: opts.settings }), 'utf8');
const config = normalizeConfig(Object.assign(defaultConfig(), opts.config || {}));
const pool = new BotPool({
connections: opts.connections || [connection('BotA'), connection('BotB')],
config,
logger: new Logger({ echo: false }),
dataService: fakeDataService(),
botStore: new BotStore(storePath),
credentialStore: opts.noCredentialStore ? null : new BotCredentialStore(credPath)
});
return { pool, storePath, credPath, config, dir };
}
test('BotPool 为每个凭据建立一个机器人条目', () => {
const { pool } = setup();
assert.strictEqual(pool.size, 2);
assert.deepStrictEqual(pool.ids(), ['BotA', 'BotB']);
assert.strictEqual(pool.defaultId, 'BotA');
assert.strictEqual(pool.get('BotA').manager.connection.username, 'BotA');
assert.strictEqual(pool.get().id, 'BotA', '不传 id 时返回默认机器人');
});
test('require 找不到机器人时抛出 404', () => {
const { pool } = setup();
assert.throws(() => pool.require('不存在'), (err) => err.statusCode === 404);
});
test('getConfigFor 未设置覆盖时等于全局配置', () => {
const { pool } = setup({ config: { strategy: 4, targetCount: 20 } });
const config = pool.getConfigFor('BotA');
assert.strictEqual(config.strategy, 4);
assert.strictEqual(config.targetCount, 20);
});
test('每个机器人可以有独立的配置覆盖,互不影响', () => {
const { pool } = setup({ config: { strategy: 1, targetCount: 10 } });
pool.setBotSetting('BotB', { config: { strategy: 2, targetCount: 3, stateName: 'B国' } });
const a = pool.getConfigFor('BotA');
const b = pool.getConfigFor('BotB');
assert.strictEqual(a.strategy, 1);
assert.strictEqual(a.targetCount, 10);
assert.strictEqual(b.strategy, 2);
assert.strictEqual(b.targetCount, 3);
assert.strictEqual(b.stateName, 'B国');
// 未覆盖的字段回退到全局
assert.strictEqual(b.waitTimeoutSeconds, a.waitTimeoutSeconds);
});
test('setBotSetting 持久化到 bots.json,重建池后仍生效', () => {
const { pool, storePath } = setup();
pool.setBotSetting('BotB', { enabled: false, autoStart: true, label: '二号机', config: { targetCount: 7 } });
const saved = JSON.parse(fs.readFileSync(storePath, 'utf8'));
assert.strictEqual(saved.bots.BotB.enabled, false);
assert.strictEqual(saved.bots.BotB.autoStart, true);
assert.strictEqual(saved.bots.BotB.label, '二号机');
const rebuilt = new BotPool({
connections: [connection('BotA'), connection('BotB')],
config: defaultConfig(),
dataService: fakeDataService(),
botStore: new BotStore(storePath)
});
assert.strictEqual(rebuilt.get('BotB').manager.label, '二号机');
assert.strictEqual(rebuilt.getConfigFor('BotB').targetCount, 7);
assert.strictEqual(rebuilt.settingSnapshot('BotB').enabled, false);
});
test('设置里绝不写入密码(密码只来自环境变量)', () => {
const { pool, storePath } = setup();
pool.setBotSetting('BotA', { label: '主号' });
const text = fs.readFileSync(storePath, 'utf8');
assert.ok(!text.includes('pw'), 'bots.json 中不得出现密码');
assert.ok(!/password/i.test(text), 'bots.json 中不得出现 password 字段');
});
test('snapshot 汇总各机器人的连接状态与任务状态', () => {
const { pool } = setup();
const snap = pool.snapshot();
assert.strictEqual(snap.length, 2);
assert.strictEqual(snap[0].id, 'BotA');
assert.strictEqual(snap[0].state, 'idle');
assert.strictEqual(snap[0].enabled, true);
assert.strictEqual(snap[0].task.state, 'IDLE');
assert.strictEqual(snap[0].task.botId, 'BotA');
});
test('positions 输出各机器人坐标供大地图打点', () => {
const { pool } = setup();
const positions = pool.positions();
assert.strictEqual(positions.length, 2);
assert.strictEqual(positions[0].id, 'BotA');
assert.strictEqual(positions[0].position, null, '未进入世界时没有坐标');
assert.strictEqual(positions[0].stateName, '');
});
test('未连接时拒绝发送指令与聊天,并说明是「未连接」', () => {
const { pool } = setup();
const command = pool.sendCommand('BotA', 'u claimmode');
assert.strictEqual(command.ok, false);
assert.ok(command.message.includes('未连接'), '离线时应明确说明未连接,而不是含糊的失败');
const chat = pool.sendChat('BotA', '大家好');
assert.strictEqual(chat.ok, false);
assert.ok(chat.message.includes('未连接'));
});
test('已连接但未完成登录验证时拒绝发送指令与聊天', () => {
const { pool } = setup();
const entry = pool.get('BotA');
entry.manager.bot = { chat: () => {} };
entry.manager.state = 'online';
entry.manager.loggedIn = false;
const command = pool.sendCommand('BotA', 'u claimmode');
assert.strictEqual(command.ok, false);
assert.ok(command.message.includes('未完成登录验证'));
const chat = pool.sendChat('BotA', '大家好');
assert.strictEqual(chat.ok, false);
assert.ok(chat.message.includes('未完成登录验证'));
});
test('未登录时验证类指令仍可下发(否则无法完成登录)', () => {
const { pool } = setup();
const entry = pool.get('BotA');
const sent = [];
entry.manager.bot = { chat: (text) => sent.push(text) };
entry.manager.state = 'online';
entry.manager.loggedIn = false;
const result = pool.sendCommand('BotA', '/auth 12345 6789');
assert.strictEqual(result.ok, true);
// 指令有节流,这里直接驱动写入验证内容
entry.manager._commandQueue.length = 0;
entry.manager._writeCommand('auth 12345 6789');
assert.deepStrictEqual(sent, ['/auth 12345 6789']);
});
test('空指令被拒绝', () => {
const { pool } = setup();
const result = pool.sendCommand('BotA', ' ');
assert.strictEqual(result.ok, false);
assert.ok(result.message.includes('指令为空'));
});
test('空聊天消息被拒绝', () => {
const { pool } = setup();
const result = pool.sendChat('BotA', ' ');
assert.strictEqual(result.ok, false);
assert.ok(result.message.includes('内容为空'));
});
test('聊天与指令共用同一入口:以 / 开头按指令发送', () => {
const { pool } = setup();
const entry = pool.get('BotA');
entry.manager.bot = { chat: () => {} };
entry.manager.state = 'online';
entry.manager.loggedIn = true;
const chat = pool.sendChat('BotA', '大家好');
assert.strictEqual(chat.kind, 'chat');
assert.ok(chat.message.includes('消息已发送'));
const command = pool.sendChat('BotA', '/u restore confirm');
assert.strictEqual(command.kind, 'command', '以 / 开头应识别为指令');
assert.ok(command.message.includes('指令已发送'));
});
test('完成登录验证后允许发送指令与聊天', () => {
const { pool } = setup();
const entry = pool.get('BotA');
const sent = [];
entry.manager.bot = { chat: (text) => sent.push(text) };
entry.manager.state = 'online';
entry.manager.loggedIn = true;
assert.strictEqual(entry.manager.canOperate(), true);
assert.strictEqual(pool.sendChat('BotA', '大家好').ok, true);
assert.strictEqual(pool.sendCommand('BotA', '/spawn').ok, true);
// 指令队列有节流,这里直接驱动写入验证内容
entry.manager._commandQueue.length = 0;
entry.manager._chatQueue.length = 0;
entry.manager.sendChat('直接发送', { internal: true });
entry.manager._chatQueue.length = 0;
entry.manager._writeChat('直接发送');
assert.deepStrictEqual(sent, ['直接发送']);
});
test('startTask 在机器人未就绪时返回失败且不抛异常', async () => {
const { pool } = setup();
const result = await pool.startTask('BotA');
assert.strictEqual(result.ok, false);
assert.ok(result.message.includes('尚未就绪'));
});
test('startTask 带配置覆盖时写入该机器人的专属配置', async () => {
const { pool } = setup({ config: { strategy: 1 } });
await pool.startTask('BotB', { strategy: 3, targetCount: 5 });
const config = pool.getConfigFor('BotB');
assert.strictEqual(config.strategy, 3);
assert.strictEqual(config.targetCount, 5);
// 另一个机器人不受影响
assert.strictEqual(pool.getConfigFor('BotA').strategy, 1);
assert.strictEqual(pool.settingSnapshot('BotB').hasOverrides, true);
});
test('clearBotConfigOverride 清除专属配置后重新继承全局', () => {
const { pool } = setup({ config: { strategy: 1, targetCount: 10 } });
pool.setBotSetting('BotB', { config: { strategy: 3, targetCount: 5 } });
assert.strictEqual(pool.getConfigFor('BotB').strategy, 3);
assert.strictEqual(pool.settingSnapshot('BotB').hasOverrides, true);
const saved = pool.clearBotConfigOverride('BotB');
assert.strictEqual(saved.hasOverrides, false);
assert.strictEqual(pool.getConfigFor('BotB').strategy, 1, '应重新继承全局');
assert.strictEqual(pool.getConfigFor('BotB').targetCount, 10);
// 清除后应下发到运行时
assert.strictEqual(pool.get('BotB').manager.config.strategy, 1);
});
test('专属配置是完整快照:存下后全局改动不再影响它(这正是需要「清除」按钮的原因)', () => {
const { pool } = setup({ config: { allowBreak: false } });
pool.setBotSetting('BotA', { config: { allowBreak: true } });
// 改全局
const next = normalizeConfig({ allowBreak: false, targetCount: 42 }, pool.config);
pool.applyGlobalConfig(next);
assert.strictEqual(pool.getConfigFor('BotA').allowBreak, true, '专属配置优先于全局');
assert.strictEqual(pool.getConfigFor('BotB').targetCount, 42, '没有覆盖的机器人跟随全局');
pool.clearBotConfigOverride('BotA');
assert.strictEqual(pool.getConfigFor('BotA').allowBreak, false, '清除后跟随全局');
});
test('clearBotConfigOverride 对不存在的机器人抛 404', () => {
const { pool } = setup();
assert.throws(() => pool.clearBotConfigOverride('不存在'), (err) => err.statusCode === 404);
});
test('applyGlobalConfig 下发到全部机器人并保留各自的覆盖', () => {
const { pool } = setup({ config: { strategy: 1, targetCount: 10 } });
pool.setBotSetting('BotB', { config: { targetCount: 3 } });
const next = normalizeConfig({ strategy: 4, targetCount: 99 }, pool.config);
pool.applyGlobalConfig(next);
assert.strictEqual(pool.get('BotA').manager.config.strategy, 4);
assert.strictEqual(pool.getConfigFor('BotA').targetCount, 99);
assert.strictEqual(pool.getConfigFor('BotB').targetCount, 3, '机器人专属覆盖优先于全局');
});
test('stop 会停止全部机器人的任务并断开连接', () => {
const { pool } = setup();
let disconnected = 0;
for (const entry of pool.entries.values()) {
entry.manager.disconnect = () => { disconnected++; };
}
pool.stop();
assert.strictEqual(disconnected, 2);
});
test('tick 驱动全部机器人且单个异常不影响其它', () => {
const { pool } = setup();
const ticks = [];
pool.get('BotA').controller.tick = () => { throw new Error('boom'); };
pool.get('BotB').controller.tick = () => { ticks.push('B'); };
assert.doesNotThrow(() => pool.tick());
assert.deepStrictEqual(ticks, ['B']);
});
// ==================== 上线 / 下线 ====================
test('disconnectBot 断线并停止自动重连、定时消息与扩地任务', () => {
const { pool } = setup();
const entry = pool.get('BotA');
let disconnected = false;
let taskStopped = false;
let messengerStopped = false;
entry.manager.disconnect = () => { disconnected = true; };
entry.controller.stop = () => { taskStopped = true; };
entry.messenger.stop = () => { messengerStopped = true; };
pool.disconnectBot('BotA');
assert.strictEqual(disconnected, true, '应断开连接');
assert.strictEqual(taskStopped, true, '应停掉扩地任务');
assert.strictEqual(messengerStopped, true, '应停掉定时消息');
});
test('connectBot 清掉「已在别处登录」标记并恢复连接', () => {
const { pool } = setup();
const entry = pool.get('BotA');
entry.manager.alreadyConnected = true;
entry.manager.reconnectAttempts = 5;
const connected = [];
entry.manager.connect = () => connected.push('BotA');
let messengerStarted = false;
entry.messenger.start = () => { messengerStarted = true; };
pool.connectBot('BotA');
assert.strictEqual(entry.manager.alreadyConnected, false, '不清掉标记会一直退避到最大间隔');
assert.strictEqual(entry.manager.reconnectAttempts, 0);
assert.strictEqual(messengerStarted, true);
assert.deepStrictEqual(connected, ['BotA']);
});
test('上线 / 下线对不存在的机器人抛 404', () => {
const { pool } = setup();
assert.throws(() => pool.disconnectBot('不存在'), (err) => err.statusCode === 404);
assert.throws(() => pool.connectBot('不存在'), (err) => err.statusCode === 404);
});
test('未启用(enabled=false)的机器人不参与连接', () => {
const { pool } = setup({ settings: { BotB: { enabled: false } } });
const connected = [];
for (const entry of pool.entries.values()) {
entry.manager.connect = () => { connected.push(entry.id); };
}
pool.start();
assert.deepStrictEqual(connected, ['BotA']);
});
// ==================== 控制台新增 / 移除玩家 ====================
test('addBot 新增玩家、落盘凭据,默认立即连接', () => {
const { pool, credPath } = setup();
const entry = pool.addBot({ username: 'NewBot', password: 'secret-pw', label: '新号' }, { connect: false });
assert.strictEqual(entry.id, 'NewBot');
assert.strictEqual(entry.source, 'store');
assert.strictEqual(pool.size, 3);
assert.strictEqual(entry.manager.label, '新号');
const saved = JSON.parse(fs.readFileSync(credPath, 'utf8'));
assert.strictEqual(saved.bots.NewBot.username, 'NewBot');
assert.strictEqual(saved.bots.NewBot.password, 'secret-pw');
});
test('addBot 在 enabled 时真的发起连接', () => {
const { pool } = setup();
const connected = [];
// addBot 内部会新建 BotManager,这里通过覆盖原型方法拦截真实网络连接
const originalConnect = Object.getPrototypeOf(pool.get('BotA').manager).connect;
Object.getPrototypeOf(pool.get('BotA').manager).connect = function stubConnect() {
connected.push(this.connection.username);
};
try {
pool.addBot({ username: 'NewBot', password: 'p1' });
assert.deepStrictEqual(connected, ['NewBot']);
} finally {
Object.getPrototypeOf(pool.get('BotA').manager).connect = originalConnect;
}
});
test('addBot 拒绝空用户名、非法用户名与重名', () => {
const { pool } = setup();
assert.throws(() => pool.addBot({ username: '' }), (err) => err.statusCode === 400);
assert.throws(() => pool.addBot({ username: '名字含中文且超长' }), (err) => err.statusCode === 400);
assert.throws(() => pool.addBot({ username: 'BotA' }), (err) => err.statusCode === 400);
assert.ok(pool.addBot({ username: 'ok_name' }, { connect: false }));
});
test('凭据文件里的账号在重启后自动恢复,且 .env 同名时以 .env 为准', () => {
const { pool, credPath, storePath, config } = setup();
pool.addBot({ username: 'NewBot', password: 'p1' }, { connect: false });
const rebuilt = new BotPool({
connections: [connection('BotA')],
config,
dataService: fakeDataService(),
botStore: new BotStore(storePath),
credentialStore: new BotCredentialStore(credPath)
});
assert.deepStrictEqual(rebuilt.ids(), ['BotA', 'NewBot']);
assert.strictEqual(rebuilt.get('NewBot').source, 'store');
// .env 里也有同名账号时,以 .env 为准(避免「改了 .env 不生效」)
const both = new BotPool({
connections: [connection('NewBot')],
config,
dataService: fakeDataService(),
credentialStore: new BotCredentialStore(credPath)
});
assert.strictEqual(both.size, 1);
assert.strictEqual(both.get('NewBot').source, 'env');
});
test('removeBot 移除控制台新增的玩家并删除凭据', () => {
const { pool, credPath } = setup();
pool.addBot({ username: 'NewBot', password: 'p1' }, { connect: false });
let disconnected = false;
pool.get('NewBot').manager.disconnect = () => { disconnected = true; };
pool.removeBot('NewBot');
assert.strictEqual(disconnected, true, '移除时应断开连接');
assert.strictEqual(pool.get('NewBot'), null);
assert.strictEqual(pool.size, 2);
assert.deepStrictEqual(JSON.parse(fs.readFileSync(credPath, 'utf8')).bots, {});
});
test('removeBot 拒绝移除 .env 里配置的账号并说明原因', () => {
const { pool } = setup();
assert.throws(() => pool.removeBot('BotA'), (err) => {
assert.strictEqual(err.statusCode, 400);
assert.ok(err.message.includes('.env'));
return true;
});
assert.strictEqual(pool.size, 2, '拒绝后不应改变列表');
});
test('未启用凭据存储时不允许在控制台新增玩家', () => {
const { pool } = setup({ noCredentialStore: true });
assert.throws(() => pool.addBot({ username: 'X' }, { connect: false }), (err) => err.statusCode === 400);
});
test('snapshot 标注账号来源,供前端决定能否移除', () => {
const { pool } = setup();
pool.addBot({ username: 'NewBot', password: 'p1' }, { connect: false });
const snap = pool.snapshot();
assert.strictEqual(snap.find((b) => b.id === 'BotA').source, 'env');
assert.strictEqual(snap.find((b) => b.id === 'NewBot').source, 'store');
});
test('凭据文件不含任何非密码字段之外的敏感内容,且权限被收紧', () => {
const { pool, credPath } = setup();
pool.addBot({ username: 'NewBot', password: 'p1' }, { connect: false });
const parsed = JSON.parse(fs.readFileSync(credPath, 'utf8'));
assert.deepStrictEqual(Object.keys(parsed), ['bots']);
assert.deepStrictEqual(Object.keys(parsed.bots.NewBot).sort(),
['auth', 'host', 'password', 'port', 'username', 'version']);
if (process.platform !== 'win32') {
const mode = fs.statSync(credPath).mode & 0o777;
assert.strictEqual(mode, 0o600, '含密码的文件必须是 0600');
}
});
+77
查看文件
@@ -0,0 +1,77 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const { parseBotsFromEnv, sanitizeId, normalizePort } = require('../src/config/botsConfig');
test('MC_BOTS JSON 数组写法解析多个机器人', () => {
const bots = parseBotsFromEnv({
MC_HOST: 'mc.example.com',
MC_PORT: '25566',
MC_VERSION: '1.21.1',
MC_AUTH: 'offline',
MC_BOTS: JSON.stringify([
{ username: 'BotA', password: 'p1' },
{ username: 'BotB', password: 'p2', label: '二号机' }
])
});
assert.strictEqual(bots.length, 2);
assert.deepStrictEqual(bots.map((b) => b.username), ['BotA', 'BotB']);
assert.strictEqual(bots[0].host, 'mc.example.com');
assert.strictEqual(bots[0].port, 25566);
assert.strictEqual(bots[0].version, '1.21.1');
assert.strictEqual(bots[1].label, '二号机');
assert.strictEqual(bots[1].password, 'p2');
});
test('MC_BOTS 逗号分隔写法解析用户名与密码', () => {
const bots = parseBotsFromEnv({ MC_BOTS: 'BotA:p1, BotB:p2' });
assert.deepStrictEqual(bots.map((b) => [b.username, b.password]), [['BotA', 'p1'], ['BotB', 'p2']]);
});
test('MC_BOTS 中没有冒号的项视为「只有用户名」', () => {
const bots = parseBotsFromEnv({ MC_BOTS: 'BotA' });
assert.strictEqual(bots.length, 1);
assert.strictEqual(bots[0].password, '');
});
test('未配置 MC_BOTS 时回退到 MC_USERNAME / MC_PASSWORD', () => {
const bots = parseBotsFromEnv({ MC_USERNAME: 'SoloBot', MC_PASSWORD: 'pw' });
assert.strictEqual(bots.length, 1);
assert.strictEqual(bots[0].username, 'SoloBot');
assert.strictEqual(bots[0].password, 'pw');
});
test('MC_BOTS 解析失败(非法 JSON)时回退到单机器人兜底', () => {
const bots = parseBotsFromEnv({ MC_BOTS: '[{bad json', MC_USERNAME: 'Fallback', MC_PASSWORD: 'p' });
assert.strictEqual(bots.length, 1);
assert.strictEqual(bots[0].username, 'Fallback');
});
test('重名机器人被去重,空用户名被丢弃', () => {
const bots = parseBotsFromEnv({ MC_BOTS: 'BotA:p1,BotA:p2,, ,BotB:p3' });
assert.deepStrictEqual(bots.map((b) => b.username), ['BotA', 'BotB']);
assert.strictEqual(bots[0].password, 'p1', '应保留第一个同名条目');
});
test('id 由用户名安全化生成,可用于 URL 与文件名', () => {
const bots = parseBotsFromEnv({ MC_BOTS: 'a/b:c:p1' });
assert.strictEqual(bots.length, 1);
assert.strictEqual(bots[0].username, 'a/b');
assert.strictEqual(bots[0].id, 'a_b');
assert.strictEqual(sanitizeId('正常名字_123'), '正常名字_123');
});
test('端口非法时回退到 25565', () => {
assert.strictEqual(normalizePort('abc'), 25565);
assert.strictEqual(normalizePort('0'), 25565);
assert.strictEqual(normalizePort('70000'), 25565);
assert.strictEqual(normalizePort('25565'), 25565);
const bots = parseBotsFromEnv({ MC_BOTS: 'BotA:p', MC_PORT: 'x' });
assert.strictEqual(bots[0].port, 25565);
});
test('没有任何配置时返回空列表', () => {
assert.deepStrictEqual(parseBotsFromEnv({}), []);
});
+114
查看文件
@@ -0,0 +1,114 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { ChatLog, ChatKind, sanitize } = require('../src/core/chatLog');
/** 建一个使用临时目录的公屏日志 */
function setup(options) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-chatlog-'));
return { log: new ChatLog(Object.assign({ dir }, options || {})), dir };
}
test('append 记录公屏消息并保留发言人与类型', () => {
const { log } = setup();
const entry = log.append({
botId: 'BotA',
bot: 'BotA',
message: '大家好',
kind: ChatKind.CHAT,
username: 'Steve'
});
assert.strictEqual(entry.botId, 'BotA');
assert.strictEqual(entry.message, '大家好');
assert.strictEqual(entry.username, 'Steve');
assert.strictEqual(entry.kind, 'chat');
assert.ok(entry.t > 0);
});
test('空消息不入库,直接返回 null', () => {
const { log } = setup();
assert.strictEqual(log.append({ botId: 'A', message: '' }), null);
assert.strictEqual(log.append({ botId: 'A' }), null);
assert.strictEqual(log.snapshot().total, 0);
});
test('list 按机器人隔离,并支持关键字与类型过滤', () => {
const { log } = setup();
log.append({ botId: 'A', message: '服务器将在 5 分钟后重启', kind: ChatKind.SYSTEM });
log.append({ botId: 'A', message: '你好', kind: ChatKind.CHAT, username: 'Steve' });
log.append({ botId: 'B', message: '另一个机器人的消息' });
assert.strictEqual(log.list('A').length, 2);
assert.strictEqual(log.list('B').length, 1);
assert.strictEqual(log.list('A', { kind: ChatKind.SYSTEM }).length, 1);
assert.strictEqual(log.list('A', { keyword: '重启' }).length, 1);
assert.strictEqual(log.list('A', { keyword: '不存在' }).length, 0);
});
test('list 限制条数时保留最新的若干条且保持时间升序', () => {
const { log } = setup();
for (let i = 0; i < 10; i++) log.append({ botId: 'A', message: 'm' + i, time: 1000 + i });
const rows = log.list('A', { limit: 3 });
assert.deepStrictEqual(rows.map((r) => r.message), ['m7', 'm8', 'm9']);
});
test('每个机器人的内存缓冲独立限长', () => {
const { log } = setup({ maxEntriesPerBot: 2 });
for (let i = 0; i < 5; i++) log.append({ botId: 'A', message: 'a' + i });
log.append({ botId: 'B', message: 'b0' });
assert.strictEqual(log.list('A').length, 2);
assert.strictEqual(log.list('B').length, 1);
const snap = log.snapshot();
const a = snap.bots.find((b) => b.botId === 'A');
assert.strictEqual(a.count, 5, '计数应统计全部写入量而非缓冲量');
assert.strictEqual(a.buffered, 2);
});
test('按机器人分文件落盘(JSONL),重启后可读回文件', () => {
const { log, dir } = setup();
log.append({ botId: 'BotA', bot: 'BotA', message: 'hello' });
log.append({ botId: 'BotA', bot: 'BotA', message: 'world' });
log.append({ botId: 'BotB', message: 'other' });
const fileA = path.join(dir, 'BotA.jsonl');
const fileB = path.join(dir, 'BotB.jsonl');
assert.strictEqual(fs.readFileSync(fileA, 'utf8').trim().split('\n').length, 2);
assert.strictEqual(fs.readFileSync(fileB, 'utf8').trim().split('\n').length, 1);
});
test('机器人名含路径分隔符时落盘文件名被安全化', () => {
const { log, dir } = setup();
log.append({ botId: 'a/b:c', message: 'x' });
const files = fs.readdirSync(dir);
assert.strictEqual(files.length, 1);
assert.strictEqual(files[0], 'a_b_c.jsonl');
assert.strictEqual(sanitize('a/b:c'), 'a_b_c');
});
test('exportTo 导出纯文本,含类型标记与发言人', () => {
const { log, dir } = setup();
log.append({ botId: 'BotA', message: '你好', kind: ChatKind.CHAT, username: 'Steve' });
log.append({ botId: 'BotA', message: 'u claimmode', kind: ChatKind.COMMAND });
const file = path.join(dir, 'out.log');
assert.strictEqual(log.exportTo('BotA', file), file);
const text = fs.readFileSync(file, 'utf8');
assert.ok(text.includes('Steve: 你好'));
assert.ok(text.includes('【指令】u claimmode'));
});
test('onEntry 回调收到新消息,回调抛异常不影响写入', () => {
const { log } = setup();
const seen = [];
log.onEntry = (entry) => {
seen.push(entry.message);
throw new Error('订阅者异常');
};
assert.doesNotThrow(() => log.append({ botId: 'A', message: 'hi' }));
assert.deepStrictEqual(seen, ['hi']);
assert.strictEqual(log.list('A').length, 1);
});
+458 -6
查看文件
@@ -8,6 +8,7 @@ const path = require('path');
const { ExpansionController, State } = require('../src/controller/expansionController');
const { TerritoryModel } = require('../src/core/territoryModel');
const { TargetLeaseTable } = require('../src/core/targetLease');
const { defaultConfig } = require('../src/config/expansionConfig');
const { Logger } = require('../src/util/logger');
const { FakeBot } = require('./helpers/fakeBot');
@@ -86,14 +87,18 @@ class FakeBotManager {
this.connection = { username: 'TestBot' };
this.commands = [];
this._model = model;
/** 设为 false 模拟「世界已退出」(掉线 / 被踢 / 被切到登录服) */
this.spawned = true;
/** 设为 false 模拟「已进入世界但还没完成登录」 */
this.ready = true;
}
isReady() {
return true;
return this.ready;
}
isSpawned() {
return true;
return this.spawned;
}
sendCommand(command) {
@@ -126,7 +131,14 @@ function setup(options) {
const botManager = new FakeBotManager(bot, model);
const dataService = new FakeDataService(model);
const logger = new Logger({ echo: false });
const controller = new ExpansionController({ botManager, dataService, logger });
const controller = new ExpansionController({
botManager,
dataService,
logger,
botId: opts.botId,
botName: opts.botName,
targetLeases: opts.targetLeases
});
const config = defaultConfig();
config.enforceClaimTime = false;
@@ -139,6 +151,68 @@ function setup(options) {
return { controller, botManager, dataService, model, bot, logger, config };
}
test('世界退出后任务暂停,重连进入世界时自动恢复', async () => {
// 本服会周期性把机器人踢下线(实测约 1~6 分钟一次)。没有自动恢复的话,
// 机器人虽然能被原点自救送回真实世界,任务却再也不会继续 —— 扩地永远跑不长。
const { controller, botManager, config } = setup();
await controller.start(config);
assert.strictEqual(controller.isRunning(), true);
botManager.spawned = false;
controller.tick();
assert.strictEqual(controller.isRunning(), false, '世界退出应停止任务');
assert.match(controller.status, /等待重连后自动恢复/);
botManager.spawned = true;
controller.tick();
assert.strictEqual(controller.isRunning(), true, '重连后应自动恢复任务');
assert.ok(controller.target, '恢复后应重新选出目标区块');
assert.strictEqual(botManager.pathing.pathCalls, 2, '恢复后应重新下发寻路');
});
test('恢复尝试失败时不会丢掉这次恢复(等就绪后继续试)', async () => {
// 回归:本服掉线频繁,重连后常常「已进入世界但还没完成登录」,
// 此时 start() 会直接返回 false。若在尝试前就清掉 resumeConfig,
// 这次恢复就永久丢了 —— 任务再也不会自己接上。
const { controller, botManager, config } = setup();
await controller.start(config);
botManager.spawned = false;
controller.tick(); // 世界退出 → 登记待恢复
botManager.spawned = true;
botManager.ready = false; // 已进入世界但未就绪
controller.tick();
assert.strictEqual(controller.isRunning(), false, '未就绪时不应启动');
assert.ok(controller.resumeConfig, '未就绪时不能丢掉待恢复的任务');
botManager.ready = true;
controller.tick();
// start() 是异步的,等它落地再断言
await new Promise((resolve) => setTimeout(resolve, 0));
assert.strictEqual(controller.isRunning(), true, '就绪后应恢复');
assert.strictEqual(controller.resumeConfig, null, '恢复成功后清掉登记');
});
test('自动恢复可以关闭', async () => {
const { controller, botManager, config } = setup({ config: { autoResumeAfterReconnect: false } });
await controller.start(config);
botManager.spawned = false;
controller.tick();
botManager.spawned = true;
controller.tick();
assert.strictEqual(controller.isRunning(), false, '关掉开关后不应自动恢复');
});
test('手动停止的任务不会被自动恢复', async () => {
const { controller, botManager, config } = setup();
await controller.start(config);
controller.stop();
botManager.spawned = false;
controller.tick();
botManager.spawned = true;
controller.tick();
assert.strictEqual(controller.isRunning(), false, '手动停止不应被重连重新拉起');
});
test('start:自动识别所在邦国并下发开垦模式指令', async () => {
const { controller, botManager, config, model } = setup({ selfName: '' });
const ok = await controller.start(config);
@@ -273,9 +347,12 @@ test('寻路超时会重新寻路,重试耗尽后走两步', async () => {
assert.strictEqual(controller.sideStepCount, 1);
});
test('连续超时累计达到阈值时回城', async () => {
test('连续超时累计达到阈值时回城(需显式开启 returnOnTimeout)', async () => {
const { controller, botManager, config } = setup({
config: { waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0, returnThresholdSeconds: 1 }
config: {
waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0,
returnThresholdSeconds: 1, returnOnTimeout: true, returnGraceSeconds: 0
}
});
await controller.start(config);
controller.stageStartedMillis = Date.now() - 10000;
@@ -284,9 +361,54 @@ test('连续超时累计达到阈值时回城', async () => {
assert.ok(botManager.commands.includes('u return'), '应发送回城指令');
});
test('默认不回城:超时只跳过目标,不把机器人拉走', async () => {
const { controller, botManager, config } = setup({
config: { waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0, returnThresholdSeconds: 1 }
});
assert.strictEqual(config.returnOnTimeout, false, '默认应关闭自动回城');
await controller.start(config);
controller.stageStartedMillis = Date.now() - 10000;
controller.tick();
assert.notStrictEqual(controller.state, State.RETURNING, '默认不应触发回城');
assert.ok(!botManager.commands.includes('u return'), '不应发送回城指令');
});
test('回城保护期内即便开启回城也不执行,避免清掉刚摆好的位置', async () => {
const { controller, botManager, config } = setup({
config: {
waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0,
returnThresholdSeconds: 1, returnOnTimeout: true, returnGraceSeconds: 600
}
});
await controller.start(config);
controller.stageStartedMillis = Date.now() - 10000;
controller.tick();
assert.notStrictEqual(controller.state, State.RETURNING, '保护期内不应回城');
assert.ok(!botManager.commands.includes('u return'));
});
test('回城指令可通过 returnCommand 自定义', async () => {
const { controller, botManager, config } = setup({
config: {
waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0,
returnThresholdSeconds: 1, returnOnTimeout: true, returnGraceSeconds: 0,
returnCommand: '/spawn'
}
});
await controller.start(config);
controller.stageStartedMillis = Date.now() - 10000;
controller.tick();
// 玩家可能顺手带上前导斜杠,发送时应被去掉
assert.ok(botManager.commands.includes('spawn'), '应发送自定义指令:' + botManager.commands.join(', '));
assert.ok(!botManager.commands.includes('u return'));
});
test('回城等待结束后重新规划(非耐久保护触发)', async () => {
const { controller, config } = setup({
config: { waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0, returnThresholdSeconds: 1, returnWaitSeconds: 1 }
config: {
waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0,
returnThresholdSeconds: 1, returnOnTimeout: true, returnGraceSeconds: 0, returnWaitSeconds: 1
}
});
await controller.start(config);
controller.stageStartedMillis = Date.now() - 10000;
@@ -314,6 +436,171 @@ test('耐久保护触发回城并在传送后停止任务', async () => {
assert.strictEqual(controller.state, State.STOPPED, '耐久保护回城后应停止任务');
});
// ==================== 多机器人目标租约(4.3)====================
test('多机器人共享租约表:不会挑到同一个目标区块', async () => {
const table = new TargetLeaseTable();
const first = setup({ targetLeases: table, botId: 'BotA' });
const second = setup({ targetLeases: table, botId: 'BotB' });
await first.controller.start(first.config);
await second.controller.start(second.config);
assert.ok(first.controller.target, 'A 应有目标');
assert.ok(second.controller.target, 'B 也应有目标');
assert.notDeepStrictEqual(second.controller.target, first.controller.target,
'两个机器人不应挑到同一区块');
assert.strictEqual(table.isLeasedByOther(first.controller.target, 'BotB'), true,
'A 的目标应对外可见(B 不会再挑它)');
});
test('停止任务时归还租约,其他机器人立刻可以接手', async () => {
const table = new TargetLeaseTable();
const { controller, config } = setup({ targetLeases: table, botId: 'BotA' });
await controller.start(config);
const target = controller.target;
assert.ok(target);
controller.stop();
assert.strictEqual(table.isLeasedByOther(target, 'BotB'), false, '停止后应释放租约');
assert.strictEqual(table.size, 0, 'A 不应留下任何占用');
});
test('运行中持续续租:租约不会在长距离寻路中途过期', async () => {
// 时序余量刻意放宽:这条用例要证明的是「续租把到期时间往后推了」,
// 即「总耗时 > 原始 TTL」但「距续租的耗时 < TTL」。
// 睡眠时长取 0.7 × TTL,两侧各留 0.3 × TTL(120ms)的调度余量 ——
// 早先用 TTL=40ms / 睡眠 25ms 只剩 15ms 余量,全量并行跑测试时必然偶发失败。
const ttlMs = 400;
const sleepMs = 280;
const table = new TargetLeaseTable({ ttlMs });
const { controller, config } = setup({ targetLeases: table, botId: 'BotA' });
await controller.start(config);
const target = controller.target;
await new Promise((resolve) => setTimeout(resolve, sleepMs));
controller.tick(); // 续租
await new Promise((resolve) => setTimeout(resolve, sleepMs));
// 总耗时已超过原始 TTL(400ms),但续租过,仍应归 A
assert.strictEqual(table.tryAcquire(target, 'BotB'), false, '续租后不应被抢走');
});
test('候选区块全被其他机器人预定时等待释放,而不是判「没有可用区块」', async () => {
const table = new TargetLeaseTable();
const first = setup({ targetLeases: table, botId: 'BotA' });
const second = setup({ targetLeases: table, botId: 'BotB' });
// A 先把 B 的所有候选区块都占了(模拟 A 的队列很长)
await first.controller.start(first.config);
const model = second.model;
for (const chunk of [...model.state('A').chunks, ...first.controller.queue, first.controller.target]
.filter(Boolean)) {
const c = typeof chunk === 'string'
? { x: chunkPos.decode(chunk).x, z: chunkPos.decode(chunk).z }
: chunk;
table.tryAcquire(c, 'BotA');
}
await second.controller.start(second.config);
assert.strictEqual(second.controller.state, State.NEXT, '应进入等待而不是 COMPLETE');
assert.ok(second.controller._nextReplanAt > Date.now(), '应登记稍后重新规划');
const lines = second.logger.list().map((e) => e.message).join('\n');
assert.ok(lines.includes('正被其他机器人预定'), '应说明为什么等待:' + lines);
});
test('连续超时达阈值但原地还能走 → 不回城,改为跳过目标', async () => {
const { controller, botManager, config } = setup({
config: {
waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0,
returnThresholdSeconds: 1, returnOnTimeout: true, returnGraceSeconds: 0
}
});
botManager.pathing.diagnostics = () => ({ neighborCount: 3 });
await controller.start(config);
controller.stageStartedMillis = Date.now() - 10000;
controller.tick();
assert.notStrictEqual(controller.state, State.RETURNING, '原地还能走时不应回城');
assert.ok(!botManager.commands.includes('u return'), '不应发送回城指令');
assert.strictEqual(controller.returnSkippedNotStuck, 1, '应记下「放弃回城」的次数');
assert.strictEqual(controller.lastReturn, null, '没有回城就不该有回城记录');
});
test('连续超时达阈值且原地无路可走 → 回城', async () => {
const { controller, botManager, config } = setup({
config: {
waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0,
returnThresholdSeconds: 1, returnOnTimeout: true, returnGraceSeconds: 0
}
});
botManager.pathing.diagnostics = () => ({ neighborCount: 0 });
await controller.start(config);
controller.stageStartedMillis = Date.now() - 10000;
controller.tick();
assert.strictEqual(controller.state, State.RETURNING, '确实卡死时应回城');
assert.ok(botManager.commands.includes('u return'));
assert.strictEqual(controller.returnSkippedNotStuck, 0);
});
test('拿不到寻路诊断时保守按卡死处理(仍会回城)', async () => {
const { controller, botManager, config } = setup({
config: {
waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0,
returnThresholdSeconds: 1, returnOnTimeout: true, returnGraceSeconds: 0
}
});
// FakePathing 本来就没有 diagnostics 方法:模拟「寻路器未就绪,判断不了」
await controller.start(config);
controller.stageStartedMillis = Date.now() - 10000;
controller.tick();
assert.strictEqual(controller.state, State.RETURNING);
});
test('回城完成后记录前后坐标与直线距离(控制台展示「从 A 挪到了 B」)', async () => {
const { controller, botManager, bot, logger, config } = setup({
config: {
waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0,
returnThresholdSeconds: 1, returnOnTimeout: true, returnGraceSeconds: 0, returnWaitSeconds: 1
}
});
botManager.pathing.diagnostics = () => ({ neighborCount: 0 });
await controller.start(config);
controller.stageStartedMillis = Date.now() - 10000;
controller.tick();
assert.strictEqual(controller.state, State.RETURNING);
// 模拟服务器把玩家传送到安全点
bot.entity.position.x = 1000;
bot.entity.position.y = 70;
bot.entity.position.z = 2000;
controller.returnStartMillis = Date.now() - 5000;
controller.tick();
const last = controller.lastReturn;
assert.ok(last, '应记录回城结果');
assert.deepStrictEqual(last.from, { x: 8, y: 64, z: 8 }, '应记录回城前坐标');
assert.deepStrictEqual(last.to, { x: 1000, y: 70, z: 2000 }, '应记录回城后坐标');
assert.ok(last.distance > 2000, `应记录直线距离,实际 ${last.distance}`);
assert.ok(logger.list().some((e) => e.message.includes('回城结果')), '应输出「从 A 挪到了 B」日志');
assert.ok(controller.snapshot().lastReturn, 'snapshot 应暴露给控制台');
});
test('回城几乎没有改变位置时给出明确提示', async () => {
const { controller, botManager, logger, config } = setup({
config: {
waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0,
returnThresholdSeconds: 1, returnOnTimeout: true, returnGraceSeconds: 0, returnWaitSeconds: 1
}
});
botManager.pathing.diagnostics = () => ({ neighborCount: 0 });
await controller.start(config);
controller.stageStartedMillis = Date.now() - 10000;
controller.tick();
controller.returnStartMillis = Date.now() - 5000;
controller.tick(); // 位置没变(模拟安全点就是原地)
assert.strictEqual(controller.lastReturn.distance, 0);
assert.ok(logger.list().some((e) => e.message.includes('几乎没有改变位置')), '应提示回城没有帮助');
});
test('达成目标数量后进入 COMPLETE', async () => {
const { controller, config, bot } = setup({ config: { targetCount: 1 } });
await controller.start(config);
@@ -415,6 +702,42 @@ test('未启动任务时 tick 不做任何事', () => {
assert.strictEqual(controller.state, State.IDLE);
});
test('寻路中只要在稳步接近目标,就不算超时(慢速推进不会被回城打断)', async () => {
const { controller, config, bot } = setup({ config: { waitTimeoutSeconds: 5, retries: 3 } });
await controller.start(config);
const target = controller.target;
assert.ok(target, '应选出目标区块');
// 先跑一次 tick 建立距离基准(plan() 会把基准清空,首次观察只记录不续期)
// 把机器人放在目标西侧 10 个区块处,后面每步靠近 2 格
const place = (offsetX) => {
bot.entity.position.x = (target.x + offsetX) * 16 + 8;
bot.entity.position.z = target.z * 16 + 8;
};
place(-10);
controller.tick();
// 模拟「走得很慢但在推进」:每次 tick 靠近 2 个区块,但耗时都超过超时阈值
for (let step = 1; step <= 4; step++) {
place(-10 + step * 2);
controller.stageStartedMillis = Date.now() - 10000; // 每次都「已超时」
controller.tick();
assert.strictEqual(controller.retryCount, 0,
`第 ${step} 次推进不应被判定为超时(retryCount=${controller.retryCount})`);
assert.notStrictEqual(controller.state, State.RETURNING, '不应触发回城');
}
assert.strictEqual(controller.consecutiveTimeoutMillis, 0, '有推进时连续超时应清零');
});
test('寻路中原地不动时仍然会超时并重试(回归保护)', async () => {
const { controller, config } = setup({ config: { waitTimeoutSeconds: 5, retries: 3 } });
await controller.start(config);
controller.tick(); // 首次 tick 只记录距离基准
controller.stageStartedMillis = Date.now() - 10000;
controller.tick();
assert.strictEqual(controller.retryCount, 1, '原地不动必须照常判超时');
});
test('规划队列为空时直接进入 COMPLETE', async () => {
// 己方领土被完全包围且无空位:构造一个只有 1 个区块、四周全被他国占领的局面
const { controller, config } = setup({
@@ -428,3 +751,132 @@ test('规划队列为空时直接进入 COMPLETE', async () => {
assert.strictEqual(controller.state, State.COMPLETE);
assert.strictEqual(controller.status, '没有可用的相邻无主区块');
});
// ==================== 多机器人:邦国归属与开拓日志 ====================
test('start 把识别到的邦国固化到 stateName,不依赖全局 selfStateName', async () => {
const { controller, config } = setup({ selfName: 'A' });
await controller.start(config);
assert.strictEqual(controller.stateName, 'A');
assert.strictEqual(controller.config.stateName, 'A', '应写回任务配置供规划器使用');
});
test('两个机器人各自开拓不同邦国时互不覆盖', async () => {
const map = new Map([
['A', { name: 'A', capital: { x: 0, z: 0 }, chunks: new Set([chunkPos.key(0, 0)]), color: 0x75bd20 }],
['B', { name: 'B', capital: { x: 20, z: 20 }, chunks: new Set([chunkPos.key(20, 20)]), color: 0xff43de }]
]);
const model = new TerritoryModel(map);
const logger = new Logger({ echo: false });
const makeController = (name) => {
const bot = new FakeBot({ x: 0, y: 64, z: 0 });
const botManager = new FakeBotManager(bot, model);
botManager.connection = { username: name };
const dataService = new FakeDataService(model);
const controller = new ExpansionController({ botManager, dataService, logger, botId: name, botName: name });
const config = defaultConfig();
config.enforceClaimTime = false;
config.autoEat = false;
config.autoAttack = false;
config.durabilityGuard = false;
return { controller, config, model };
};
const first = makeController('BotA');
first.config.stateName = 'A';
const second = makeController('BotB');
second.config.stateName = 'B';
await first.controller.start(first.config);
await second.controller.start(second.config);
// 全局 selfStateName 会被最后启动的机器人改写,但各控制器仍按自己的邦国工作
assert.strictEqual(first.controller.stateName, 'A');
assert.strictEqual(second.controller.stateName, 'B');
first.controller.playerChunk = { x: 0, z: 0 };
second.controller.playerChunk = { x: 20, z: 20 };
first.controller.monitor.onChat('疆土(0,1) [8,24] 已为你的邦国开拓');
second.controller.monitor.onChat('疆土(20,21) [328,344] 已为你的邦国开拓');
assert.ok(model.state('A').chunks.has(chunkPos.key(0, 1)), 'A 的新占领应记到 A 名下');
assert.ok(!model.state('A').chunks.has(chunkPos.key(20, 21)));
assert.ok(model.state('B').chunks.has(chunkPos.key(20, 21)), 'B 的新占领应记到 B 名下');
});
test('占领成功时写入开拓日志(含玩家、区块、时间与来源)', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-claimlog-'));
const { ExpansionLog } = require('../src/core/expansionLog');
const expansionLog = new ExpansionLog({ filePath: path.join(dir, 'expansion-log.jsonl') });
const { controller, config, bot } = setup();
controller.expansionLog = expansionLog;
controller.botId = 'BotA';
controller.botName = 'BotA';
await controller.start(config);
const target = controller.target;
bot.entity.position.x = target.x * 16 + 8;
bot.entity.position.z = target.z * 16 + 8;
controller.tick();
const before = Date.now();
controller.monitor.onChat(`疆土(${target.x},${target.z}) [${target.x * 16 + 1},${target.z * 16 + 2}] 已为你的邦国开拓`);
const rows = expansionLog.list();
assert.strictEqual(rows.length, 1);
const entry = rows[0];
assert.strictEqual(entry.bot, 'BotA');
assert.strictEqual(entry.botId, 'BotA');
assert.strictEqual(entry.state, 'A');
assert.strictEqual(entry.cx, target.x);
assert.strictEqual(entry.cz, target.z);
assert.strictEqual(entry.bx, target.x * 16 + 1);
assert.strictEqual(entry.bz, target.z * 16 + 2);
assert.strictEqual(entry.source, 'chat');
assert.ok(entry.t >= before && entry.t <= Date.now());
assert.strictEqual(entry.sessionCount, 1);
fs.rmSync(dir, { recursive: true, force: true });
});
test('无坐标的通用成功消息记为 chat-generic 来源', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-claimlog2-'));
const { ExpansionLog } = require('../src/core/expansionLog');
const expansionLog = new ExpansionLog({ filePath: path.join(dir, 'expansion-log.jsonl') });
const { controller, config, bot } = setup();
controller.expansionLog = expansionLog;
await controller.start(config);
const target = controller.target;
bot.entity.position.x = target.x * 16 + 8;
bot.entity.position.z = target.z * 16 + 8;
controller.tick();
controller.monitor.onChat('你的疆土已开拓');
const rows = expansionLog.list();
assert.strictEqual(rows.length, 1);
assert.strictEqual(rows[0].source, 'chat-generic');
assert.strictEqual(rows[0].bx, null);
fs.rmSync(dir, { recursive: true, force: true });
});
test('同一区块重复成功只记一条开拓日志', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-claimlog3-'));
const { ExpansionLog } = require('../src/core/expansionLog');
const expansionLog = new ExpansionLog({ filePath: path.join(dir, 'expansion-log.jsonl') });
const { controller, config, bot } = setup();
controller.expansionLog = expansionLog;
await controller.start(config);
const target = controller.target;
bot.entity.position.x = target.x * 16 + 8;
bot.entity.position.z = target.z * 16 + 8;
controller.tick();
const message = `疆土(${target.x},${target.z}) [0,0] 已为你的邦国开拓`;
controller.monitor.onChat(message);
controller.monitor.onChat(message);
assert.strictEqual(expansionLog.list().length, 1);
fs.rmSync(dir, { recursive: true, force: true });
});
+148
查看文件
@@ -0,0 +1,148 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { ExpansionLog } = require('../src/core/expansionLog');
/** 建一个使用临时目录的日志实例 */
function setup(options) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-explog-'));
const filePath = path.join(dir, 'expansion-log.jsonl');
return { log: new ExpansionLog(Object.assign({ filePath }, options || {})), dir, filePath };
}
test('record 记录开拓玩家、区块与时间', () => {
const { log } = setup();
const before = Date.now();
const entry = log.record({
botId: 'BotA',
bot: 'BotA',
state: '现代大陆',
chunk: { x: 12, z: -34 },
block: { x: 200, z: -540 },
source: 'chat',
sessionCount: 7
});
assert.strictEqual(entry.bot, 'BotA');
assert.strictEqual(entry.state, '现代大陆');
assert.strictEqual(entry.cx, 12);
assert.strictEqual(entry.cz, -34);
assert.strictEqual(entry.bx, 200);
assert.strictEqual(entry.bz, -540);
assert.strictEqual(entry.source, 'chat');
assert.strictEqual(entry.sessionCount, 7);
assert.ok(entry.t >= before && entry.t <= Date.now(), '时间戳应落在调用区间内');
});
test('record 缺省字段被安全兜底(无方块坐标 / 无邦国)', () => {
const { log } = setup();
const entry = log.record({ botId: 'B', chunk: { x: 1, z: 2 } });
assert.strictEqual(entry.bx, null);
assert.strictEqual(entry.bz, null);
assert.strictEqual(entry.state, '');
assert.strictEqual(entry.source, 'chat');
assert.strictEqual(entry.sessionCount, null);
});
test('list 支持按机器人 / 邦国 / 时间过滤并限制条数', () => {
const { log } = setup();
const base = Date.now();
log.record({ botId: 'A', chunk: { x: 0, z: 0 }, state: 'X' });
log.record({ botId: 'B', chunk: { x: 1, z: 0 }, state: 'Y' });
log.record({ botId: 'A', chunk: { x: 2, z: 0 }, state: 'Y' });
assert.strictEqual(log.list().length, 3);
assert.strictEqual(log.list({ botId: 'A' }).length, 2);
assert.strictEqual(log.list({ state: 'Y' }).length, 2);
assert.strictEqual(log.list({ since: base + 1e9 }).length, 0);
const limited = log.list({ limit: 2 });
assert.strictEqual(limited.length, 2);
// 取最新 2 条但保持时间升序
assert.deepStrictEqual(limited.map((e) => e.cx), [1, 2]);
});
test('stats 汇总总数、按机器人分布与今日条数', () => {
const { log } = setup();
log.record({ botId: 'A', chunk: { x: 0, z: 0 } });
log.record({ botId: 'A', chunk: { x: 1, z: 0 } });
log.record({ botId: 'B', chunk: { x: 2, z: 0 } });
const stats = log.stats();
assert.strictEqual(stats.total, 3);
assert.strictEqual(stats.buffered, 3);
assert.strictEqual(stats.today, 3);
assert.strictEqual(stats.byBot[0].botId, 'A');
assert.strictEqual(stats.byBot[0].count, 2);
assert.ok(stats.firstAt > 0 && stats.lastAt >= stats.firstAt);
});
test('落盘为 JSONL,重启后能从磁盘恢复历史', () => {
const { log, filePath } = setup();
log.record({ botId: 'A', bot: 'A', chunk: { x: 5, z: 6 }, state: 'S' });
log.record({ botId: 'B', bot: 'B', chunk: { x: 7, z: 8 } });
const lines = fs.readFileSync(filePath, 'utf8').trim().split('\n');
assert.strictEqual(lines.length, 2);
assert.strictEqual(JSON.parse(lines[0]).cx, 5);
const reloaded = new ExpansionLog({ filePath });
assert.strictEqual(reloaded.entries.length, 2);
assert.strictEqual(reloaded.totalRecorded, 2);
assert.strictEqual(reloaded.stats().byBot.length, 2);
});
test('磁盘上的损坏行(进程被杀留下的半行)被跳过而不是崩溃', () => {
const { filePath } = setup();
fs.writeFileSync(filePath, '{"t":1,"botId":"A","bot":"A","cx":1,"cz":1}\n{"t":2,"botId"', 'utf8');
const log = new ExpansionLog({ filePath });
assert.strictEqual(log.entries.length, 1);
assert.strictEqual(log.entries[0].cx, 1);
});
test('内存环形缓冲超过上限时丢弃最旧记录,但总数继续累计', () => {
const { log } = setup({ maxEntries: 3 });
for (let i = 0; i < 5; i++) log.record({ botId: 'A', chunk: { x: i, z: 0 } });
assert.strictEqual(log.entries.length, 3);
assert.strictEqual(log.entries[0].cx, 2);
assert.strictEqual(log.stats().total, 5);
});
test('onEntry 回调收到新记录,回调抛异常不影响写入', () => {
const { log } = setup();
const seen = [];
log.onEntry = (entry) => {
seen.push(entry.cx);
throw new Error('订阅者异常');
};
assert.doesNotThrow(() => log.record({ botId: 'A', chunk: { x: 9, z: 9 } }));
assert.deepStrictEqual(seen, [9]);
assert.strictEqual(log.entries.length, 1);
});
test('exportCsv 输出带 BOM 的表格,含表头与全部记录', () => {
const { log, dir } = setup();
log.record({ botId: 'A', bot: 'A', state: 'X', chunk: { x: 1, z: 2 }, block: { x: 16, z: 32 } });
log.record({ botId: 'B', bot: 'B,带逗号', state: 'Y', chunk: { x: 3, z: 4 } });
const file = path.join(dir, 'out.csv');
assert.strictEqual(log.exportCsv(file), file);
const text = fs.readFileSync(file, 'utf8');
assert.ok(text.startsWith('\ufeff'), '应带 BOM 以便 Excel 正确识别中文');
const lines = text.replace(/^\ufeff/, '').split('\r\n');
assert.ok(lines[0].includes('机器人'));
assert.strictEqual(lines.length, 3);
assert.ok(lines[2].includes('"B,带逗号"'), '含逗号的字段应被引号包裹');
});
test('clearBuffer 只清内存,磁盘文件保留', () => {
const { log, filePath } = setup();
log.record({ botId: 'A', chunk: { x: 1, z: 1 } });
log.clearBuffer();
assert.strictEqual(log.entries.length, 0);
assert.ok(fs.readFileSync(filePath, 'utf8').trim().length > 0);
});
+104 -2
查看文件
@@ -8,6 +8,7 @@ const { FakeBot } = require('./helpers/fakeBot');
const {
GrimCompat,
shortestAngleDelta,
wrapAngle,
eyeDistanceToEntity,
VANILLA_PHYSICS_BASELINE
} = require('../src/bot/grimCompat');
@@ -26,8 +27,84 @@ function setup(options) {
return { bot, grim, config };
}
test('shortestAngleDelta 走最短路径并处理环绕', () => {
assert.ok(Math.abs(shortestAngleDelta(0, 0.5) - 0.5) < 1e-9);
// ==================== 协议补包(线上服务器「走不动」的根因)====================
/** 等一轮宏任务,让 process.nextTick 里发出的 tick_end 落地 */
function flushTick() {
return new Promise((resolve) => setTimeout(resolve, 5));
}
test('每 tick 补发 player_input,内容反映当前控制键', async () => {
const { bot } = setup();
bot.setControlState('forward', true);
bot.setControlState('sprint', true);
bot.clearWritten();
bot.tick();
const packets = bot.writtenPackets('player_input');
assert.strictEqual(packets.length, 1, '每个 physicsTick 都应补发一次 player_input');
assert.strictEqual(packets[0].params.inputs.forward, true);
assert.strictEqual(packets[0].params.inputs.sprint, true);
assert.strictEqual(packets[0].params.inputs.jump, false);
assert.strictEqual(packets[0].params.inputs.backward, false);
assert.strictEqual(packets[0].params.inputs.shift, false);
});
test('每 tick 补发 tick_end,且排在本帧位置包之后', async () => {
const { bot } = setup();
bot.clearWritten();
bot.tick();
assert.strictEqual(bot.writtenPackets('tick_end').length, 0, 'tick_end 应延后到下一轮微任务/宏任务');
await flushTick();
assert.strictEqual(bot.writtenPackets('tick_end').length, 1);
});
test('版本不支持 player_input 时只补 tick_end,不报错', async () => {
const { bot } = setup({ bot: { features: { newPlayerInputPacket: false } } });
bot.clearWritten();
bot.tick();
await flushTick();
assert.strictEqual(bot.writtenPackets('player_input').length, 0);
assert.strictEqual(bot.writtenPackets('tick_end').length, 1);
});
test('关闭开关后不再补发对应包', async () => {
const { bot } = setup({ config: { grim: { sendPlayerInput: false, sendTickEnd: false } } });
bot.clearWritten();
bot.tick();
await flushTick();
assert.strictEqual(bot._client.written.length, 0);
});
test('关掉 Grim 合规层后协议补包仍然生效(否则机器人根本走不动)', async () => {
const { bot } = setup({ config: { grim: { enabled: false } } });
bot.clearWritten();
bot.tick();
await flushTick();
assert.strictEqual(bot.writtenPackets('player_input').length, 1);
assert.strictEqual(bot.writtenPackets('tick_end').length, 1);
});
test('uninstall 后不再补包', async () => {
const { bot, grim } = setup();
grim.uninstall();
bot.clearWritten();
bot.tick();
await flushTick();
assert.strictEqual(bot._client.written.length, 0);
});
test('snapshot 暴露协议补包状态与计数', () => {
const { bot, grim } = setup();
bot.tick();
const snap = grim.snapshot();
assert.strictEqual(snap.protocolPackets.playerInputSupported, true);
assert.strictEqual(snap.protocolPackets.playerInputEnabled, true);
assert.strictEqual(snap.protocolPackets.playerInputSent, 1);
assert.strictEqual(snap.protocolPackets.tickEndEnabled, true);
});
test('shortestAngleDelta 走最短路径并处理环绕', () => { assert.ok(Math.abs(shortestAngleDelta(0, 0.5) - 0.5) < 1e-9);
// 从 170° 到 -170° 应走 +20° 而不是 -340°
const from = (170 * Math.PI) / 180;
const to = (-170 * Math.PI) / 180;
@@ -35,6 +112,31 @@ test('shortestAngleDelta 走最短路径并处理环绕', () => {
assert.ok(Math.abs(shortestAngleDelta(0, Math.PI * 2)) < 1e-9);
});
test('wrapAngle 把任意角度归一化到 (-π, π]', () => {
assert.ok(Math.abs(wrapAngle(0.5) - 0.5) < 1e-9, '范围内不动');
assert.ok(Math.abs(wrapAngle(Math.PI * 2) - 0) < 1e-9, '整圈归零');
// 实测抓到过 19254°(≈336 弧度)的 yaw —— 必须被归一化
const huge = (19254 * Math.PI) / 180;
const wrapped = wrapAngle(huge);
assert.ok(wrapped > -Math.PI && wrapped <= Math.PI, `归一化后应在 (-π, π],实际 ${wrapped}`);
assert.ok(Math.abs(shortestAngleDelta(huge, wrapped)) < 1e-9, '归一化不应改变朝向');
assert.strictEqual(wrapAngle(NaN), 0);
assert.strictEqual(wrapAngle(Infinity), 0);
});
test('旋转推进后 yaw 始终保持归一化(回归:无界增长导致原地走之字形)', () => {
// mineflayer 收到 position 包时会直接给 entity.yaw 赋一个未取模的值,
// 本层又在其上累加;不归一化的话 yaw 会一路涨到上万度,
// 发包朝向在两个方向间来回跳,机器人原地走之字形、净位移恒为 0。
const { grim, bot } = setup();
bot.entity.yaw = (19254 * Math.PI) / 180; // 未归一化的巨大值
grim.setLook(0.5, 0);
for (let i = 0; i < 40; i++) grim._onPhysicsTick();
assert.ok(bot.entity.yaw > -Math.PI && bot.entity.yaw <= Math.PI,
`yaw 应被归一化,实际 ${bot.entity.yaw}`);
assert.ok(Math.abs(shortestAngleDelta(bot.entity.yaw, 0.5)) < 1e-4, '最终仍应朝向目标');
});
test('eyeDistanceToEntity 计算眼睛到碰撞箱的最近距离', () => {
const bot = new FakeBot({ x: 0, y: 64, z: 0 });
// 目标在正前方 2 格,碰撞箱中心高度与眼睛同高
+49
查看文件
@@ -50,6 +50,16 @@ class FakeBot extends EventEmitter {
};
/** @type {Record<string, boolean>} */
this.controls = {};
/**
* 与 mineflayer 保持一致:用带 getter 的 `controlState` 暴露实时控制键。
* mineflayer 传给物理引擎的就是这个对象,所以诊断代码按它读输入。
*/
this.controlState = {};
for (const control of ['forward', 'back', 'left', 'right', 'jump', 'sprint', 'sneak']) {
Object.defineProperty(this.controlState, control, {
get: () => this.controls[control] === true
});
}
this.heldItem = opts.heldItem || null;
this.inventory = { slots: opts.slots || [] };
this.entities = {};
@@ -57,7 +67,37 @@ class FakeBot extends EventEmitter {
this.registry = opts.registry || defaultRegistry();
this.pathfinder = null;
this.blockMap = opts.blockMap || new Map();
/**
* 模拟「已加载区块范围」:设置后,超出该半径(方块)的位置 blockAt 返回 null,
* 用于测试寻路器只能规划已加载范围、需要分段寻路的行为。
* 圆心始终跟随实体当前位置,模拟「人走到哪、区块加载到哪」。
*/
this.loadedRadius = opts.loadedRadius === undefined ? null : opts.loadedRadius;
this.creative = { flying: false };
/** 版本能力探测:默认支持 1.21.3+ 的 player_input 包 */
this.features = Object.assign({ newPlayerInputPacket: true }, opts.features || {});
/** 假的协议客户端:记录写出的包,便于断言「有没有补发 player_input / tick_end」 */
this._client = opts.client || {
state: 'play',
written: [],
write(name, params) {
this.written.push({ name, params });
},
on() {}
};
}
supportFeature(name) {
return this.features[name] === true;
}
/** 取出写出的指定包 */
writtenPackets(name) {
return this._client.written.filter((p) => p.name === name);
}
clearWritten() {
this._client.written.length = 0;
}
getControlState(control) {
@@ -72,6 +112,10 @@ class FakeBot extends EventEmitter {
blockAt(pos) {
const key = `${Math.floor(pos.x)},${Math.floor(pos.y)},${Math.floor(pos.z)}`;
if (this.blockMap.has(key)) return this.blockMap.get(key);
if (this.loadedRadius !== null) {
const center = this.entity.position;
if (Math.hypot(pos.x - center.x, pos.z - center.z) > this.loadedRadius) return null;
}
return { name: 'air', boundingBox: 'empty' };
}
@@ -136,6 +180,11 @@ class FakeBot extends EventEmitter {
tickTimes(n) {
for (let i = 0; i < n; i++) this.emit('physicsTick');
}
/** 接受资源包(记录调用,供测试断言) */
acceptResourcePack() {
this.resourcePackAccepted = true;
}
}
module.exports = { FakeBot };
+290
查看文件
@@ -0,0 +1,290 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const { ManualMover, STUCK_TICKS } = require('../src/bot/manualMover');
const { PathingService, PathEvent } = require('../src/bot/pathingService');
const { defaultConfig } = require('../src/config/expansionConfig');
const { FakeBot } = require('./helpers/fakeBot');
/**
* 造一个「会走路」的假 bot:
* - 每个物理 tick 让出一次事件循环(真实 tick 那样),这样超时能按真实时间生效;
* - 按着 forward 就沿朝向走若干格(direction 与 mineflayer 的 forward 一致);
* - 按着 jump 且站着且冷却好了就抬升 1 格(模拟跳上一格台阶,含原版 10 tick 冷却,
* 避免「按住跳键连升」这种假物理)。
*
* @param {{x?: number, y?: number, z?: number, walkSpeed?: number}} [options]
*/
function setupWalker(options) {
const opts = options || {};
const speed = opts.walkSpeed === undefined ? 0.25 : opts.walkSpeed;
const bot = new FakeBot({
x: opts.x === undefined ? 0.5 : opts.x,
y: opts.y === undefined ? 64 : opts.y,
z: opts.z === undefined ? 0.5 : opts.z
});
let jumpCooldown = 0;
bot.on('physicsTick', () => {
if (jumpCooldown > 0) jumpCooldown--;
if (bot.getControlState('jump') && bot.entity.onGround && jumpCooldown === 0) {
bot.entity.position.y += 1;
bot.entity.onGround = false;
jumpCooldown = 10;
return;
}
if (!bot.entity.onGround) {
// 简化垂直物理:滞空一 tick 后落回整数高度(不模拟抛物线)
bot.entity.position.y = Math.round(bot.entity.position.y);
bot.entity.onGround = true;
return;
}
if (bot.getControlState('forward')) {
const yaw = bot.entity.yaw;
bot.entity.position.x -= Math.sin(yaw) * speed;
bot.entity.position.z -= Math.cos(yaw) * speed;
}
});
// 让每个 tick 都让出事件循环:时间型超时(deadline)才能按真实时间生效
bot.waitForTicks = async () => {
bot.emit('physicsTick');
await new Promise((resolve) => setImmediate(resolve));
};
return bot;
}
/** 造一段最简单的直线路径(沿 +z 方向) */
function straightPath(length) {
const path = [];
for (let i = 0; i <= length; i++) path.push({ x: 0, y: 64, z: i });
return path;
}
test('手动执行器沿路径点走到目标,结束后松开所有控制键', async () => {
const bot = setupWalker();
const mover = new ManualMover(bot, defaultConfig(), null, {
plan: () => Promise.resolve(straightPath(2)),
timeoutMs: 10000
});
const result = await mover.moveTo({ x: 0, z: 2 });
assert.strictEqual(result.ok, true, `应走完路径,实际 ${JSON.stringify(result)}`);
assert.ok(result.waypoints >= 3, '应记录走过的路径点数');
// 最后一段的路径点中心在 z=2.5;走到 2.0 即满足 0.6 格的到达判定
assert.ok(bot.entity.position.z >= 1.9, `应走到目标附近,实际 z=${bot.entity.position.z}`);
assert.strictEqual(bot.getControlState('forward'), false, '结束后必须松开前进键');
assert.strictEqual(bot.getControlState('jump'), false, '结束后必须松开跳跃键');
assert.strictEqual(mover.active, false);
});
test('目标高出一格时按一下 jump(对应手动按键时的爬坎)', async () => {
const bot = setupWalker();
const controls = [];
bot.on('controlState', (control, state) => controls.push({ control, state }));
const mover = new ManualMover(bot, defaultConfig(), null, {
plan: () => Promise.resolve([{ x: 0, y: 64, z: 0 }, { x: 0, y: 65, z: 1 }]),
timeoutMs: 10000
});
const result = await mover.moveTo({ x: 0, z: 1 });
assert.strictEqual(result.ok, true, `应跳上台阶并到位,实际 ${JSON.stringify(result)}`);
assert.ok(controls.some((c) => c.control === 'jump' && c.state === true), '应按下过 jump');
assert.ok(controls.some((c) => c.control === 'jump' && c.state === false), '按一下之后要松开');
assert.ok(Math.abs(bot.entity.position.y - 65) < 1e-6, '应已站到台阶上');
});
test('长时间没有位移时判定卡住并终止(不会无限按键)', async () => {
// 不挂物理模拟:按什么都没用,模拟「服务端把机器人按在原地」
const bot = new FakeBot({ x: 0.5, y: 64, z: 0.5 });
bot.waitForTicks = async () => {
bot.emit('physicsTick');
await new Promise((resolve) => setImmediate(resolve));
};
let planCalls = 0;
const mover = new ManualMover(bot, defaultConfig(), null, {
plan: () => {
planCalls++;
return Promise.resolve(straightPath(1));
},
timeoutMs: 20000
});
const result = await mover.moveTo({ x: 0, z: 1 });
assert.strictEqual(result.ok, false);
assert.strictEqual(result.reason, 'stuck');
assert.ok(planCalls > 1, '卡住后应重新规划再试');
assert.ok(result.ticks >= STUCK_TICKS, `应至少跑满一次卡住判定,实际 ${result.ticks} tick`);
assert.strictEqual(bot.getControlState('forward'), false, '终止后必须松开前进键');
});
test('stop() 立即取消执行并松开控制键', async () => {
// 走慢一点、路径长一点,保证取消发生在半路上而不是走完之后
const bot = setupWalker({ walkSpeed: 0.05 });
const controls = [];
bot.on('controlState', (control, state) => controls.push({ control, state }));
const mover = new ManualMover(bot, defaultConfig(), null, {
plan: () => Promise.resolve(straightPath(500)),
timeoutMs: 20000
});
let ticks = 0;
bot.on('physicsTick', () => {
if (++ticks === 20) mover.stop(); // 跑了 20 tick(前进键按下中)后取消
});
const result = await mover.moveTo({ x: 0, z: 500 });
assert.strictEqual(result.ok, false);
assert.strictEqual(result.reason, 'cancelled');
assert.ok(controls.some((c) => c.control === 'forward' && c.state === true), '取消前应确实按下了前进键');
assert.strictEqual(bot.getControlState('forward'), false, '取消后必须松开前进键');
});
test('默认规划函数逐片推进 A*,success 才返回路径', async () => {
const bot = setupWalker();
const nodes = straightPath(2);
bot.pathfinder = {
movements: {},
thinkTimeout: 100,
tickTimeout: 1,
searchRadius: 0,
getPathFromTo: function * () {
yield { result: { status: 'partial', path: [] } };
yield { result: { status: 'success', path: nodes } };
}
};
const mover = new ManualMover(bot, defaultConfig(), null, { timeoutMs: 10000 });
const result = await mover.moveTo({ x: 0, z: 2 });
assert.strictEqual(result.ok, true, `应走完路径,实际 ${JSON.stringify(result)}`);
assert.strictEqual(mover.lastStatus(), 'success');
});
test('默认规划函数在 A* 报 noPath 时如实返回失败', async () => {
const bot = setupWalker();
bot.pathfinder = {
movements: {},
thinkTimeout: 100,
tickTimeout: 1,
searchRadius: 0,
getPathFromTo: function * () {
yield { result: { status: 'noPath', path: [] } };
}
};
const mover = new ManualMover(bot, defaultConfig(), null, { timeoutMs: 10000 });
const result = await mover.moveTo({ x: 500, z: 500 });
assert.strictEqual(result.ok, false);
assert.strictEqual(result.reason, 'noPath');
});
test('pathfinder 未加载时返回 noPathfinder,不抛异常', async () => {
const bot = setupWalker();
const mover = new ManualMover(bot, defaultConfig(), null, { timeoutMs: 10000 });
const result = await mover.moveTo({ x: 0, z: 0 });
assert.strictEqual(result.ok, false);
assert.strictEqual(result.reason, 'noPathfinder');
});
// ==================== PathingService 的手动执行模式 ====================
/** 造一个手动执行模式的寻路服务(并替换掉真实的 mover,便于断言调用) */
function setupManualService(options) {
const opts = options || {};
const bot = new FakeBot({ x: 0.5, y: 64, z: 0.5, loadedRadius: opts.loadedRadius === undefined ? 128 : opts.loadedRadius });
const goals = [];
bot.pathfinder = {
setMovements: () => {},
setGoal: (goal) => goals.push(goal),
thinkTimeout: 0,
tickTimeout: 0,
searchRadius: 0
};
const config = defaultConfig();
config.pathing.executor = 'manual';
Object.assign(config.pathing, opts.config || {});
const service = new PathingService(bot, config, null);
service.configure();
return { bot, service, goals, config };
}
const flush = () => new Promise((resolve) => setTimeout(resolve, 10));
test('手动模式:configure 会创建手动执行器,且不下发 setGoal', () => {
const { service } = setupManualService();
assert.ok(service.mover, '手动模式下应创建 ManualMover');
assert.strictEqual(service.diagnostics().executor, 'manual');
});
test('手动模式:执行成功时按同一套事件逻辑报到达', async () => {
const { service, goals } = setupManualService();
const events = [];
service.onPathEvent = (event) => events.push(event);
service.mover = {
stop: () => {},
lastStatus: () => 'success',
moveTo: () => Promise.resolve({ ok: true, reason: 'arrived', waypoints: 4, ticks: 20 })
};
assert.strictEqual(service.pathTo({ x: 5, z: 0 }), true);
await flush();
assert.deepStrictEqual(goals, [], '手动模式不应把执行交给 pathfinder');
assert.ok(events.includes(PathEvent.CALC_STARTED));
assert.ok(events.includes(PathEvent.AT_GOAL), '走到最终目标应报到达');
assert.strictEqual(service.isPathing(), false);
});
test('手动模式:执行失败时先缩短段长重试', async () => {
const { service } = setupManualService({ loadedRadius: 128 });
let calls = 0;
service.mover = {
stop: () => {},
lastStatus: () => 'stuck',
moveTo: () => {
calls++;
return Promise.resolve({ ok: false, reason: 'stuck', waypoints: 3, ticks: 40 });
}
};
service.pathTo({ x: 50, z: 0 }); // 远目标 → 走分段逻辑
await flush();
assert.ok(service._segmentShrink >= 1, '应先缩短段长重试');
assert.ok(calls >= 2, '应重新发起执行');
});
test('手动模式:反复失败后如实报 CALC_FAILED,不会无限重试', async () => {
const { service } = setupManualService({ loadedRadius: 128 });
const events = [];
service.onPathEvent = (event) => events.push(event);
service.mover = {
stop: () => {},
lastStatus: () => 'stuck',
moveTo: () => Promise.resolve({ ok: false, reason: 'stuck', waypoints: 3, ticks: 40 })
};
service.pathTo({ x: 50, z: 0 });
await flush();
assert.strictEqual(service.calcFailed, true);
assert.ok(events.includes(PathEvent.CALC_FAILED));
assert.strictEqual(service.isPathing(), false);
});
test('手动模式:cancel 会让在途结果失效并停掉执行器', async () => {
const { service } = setupManualService();
let stopped = false;
let resolveMove = null;
service.mover = {
stop: () => { stopped = true; },
lastStatus: () => null,
moveTo: () => new Promise((resolve) => { resolveMove = resolve; })
};
service.pathTo({ x: 5, z: 0 });
service.cancel();
assert.strictEqual(stopped, true);
// 即使旧执行随后返回成功,也不应再被当成到达
const events = [];
service.onPathEvent = (event) => events.push(event);
resolveMove({ ok: true, reason: 'arrived', waypoints: 1, ticks: 1 });
await flush();
assert.ok(!events.includes(PathEvent.AT_GOAL), '取消后的在途结果必须被忽略');
});
+587
查看文件
@@ -0,0 +1,587 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { WebConsole } = require('../src/web/server');
const { BotPool } = require('../src/bot/botPool');
const { BotStore, BotCredentialStore } = require('../src/config/botStore');
const { ExpansionLog } = require('../src/core/expansionLog');
const { ChatLog, ChatKind } = require('../src/core/chatLog');
const { BigMapService } = require('../src/core/bigMapService');
const { TerritoryModel } = require('../src/core/territoryModel');
const { defaultConfig, normalizeConfig } = require('../src/config/expansionConfig');
const { Logger } = require('../src/util/logger');
const chunkPos = require('../src/core/chunkPos');
/** 造一个含 2 个机器人、完整依赖的控制台 */
function setup(options) {
const opts = options || {};
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-webmulti-'));
const map = new Map([
['A', {
name: 'A',
capital: { x: 1, z: 2 },
chunks: new Set([chunkPos.key(0, 0), chunkPos.key(1, 0)]),
color: 0x2f81f7,
polygons: [[0, 0, 16, 0, 16, 16, 0, 16]]
}],
['B', {
name: 'B',
capital: { x: 9, z: 9 },
chunks: new Set([chunkPos.key(9, 9)]),
color: 0xff43de,
polygons: []
}]
]);
const model = new TerritoryModel(map);
model.setSelfStateName('A');
model.addSessionClaim({ x: 5, z: 5 }, 'A');
const dataService = {
model,
getModel: () => model,
snapshot: () => ({ stateCount: 2, claimedChunks: 4, markersUrl: 'http://x/markers.json' }),
refreshAsync: () => {},
fetchAllTerritoryOnce: async () => ({ ok: true, message: 'ok' }),
detectStateByPlayer: async () => null,
setSelectedStateName: (name) => model.setSelfStateName(name),
addSessionClaim: (chunk, name) => model.addSessionClaim(chunk, name),
applyApiConfig: () => {},
reschedule: () => {},
start: () => {},
stop: () => {}
};
const config = normalizeConfig(Object.assign(defaultConfig(), opts.config || {}));
const logger = new Logger({ echo: false });
const expansionLog = new ExpansionLog({ filePath: path.join(dir, 'expansion-log.jsonl') });
const chatLog = new ChatLog({ dir: path.join(dir, 'chat') });
const bigMap = new BigMapService(config, logger, {
baseUrl: 'http://bgjq.simpfun.cn',
world: 'minecraft_overworld',
cacheDir: path.join(dir, 'mapcache'),
fetchImpl: async () => ({
ok: true,
status: 200,
arrayBuffer: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47]).buffer
})
});
const pool = new BotPool({
connections: [
{ id: 'BotA', username: 'BotA', password: 'pw', host: 'h', port: 25565, version: '1.21.4' },
{ id: 'BotB', username: 'BotB', password: 'pw', host: 'h', port: 25565, version: '1.21.4' }
],
config,
logger,
dataService,
expansionLog,
chatLog,
botStore: new BotStore(path.join(dir, 'bots.json')),
credentialStore: new BotCredentialStore(path.join(dir, 'bots.credentials.json'))
});
const webConsole = new WebConsole({
pool,
dataService,
logger,
expansionLog,
chatLog,
bigMap,
getConfig: () => config,
applyConfig: (next) => Object.assign(config, next)
}, { host: '127.0.0.1', port: 0, exportDir: dir, password: opts.password });
return { webConsole, pool, model, chatLog, expansionLog, bigMap, dir, config, logger };
}
test('statusPayload 汇总全部机器人与新增日志 / 大地图状态', () => {
const { webConsole, pool } = setup();
const payload = webConsole.statusPayload();
assert.strictEqual(payload.bots.length, 2);
assert.strictEqual(payload.bot.id, 'BotA', '单机器人兼容字段指向默认机器人');
assert.strictEqual(payload.task.state, 'IDLE');
assert.strictEqual(payload.defaultBotId, 'BotA');
assert.ok(payload.expansionLog);
assert.ok(payload.chatLog);
assert.ok(payload.bigMap);
// 默认图幅 4096 格半径、层级 0 每张 4096 格 → 4 张瓦片(不再是把全世界拉下来)
assert.strictEqual(payload.bigMap.tiles.count, 4);
assert.strictEqual(payload.bigMap.followBot, true);
assert.strictEqual(pool.size, 2);
});
test('HTTP:/api/bots 返回机器人列表,/api/bots/:id/status 返回单个机器人', async () => {
const { webConsole } = setup();
await webConsole.start();
const base = webConsole.baseUrl();
try {
const bots = await fetch(base + '/api/bots').then((r) => r.json());
assert.strictEqual(bots.ok, true);
assert.deepStrictEqual(bots.data.map((b) => b.id), ['BotA', 'BotB']);
const single = await fetch(base + '/api/bots/BotB/status').then((r) => r.json());
assert.strictEqual(single.data.id, 'BotB');
assert.strictEqual(single.data.task.botId, 'BotB');
const missing = await fetch(base + '/api/bots/没有这个/status');
assert.strictEqual(missing.status, 404);
} finally {
await webConsole.stop();
}
});
test('HTTP:机器人设置可读可写,且不会泄露密码', async () => {
const { webConsole } = setup();
await webConsole.start();
const base = webConsole.baseUrl();
try {
const saved = await fetch(base + '/api/bots/BotB/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label: '二号机', enabled: true, autoStart: false, config: { targetCount: 6 } })
}).then((r) => r.json());
assert.strictEqual(saved.ok, true);
assert.strictEqual(saved.data.label, '二号机');
assert.strictEqual(saved.data.config.targetCount, 6);
assert.ok(!JSON.stringify(saved.data).includes('pw'), '响应中不得含密码');
const read = await fetch(base + '/api/bots/BotB/settings').then((r) => r.json());
assert.strictEqual(read.data.label, '二号机');
} finally {
await webConsole.stop();
}
});
test('HTTP:机器人未就绪时启动任务返回 ok=false 并给出原因', async () => {
const { webConsole } = setup();
await webConsole.start();
const base = webConsole.baseUrl();
try {
const res = await fetch(base + '/api/bots/BotA/task/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
}).then((r) => r.json());
assert.strictEqual(res.ok, false);
assert.ok(res.message.includes('尚未就绪'));
} finally {
await webConsole.stop();
}
});
test('HTTP:聊天接口在未完成登录验证时返回 409,缺参数返回 400', async () => {
const { webConsole, pool } = setup();
await webConsole.start();
const base = webConsole.baseUrl();
try {
const missing = await fetch(base + '/api/bots/BotA/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
assert.strictEqual(missing.status, 400);
// 未连接:提示应指向「没连上」而不是含糊的失败
const offline = await fetch(base + '/api/bots/BotA/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: '大家好' })
});
assert.strictEqual(offline.status, 409);
const offlineBody = await offline.json();
assert.ok(offlineBody.message.includes('未连接'), '离线时应说明未连接');
// 已连接但未通过登录验证:提示应指向登录门禁
const manager = pool.get('BotA').manager;
manager.state = 'online';
manager.bot = { chat: () => {} };
manager.loggedIn = false;
const denied = await fetch(base + '/api/bots/BotA/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: '大家好' })
});
assert.strictEqual(denied.status, 409);
const body = await denied.json();
assert.ok(body.message.includes('未完成登录验证'));
} finally {
await webConsole.stop();
}
});
test('HTTP:指令接口区分「未连接」与「未通过登录验证」,并放行验证类指令', async () => {
const { webConsole, pool } = setup();
await webConsole.start();
const base = webConsole.baseUrl();
const post = (command) => fetch(base + '/api/bots/BotA/command', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command })
});
try {
const offline = await post('u claimmode');
assert.strictEqual(offline.status, 409);
assert.ok((await offline.json()).message.includes('未连接'));
const manager = pool.get('BotA').manager;
const sent = [];
manager.bot = { chat: (text) => sent.push(text) };
manager.state = 'online';
manager.loggedIn = false;
// 任务类指令仍被拦下
const blocked = await post('u claimmode');
assert.strictEqual(blocked.status, 409);
assert.ok((await blocked.json()).message.includes('未完成登录验证'));
// 验证类指令放行(否则无法完成登录,形成死锁)
const allowed = await post('/auth 12345 6789');
assert.strictEqual(allowed.status, 200);
assert.strictEqual((await allowed.json()).ok, true);
} finally {
await webConsole.stop();
}
});
test('HTTP:公屏日志可按机器人查询与导出', async () => {
const { webConsole, chatLog } = setup();
chatLog.append({ botId: 'BotA', bot: 'BotA', message: '你好', kind: ChatKind.CHAT, username: 'Steve' });
chatLog.append({ botId: 'BotB', bot: 'BotB', message: '别的机器人的消息' });
await webConsole.start();
const base = webConsole.baseUrl();
try {
const rows = await fetch(base + '/api/bots/BotA/chat').then((r) => r.json());
assert.strictEqual(rows.data.length, 1);
assert.strictEqual(rows.data[0].message, '你好');
const exported = await fetch(base + '/api/bots/BotA/chat/export', { method: 'POST' }).then((r) => r.json());
assert.strictEqual(exported.ok, true);
assert.ok(fs.existsSync(exported.data.file));
} finally {
await webConsole.stop();
}
});
test('HTTP:开拓日志查询与 CSV 导出', async () => {
const { webConsole, expansionLog } = setup();
expansionLog.record({
botId: 'BotA',
bot: 'BotA',
state: 'A',
chunk: { x: 3, z: 4 },
block: { x: 48, z: 64 },
source: 'chat'
});
expansionLog.record({ botId: 'BotB', bot: 'BotB', state: 'B', chunk: { x: 9, z: 9 } });
await webConsole.start();
const base = webConsole.baseUrl();
try {
const all = await fetch(base + '/api/expansion-log').then((r) => r.json());
assert.strictEqual(all.data.entries.length, 2);
assert.strictEqual(all.data.stats.total, 2);
assert.strictEqual(all.data.stats.byBot.length, 2);
const filtered = await fetch(base + '/api/expansion-log?botId=BotA').then((r) => r.json());
assert.strictEqual(filtered.data.entries.length, 1);
assert.strictEqual(filtered.data.entries[0].cx, 3);
const exported = await fetch(base + '/api/expansion-log/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
}).then((r) => r.json());
assert.strictEqual(exported.ok, true);
const text = fs.readFileSync(exported.data.file, 'utf8');
assert.ok(text.includes('机器人'), 'CSV 应含表头');
assert.ok(text.includes('BotA'));
} finally {
await webConsole.stop();
}
});
test('HTTP:大地图元信息、领土标记与瓦片代理', async () => {
const { webConsole } = setup();
await webConsole.start();
const base = webConsole.baseUrl();
try {
const meta = await fetch(base + '/api/bigmap').then((r) => r.json());
assert.strictEqual(meta.data.world, 'minecraft_overworld');
assert.strictEqual(meta.data.tilePixels, 512);
assert.strictEqual(meta.data.tiles.count, 4);
assert.strictEqual(meta.data.rangeBlocks, 4096);
// 机器人还没进世界 → 中心回退到世界原点
assert.strictEqual(meta.data.center.source, 'origin');
assert.strictEqual(meta.data.bots.length, 2, '大地图应带上全部机器人坐标');
const markers = await fetch(base + '/api/bigmap/markers').then((r) => r.json());
const a = markers.data.states.find((s) => s.name === 'A');
assert.strictEqual(a.color, '#2f81f7');
assert.strictEqual(a.polygons.length, 1);
assert.deepStrictEqual(a.polygons[0], [0, 0, 16, 0, 16, 16, 0, 16]);
const tile = await fetch(base + '/api/bigmap/tile/0/0/0.png');
assert.strictEqual(tile.status, 200);
assert.strictEqual(tile.headers.get('content-type'), 'image/png');
const badTile = await fetch(base + '/api/bigmap/tile/x/0/0.png');
assert.strictEqual(badTile.status, 400);
} finally {
await webConsole.stop();
}
});
test('HTTP:地图数据可按机器人视角返回各自的邦国与目标', async () => {
const { webConsole, pool } = setup();
pool.get('BotB').controller.stateName = 'B';
await webConsole.start();
const base = webConsole.baseUrl();
try {
const a = await fetch(base + '/api/map?bot=BotA').then((r) => r.json());
assert.strictEqual(a.data.selfState, 'A');
// 邦国 A 原有 2 个区块 + 1 个会话占领区块 = 3 个区块 → 6 个数字
assert.strictEqual(a.data.self.length, 6);
const b = await fetch(base + '/api/map?bot=BotB').then((r) => r.json());
assert.strictEqual(b.data.selfState, 'B');
assert.strictEqual(b.data.self.length, 2);
} finally {
await webConsole.stop();
}
});
test('HTTP:配置了控制台密码时未登录一律 401,登录后可访问', async () => {
const { webConsole } = setup({ password: 'console-pw' });
await webConsole.start();
const base = webConsole.baseUrl();
try {
// 会话探测接口本身免鉴权,用于前端决定是否弹登录框
const session = await fetch(base + '/api/session').then((r) => r.json());
assert.strictEqual(session.data.required, true);
assert.strictEqual(session.data.authenticated, false);
const denied = await fetch(base + '/api/bots');
assert.strictEqual(denied.status, 401);
const wrong = await fetch(base + '/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: '错的' })
});
assert.strictEqual(wrong.status, 401);
const login = await fetch(base + '/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: 'console-pw' })
}).then((r) => r.json());
assert.strictEqual(login.ok, true);
assert.strictEqual(login.data.authenticated, true);
const allowed = await fetch(base + '/api/bots', { headers: { 'X-Auth-Token': 'console-pw' } });
assert.strictEqual(allowed.status, 200);
// 静态页面本身仍需可加载,否则用户看不到登录框
const html = await fetch(base + '/');
assert.strictEqual(html.status, 200);
} finally {
await webConsole.stop();
}
});
test('HTTP:可在控制台新增与移除玩家,响应中不含密码', async () => {
const { webConsole, pool } = setup();
// 避免测试真的发起网络连接
const proto = Object.getPrototypeOf(pool.get('BotA').manager);
const originalConnect = proto.connect;
proto.connect = function stub() {};
await webConsole.start();
const base = webConsole.baseUrl();
try {
const added = await fetch(base + '/api/bots', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'NewBot', password: 'super-secret', label: '新号' })
}).then((r) => r.json());
assert.strictEqual(added.ok, true);
assert.strictEqual(added.data.id, 'NewBot');
assert.ok(!JSON.stringify(added).includes('super-secret'), '响应中不得回显密码');
assert.deepStrictEqual(added.data.bots.map((b) => b.id), ['BotA', 'BotB', 'NewBot']);
assert.strictEqual(added.data.bots[2].source, 'store');
const duplicate = await fetch(base + '/api/bots', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'NewBot' })
});
assert.strictEqual(duplicate.status, 400);
const invalid = await fetch(base + '/api/bots', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: '带中文的名字' })
});
assert.strictEqual(invalid.status, 400);
// .env 里配置的账号不允许从控制台移除
const envBot = await fetch(base + '/api/bots/BotA', { method: 'DELETE' });
assert.strictEqual(envBot.status, 400);
const removed = await fetch(base + '/api/bots/NewBot', { method: 'DELETE' }).then((r) => r.json());
assert.strictEqual(removed.ok, true);
assert.deepStrictEqual(removed.data.bots.map((b) => b.id), ['BotA', 'BotB']);
} finally {
proto.connect = originalConnect;
await webConsole.stop();
}
});
test('HTTP:定时消息可读可写,且不影响其它设置', async () => {
const { webConsole, pool } = setup();
await webConsole.start();
const base = webConsole.baseUrl();
try {
const initial = await fetch(base + '/api/bots/BotA/schedules').then((r) => r.json());
assert.deepStrictEqual(initial.data, []);
const saved = await fetch(base + '/api/bots/BotA/schedules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
schedules: [
{ id: 'restore', label: '恢复民心', content: '/u restore confirm', intervalSeconds: 300 },
{ content: '大家好', intervalSeconds: 600, enabled: false },
{ content: ' ' }
]
})
}).then((r) => r.json());
assert.strictEqual(saved.ok, true);
assert.strictEqual(saved.data.length, 2, '空内容应被丢弃');
assert.strictEqual(saved.data[0].kind, 'command', '以 / 开头识别为指令');
assert.strictEqual(saved.data[1].kind, 'chat');
assert.strictEqual(saved.data[1].enabled, false);
// 定时任务也应出现在状态负载里,前端据此渲染
const status = await fetch(base + '/api/status').then((r) => r.json());
const bot = status.data.bots.find((b) => b.id === 'BotA');
assert.strictEqual(bot.schedules.length, 2);
assert.ok(bot.schedules[0].nextDueAt > 0);
// 间隔过小会被钳制,防止刷屏
const clamped = await fetch(base + '/api/bots/BotA/schedules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ schedules: [{ content: 'x', intervalSeconds: 1 }] })
}).then((r) => r.json());
assert.strictEqual(clamped.data[0].intervalSeconds, 5);
// 保存定时任务不应清掉别的设置
assert.strictEqual(pool.settingSnapshot('BotA').enabled, true);
} finally {
await webConsole.stop();
}
});
test('HTTP:大地图图幅可按机器人位置与半径计算瓦片范围', async () => {
const { webConsole, pool } = setup();
// 给机器人一个真实坐标,验证中心会跟着它走
const manager = pool.get('BotB').manager;
manager.bot = { entity: { position: { x: 18432, y: 72, z: 18432 } } };
manager.state = 'online';
await webConsole.start();
const base = webConsole.baseUrl();
try {
const meta = await fetch(base + '/api/bigmap?rangeBlocks=1024&bot=BotB').then((r) => r.json());
assert.strictEqual(meta.data.center.source, 'bot');
assert.strictEqual(meta.data.center.x, 18432);
assert.strictEqual(meta.data.rangeBlocks, 1024);
assert.strictEqual(meta.data.tiles.count, 1, '1024 格半径且中心落在瓦片正中时只要 1 张瓦片');
assert.strictEqual(meta.data.tiles.minX, 4);
// 换成大图幅后瓦片数随之增加
const wide = await fetch(base + '/api/bigmap?rangeBlocks=8192&bot=BotB').then((r) => r.json());
assert.ok(wide.data.tiles.count > meta.data.tiles.count);
assert.strictEqual(wide.data.rangeBlocks, 8192);
} finally {
await webConsole.stop();
}
});
test('HTTP:可让机器人下线(停止自动重连)与重新上线', async () => {
const { webConsole, pool } = setup();
const entry = pool.get('BotA');
let disconnected = false;
entry.manager.disconnect = () => { disconnected = true; };
const connected = [];
entry.manager.connect = () => connected.push('BotA');
await webConsole.start();
const base = webConsole.baseUrl();
try {
const offline = await fetch(base + '/api/bots/BotA/offline', { method: 'POST' }).then((r) => r.json());
assert.strictEqual(offline.ok, true);
assert.ok(offline.message.includes('已下线'));
assert.strictEqual(disconnected, true);
assert.ok(offline.data.bots, '应返回最新状态负载供前端直接渲染');
const online = await fetch(base + '/api/bots/BotA/online', { method: 'POST' }).then((r) => r.json());
assert.strictEqual(online.ok, true);
assert.ok(online.message.includes('正在上线'));
assert.deepStrictEqual(connected, ['BotA']);
const missing = await fetch(base + '/api/bots/没有这个/offline', { method: 'POST' });
assert.strictEqual(missing.status, 404);
} finally {
await webConsole.stop();
}
});
test('HTTP:未配置密码时免登录(本机开发模式)', async () => {
const { webConsole } = setup();
await webConsole.start();
const base = webConsole.baseUrl();
try {
const session = await fetch(base + '/api/session').then((r) => r.json());
assert.strictEqual(session.data.required, false);
assert.strictEqual(session.data.authenticated, true);
const bots = await fetch(base + '/api/bots');
assert.strictEqual(bots.status, 200);
} finally {
await webConsole.stop();
}
});
test('WebSocket:握手鉴权失败时被拒绝,成功后可收到状态推送', async () => {
const { webConsole } = setup({ password: 'console-pw' });
await webConsole.start();
const base = webConsole.baseUrl();
const WebSocket = require('ws');
try {
const rejected = await new Promise((resolve) => {
const socket = new WebSocket(base.replace('http', 'ws') + '/ws');
socket.on('error', () => resolve('error'));
socket.on('open', () => { socket.close(); resolve('open'); });
});
assert.strictEqual(rejected, 'error');
const payload = await new Promise((resolve, reject) => {
const socket = new WebSocket(base.replace('http', 'ws') + '/ws?token=console-pw');
const timer = setTimeout(() => reject(new Error('未收到状态推送')), 3000);
socket.on('message', (data) => {
const parsed = JSON.parse(data.toString());
if (parsed.type === 'status') {
clearTimeout(timer);
socket.close();
resolve(parsed.data);
}
});
socket.on('error', reject);
});
assert.strictEqual(payload.bots.length, 2);
} finally {
await webConsole.stop();
}
});
+109
查看文件
@@ -0,0 +1,109 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const { Notifier } = require('../src/core/notifier');
const { defaultConfig } = require('../src/config/expansionConfig');
/** 造一个记录请求的假 fetch */
function setup(options) {
const opts = options || {};
const config = defaultConfig();
Object.assign(config.notify, opts.notify || {});
const calls = [];
const fetchImpl = opts.fetchImpl || (async (url, init) => {
calls.push({ url, init });
return { ok: true, status: 200 };
});
const notifier = new Notifier(config, null, { fetchImpl, minIntervalMs: opts.minIntervalMs });
return { notifier, config, calls };
}
test('默认关闭:没启用或没配地址时什么都不发', async () => {
const off = setup();
assert.strictEqual(off.notifier.isEnabled(), false);
assert.strictEqual(await off.notifier.notify('hi'), false);
assert.strictEqual(off.calls.length, 0);
const noUrl = setup({ notify: { enabled: true } });
assert.strictEqual(noUrl.notifier.isEnabled(), false, '没有地址等同于没启用');
assert.strictEqual(await noUrl.notifier.notify('hi'), false);
});
test('启用后按飞书自定义机器人的文本格式 POST', async () => {
const { notifier, calls } = setup({
notify: { enabled: true, webhookUrl: 'https://open.feishu.cn/open-apis/bot/v2/hook/abc' }
});
assert.strictEqual(notifier.isEnabled(), true);
assert.strictEqual(await notifier.notify('需要验证码'), true);
assert.strictEqual(calls.length, 1);
assert.strictEqual(calls[0].url, 'https://open.feishu.cn/open-apis/bot/v2/hook/abc');
const body = JSON.parse(calls[0].init.body);
assert.strictEqual(body.msgtype, 'text');
assert.strictEqual(body.content.text, '需要验证码');
assert.strictEqual(notifier.sent, 1);
});
test('同一个去重 key 在最小间隔内只发一次(避免反复打扰)', async () => {
const { notifier, calls } = setup({
notify: { enabled: true, webhookUrl: 'https://example.com/hook' },
minIntervalMs: 60000
});
assert.strictEqual(await notifier.notify('a', { key: 'verification:BotA' }), true);
assert.strictEqual(await notifier.notify('a', { key: 'verification:BotA' }), false, '同一问题不重复提醒');
assert.strictEqual(calls.length, 1);
// 不同 key 互不影响
assert.strictEqual(await notifier.notify('b', { key: 'rescue-failed:BotA' }), true);
assert.strictEqual(calls.length, 2);
// force 可以跳过去重(手动重发用)
assert.strictEqual(await notifier.notify('a', { key: 'verification:BotA', force: true }), true);
assert.strictEqual(calls.length, 3);
});
test('Webhook 地址可回退环境变量 NOTIFY_WEBHOOK_URL', async () => {
const previous = process.env.NOTIFY_WEBHOOK_URL;
process.env.NOTIFY_WEBHOOK_URL = 'https://example.com/from-env';
try {
const { notifier, calls } = setup({ notify: { enabled: true } });
assert.strictEqual(notifier.isEnabled(), true, '配置里没填地址时应回退环境变量');
await notifier.notify('hi');
assert.strictEqual(calls[0].url, 'https://example.com/from-env');
} finally {
if (previous === undefined) delete process.env.NOTIFY_WEBHOOK_URL;
else process.env.NOTIFY_WEBHOOK_URL = previous;
}
});
test('发送失败与异常都被记下来,不会抛出', async () => {
const { notifier } = setup({
notify: { enabled: true, webhookUrl: 'https://example.com/hook' },
fetchImpl: async () => ({ ok: false, status: 500 })
});
assert.strictEqual(await notifier.notify('hi'), false);
assert.strictEqual(notifier.failed, 1);
assert.strictEqual(notifier.lastError, 'HTTP 500');
const throwing = setup({
notify: { enabled: true, webhookUrl: 'https://example.com/hook' },
fetchImpl: async () => {
throw new Error('网络不通');
}
});
assert.strictEqual(await throwing.notifier.notify('hi'), false, '异常必须被吞掉');
assert.strictEqual(throwing.notifier.failed, 1);
assert.ok(throwing.notifier.lastError.includes('网络不通'));
});
test('snapshot 只暴露域名,不泄露带凭证的完整地址', async () => {
const { notifier } = setup({
notify: { enabled: true, webhookUrl: 'https://open.feishu.cn/open-apis/bot/v2/hook/secret-token' }
});
await notifier.notify('hi');
const snap = notifier.snapshot();
assert.strictEqual(snap.enabled, true);
assert.strictEqual(snap.configured, true);
assert.strictEqual(snap.host, 'open.feishu.cn');
assert.strictEqual(snap.sent, 1);
assert.ok(!JSON.stringify(snap).includes('secret-token'), '完整地址不能出现在接口响应里');
});
+251
查看文件
@@ -0,0 +1,251 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const { PacketCapture, DEFAULT_MAX_PACKETS } = require('../src/core/packetCapture');
/** 造一个假的 mineflayer client:只记下 write 被调用了什么 */
function fakeClient() {
const sent = [];
return {
sent,
write(name, params) {
sent.push({ name, params });
return true;
}
};
}
/** 一个类实例形式的向量(用来验证「类实例只留坐标」) */
class FakeVec3 {
constructor(x, y, z) {
this.x = x;
this.y = y;
this.z = z;
}
clone() {
return new FakeVec3(this.x, this.y, this.z);
}
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
test('没有连接时拒绝开始抓包', () => {
const capture = new PacketCapture();
const result = capture.start(1);
assert.strictEqual(result.ok, false);
assert.match(result.message, /没有可录制的连接/);
assert.strictEqual(capture.running, false);
});
test('录制期间记录出站包,同时原样发包', () => {
const client = fakeClient();
const capture = new PacketCapture();
capture.setClient(client);
const original = client.write;
assert.strictEqual(capture.start(1).ok, true);
assert.strictEqual(capture.running, true);
assert.notStrictEqual(client.write, original, '录制期间 write 应被挂钩');
client.write('position_look', { x: 1, y: 2, z: 3, onGround: true });
client.write('player_input', { inputs: { forward: true } });
// 发包链路不受影响:原 write 照样被调用
assert.strictEqual(client.sent.length, 2);
assert.deepStrictEqual(client.sent[0], {
name: 'position_look',
params: { x: 1, y: 2, z: 3, onGround: true }
});
const snap = capture.snapshot();
assert.strictEqual(snap.count, 2);
assert.deepStrictEqual(snap.byType, { position_look: 1, player_input: 1 });
assert.strictEqual(snap.packets[0].name, 'position_look');
assert.strictEqual(snap.packets[0].params.onGround, true);
assert.ok(snap.packets[1].t >= snap.packets[0].t, 't 应为相对开始时间的非递减偏移');
});
test('position_look 的参数是普通对象,yaw / pitch / onGround / flags 都必须保留', () => {
// 回归:曾按「有 x/y/z 就当向量」处理,会把 position_look 的
// yaw / pitch / onGround / flags 全部吞掉 —— 而那正是最需要对照的字段
const client = fakeClient();
const capture = new PacketCapture();
capture.setClient(client);
capture.start();
client.write('position_look', {
x: 1.23456789,
y: 64,
z: -3.5,
yaw: 90.5,
pitch: -12.25,
onGround: true,
flags: { onGround: true, hasHorizontalCollision: undefined }
});
const params = capture.snapshot().packets[0].params;
assert.strictEqual(params.yaw, 90.5);
assert.strictEqual(params.pitch, -12.25);
assert.strictEqual(params.onGround, true);
assert.deepStrictEqual(params.flags, { onGround: true, hasHorizontalCollision: null });
assert.strictEqual(params.x, 1.23457, '浮点保留 5 位');
});
test('类实例形式的向量只留坐标', () => {
const client = fakeClient();
const capture = new PacketCapture();
capture.setClient(client);
capture.start();
client.write('custom_payload', { pos: new FakeVec3(1, 2, 3), name: 'x' });
const params = capture.snapshot().packets[0].params;
assert.deepStrictEqual(params.pos, { x: 1, y: 2, z: 3 });
assert.strictEqual(params.name, 'x');
});
test('停止后摘掉钩子并不再记录', () => {
const client = fakeClient();
const capture = new PacketCapture();
capture.setClient(client);
const original = client.write;
capture.start();
client.write('position', { x: 0 });
const result = capture.stop();
assert.strictEqual(result.ok, true);
assert.strictEqual(capture.running, false);
assert.strictEqual(client.write, original, '停止后 write 应恢复原函数');
client.write('position', { x: 1 });
assert.strictEqual(capture.snapshot().count, 1, '停止后不应再记录');
assert.strictEqual(client.sent.length, 2, '停止后仍然正常发包');
});
test('重复 stop 不会报错', () => {
const client = fakeClient();
const capture = new PacketCapture();
capture.setClient(client);
capture.start();
assert.strictEqual(capture.stop().ok, true);
assert.strictEqual(capture.stop().ok, true);
assert.match(capture.stop().message, /没有在抓包/);
});
test('到达上限后丢弃并计数,不无限吃内存', () => {
const client = fakeClient();
const capture = new PacketCapture({ maxPackets: 3 });
capture.setClient(client);
capture.start();
for (let i = 0; i < 10; i++) client.write('position', { x: i });
const snap = capture.snapshot();
assert.strictEqual(snap.count, 3);
assert.strictEqual(snap.dropped, 7);
assert.strictEqual(snap.packets[2].params.x, 2, '保留的是最早的那几个包');
});
test('snapshot 支持按包名过滤与 limit', () => {
const client = fakeClient();
const capture = new PacketCapture();
capture.setClient(client);
capture.start();
client.write('position_look', { x: 1 });
client.write('look', { yaw: 1 });
client.write('position_look', { x: 2 });
client.write('position_look', { x: 3 });
const filtered = capture.snapshot({ types: 'position_look,look' });
assert.strictEqual(filtered.count, 4, 'count 是过滤前的总数');
assert.strictEqual(filtered.returned, 4);
const onlyLook = capture.snapshot({ types: ['look'] });
assert.strictEqual(onlyLook.returned, 1);
assert.strictEqual(onlyLook.packets[0].name, 'look');
const limited = capture.snapshot({ limit: 2 });
assert.strictEqual(limited.returned, 2);
assert.strictEqual(limited.count, 4);
});
test('给出时长后到点自动停止并摘钩子', async () => {
const client = fakeClient();
const capture = new PacketCapture();
capture.setClient(client);
const original = client.write;
capture.start(0.05);
assert.strictEqual(capture.running, true);
await sleep(150);
assert.strictEqual(capture.running, false, '到点应自动停止');
assert.strictEqual(client.write, original, '自动停止也要摘钩子');
assert.ok(capture.snapshot().stoppedAt > 0);
});
test('录制时长被上限截断', () => {
const client = fakeClient();
const capture = new PacketCapture();
capture.setClient(client);
const result = capture.start(9999);
assert.match(result.message, /60 秒/);
capture.stop();
});
test('换连接时先收尾旧录制', () => {
const first = fakeClient();
const second = fakeClient();
const capture = new PacketCapture();
capture.setClient(first);
capture.start();
first.write('position', { x: 1 });
const firstOriginal = first.write;
assert.notStrictEqual(firstOriginal, undefined);
capture.setClient(second);
assert.strictEqual(capture.running, false, '换连接应停止录制');
assert.strictEqual(capture.snapshot().count, 1, '旧连接录到的内容保留');
first.write('position', { x: 2 });
assert.strictEqual(capture.snapshot().count, 1, '旧连接不再被记录');
});
test('clear 清空已录内容', () => {
const client = fakeClient();
const capture = new PacketCapture();
capture.setClient(client);
capture.start();
client.write('position', { x: 1 });
assert.strictEqual(capture.snapshot().count, 1);
capture.clear();
const snap = capture.snapshot();
assert.strictEqual(snap.count, 0);
assert.deepStrictEqual(snap.byType, {});
assert.strictEqual(snap.running, true, 'clear 不影响录制状态');
});
test('抓包自身出异常也绝不影响发包', () => {
const client = fakeClient();
const capture = new PacketCapture();
capture.setClient(client);
capture.start();
// 造一个序列化时会抛的对象(getter 抛错)
const evil = {};
Object.defineProperty(evil, 'boom', {
enumerable: true,
get() {
throw new Error('getter 炸了');
}
});
assert.doesNotThrow(() => client.write('custom_payload', evil));
assert.strictEqual(client.sent.length, 1, '异常包仍然要发出去');
});
test('默认上限是一个合理的正数', () => {
assert.ok(DEFAULT_MAX_PACKETS > 0);
assert.strictEqual(new PacketCapture().maxPackets, DEFAULT_MAX_PACKETS);
});
+184 -2
查看文件
@@ -35,8 +35,190 @@ test('AltitudeGoal 使用 GoalNearXZ 时保留其到达范围', () => {
assert.strictEqual(goal.isEnd({ x: 100, y: 70, z: 201 }), true, '范围内算到达');
});
test('configure 施加 Grim 合规约束(默认禁止挖 / 放 / 跑酷 / 搭塔)', () => {
const bot = new FakeBot();
test('高度偏好默认关闭:它的启发值会破坏 partial 路径的终点选择', () => {
// 回归:开启时实测 inputTicks = 0(寻路器一次移动指令都发不出),
// 关掉后 inputTicks = 237(立刻开始移动)。详见 AltitudeGoal 的注释。
const config = defaultConfig();
assert.strictEqual(config.altitudePreference, false, '默认必须是关闭的');
// 上面的差值断言同时说明了「为什么」:h 被减去了 weight × y,
// 而 mineflayer-pathfinder 用 h 最小来挑 partial 路径终点,
// 于是又高又偏的节点会胜出,路径永远到不了目标附近。
const goal = new AltitudeGoal(new GoalXZ(0, 0), 0.1, 64);
const farButHigh = goal.heuristic({ x: 0, y: 300, z: 0 }); // 就在目标正上方
const nearButLow = goal.heuristic({ x: 40, y: 64, z: 0 }); // 离目标 40 格
assert.ok(farButHigh <= nearButLow,
`高处节点的启发值 ${farButHigh} 不大于低处近节点的 ${nearButLow},正是问题所在`);
});
test('寻路状态日志被节流:进度刷屏不会把日志写爆', () => {
// mineflayer-pathfinder 每用满一个 tick 的计算预算(tickTimeout,默认 40ms)
// 就发一次 path_update。不节流时实测 4 分钟写了 3893 条「寻路状态」,
// 既淹没真正有用的日志(一天上百 MB,本服磁盘本就只剩 2.4G),
// 也让「寻路卡在哪」更难看清。
const bot = new FakeBot({ x: 0.5, y: 64, z: 0.5 });
bot.pathfinder = {
setMovements: () => {},
setGoal: () => {},
thinkTimeout: 0,
tickTimeout: 0,
searchRadius: 0
};
const logs = [];
const logger = { log: (msg) => logs.push(String(msg)), error: () => {}, list: () => [], clear: () => {} };
const service = new PathingService(bot, defaultConfig(), logger);
service.configure();
// 模拟一次长规划:状态一直是 partial、节点数快速增长
for (let i = 0; i < 300; i++) {
bot.emit('path_update', { status: 'partial', visitedNodes: i * 20, cost: 90 });
}
const progressLogs = logs.filter((m) => m.includes('寻路状态'));
assert.ok(progressLogs.length >= 2, `不应把进度信息完全丢掉,实际 ${progressLogs.length} 条`);
assert.ok(progressLogs.length <= 12, `300 次 partial 应被节流到十几条以内,实际 ${progressLogs.length} 条`);
// 状态变化是最要紧的信号,必须立刻记一条
logs.length = 0;
bot.emit('path_update', { status: 'success', visitedNodes: 6100, cost: 90 });
assert.strictEqual(logs.filter((m) => m.includes('寻路状态')).length, 1, '状态变化要立刻记录');
});
// ==================== 长距离分段寻路 ====================
//
// mineflayer-pathfinder 只能规划已加载区块范围(实测线上约 8 区块 ≈ 130 格),
// 而扩地目标常在几十个区块外,A* 会瞬间 noPath。分段寻路就是为这个场景写的。
/** 造一个「已加载范围」有限的 bot 与寻路服务 */
function setupSegment(options) {
const opts = options || {};
const bot = new FakeBot({ x: opts.x === undefined ? 0.5 : opts.x, y: 64, z: opts.z === undefined ? 0.5 : opts.z, loadedRadius: opts.loadedRadius === undefined ? 128 : opts.loadedRadius });
const goals = [];
bot.pathfinder = {
setMovements: () => {},
setGoal: (goal) => goals.push(goal),
thinkTimeout: 0,
tickTimeout: 0,
searchRadius: 0
};
const config = defaultConfig();
config.enforceClaimTime = false;
Object.assign(config.pathing, opts.config || {});
const service = new PathingService(bot, config, null);
service.configure();
return { bot, service, goals, config };
}
test('_probeLoadedDistance 沿方向探测到已加载范围的边缘', () => {
const { service } = setupSegment({ loadedRadius: 128 });
assert.ok(Math.abs(service._probeLoadedDistance(1, 0, 512) - 128) < 2,
'向右探测应停在已加载半径处');
assert.ok(Math.abs(service._probeLoadedDistance(0, -1, 512) - 128) < 2);
});
test('_segmentGoal 把远超已加载范围的目标裁剪到边缘附近', () => {
const { service } = setupSegment({
loadedRadius: 128,
config: { segmentMarginBlocks: 24, maxSegmentBlocks: 4096 }
});
const far = { x: 0.5 + 800, z: 0.5 };
const waypoint = service._segmentGoal(far);
assert.ok(waypoint.x < far.x, '必须裁剪,否则寻路器会瞬间 noPath');
assert.ok(waypoint.x > 0.5, '不能原地不动');
assert.ok(Math.abs(waypoint.x - (0.5 + 128 - 24)) < 2, `段长应约为 已加载半径 - 边距,实际 ${waypoint.x}`);
assert.ok(Math.abs(waypoint.z - 0.5) < 1, '方向应保持指向目标');
});
test('_segmentGoal 的段长同时受「单段上限」约束', () => {
const { service } = setupSegment({
loadedRadius: 512,
config: { segmentMarginBlocks: 24, maxSegmentBlocks: 64 }
});
const waypoint = service._segmentGoal({ x: 0.5 + 800, z: 0.5 });
assert.ok(Math.abs(waypoint.x - (0.5 + 64)) < 3,
`即使已加载范围很大,单段也不该超过 maxSegmentBlocks,实际 ${waypoint.x}`);
});
test('_segmentGoal 目标在已加载范围内时不做裁剪', () => {
const { service } = setupSegment({ loadedRadius: 128 });
const near = { x: 0.5 + 40, z: 0.5 };
assert.deepStrictEqual(service._segmentGoal(near), near);
});
test('pathTo 长距离目标时下发的是分段中途点而不是最终目标', () => {
const { service, goals } = setupSegment({ loadedRadius: 128 });
// 目标区块中心约在 (800, 0)
assert.strictEqual(service.pathTo({ x: 50, z: 0 }), true);
assert.strictEqual(goals.length, 1);
assert.strictEqual(service._waypointIsFinal, false, '这一段不是最终目标');
assert.ok(service.currentWaypoint.x < 800);
});
test('pathTo 近距离目标时直接下发最终目标', () => {
const { service, goals } = setupSegment({ loadedRadius: 512 });
assert.strictEqual(service.pathTo({ x: 5, z: 0 }), true);
assert.strictEqual(goals.length, 1);
assert.strictEqual(service._waypointIsFinal, true);
});
test('走到中途点后自动规划下一段,而不是当作已到达', () => {
const { bot, service, goals } = setupSegment({ loadedRadius: 128 });
const events = [];
service.onPathEvent = (event) => events.push(event);
service.pathTo({ x: 50, z: 0 });
const before = goals.length;
bot.emit('goal_reached');
assert.strictEqual(goals.length, before + 1, '应继续规划下一段');
assert.strictEqual(service._segmentCount, 1);
assert.ok(events.includes(PathEvent.SEGMENT_REACHED), '应发出 SEGMENT_REACHED 供控制器重置超时');
assert.ok(!events.includes(PathEvent.AT_GOAL), '还没到最终目标,不能报到达');
});
test('走到最终目标时发出 AT_GOAL 且不再规划', () => {
const { bot, service, goals } = setupSegment({ loadedRadius: 512 });
const events = [];
service.onPathEvent = (event) => events.push(event);
service.pathTo({ x: 5, z: 0 });
const before = goals.length;
bot.emit('goal_reached');
assert.strictEqual(goals.length, before, '已到最终目标,不应再规划');
assert.ok(events.includes(PathEvent.AT_GOAL));
});
test('本段算出 noPath 时先缩短段长重试', () => {
const { service } = setupSegment({ loadedRadius: 128 });
service.pathTo({ x: 50, z: 0 });
const firstWaypoint = service.currentWaypoint.x;
// 模拟寻路器报告本段不可达
service.bot.emit('path_update', { status: 'noPath' });
assert.strictEqual(service.calcFailed, false, '第一次应重试而不是直接失败');
assert.ok(service.currentWaypoint.x < firstWaypoint, '段长应缩短');
});
test('反复 noPath 最终会如实报失败,不会无限重试', () => {
const { service } = setupSegment({ loadedRadius: 128 });
const events = [];
service.onPathEvent = (event) => events.push(event);
service.pathTo({ x: 50, z: 0 });
for (let i = 0; i < 10; i++) service.bot.emit('path_update', { status: 'noPath' });
assert.strictEqual(service.calcFailed, true);
assert.ok(events.includes(PathEvent.CALC_FAILED));
});
test('cancel 清空分段状态', () => {
const { service } = setupSegment({ loadedRadius: 128 });
service.pathTo({ x: 50, z: 0 });
service.cancel();
assert.strictEqual(service.finalTarget, null);
assert.strictEqual(service.currentWaypoint, null);
assert.strictEqual(service._segmentCount, 0);
assert.strictEqual(service.isPathing(), false);
});
test('configure 施加 Grim 合规约束(默认禁止挖 / 放 / 跑酷 / 搭塔)', () => { const bot = new FakeBot();
const movements = {};
bot.pathfinder = {
setMovements: (m) => { movements.value = m; },
+239
查看文件
@@ -0,0 +1,239 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const { PhysicsRecorder, DEFAULT_MAX_TICKS } = require('../src/core/physicsRecorder');
const { FakeBot } = require('./helpers/fakeBot');
/** 造一个站在石头上的假 bot(脚下是 stone,便于断言方块名) */
function setup(options) {
const opts = options || {};
const bot = new FakeBot({ x: 0, y: 64, z: 0 });
bot.blockMap.set('0,63,0', { name: 'stone', boundingBox: 'block' });
bot.blockMap.set('0,64,0', { name: 'air', boundingBox: 'empty' });
bot.blockMap.set('0,65,0', { name: 'air', boundingBox: 'empty' });
const recorder = new PhysicsRecorder(opts);
recorder.setBot(bot);
return { bot, recorder };
}
/** 模拟一 tick:先改位置/速度/碰撞状态,再触发 physicsTick */
function tick(bot, patch) {
Object.assign(bot.entity, patch || {});
bot.emit('physicsTick');
}
test('没有 bot 时拒绝开始录制', () => {
const recorder = new PhysicsRecorder();
const result = recorder.start(1);
assert.strictEqual(result.ok, false);
assert.match(result.message, /没有可录制的连接/);
assert.strictEqual(recorder.running, false);
});
test('逐 tick 记录输入、速度、位移与脚下方块', () => {
const { bot, recorder } = setup();
assert.strictEqual(recorder.start(1).ok, true);
assert.strictEqual(recorder.running, true);
bot.setControlState('forward', true);
bot.entity.velocity.set(0.1, -0.078, 0);
tick(bot, { position: bot.entity.position.offset(0, 0, 0.1) });
tick(bot, { position: bot.entity.position.offset(0, 0, 0.1) });
const snap = recorder.snapshot();
assert.strictEqual(snap.count, 2);
assert.strictEqual(snap.physicsEnabled, true);
const first = snap.ticks[0];
assert.strictEqual(first.control.forward, true);
assert.strictEqual(first.control.back, false);
assert.strictEqual(first.vx, 0.1);
assert.strictEqual(first.onGround, true);
assert.strictEqual(first.blockBelow, 'stone');
assert.strictEqual(first.blockAtFeet, 'air');
assert.strictEqual(first.blockAtHead, 'air');
// 录制开始时会先取一次当前位置当基线,所以第一个 tick 也有有效位移
assert.strictEqual(first.dz, 0.1);
assert.strictEqual(snap.ticks[1].dz, 0.1);
assert.strictEqual(snap.inputTicks, 2);
assert.strictEqual(snap.inputMovedTicks, 2);
assert.strictEqual(snap.maxHorizDelta, 0.1);
});
test('有输入但没位移且物理报告水平碰撞 → 记为「被方块挡住」', () => {
const { bot, recorder } = setup();
recorder.start();
bot.setControlState('forward', true);
// 位置不变、物理报告水平碰撞:典型的「撞墙」
tick(bot, { isCollidedHorizontally: true });
tick(bot, { isCollidedHorizontally: true });
const snap = recorder.snapshot();
assert.strictEqual(snap.inputTicks, 2);
assert.strictEqual(snap.inputMovedTicks, 0);
assert.strictEqual(snap.blockedTicks, 2);
assert.strictEqual(snap.frozenTicks, 0, '报告了碰撞就不算「输入没生效」');
});
test('有输入但没位移且物理没报告碰撞 → 记为「输入没进物理」', () => {
const { bot, recorder } = setup();
recorder.start();
bot.setControlState('forward', true);
// 位置不变、速度恒为 0、也没有碰撞报告:输入根本没生效
tick(bot, { isCollidedHorizontally: false });
tick(bot, { isCollidedHorizontally: false });
const snap = recorder.snapshot();
assert.strictEqual(snap.inputTicks, 2);
assert.strictEqual(snap.inputMovedTicks, 0);
assert.strictEqual(snap.frozenTicks, 2);
assert.strictEqual(snap.blockedTicks, 0, '没报告碰撞就不算「被挡住」');
});
test('有输入、没位移、没碰撞,但水平速度非零 → 记为「被服务端推回」', () => {
// 实测形态:大厅里物理每 tick 都在动,位移却被服务端的位置包抹掉
// (推回发生在 tick 之间,所以 tick 级位移为 0、也没有方块碰撞)
const { bot, recorder } = setup();
recorder.start();
bot.setControlState('forward', true);
bot.entity.velocity.set(0, -0.0784, 0.02319);
tick(bot, { isCollidedHorizontally: false });
tick(bot, { isCollidedHorizontally: false });
const snap = recorder.snapshot();
assert.strictEqual(snap.inputTicks, 2);
assert.strictEqual(snap.inputMovedTicks, 0);
assert.strictEqual(snap.pushedBackTicks, 2);
assert.strictEqual(snap.frozenTicks, 0, '速度非零就不该算「输入没进物理」');
assert.strictEqual(snap.blockedTicks, 0);
});
test('没有输入时不计入 inputTicks', () => {
const { bot, recorder } = setup();
recorder.start();
tick(bot);
tick(bot);
const snap = recorder.snapshot();
assert.strictEqual(snap.count, 2);
assert.strictEqual(snap.inputTicks, 0);
assert.strictEqual(snap.movedTicks, 0);
});
test('停止后摘掉监听并不再记录', () => {
const { bot, recorder } = setup();
recorder.start();
tick(bot);
const result = recorder.stop();
assert.strictEqual(result.ok, true);
assert.strictEqual(recorder.running, false);
assert.match(result.message, /共 1 个 tick/);
tick(bot);
assert.strictEqual(recorder.snapshot().count, 1, '停止后不应再记录');
assert.strictEqual(bot.listenerCount('physicsTick'), 0, '监听应已摘掉');
});
test('重复 stop 不报错', () => {
const { recorder } = setup();
recorder.start();
assert.strictEqual(recorder.stop().ok, true);
assert.match(recorder.stop().message, /没有在录制/);
});
test('滚动缓冲只保留最近 maxTicks 个 tick', () => {
const { bot, recorder } = setup({ maxTicks: 3 });
recorder.start();
for (let i = 0; i < 10; i++) {
tick(bot, { position: bot.entity.position.offset(0, 0, 1) });
}
const snap = recorder.snapshot();
assert.strictEqual(snap.count, 3);
assert.strictEqual(snap.ticks.length, 3);
});
test('snapshot 的 limit 只影响返回条数,不影响统计', () => {
const { bot, recorder } = setup();
recorder.start();
bot.setControlState('forward', true);
for (let i = 0; i < 5; i++) tick(bot, { position: bot.entity.position.offset(0, 0, 1) });
const snap = recorder.snapshot({ limit: 2 });
assert.strictEqual(snap.count, 5);
assert.strictEqual(snap.returned, 2);
assert.strictEqual(snap.ticks.length, 2);
assert.strictEqual(snap.inputMovedTicks, 5);
});
test('给出时长后到点自动停止并摘监听', async () => {
const { bot, recorder } = setup();
recorder.start(0.05);
assert.strictEqual(recorder.running, true);
await new Promise((resolve) => setTimeout(resolve, 150));
assert.strictEqual(recorder.running, false);
assert.strictEqual(bot.listenerCount('physicsTick'), 0);
assert.ok(recorder.snapshot().stoppedAt > 0);
});
test('录制时长被上限截断', () => {
const { recorder } = setup();
const result = recorder.start(9999);
assert.match(result.message, /120 秒/);
recorder.stop();
});
test('换 bot 时先收尾旧录制', () => {
const { bot, recorder } = setup();
recorder.start();
tick(bot);
const other = new FakeBot();
recorder.setBot(other);
assert.strictEqual(recorder.running, false, '换 bot 应停止录制');
assert.strictEqual(recorder.snapshot().count, 1, '旧 bot 录到的内容保留');
tick(bot);
assert.strictEqual(recorder.snapshot().count, 1, '旧 bot 不再被记录');
});
test('clear 清空已录内容且不影响录制状态', () => {
const { bot, recorder } = setup();
recorder.start();
tick(bot);
assert.strictEqual(recorder.snapshot().count, 1);
recorder.clear();
const snap = recorder.snapshot();
assert.strictEqual(snap.count, 0);
assert.strictEqual(snap.running, true);
});
test('方块读不到(区块未加载)时记 null 而不是抛错', () => {
const { bot, recorder } = setup();
recorder.start();
bot.blockAt = () => null;
tick(bot);
const row = recorder.snapshot().ticks[0];
assert.strictEqual(row.blockBelow, null);
assert.strictEqual(row.blockAtFeet, null);
});
test('录制自身出异常也不影响物理循环', () => {
const { bot, recorder } = setup();
recorder.start();
// 位置是 NaN 时直接跳过,不抛错
tick(bot, { position: { x: NaN, y: 0, z: 0 } });
assert.strictEqual(recorder.snapshot().count, 0);
// blockAt 抛错也不能让 physicsTick 崩
bot.entity.position = { x: 1, y: 64, z: 1, offset: () => ({ x: 1, y: 63, z: 1 }) };
bot.blockAt = () => {
throw new Error('炸了');
};
assert.doesNotThrow(() => bot.emit('physicsTick'));
const snap = recorder.snapshot();
assert.strictEqual(snap.count, 1);
assert.strictEqual(snap.ticks[0].blockBelow, null);
});
test('默认上限是一个合理的正数', () => {
assert.ok(DEFAULT_MAX_TICKS >= 20);
assert.strictEqual(new PhysicsRecorder().maxTicks, DEFAULT_MAX_TICKS);
});
+288
查看文件
@@ -0,0 +1,288 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const { ScheduledMessenger, normalizeTasks, clampInterval, parseCron, nextCronTime,
MIN_INTERVAL_SECONDS, MAX_TASKS } = require('../src/bot/scheduledMessenger');
const { Logger } = require('../src/util/logger');
const { EventEmitter } = require('events');
/** 造一个会发事件、可控的假 BotManager(用于「发送结果确认」测试) */
function emittableManager(options) {
const opts = options || {};
const manager = new EventEmitter();
manager.connection = { username: 'BotA' };
manager.sent = [];
manager.canSendChat = () => opts.canSend !== false;
manager.sendChat = (content) => {
manager.sent.push(content);
return true;
};
return manager;
}
/** 造一个可控的假 BotManager */
function fakeManager(options) {
const opts = options || {};
const sent = [];
return {
connection: { username: 'BotA' },
canSendChat: () => opts.canSend !== false,
sendChat: (content) => {
sent.push(content);
return true;
},
sent
};
}
test('normalizeTasks 丢弃空内容、补齐默认值、按 / 前缀判定类型', () => {
const tasks = normalizeTasks([
{ id: 'a', content: '/u restore confirm', intervalSeconds: 300, label: '恢复民心' },
{ content: '大家好', intervalSeconds: 60 },
{ content: ' ' },
null,
{ intervalSeconds: 10 }
]);
assert.strictEqual(tasks.length, 2);
assert.strictEqual(tasks[0].kind, 'command', '以 / 开头应识别为指令');
assert.strictEqual(tasks[0].enabled, true, '默认启用');
assert.strictEqual(tasks[1].kind, 'chat');
assert.ok(tasks[1].id, '缺 id 时应自动生成');
});
test('normalizeTasks 钳制间隔到合理范围并限制条数', () => {
assert.strictEqual(clampInterval(1), MIN_INTERVAL_SECONDS, '间隔不能小于下限,否则等同刷屏');
assert.strictEqual(clampInterval(999999), 86400);
assert.strictEqual(clampInterval('abc'), 300, '非法值回退到 5 分钟');
const many = [];
for (let i = 0; i < MAX_TASKS + 10; i++) many.push({ content: 'm' + i, intervalSeconds: 60 });
assert.strictEqual(normalizeTasks(many).length, MAX_TASKS);
});
test('id 重复时自动改名,保证每个任务独立计时', () => {
const tasks = normalizeTasks([
{ id: 'x', content: 'a', intervalSeconds: 60 },
{ id: 'x', content: 'b', intervalSeconds: 60 }
]);
assert.strictEqual(tasks.length, 2);
assert.notStrictEqual(tasks[0].id, tasks[1].id);
});
test('到点才发送,且按间隔重复', () => {
const manager = fakeManager();
const messenger = new ScheduledMessenger(manager, null);
const t0 = 1_000_000;
messenger.setTasks([{ id: 'restore', content: '/u restore confirm', intervalSeconds: 300 }], t0);
assert.strictEqual(messenger.tick(t0), 0, '刚设置完不应立即发送');
assert.strictEqual(messenger.tick(t0 + 299_000), 0, '未到间隔不发');
assert.strictEqual(messenger.tick(t0 + 300_000), 1, '到点应发送');
assert.deepStrictEqual(manager.sent, ['/u restore confirm']);
assert.strictEqual(messenger.tick(t0 + 599_000), 0);
assert.strictEqual(messenger.tick(t0 + 600_000), 1, '第二个周期应再发一次');
assert.strictEqual(manager.sent.length, 2);
});
test('未连接 / 未通过登录验证时跳过本次并顺延,不补发', () => {
const manager = fakeManager({ canSend: false });
const messenger = new ScheduledMessenger(manager, null);
const t0 = 1_000_000;
messenger.setTasks([{ id: 'a', content: 'hi', intervalSeconds: 60 }], t0);
assert.strictEqual(messenger.tick(t0 + 60_000), 0, '未就绪时不发送');
assert.deepStrictEqual(manager.sent, []);
// 顺延一个周期:即使中间恢复也不会把漏掉的补发出来
manager.canSendChat = () => true;
assert.strictEqual(messenger.tick(t0 + 61_000), 0, '不能补发上一周期的内容');
assert.strictEqual(messenger.tick(t0 + 120_000), 1);
assert.deepStrictEqual(manager.sent, ['hi']);
});
test('禁用的任务不发送', () => {
const manager = fakeManager();
const messenger = new ScheduledMessenger(manager, null);
messenger.setTasks([{ id: 'a', content: 'hi', intervalSeconds: 60, enabled: false }], 1_000_000);
assert.strictEqual(messenger.tick(1_000_000 + 120_000), 0);
assert.deepStrictEqual(manager.sent, []);
});
test('多个任务各自独立计时', () => {
const manager = fakeManager();
const messenger = new ScheduledMessenger(manager, null);
const t0 = 1_000_000;
messenger.setTasks([
{ id: 'fast', content: 'A', intervalSeconds: 30 },
{ id: 'slow', content: 'B', intervalSeconds: 300 }
], t0);
assert.strictEqual(messenger.tick(t0 + 30_000), 1);
assert.strictEqual(messenger.tick(t0 + 60_000), 1);
assert.strictEqual(messenger.tick(t0 + 300_000), 2, '两个任务在同一时刻都到点');
assert.deepStrictEqual(manager.sent, ['A', 'A', 'A', 'B']);
});
test('snapshot 暴露剩余秒数供控制台展示', () => {
const manager = fakeManager();
const messenger = new ScheduledMessenger(manager, null);
messenger.setTasks([{ id: 'a', label: '恢复', content: '/u restore confirm', intervalSeconds: 300 }], Date.now());
const snap = messenger.snapshot();
assert.strictEqual(snap.length, 1);
assert.strictEqual(snap[0].label, '恢复');
assert.strictEqual(snap[0].kind, 'command');
assert.ok(snap[0].nextDueAt > 0);
assert.ok(snap[0].nextInSeconds <= 300 && snap[0].nextInSeconds >= 0);
});
test('setTasks 整体替换并重置计时', () => {
const manager = fakeManager();
const messenger = new ScheduledMessenger(manager, null);
const t0 = 1_000_000;
messenger.setTasks([{ id: 'a', content: 'A', intervalSeconds: 60 }], t0);
messenger.tick(t0 + 60_000);
assert.strictEqual(manager.sent.length, 1);
messenger.setTasks([{ id: 'b', content: 'B', intervalSeconds: 60 }], t0 + 60_000);
assert.strictEqual(messenger.snapshot().length, 1);
assert.strictEqual(messenger.tick(t0 + 61_000), 0, '替换后重新计时,不应立刻发送');
assert.strictEqual(messenger.tick(t0 + 200_000), 1);
assert.deepStrictEqual(manager.sent, ['A', 'B']);
});
test('start / stop 控制心跳定时器', () => {
const messenger = new ScheduledMessenger(fakeManager(), null);
messenger.start();
assert.ok(messenger._timer, 'start 后应有定时器');
const timer = messenger._timer;
messenger.start();
assert.strictEqual(messenger._timer, timer, '重复 start 不应叠加定时器');
messenger.stop();
assert.strictEqual(messenger._timer, null);
});
// ==================== cron 式调度 ====================
test('parseCron 接受常用写法,拒绝非法表达式', () => {
assert.ok(parseCron('0 9 * * *'), '每天 09:00');
assert.ok(parseCron('0/30 * * * *'), '每 30 分钟');
assert.ok(parseCron('0 12 * * 1-5'), '工作日 12:00');
assert.ok(parseCron('5,35 8-18 * * *'), '区间 + 列表');
assert.strictEqual(parseCron('0 9 * *'), null, '段数不足应拒绝');
assert.strictEqual(parseCron('99 9 * * *'), null, '越界应拒绝');
assert.strictEqual(parseCron('0 9 * * 9'), null, '周只能是 0-6');
assert.strictEqual(parseCron(''), null);
});
test('cron 任务只在匹配时刻发送,且不会同一天重复发', () => {
const manager = fakeManager();
const messenger = new ScheduledMessenger(manager, null);
const now = new Date(2026, 8, 28, 8, 0, 0); // 2026-09-28 08:00(本地时间)
messenger.setTasks([{ id: 'daily', content: '早安公告', cron: '0 9 * * *' }], now.getTime());
messenger.tick(new Date(2026, 8, 28, 8, 59, 0).getTime());
assert.strictEqual(manager.sent.length, 0, '09:00 之前不该发');
messenger.tick(new Date(2026, 8, 28, 9, 0, 10).getTime());
assert.strictEqual(manager.sent.length, 1, '09:00 到点应发送');
messenger.tick(new Date(2026, 8, 28, 9, 1, 0).getTime());
assert.strictEqual(manager.sent.length, 1, '同一天不应重复发');
const snap = messenger.snapshot()[0];
assert.strictEqual(snap.mode, 'cron');
assert.strictEqual(snap.cron, '0 9 * * *');
assert.strictEqual(new Date(snap.nextDueAt).getDate(), 29, '下一次应是明天 09:00');
assert.strictEqual(new Date(snap.nextDueAt).getHours(), 9);
});
test('cron 支持星期范围:工作日表达式会跳过周末', () => {
const manager = fakeManager();
const messenger = new ScheduledMessenger(manager, null);
const saturday = new Date(2026, 8, 26, 13, 0, 0); // 2026-09-26 周六
assert.strictEqual(saturday.getDay(), 6);
messenger.setTasks([{ id: 'workday', content: 'x', cron: '0 12 * * 1-5' }], saturday.getTime());
const due = new Date(messenger.snapshot()[0].nextDueAt);
assert.strictEqual(due.getDay(), 1, '应跳到周一');
assert.strictEqual(due.getDate(), 28);
});
test('cron 步长写法:每 30 分钟', () => {
const manager = fakeManager();
const messenger = new ScheduledMessenger(manager, null);
const now = new Date(2026, 8, 28, 8, 1, 0);
messenger.setTasks([{ id: 'half', content: 'x', cron: '0/30 * * * *' }], now.getTime());
const due = new Date(messenger.snapshot()[0].nextDueAt);
assert.strictEqual(due.getHours(), 8);
assert.strictEqual(due.getMinutes(), 30, '下一次应是 08:30');
});
test('无效 cron 表达式回退到固定间隔,并说明原因', () => {
const tasks = normalizeTasks([{ content: 'x', cron: '99 99 * * *', intervalSeconds: 60 }]);
assert.strictEqual(tasks[0].mode, 'interval', '非法表达式不能静默吞掉,应按间隔发送');
assert.ok(tasks[0].cronError.includes('无效'), '要给出可见的原因:' + tasks[0].cronError);
assert.strictEqual(tasks[0].cron, '', '非法表达式不应被保留');
});
test('nextCronTime 能跳到下一个月(稀缺表达式)', () => {
const parsed = parseCron('0 0 1 * *'); // 每月 1 日 00:00
const from = new Date(2026, 8, 28, 12, 0, 0).getTime();
const next = new Date(nextCronTime(parsed, from));
assert.strictEqual(next.getMonth(), 9, '应跳到 10 月');
assert.strictEqual(next.getDate(), 1);
});
// ==================== 发送结果确认 ====================
test('发送结果确认:命中服务器回复记为「已确认」', () => {
const manager = emittableManager();
const messenger = new ScheduledMessenger(manager, null);
const t0 = 1_000_000;
messenger.setTasks([{
id: 'restore',
content: '/u restore confirm',
intervalSeconds: 300,
expectPattern: '民心.*恢复',
confirmTimeoutSeconds: 10
}], t0);
messenger.start(); // 注册 chat 监听
assert.strictEqual(messenger.tick(t0 + 300_000), 1);
manager.emit('chat', '你的疆土民心已经恢复', 'system');
const result = messenger.snapshot()[0].lastResult;
assert.strictEqual(result.ok, true);
assert.ok(result.detail.includes('民心'));
messenger.stop();
});
test('发送结果确认:超时未回复记为「未确认」并写警告', () => {
const manager = emittableManager();
const logger = new Logger({ echo: false });
const messenger = new ScheduledMessenger(manager, logger);
const t0 = 1_000_000;
messenger.setTasks([{
id: 'restore',
content: '/u restore confirm',
intervalSeconds: 300,
expectPattern: '民心.*恢复',
confirmTimeoutSeconds: 10
}], t0);
messenger.start();
assert.strictEqual(messenger.tick(t0 + 300_000), 1);
messenger.tick(t0 + 300_000 + 11_000); // 超过确认超时
const result = messenger.snapshot()[0].lastResult;
assert.strictEqual(result.ok, false);
assert.ok(result.detail.includes('没有收到预期回复'));
assert.ok(logger.list().some((e) => e.level === 'warn' && e.message.includes('没有收到预期回复')),
'日志里必须能看到「没生效」这件事');
messenger.stop();
});
test('没配「期望回复」的任务不做确认,也不会误报未确认', () => {
const manager = emittableManager();
const messenger = new ScheduledMessenger(manager, null);
const t0 = 1_000_000;
messenger.setTasks([{ id: 'plain', content: 'hi', intervalSeconds: 60 }], t0);
messenger.start();
assert.strictEqual(messenger.tick(t0 + 60_000), 1);
messenger.tick(t0 + 120_000);
assert.strictEqual(messenger.snapshot()[0].lastResult, null, '未配置确认就不该有结果状态');
messenger.stop();
});
+261
查看文件
@@ -0,0 +1,261 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const {
SimpApiClient,
SimpApiV2Client,
BASE_URL,
BASE_URL_V2,
componentToText,
normalizeLimit,
parseUnionSummary,
parseTerritoryChunk
} = require('../src/core/simpApiClient');
test('componentToText 把公告的组件 JSON 压平成纯文本', () => {
const raw = '{"color":"gold","extra":[{"color":"aqua","text":"wans7891"},'
+ '{"color":"yellow","text":" 在坐标 (-8109, -8976)"},'
+ '{"color":"gold","text":" 创立了新的邦国 "},'
+ '{"color":"aqua","text":"「雅典维亚城邦」"}]}';
assert.strictEqual(componentToText(raw), 'wans7891 在坐标 (-8109, -8976) 创立了新的邦国 「雅典维亚城邦」');
// 嵌套 extra
const nested = '{"text":"公告: ","extra":[{"text":"外层","extra":[{"text":"内层"}]}]}';
assert.strictEqual(componentToText(nested), '公告: 外层内层');
});
test('componentToText 对非 JSON 文本原样返回', () => {
assert.strictEqual(componentToText('普通公告'), '普通公告');
assert.strictEqual(componentToText(''), '');
assert.strictEqual(componentToText(null), '');
});
test('normalizeLimit 钳制到 1..1000', () => {
assert.strictEqual(normalizeLimit(0), 1);
assert.strictEqual(normalizeLimit(50), 50);
assert.strictEqual(normalizeLimit(99999), 1000);
assert.strictEqual(normalizeLimit('abc'), 100, '非法值回退到文档默认值 100');
assert.strictEqual(normalizeLimit(undefined), 100);
});
test('parseUnionSummary 解析邦国摘要并按方块坐标换算王城区块', () => {
const union = parseUnionSummary({
id: 12,
name: '养老院',
leader_name: 'LouieMAIN',
member_count: 6,
territory_chunk_count: 28853,
territory_block_area: 7386368,
special_output_material: 'TERRACOTTA',
capital_x: 6763.4,
capital_z: 11929.2
});
assert.strictEqual(union.name, '养老院');
assert.strictEqual(union.leader, 'LouieMAIN');
assert.strictEqual(union.memberCount, 6);
assert.strictEqual(union.chunkCount, 28853);
assert.strictEqual(union.specialOutput, 'TERRACOTTA');
// 方块坐标 ÷ 16 才是区块坐标
assert.deepStrictEqual(union.capital, { x: 422, z: 745 });
});
test('parseUnionSummary 缺王城坐标时返回 null 而不是 NaN', () => {
const union = parseUnionSummary({ name: '没有王城的邦国', capital_x: null, capital_z: null });
assert.strictEqual(union.capital, null);
assert.strictEqual(parseUnionSummary({}), null, '没有邦国名应视为无效');
assert.strictEqual(parseUnionSummary(null), null);
});
test('parseTerritoryChunk 解析区块并拒绝坐标缺失的行', () => {
const chunk = parseTerritoryChunk({
x: 1,
z: -2,
union_id: 12,
union_name: '养老院',
loyalty: 100,
last_claimed_at: '2025-08-08T13:43:21.323+08:00'
});
assert.deepStrictEqual(chunk, {
x: 1,
z: -2,
unionId: 12,
unionName: '养老院',
loyalty: 100,
lastClaimedAt: '2025-08-08T13:43:21.323+08:00'
});
assert.strictEqual(parseTerritoryChunk({ x: null, z: 1 }), null);
assert.strictEqual(parseTerritoryChunk({}), null);
assert.strictEqual(parseTerritoryChunk(null), null);
});
test('客户端在未配置 Key 时把错误写清楚,而不是静默失败', async () => {
const client = new SimpApiClient(null);
assert.strictEqual(client.hasApiKey(), false);
assert.strictEqual(await client.getUnionTerritoryStats('x'), null);
assert.strictEqual(client.lastError(), '未配置 API Key');
const v2 = new SimpApiV2Client(null);
// 在线列表在无 Key 时返回空结构,但 lastError 必须说明原因
assert.deepStrictEqual(await v2.getOnlinePlayers(), { online: 0, max: 0, players: [] });
assert.strictEqual(v2.lastError(), '未配置 API-V2 Key');
assert.strictEqual(await v2.getDragon(), null);
});
// ==================== API-V2(字段名以实测响应为准)====================
/** 造一个返回固定响应的假 fetch */
function stubV2(responses) {
const calls = [];
return {
calls,
fetchImpl: async (url) => {
calls.push(url);
const path = url.split('/api-v2/')[1] || '';
const body = responses[path];
if (body === undefined) return { status: 404, text: async () => '{"error":"not found"}' };
if (typeof body === 'object' && body.__status) {
return { status: body.__status, text: async () => JSON.stringify(body.body) };
}
return { status: 200, text: async () => JSON.stringify(body) };
}
};
}
/** 用桩 fetch 建一个 V2 客户端 */
function v2With(responses) {
const stub = stubV2(responses);
const client = new SimpApiV2Client(null);
client.setApiKey('test-key');
client.fetchImpl = stub.fetchImpl;
return { client, stub };
}
test('getOnlinePlayers 解析 {online,max,players} 结构而不是裸数组', async () => {
const { client } = v2With({
players: {
online: 2,
max: 5000,
players: [
{ name: 'wans2023', displayName: 'wans2023', uuid: 'u1', world: 'world' },
{ name: 'xiaofan', displayName: 'xiaofan', uuid: 'u2', world: 'world' }
]
}
});
const result = await client.getOnlinePlayers();
assert.strictEqual(result.online, 2);
assert.strictEqual(result.max, 5000);
assert.strictEqual(result.players.length, 2);
assert.strictEqual(result.players[0].name, 'wans2023');
assert.strictEqual(result.players[0].world, 'world');
});
test('getGameTime 直接使用服务端算好的时刻,不再用 tick 换算', async () => {
const { client } = v2With({
time: { world: 'world', tick: 10085, phase: 'DAY', time12: '04:05 PM', time24: '16:05' }
});
const time = await client.getGameTime();
assert.strictEqual(time.time24, '16:05');
assert.strictEqual(time.time12, '04:05 PM');
assert.strictEqual(time.phase, 'DAY');
assert.strictEqual(time.tick, 10085);
});
test('getDragon 读的是 dragonAlive 而不是 alive', async () => {
const { client } = v2With({ dragon: { dragonAlive: false, world: 'world_the_end' } });
const dragon = await client.getDragon();
assert.strictEqual(dragon.alive, false);
assert.strictEqual(dragon.world, 'world_the_end');
const alive = v2With({ dragon: { dragonAlive: true, world: 'world_the_end' } });
assert.strictEqual((await alive.client.getDragon()).alive, true);
});
test('getPlayerEquipment 解析 equipment 子对象,并把 damage 换算成剩余耐久', async () => {
const { client } = v2With({
'players/wans2023/equipment': {
name: 'wans2023',
displayName: 'wans2023',
uuid: 'u1',
equipment: {
mainHand: { type: 'BAMBOO_PLANKS', key: 'minecraft:bamboo_planks', amount: 9, damage: 0, maxDurability: 0, unbreakable: false },
offHand: null,
helmet: { type: 'IRON_HELMET', key: 'minecraft:iron_helmet', amount: 1, damage: 157, maxDurability: 165, unbreakable: false }
}
}
});
const equipment = await client.getPlayerEquipment('wans2023');
assert.strictEqual(equipment.player, 'wans2023');
assert.strictEqual(equipment.mainHand.key, 'minecraft:bamboo_planks');
assert.strictEqual(equipment.mainHand.amount, 9);
assert.strictEqual(equipment.mainHand.maxDurability, 0, '方块类物品没有耐久');
assert.strictEqual(equipment.offHand, null);
// damage=157 / max=165 -> 剩余 8,而不是把 157 当成剩余耐久
assert.strictEqual(equipment.helmet.damage, 157);
assert.strictEqual(equipment.helmet.maxDurability, 165);
assert.strictEqual(equipment.helmet.remainingDurability, 8);
assert.strictEqual(equipment.chestplate, null);
});
test('V2 触发限流时把 retryAfterSeconds 写进错误信息', async () => {
const { client } = v2With({
dragon: { __status: 429, body: { error: '请求过于频繁,已超出限速', limit: 1, retryAfterSeconds: 16 } }
});
assert.strictEqual(await client.getDragon(), null);
assert.ok(client.lastError().includes('还需等待 16 秒'), '实际:' + client.lastError());
assert.strictEqual(client.lastHttpStatus(), 429);
});
test('V2 的 401 提示要点明「需要单独申请密钥」', async () => {
const { client } = v2With({
time: { __status: 401, body: { error: 'missing or invalid api key' } }
});
assert.strictEqual(await client.getGameTime(), null);
assert.ok(client.lastError().includes('单独申请'), '实际:' + client.lastError());
});
test('V2 响应走本地缓存,不会因为反复点击把配额打满', async () => {
const { client, stub } = v2With({
time: { world: 'world', tick: 1, phase: 'DAY', time12: 'x', time24: '06:00' }
});
await client.getGameTime();
await client.getGameTime();
await client.getGameTime();
assert.strictEqual(stub.calls.length, 1, '三次查询只应打一次接口');
assert.strictEqual(client.snapshot().cacheHits, 2);
});
test('setApiKey 变更时清空缓存,避免旧 Key 的响应串味', () => {
const client = new SimpApiClient(null);
client.cache.set('info/unions', { body: '[]', expiresAt: Date.now() + 10000 });
client.setApiKey('first');
assert.strictEqual(client.cache.size, 0, '第一次设置也应从空 Key 状态切换');
client.cache.set('info/unions', { body: '[]', expiresAt: Date.now() + 10000 });
client.setApiKey('second');
assert.strictEqual(client.cache.size, 0, '换 Key 必须清缓存');
// 相同 Key 重复设置不应清缓存(否则每次配置下发都会白打接口)
client.cache.set('info/unions', { body: '[]', expiresAt: Date.now() + 10000 });
client.setApiKey('second');
assert.strictEqual(client.cache.size, 1);
});
test('API 与 API-V2 是两个独立的基础地址与密钥', () => {
const v1 = new SimpApiClient(null, {});
const v2 = new SimpApiV2Client(null, {});
assert.strictEqual(v1.baseUrl, BASE_URL);
assert.strictEqual(v2.baseUrl, BASE_URL_V2);
assert.notStrictEqual(v1.baseUrl, v2.baseUrl);
v1.setApiKey('key-v1');
assert.strictEqual(v1.hasApiKey(), true);
assert.strictEqual(v2.hasApiKey(), false, 'V1 的 Key 不应影响 V2');
});
test('snapshot 暴露用量与缓存统计供控制台展示', () => {
const client = new SimpApiClient(null);
const snap = client.snapshot();
assert.strictEqual(snap.hasKey, false);
assert.strictEqual(snap.requestCount, 0);
assert.strictEqual(snap.cacheHits, 0);
assert.strictEqual(snap.cachedPaths, 0);
assert.strictEqual(snap.baseUrl, BASE_URL);
});
+89
查看文件
@@ -0,0 +1,89 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const { TargetLeaseTable, DEFAULT_TTL_MS } = require('../src/core/targetLease');
test('tryAcquire:先抢到的机器人独占,其他机器人拿不到', () => {
const table = new TargetLeaseTable();
const chunk = { x: 10, z: 20 };
assert.strictEqual(table.tryAcquire(chunk, 'A'), true);
assert.strictEqual(table.tryAcquire(chunk, 'B'), false, '被别人占用时应失败');
assert.strictEqual(table.isLeasedByOther(chunk, 'B'), true);
assert.strictEqual(table.isLeasedByOther(chunk, 'A'), false, '自己占用不算「被别人占用」');
});
test('tryAcquire:同一机器人可重复占用(视为续租)', () => {
const table = new TargetLeaseTable({ ttlMs: 1000 });
const chunk = { x: 1, z: 1 };
assert.strictEqual(table.tryAcquire(chunk, 'A'), true);
assert.strictEqual(table.tryAcquire(chunk, 'A'), true, '自己再抢一次不应失败');
assert.strictEqual(table.size, 1, '不应产生第二条记录');
});
test('租约到期后自动失效,其他机器人可接手(不会永久锁死区块)', async () => {
const table = new TargetLeaseTable({ ttlMs: 30 });
const chunk = { x: 5, z: 5 };
assert.strictEqual(table.tryAcquire(chunk, 'A'), true);
assert.strictEqual(table.tryAcquire(chunk, 'B'), false);
await new Promise((resolve) => setTimeout(resolve, 45));
assert.strictEqual(table.isLeasedByOther(chunk, 'B'), false, '过期后应视为空闲');
assert.strictEqual(table.tryAcquire(chunk, 'B'), true, '过期后其他机器人可接手');
});
test('release:只有持有者能释放,释放后别人立刻可用', () => {
const table = new TargetLeaseTable();
const chunk = { x: 2, z: 3 };
table.tryAcquire(chunk, 'A');
assert.strictEqual(table.release(chunk, 'B'), false, '非持有者不能释放');
assert.strictEqual(table.tryAcquire(chunk, 'B'), false);
assert.strictEqual(table.release(chunk, 'A'), true);
assert.strictEqual(table.tryAcquire(chunk, 'B'), true);
});
test('releaseAll 释放某机器人的全部占用(停止任务 / 断线时用)', () => {
const table = new TargetLeaseTable();
table.tryAcquire({ x: 0, z: 0 }, 'A');
table.tryAcquire({ x: 1, z: 0 }, 'A');
table.tryAcquire({ x: 0, z: 1 }, 'B');
assert.strictEqual(table.releaseAll('A'), 2);
assert.strictEqual(table.size, 1, '只应剩下 B 的占用');
assert.strictEqual(table.isLeasedByOther({ x: 0, z: 0 }, 'B'), false);
});
test('renew 延长租约,防止长距离寻路中途被抢', async () => {
// 时序余量刻意放宽:要证明的是「续租把到期时间往后推了」,
// 即「总耗时 > 原始 TTL」但「距续租的耗时 < TTL」。
// 睡眠取 0.7 × TTL,两侧各留 0.3 × TTL(120ms)调度余量 ——
// 早先用 TTL=40ms / 睡眠 25ms 只剩 15ms,全量并行跑测试时会偶发失败。
const ttlMs = 400;
const sleepMs = 280;
const table = new TargetLeaseTable({ ttlMs });
const chunk = { x: 7, z: 7 };
table.tryAcquire(chunk, 'A');
await new Promise((resolve) => setTimeout(resolve, sleepMs));
assert.strictEqual(table.renew(chunk, 'A'), true);
await new Promise((resolve) => setTimeout(resolve, sleepMs));
// 已超过原始 TTL,但续租过 → 仍是 A 的
assert.strictEqual(table.tryAcquire(chunk, 'B'), false, '续租后不应被抢走');
});
test('renew 对非持有者无效', () => {
const table = new TargetLeaseTable();
table.tryAcquire({ x: 9, z: 9 }, 'A');
assert.strictEqual(table.renew({ x: 9, z: 9 }, 'B'), false);
});
test('sweep 清理过期租约,snapshot 只列出有效占用', async () => {
const table = new TargetLeaseTable({ ttlMs: 20 });
table.tryAcquire({ x: 1, z: 1 }, 'A');
await new Promise((resolve) => setTimeout(resolve, 30));
assert.strictEqual(table.sweep(), 1);
assert.deepStrictEqual(table.snapshot(), []);
});
test('默认租约时长与配置常量一致', () => {
assert.strictEqual(new TargetLeaseTable().ttlMs, DEFAULT_TTL_MS);
assert.ok(DEFAULT_TTL_MS >= 60000, '默认不应太短,否则长距离寻路会被抢');
});
+118 -3
查看文件
@@ -5,6 +5,9 @@ const assert = require('node:assert');
const { flattenChunks, boundsOf, timingSafeEqual, WebConsole } = require('../src/web/server');
const { TerritoryModel } = require('../src/core/territoryModel');
const { PacketCapture } = require('../src/core/packetCapture');
const { PhysicsRecorder } = require('../src/core/physicsRecorder');
const { FakeBot } = require('./helpers/fakeBot');
const { defaultConfig } = require('../src/config/expansionConfig');
const { Logger } = require('../src/util/logger');
const chunkPos = require('../src/core/chunkPos');
@@ -33,7 +36,8 @@ test('timingSafeEqual 只接受等长且完全相同的字符串', () => {
});
/** 搭建一个使用桩依赖的控制台实例 */
function setupConsole() {
function setupConsole(options) {
const opts = options || {};
const map = new Map([[
'A',
{
@@ -48,9 +52,28 @@ function setupConsole() {
model.addSessionClaim({ x: 1, z: 0 });
const logger = new Logger({ echo: false });
// 抓包接口的桩:挂一个真的 PacketCapture + 假 client,接口测到的是真实逻辑
const captureClient = {
sent: [],
write(name, params) {
this.sent.push({ name, params });
}
};
const packetCapture = new PacketCapture();
packetCapture.setClient(captureClient);
// 物理录制接口的桩:同样挂真的 PhysicsRecorder + FakeBot
const physicsBot = new FakeBot({ x: 0, y: 64, z: 0 });
const physicsRecorder = new PhysicsRecorder();
physicsRecorder.setBot(physicsBot);
const botManager = {
snapshot: () => ({ state: 'online', username: 'TestBot' }),
sendCommand: () => {}
sendCommand: () => {},
startPacketCapture: (seconds) => packetCapture.start(seconds),
stopPacketCapture: () => packetCapture.stop(),
packetCaptureSnapshot: (options) => packetCapture.snapshot(options),
startPhysicsRecorder: (seconds) => physicsRecorder.start(seconds),
stopPhysicsRecorder: () => physicsRecorder.stop(),
physicsSnapshot: (options) => physicsRecorder.snapshot(options)
};
const controller = {
target: { x: 2, z: 0 },
@@ -73,10 +96,12 @@ function setupConsole() {
controller,
dataService,
logger,
bigMap: opts.bigMap,
notifier: opts.notifier,
getConfig: () => config,
applyConfig: (next) => next
}, { host: '127.0.0.1', port: 0 });
return { webConsole, model, logger, controller };
return { webConsole, model, logger, controller, captureClient, packetCapture, physicsBot, physicsRecorder };
}
test('mapPayload 返回己方 / 会话 / 飞地 / 目标与各邦国摘要', () => {
@@ -116,6 +141,20 @@ test('statusPayload 汇总 bot / 任务 / 数据源状态', () => {
assert.strictEqual(payload.data.stateCount, 1);
assert.strictEqual(payload.claimTime.start, 11);
assert.strictEqual(payload.claimTime.end, 22);
assert.strictEqual(payload.notify, null, '未注入通知器时为 null(状态接口不应报错)');
});
test('注入通知器时状态接口只暴露域名,不泄露完整 Webhook 地址', () => {
const { Notifier } = require('../src/core/notifier');
const config = defaultConfig();
config.notify.enabled = true;
config.notify.webhookUrl = 'https://open.feishu.cn/open-apis/bot/v2/hook/secret-token';
const notifier = new Notifier(config, null, { fetchImpl: async () => ({ ok: true, status: 200 }) });
const { webConsole } = setupConsole({ notifier });
const payload = webConsole.statusPayload();
assert.strictEqual(payload.notify.enabled, true);
assert.strictEqual(payload.notify.host, 'open.feishu.cn');
assert.ok(!JSON.stringify(payload).includes('secret-token'), '完整地址不能出现在状态响应里');
});
test('HTTP 接口:/api/status、/api/config、/api/map 可访问', async () => {
@@ -204,3 +243,79 @@ test('HTTP 接口:POST /api/command 缺参数时返回 400', async () => {
await webConsole.stop();
}
});
test('HTTP 接口:抓包可开始、取结果(含按包名过滤)、停止', async () => {
const { webConsole, captureClient } = setupConsole();
await webConsole.start();
const base = webConsole.baseUrl();
try {
const started = await fetch(base + '/api/bots/bot/capture/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ seconds: 5 })
}).then((r) => r.json());
assert.strictEqual(started.ok, true);
assert.match(started.message, /已开始抓包/);
captureClient.write('position_look', { x: 1, y: 64, z: 2, yaw: 90, onGround: true });
captureClient.write('look', { yaw: 91 });
const snap = await fetch(base + '/api/bots/bot/capture').then((r) => r.json());
assert.strictEqual(snap.ok, true);
assert.strictEqual(snap.data.running, true);
assert.strictEqual(snap.data.count, 2);
assert.deepStrictEqual(snap.data.byType, { position_look: 1, look: 1 });
assert.strictEqual(snap.data.packets[0].params.onGround, true);
const filtered = await fetch(base + '/api/bots/bot/capture?types=look').then((r) => r.json());
assert.strictEqual(filtered.data.count, 2, 'count 是过滤前的总数');
assert.strictEqual(filtered.data.returned, 1);
assert.strictEqual(filtered.data.packets[0].name, 'look');
const stopped = await fetch(base + '/api/bots/bot/capture/stop', { method: 'POST' }).then((r) => r.json());
assert.strictEqual(stopped.ok, true);
const after = await fetch(base + '/api/bots/bot/capture').then((r) => r.json());
assert.strictEqual(after.data.running, false);
assert.strictEqual(after.data.count, 2, '停止后已录内容仍可读');
} finally {
await webConsole.stop();
}
});
test('HTTP 接口:物理录制可开始、取结果、停止', async () => {
const { webConsole, physicsBot } = setupConsole();
await webConsole.start();
const base = webConsole.baseUrl();
try {
const started = await fetch(base + '/api/bots/bot/physics/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ seconds: 5 })
}).then((r) => r.json());
assert.strictEqual(started.ok, true);
assert.match(started.message, /已开始录制物理 tick/);
// 模拟「按着前进但物理没动」:应当被统计成 frozenTicks
physicsBot.setControlState('forward', true);
physicsBot.emit('physicsTick');
physicsBot.emit('physicsTick');
const snap = await fetch(base + '/api/bots/bot/physics').then((r) => r.json());
assert.strictEqual(snap.ok, true);
assert.strictEqual(snap.data.running, true);
assert.strictEqual(snap.data.count, 2);
assert.strictEqual(snap.data.inputTicks, 2);
assert.strictEqual(snap.data.inputMovedTicks, 0);
assert.strictEqual(snap.data.frozenTicks, 2);
const limited = await fetch(base + '/api/bots/bot/physics?limit=1').then((r) => r.json());
assert.strictEqual(limited.data.count, 2);
assert.strictEqual(limited.data.returned, 1);
const stopped = await fetch(base + '/api/bots/bot/physics/stop', { method: 'POST' }).then((r) => r.json());
assert.strictEqual(stopped.ok, true);
} finally {
await webConsole.stop();
}
});