比较提交
22
次代码提交
71e78f06dc
...
master
| 作者 | SHA1 | 提交日期 | |
|---|---|---|---|
|
|
6ff45a48ae | ||
|
|
6e420e13f0 | ||
|
|
ab598a7101 | ||
|
|
841d20afef | ||
|
|
1339765152 | ||
|
|
1b958cb804 | ||
|
|
2ae2833568 | ||
|
|
ed55308885 | ||
|
|
0bed12e189 | ||
|
|
5513ef9f5f | ||
|
|
468c719e37 | ||
|
|
3a508f6818 | ||
|
|
c89f4f8f0f | ||
|
|
b2b0317af7 | ||
|
|
0c6700642e | ||
|
|
8ab2d59eb0 | ||
|
|
51664c55b6 | ||
|
|
8d1b8d5122 | ||
|
|
0cd65cf9be | ||
|
|
9ca80d2f6e | ||
|
|
0783249d1e | ||
|
|
42a9dd6cf0 |
+18
@@ -46,3 +46,21 @@ SIMP_API_KEY=<your-simp-api-key>
|
|||||||
# https://bgjq.simpfun.cn/api-v2/
|
# https://bgjq.simpfun.cn/api-v2/
|
||||||
# 配额很小(末影龙仅 1 次/分钟),程序只按需查询、不做轮询
|
# 配额很小(末影龙仅 1 次/分钟),程序只按需查询、不做轮询
|
||||||
SIMP_API_V2_KEY=<your-simp-api-v2-key>
|
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=
|
||||||
|
|||||||
+324
@@ -2,6 +2,330 @@
|
|||||||
|
|
||||||
本项目遵循 [约定式提交](https://www.conventionalcommits.org/zh-hans/) 与 [语义化版本](https://semver.org/lang/zh-CN/)。
|
本项目遵循 [约定式提交](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
|
## [1.5.1] - 2026-09-28
|
||||||
|
|
||||||
接入 API-V2 密钥后按实测响应修正字段名(之前是按文档猜的,跑起来全不对)。
|
接入 API-V2 密钥后按实测响应修正字段名(之前是按文档猜的,跑起来全不对)。
|
||||||
|
|||||||
@@ -36,11 +36,15 @@ npm start # 打开 http://127.0.0.1:3081
|
|||||||
| 日志导出 | ✅ | 网页按钮导出到 `data/` |
|
| 日志导出 | ✅ | 网页按钮导出到 `data/` |
|
||||||
| SimpCore 开放 API 第四数据源 | ✅ | 低频轮询 + 手动全服拉取 |
|
| SimpCore 开放 API 第四数据源 | ✅ | 低频轮询 + 手动全服拉取 |
|
||||||
| Grim 反作弊适配 | ✅ 新增 | 见下方专章 |
|
| Grim 反作弊适配 | ✅ 新增 | 见下方专章 |
|
||||||
| **多机器人同时挂机** | ✅ 新增 | 一个进程挂多个账号,各自独立连接 / 登录 / 扩地,配置可独立覆盖 |
|
| **多机器人同时挂机** | ✅ 新增 | 一个进程挂多个账号,各自独立连接 / 登录 / 扩地,配置可独立覆盖;**目标区块带租约**,不会撞在同一块上 |
|
||||||
| **开拓日志** | ✅ 新增 | 逐条记录开拓玩家、邦国、区块、方块坐标、时间、判定来源,落盘可导出 CSV |
|
| **开拓日志** | ✅ 新增 | 逐条记录开拓玩家、邦国、区块、方块坐标、时间、判定来源,落盘可导出 CSV |
|
||||||
| **游戏公屏完整日志 + 聊天框** | ✅ 新增 | 全部公屏 / 系统消息按机器人分文件留档,控制台可直接发公屏消息 |
|
| **游戏公屏完整日志 + 聊天框** | ✅ 新增 | 全部公屏 / 系统消息按机器人分文件留档,控制台可直接发公屏消息 |
|
||||||
| **大地图** | ✅ 新增 | 爬取服务器地图站点瓦片与领土标记,一张图上看全部机器人坐标 |
|
| **大地图** | ✅ 新增 | 爬取服务器地图站点瓦片与领土标记,一张图上看全部机器人坐标;高层级瓦片后台预热 |
|
||||||
| **登录验证门禁** | ✅ 新增 | 游戏内登录 + 控制台登录双重校验,通过后才允许操作机器人与发送消息 |
|
| **登录验证门禁** | ✅ 新增 | 游戏内登录 + 控制台登录双重校验,通过后才允许操作机器人与发送消息 |
|
||||||
|
| **定时消息(间隔 / cron)** | ✅ 新增 | 固定间隔或 cron 表达式定时发送,可选「期望回复」确认发送是否真的生效 |
|
||||||
|
| **人工介入提醒(Webhook)** | ✅ 新增 | 等待验证码 / 原点自救失败时推送到飞书等自定义机器人,不必一直盯控制台 |
|
||||||
|
| **出站包抓取(诊断)** | ✅ 新增 | 按需录下机器人真正发出的协议包(含内容),用于与「能走」时的序列逐包对照,定位反作弊拒绝移动的原因 |
|
||||||
|
| **物理 tick 录制(诊断)** | ✅ 新增 | 逐 tick 记录物理引擎读到的输入、算出的速度、有无位移与碰撞,把「输入没进物理 / 被方块挡住 / 服务端回弹」三种原因切开 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -76,6 +80,11 @@ MC_PASSWORD=xxxxxx
|
|||||||
|
|
||||||
- **共享一份领土模型**:`markers.json` 是全服数据(约 160KB / 11.5 万区块),
|
- **共享一份领土模型**:`markers.json` 是全服数据(约 160KB / 11.5 万区块),
|
||||||
没必要每个机器人各拉一遍,因此数据服务是单例、共享给全部控制器。
|
没必要每个机器人各拉一遍,因此数据服务是单例、共享给全部控制器。
|
||||||
|
- **目标区块带租约**(`src/core/targetLease.js`):领土模型是全局共享的,但「谁要去占哪一块」
|
||||||
|
原先只存在各自的队列里,彼此看不见 —— 多机器人很容易撞在同一批候选区块上。
|
||||||
|
现在抢到的机器人独占该区块(其余机器人跳过),持有者持续续租,
|
||||||
|
掉线 / 卡死后租约到期自动失效,不会永久锁死区块;
|
||||||
|
候选区块全被预定时会等 15 秒再规划,而不是提前判「没有可用区块」。
|
||||||
- **邦国归属按机器人解析**:`ExpansionController` 在 `start()` 时把识别到的邦国固化到
|
- **邦国归属按机器人解析**:`ExpansionController` 在 `start()` 时把识别到的邦国固化到
|
||||||
`controller.stateName` 并写回本次任务配置,规划器不再读全局 `selfStateName`
|
`controller.stateName` 并写回本次任务配置,规划器不再读全局 `selfStateName`
|
||||||
(否则会被最后启动的机器人覆盖,导致 A 机器人按 B 的邦国规划)。
|
(否则会被最后启动的机器人覆盖,导致 A 机器人按 B 的邦国规划)。
|
||||||
@@ -158,12 +167,17 @@ squaremap 前端用 `L.CRS.Simple` + `tileSize: 512`,瓦片路径为
|
|||||||
| 层级 | 每瓦片方块数 | 全世界瓦片数 | 策略 |
|
| 层级 | 每瓦片方块数 | 全世界瓦片数 | 策略 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| 0 | 4096 | 10×10 = 100 | 启动时自动预爬取(以机器人位置为中心,只爬当前图幅内) |
|
| 0 | 4096 | 10×10 = 100 | 启动时自动预爬取(以机器人位置为中心,只爬当前图幅内) |
|
||||||
| 1 | 2048 | 20×20 = 400 | 按需拉取 + 永久缓存 |
|
| 1 | 2048 | 20×20 = 400 | 按需拉取 + **后台预热** + 永久缓存 |
|
||||||
| 2 | 1024 | 40×40 = 1600 | 按需拉取 + 永久缓存 |
|
| 2 | 1024 | 40×40 = 1600 | 按需拉取 + **后台预热** + 永久缓存 |
|
||||||
| 3 | 512 | 80×80 = 6400 | 按需拉取 + 永久缓存 |
|
| 3 | 512 | 80×80 = 6400 | 按需拉取 + **后台预热** + 永久缓存 |
|
||||||
|
|
||||||
|
- **后台预热**:机器人移动到新区域(默认超过 512 格)时,用低并发(批量爬取并发的一半)
|
||||||
|
顺带把周边高层级瓦片拉下来,避免首次缩放时干等;距上次预热超过 10 分钟也会补一次。
|
||||||
|
已缓存的瓦片只会做一次 `statSync`,不会重复下载;正在批量爬取时预热自动让路
|
||||||
- 磁盘缓存:`data/mapcache/<world>/<z>/<x>_<y>.png`,命中即不再回源
|
- 磁盘缓存:`data/mapcache/<world>/<z>/<x>_<y>.png`,命中即不再回源
|
||||||
- 瓦片由后端代理(`/api/bigmap/tile/:z/:x/:y`),避免浏览器直连地图站点的跨域问题
|
- 瓦片由后端代理(`/api/bigmap/tile/:z/:x/:y`),避免浏览器直连地图站点的跨域问题
|
||||||
|
- 领土多边形按**视野裁剪**绘制(预编译路径时顺带记包围盒,视野外的直接跳过),
|
||||||
|
全世界视角 + 全部邦国也不会掉帧
|
||||||
- 领土以 `markers.json` 的**原始多边形**绘制(预编译成 `Path2D`,按 even-odd 填充保留国中孔洞),
|
- 领土以 `markers.json` 的**原始多边形**绘制(预编译成 `Path2D`,按 even-odd 填充保留国中孔洞),
|
||||||
叠加本次会话新占领区块、王城与**全部机器人坐标(含名字标签)**
|
叠加本次会话新占领区块、王城与**全部机器人坐标(含名字标签)**
|
||||||
- 操作:滚轮缩放、拖拽平移、图幅切换、「爬取周边」/「回到机器人」一键定位
|
- 操作:滚轮缩放、拖拽平移、图幅切换、「爬取周边」/「回到机器人」一键定位
|
||||||
@@ -235,6 +249,11 @@ V2 的接口级配额很小(末影龙只有 **1 次/分钟**),因此 V2
|
|||||||
若该服务器提供固定验证码,可在配置的「账号绑定 / 二次验证模板」(`server.authCommand`)里填写
|
若该服务器提供固定验证码,可在配置的「账号绑定 / 二次验证模板」(`server.authCommand`)里填写
|
||||||
- **失败信息可操作**:发送失败会区分「未连接」「登录被服务器拒绝」「未通过登录验证」三种情况,
|
- **失败信息可操作**:发送失败会区分「未连接」「登录被服务器拒绝」「未通过登录验证」三种情况,
|
||||||
而不是笼统报一句「发送失败」
|
而不是笼统报一句「发送失败」
|
||||||
|
- **人工介入提醒(Webhook)**:验证码只有 120 秒时限,错过就得等下次重登。
|
||||||
|
在控制台「服务器 → 通知提醒」勾选「启用通知」并填 Webhook 地址(地址也可走环境变量
|
||||||
|
`NOTIFY_WEBHOOK_URL`)后,服务器要求验证码时会推送一条提醒到飞书 / Lark 自定义机器人;
|
||||||
|
原文里带上服务器提示与处理步骤。同一问题默认 60 秒内只推一次,避免重连循环里反复轰炸;
|
||||||
|
接口里只暴露 Webhook 域名,不会把带凭证的完整地址打进响应
|
||||||
|
|
||||||
**控制台侧**(`src/web/server.js`)
|
**控制台侧**(`src/web/server.js`)
|
||||||
|
|
||||||
@@ -249,6 +268,40 @@ V2 的接口级配额很小(末影龙只有 **1 次/分钟**),因此 V2
|
|||||||
|
|
||||||
本项目已在 Debian 12 上以 pm2 常驻运行验证过,关键点:
|
本项目已在 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
|
### 1. Node.js 版本要求 ≥ 22
|
||||||
|
|
||||||
`mineflayer 4.39` 的 `engines` 声明为 `>=22`,用 Node 18 会在运行时出问题。
|
`mineflayer 4.39` 的 `engines` 声明为 `>=22`,用 Node 18 会在运行时出问题。
|
||||||
@@ -416,6 +469,23 @@ Grim 的 Prediction 检查正是逐 tick 复算玩家位置并与上报位置比
|
|||||||
| `liquidCost` | 200(avoid 模式) | mineflayer 液体物理与原版差异最大,优先绕开水域 |
|
| `liquidCost` | 200(avoid 模式) | mineflayer 液体物理与原版差异最大,优先绕开水域 |
|
||||||
| `allowFreeMotion` | 关闭 | 禁止自由运动 |
|
| `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` 里还有出站包类型分布,
|
||||||
|
便于与旧模组抓包逐项对照。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 与原模组的行为差异(重要)
|
## 与原模组的行为差异(重要)
|
||||||
@@ -529,7 +599,19 @@ npm start
|
|||||||
### 定时重复发送消息
|
### 定时重复发送消息
|
||||||
|
|
||||||
挂机时常需要周期性发同一条指令(例如每 300 秒 `/u restore confirm` 维持疆土民心)。
|
挂机时常需要周期性发同一条指令(例如每 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`:
|
实现上刻意**不用**朴素的 `setInterval`:
|
||||||
|
|
||||||
@@ -598,9 +680,11 @@ npm start
|
|||||||
| 寻路 | 疾跑 / 游泳 / 跳跃 / 挖方块 / 放方块、高度偏好、跑酷、搭塔、下落高度、液体代价 |
|
| 寻路 | 疾跑 / 游泳 / 跳跃 / 挖方块 / 放方块、高度偏好、跑酷、搭塔、下落高度、液体代价 |
|
||||||
| 生存 | 自动进食、自动攻击、耐久保护、战斗筛选、水域策略、自动用船 |
|
| 生存 | 自动进食、自动攻击、耐久保护、战斗筛选、水域策略、自动用船 |
|
||||||
| 数据 | 地图刷新间隔、SimpCore API |
|
| 数据 | 地图刷新间隔、SimpCore API |
|
||||||
| 大地图 | 开关、世界名、预爬取层级、最大层级、**图幅半径、是否跟随机器人**、启动预爬取、下载并发数 |
|
| 大地图 | 开关、世界名、预爬取层级、最大层级、**图幅半径、是否跟随机器人**、启动预爬取、下载并发数、**后台预热(开关 / 半径 / 层级 / 移动阈值 / 最小间隔)** |
|
||||||
| Grim | 合规层全部阈值 + 协议补包开关(见上) |
|
| 协调 | 目标区块租约时长(多机器人避免撞车) |
|
||||||
| 服务器 | 开垦模式指令、登录 / 注册模板、登录确认超时、指令节流、重连 |
|
| Grim | 合规层全部阈值 + 协议补包开关 + **地面状态修正(方案 A / B)**(见上) |
|
||||||
|
| 服务器 | 开垦模式指令、登录 / 注册 / 绑定模板、登录确认超时、指令节流、重连 |
|
||||||
|
| 通知 | Webhook 开关与地址(`NOTIFY_WEBHOOK_URL` 兜底)、验证码提醒、自救失败提醒 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -614,7 +698,7 @@ npm run dev # 文件变更自动重启
|
|||||||
|
|
||||||
### 测试覆盖
|
### 测试覆盖
|
||||||
|
|
||||||
242 个测试用例,覆盖:
|
436 个测试用例,覆盖:
|
||||||
|
|
||||||
- 区块坐标编解码(含负数与边界)
|
- 区块坐标编解码(含负数与边界)
|
||||||
- 领土模型:飞地识别、会话占领、按邦国名取飞地与己方区块、API 数据合并、多边形保留
|
- 领土模型:飞地识别、会话占领、按邦国名取飞地与己方区块、API 数据合并、多边形保留
|
||||||
@@ -622,19 +706,23 @@ npm run dev # 文件变更自动重启
|
|||||||
按 `config.stateName` 解析己方邦国
|
按 `config.stateName` 解析己方邦国
|
||||||
- markers.json 解析:奇偶填充孔洞、王城换算、颜色解析、多边形扁平化
|
- markers.json 解析:奇偶填充孔洞、王城换算、颜色解析、多边形扁平化
|
||||||
- 字幕 / 聊天解析:进度、带坐标成功、通用成功、模糊匹配
|
- 字幕 / 聊天解析:进度、带坐标成功、通用成功、模糊匹配
|
||||||
- Grim 合规层:旋转限速、动作互斥、攻击冷却表、距离 / 视线校验、物理自检
|
- Grim 合规层:旋转限速、动作互斥、攻击冷却表、距离 / 视线校验、物理自检、
|
||||||
|
**地面状态修正(方案 A / B 的正反用例)**、出站包类型分布
|
||||||
- 战斗筛选:中立生物豁免、条件敌对、猪灵金甲判定、耐久保护
|
- 战斗筛选:中立生物豁免、条件敌对、猪灵金甲判定、耐久保护
|
||||||
- 寻路服务:Grim 约束是否真正施加到 Movements、高度偏好目标
|
- 寻路服务:Grim 约束是否真正施加到 Movements、高度偏好目标、**手动执行器(沿路点行进、跳坎、卡住终止、取消、分段重试)**
|
||||||
- 控制器状态机:完整扩地流程、超时重试、走两步、回城、耐久保护停止、完成、
|
- 控制器状态机:完整扩地流程、超时重试、走两步、回城、耐久保护停止、完成、
|
||||||
多机器人邦国归属隔离、开拓日志写入与去重
|
多机器人邦国归属隔离、开拓日志写入与去重、**回城前「原地是否更差」评估与前后坐标**、
|
||||||
|
**目标租约的挑选 / 续租 / 归还**
|
||||||
- 多机器人:凭据解析(JSON / 逗号分隔 / 兜底)、机器人池组装、配置覆盖与持久化、
|
- 多机器人:凭据解析(JSON / 逗号分隔 / 兜底)、机器人池组装、配置覆盖与持久化、
|
||||||
密码不落配置文件、未登录时拒绝发送、全局配置下发保留各自覆盖
|
密码不落配置文件、未登录时拒绝发送、全局配置下发保留各自覆盖、**目标区块租约表**
|
||||||
- 机器人登录门禁:就绪判定、登录成功 / 失败 / 已登录提示、注册与登录自动补发、
|
- 机器人登录门禁:就绪判定、登录成功 / 失败 / 已登录提示、注册与登录自动补发、
|
||||||
确认兜底超时、公屏消息类型映射
|
确认兜底超时、公屏消息类型映射、**原点自救(判定成败 / 换方向重试 / 提前结束 / 通知提醒)**
|
||||||
- 开拓日志:字段记录、筛选、统计、JSONL 落盘与重启恢复、损坏行容错、环形缓冲、CSV 导出
|
- 开拓日志:字段记录、筛选、统计、JSONL 落盘与重启恢复、损坏行容错、环形缓冲、CSV 导出
|
||||||
- 公屏日志:按机器人隔离、类型 / 关键字过滤、分文件落盘、文件名安全化、文本导出
|
- 公屏日志:按机器人隔离、类型 / 关键字过滤、分文件落盘、文件名安全化、文本导出
|
||||||
- 大地图:坐标系换算(含对源站实测标定的王城用例)、**以机器人为中心的图幅换算**、瓦片范围、缓存命中、批量爬取、缓存清理
|
- 大地图:坐标系换算(含对源站实测标定的王城用例)、**以机器人为中心的图幅换算**、瓦片范围、缓存命中、批量爬取、缓存清理、**后台预热的节流与层级**
|
||||||
- 定时消息:内容归一化与间隔钳制、到点发送与按周期重复、未就绪时顺延不补发、多任务独立计时、整体替换重置计时
|
- 定时消息:内容归一化与间隔钳制、到点发送与按周期重复、未就绪时顺延不补发、多任务独立计时、整体替换重置计时、
|
||||||
|
**cron 解析与下次时刻计算(步长 / 区间 / 星期 / 稀缺表达式 / 非法回退)**、**发送结果确认(命中 / 超时 / 未配置)**
|
||||||
|
- 通知提醒:启用判定、飞书文本格式、去重节流、环境变量兜底、失败不抛异常、不泄露完整地址
|
||||||
- Web 控制台:地图负载、单 / 多机器人接口、聊天与开拓日志接口、定时任务接口、
|
- Web 控制台:地图负载、单 / 多机器人接口、聊天与开拓日志接口、定时任务接口、
|
||||||
图幅与中心计算、瓦片代理、令牌与登录鉴权、WebSocket 握手鉴权
|
图幅与中心计算、瓦片代理、令牌与登录鉴权、WebSocket 握手鉴权
|
||||||
|
|
||||||
@@ -657,13 +745,16 @@ src/
|
|||||||
│ ├── subtitleChatMonitor.js 字幕 / 聊天解析与模糊匹配
|
│ ├── subtitleChatMonitor.js 字幕 / 聊天解析与模糊匹配
|
||||||
│ ├── expansionLog.js ★ 开拓日志(玩家 / 邦国 / 区块 / 时间 / 来源)
|
│ ├── expansionLog.js ★ 开拓日志(玩家 / 邦国 / 区块 / 时间 / 来源)
|
||||||
│ ├── chatLog.js ★ 游戏公屏完整日志(按机器人分文件)
|
│ ├── chatLog.js ★ 游戏公屏完整日志(按机器人分文件)
|
||||||
│ └── bigMapService.js ★ 大地图瓦片爬取与缓存
|
│ ├── bigMapService.js ★ 大地图瓦片爬取与缓存 / 后台预热
|
||||||
|
│ ├── targetLease.js ★ 目标区块租约表(多机器人避免撞车)
|
||||||
|
│ └── notifier.js ★ 人工介入提醒(飞书等 Webhook)
|
||||||
├── bot/
|
├── bot/
|
||||||
│ ├── botPool.js ★ 机器人池:同时挂多个玩家
|
│ ├── botPool.js ★ 机器人池:同时挂多个玩家
|
||||||
│ ├── botManager.js 连接、登录验证、公屏留档、指令 / 聊天发送
|
│ ├── botManager.js 连接、登录验证、公屏留档、指令 / 聊天发送、地面状态修正
|
||||||
│ ├── scheduledMessenger.js ★ 定时重复发送消息 / 指令
|
│ ├── scheduledMessenger.js ★ 定时发送:固定间隔 / cron + 发送结果确认
|
||||||
│ ├── grimCompat.js ★ Grim 反作弊合规层 + 协议补包(player_input / tick_end)
|
│ ├── grimCompat.js ★ Grim 反作弊合规层 + 协议补包(player_input / tick_end)
|
||||||
│ ├── pathingService.js 寻路服务(替代 Baritone)
|
│ ├── pathingService.js 寻路服务(替代 Baritone)
|
||||||
|
│ ├── manualMover.js ★ 手动执行器(只借用 A* 规划,自己按键执行)
|
||||||
│ ├── autoCombat.js 战斗筛选与耐久检查
|
│ ├── autoCombat.js 战斗筛选与耐久检查
|
||||||
│ ├── autoEat.js 自动进食
|
│ ├── autoEat.js 自动进食
|
||||||
│ ├── autoBoat.js 自动用船(默认关闭)
|
│ ├── autoBoat.js 自动用船(默认关闭)
|
||||||
|
|||||||
@@ -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",
|
"name": "autostore-bot",
|
||||||
"version": "1.0.0",
|
"version": "1.11.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "autostore-bot",
|
"name": "autostore-bot",
|
||||||
"version": "1.0.0",
|
"version": "1.11.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chalk": "^4.1.2",
|
"chalk": "^4.1.2",
|
||||||
@@ -18,7 +18,8 @@
|
|||||||
"ws": "^8.18.0"
|
"ws": "^8.18.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"minecraft-data": "^3.102.3"
|
"minecraft-data": "^3.102.3",
|
||||||
|
"ssh2": "^1.17.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
@@ -177,6 +178,16 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/body-parser": {
|
||||||
"version": "1.20.8",
|
"version": "1.20.8",
|
||||||
"resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-1.20.8.tgz",
|
"resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-1.20.8.tgz",
|
||||||
@@ -243,6 +254,16 @@
|
|||||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
"license": "BSD-3-Clause"
|
"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": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz",
|
||||||
@@ -357,6 +378,21 @@
|
|||||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "2.6.9",
|
"version": "2.6.9",
|
||||||
"resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz",
|
"resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz",
|
||||||
@@ -1077,6 +1113,14 @@
|
|||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/nearley": {
|
||||||
"version": "2.20.1",
|
"version": "2.20.1",
|
||||||
"resolved": "https://registry.npmmirror.com/nearley/-/nearley-2.20.1.tgz",
|
"resolved": "https://registry.npmmirror.com/nearley/-/nearley-2.20.1.tgz",
|
||||||
@@ -1748,6 +1792,34 @@
|
|||||||
"npm": ">= 3.0.0"
|
"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": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz",
|
||||||
@@ -1800,6 +1872,13 @@
|
|||||||
"license": "0BSD",
|
"license": "0BSD",
|
||||||
"optional": true
|
"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": {
|
"node_modules/type-is": {
|
||||||
"version": "1.6.18",
|
"version": "1.6.18",
|
||||||
"resolved": "https://registry.npmmirror.com/type-is/-/type-is-1.6.18.tgz",
|
"resolved": "https://registry.npmmirror.com/type-is/-/type-is-1.6.18.tgz",
|
||||||
|
|||||||
+6
-3
@@ -1,13 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "autostore-bot",
|
"name": "autostore-bot",
|
||||||
"version": "1.1.0",
|
"version": "1.11.1",
|
||||||
"description": "AutoStore Bot —— Minecraft 自动扩地机器人(mineflayer 重写版,多机器人挂机,Web 控制台,开拓日志,大地图,Grim 反作弊适配)",
|
"description": "AutoStore Bot —— Minecraft 自动扩地机器人(mineflayer 重写版,多机器人挂机,Web 控制台,开拓日志,大地图,Grim 反作弊适配)",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/index.js",
|
"start": "node src/index.js",
|
||||||
"dev": "node --watch src/index.js",
|
"dev": "node --watch src/index.js",
|
||||||
"test": "node --test",
|
"test": "node --test",
|
||||||
"check": "node scripts/check-syntax.js && node scripts/check-web-ids.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": [
|
"keywords": [
|
||||||
"minecraft",
|
"minecraft",
|
||||||
@@ -31,6 +33,7 @@
|
|||||||
"ws": "^8.18.0"
|
"ws": "^8.18.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"minecraft-data": "^3.102.3"
|
"minecraft-data": "^3.102.3",
|
||||||
|
"ssh2": "^1.17.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+68
-47
@@ -1,71 +1,92 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 协议自检:核对 mineflayer 写出的 `teleport_confirm` 包在 1.21.4 上是否存在、
|
* 协议自检:核对 mineflayer 写出的移动相关包在目标版本上的字段名与结构。
|
||||||
* 字段名是否匹配。
|
|
||||||
*
|
*
|
||||||
* 背景:部分服务端在收到 `player_position` 后会每 tick 重发位置包,
|
* 背景:部分服务端在收到位置包后会持续重发 `position`,直到客户端回一个
|
||||||
* 直到客户端回一个 teleport_confirm 确认。若 mineflayer 因为版本字段名不一致
|
* `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 mcData = require('minecraft-data')('1.21.4');
|
||||||
|
|
||||||
|
const VERSION = '1.21.4';
|
||||||
const toServer = mcData.protocol.play.toServer;
|
const toServer = mcData.protocol.play.toServer;
|
||||||
const mappings = toServer.types.packet[1][0].type[1].mappings;
|
const toClient = mcData.protocol.play.toClient;
|
||||||
|
|
||||||
console.log('=== 1.21.4 客户端 → 服务端 中与传送/位置相关的包名 ===');
|
/** 取某一方向「包名 → 类型名」的映射 */
|
||||||
for (const [id, name] of Object.entries(mappings)) {
|
function packetNameMap(direction) {
|
||||||
if (/teleport|position|confirm/i.test(name)) {
|
const paramsField = direction.types.packet[1][1];
|
||||||
console.log(` ${id.padStart(3)} ${name}`);
|
return paramsField.type[1].fields;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('\n=== 客户端 ← 服务端 中与传送/位置相关的包名 ===');
|
/** 把类型名解析成字段列表;解析不到返回 null */
|
||||||
const toClientMappings = mcData.protocol.play.toClient.types.packet[1][0].type[1].mappings;
|
function resolveFields(direction, typeName) {
|
||||||
for (const [id, name] of Object.entries(toClientMappings)) {
|
const type = direction.types[typeName];
|
||||||
if (/teleport|position/i.test(name)) {
|
if (!Array.isArray(type) || type[0] !== 'container') return null;
|
||||||
console.log(` ${id.padStart(3)} ${name}`);
|
return type[1];
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('\n=== teleport_confirm / position_look 的字段定义(客户端 → 服务端)===');
|
/** 打印一个包的字段定义 */
|
||||||
function dumpPacketFields(packetName) {
|
function dumpFields(direction, label, packetName) {
|
||||||
// protocol 结构:types.packet = ['container', [nameField, paramsField]]
|
const typeName = packetNameMap(direction)[packetName];
|
||||||
// paramsField.type = ['switch', { compareTo: 'name', fields: { 包名: [字段...] } }]
|
if (!typeName) {
|
||||||
const paramsField = toServer.types.packet[1][1];
|
console.log(` ${packetName.padEnd(18)} (该版本没有这个包)`);
|
||||||
const fields = paramsField.type[1].fields;
|
|
||||||
const def = fields[packetName];
|
|
||||||
if (!def) {
|
|
||||||
console.log(` (没有名为 ${packetName} 的包)`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log(` ${packetName}: ` + def.map((f) => `${f.name}:${JSON.stringify(f.type)}`).join(', '));
|
const fields = resolveFields(direction, typeName);
|
||||||
}
|
if (!fields) {
|
||||||
|
console.log(` ${packetName.padEnd(18)} ${typeName}(不是 container,无法展开)`);
|
||||||
dumpPacketFields('teleport_confirm');
|
|
||||||
dumpPacketFields('position');
|
|
||||||
dumpPacketFields('position_look');
|
|
||||||
|
|
||||||
console.log('\n=== 客户端 ← 服务端 position 包的字段定义 ===');
|
|
||||||
function dumpToClientFields(packetName) {
|
|
||||||
const paramsField = mcData.protocol.play.toClient.types.packet[1][1];
|
|
||||||
const def = paramsField.type[1].fields[packetName];
|
|
||||||
if (!def) {
|
|
||||||
console.log(` (没有名为 ${packetName} 的包)`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log(` ${packetName}: ` + def.map((f) => `${f.name}:${JSON.stringify(f.type)}`).join(', '));
|
const text = fields.map((f) => `${f.name}:${JSON.stringify(f.type)}`).join(', ');
|
||||||
|
console.log(` ${packetName.padEnd(18)} ${text}`);
|
||||||
}
|
}
|
||||||
dumpToClientFields('position');
|
|
||||||
|
|
||||||
console.log('\n=== mineflayer 实际使用的包名 / 字段名 ===');
|
console.log(`=== ${VERSION} 客户端 → 服务端:移动 / 传送相关包 ===`);
|
||||||
const fs = require('fs');
|
const serverNames = packetNameMap(toServer);
|
||||||
const path = require('path');
|
for (const name of Object.keys(serverNames).sort()) {
|
||||||
const physics = fs.readFileSync(path.join(__dirname, 'node_modules', 'mineflayer', 'lib', 'plugins', 'physics.js'), 'utf8');
|
if (/teleport|position|input|flying|^look$/i.test(name)) console.log(` ${name}`);
|
||||||
const lines = physics.split('\n');
|
}
|
||||||
|
|
||||||
|
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) => {
|
lines.forEach((line, index) => {
|
||||||
if (/teleport_confirm|teleportId|teleport_id/.test(line)) {
|
if (/teleport_confirm|teleportId|_client\.write\(/.test(line)) {
|
||||||
console.log(` physics.js:${index + 1} ${line.trim()}`);
|
console.log(` physics.js:${index + 1} ${line.trim()}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(' 读不到 mineflayer/physics.js:' + (err && err.message ? err.message : err));
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|||||||
+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 {
|
||||||
|
// 临时文件删不掉无所谓
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
+698
-9
@@ -11,6 +11,8 @@ const { AutoEater } = require('./autoEat');
|
|||||||
const { AutoBoatController } = require('./autoBoat');
|
const { AutoBoatController } = require('./autoBoat');
|
||||||
const { DurabilityGuard } = require('./durabilityGuard');
|
const { DurabilityGuard } = require('./durabilityGuard');
|
||||||
const { ChatKind } = require('../core/chatLog');
|
const { ChatKind } = require('../core/chatLog');
|
||||||
|
const { PacketCapture } = require('../core/packetCapture');
|
||||||
|
const { PhysicsRecorder } = require('../core/physicsRecorder');
|
||||||
|
|
||||||
/** 服务器要求登录 / 注册时的提示关键词 */
|
/** 服务器要求登录 / 注册时的提示关键词 */
|
||||||
const LOGIN_PROMPT_PATTERN = /请(先)?(输入)?登录|请登录|请输入密码|login|登入/i;
|
const LOGIN_PROMPT_PATTERN = /请(先)?(输入)?登录|请登录|请输入密码|login|登入/i;
|
||||||
@@ -19,14 +21,24 @@ const LOGIN_OK_PATTERN = /登录成功|登陆成功|注册成功|欢迎回来|
|
|||||||
const ALREADY_LOGGED_IN_PATTERN = /已登录|无需登录|你已经登录/;
|
const ALREADY_LOGGED_IN_PATTERN = /已登录|无需登录|你已经登录/;
|
||||||
/** 登录 / 注册明确失败的关键词(命中后不再自动重试,也不放行操作) */
|
/** 登录 / 注册明确失败的关键词(命中后不再自动重试,也不放行操作) */
|
||||||
const LOGIN_FAIL_PATTERN = /密码(错误|不正确|有误)|登录失败|注册失败|wrong password|invalid password/i;
|
const LOGIN_FAIL_PATTERN = /密码(错误|不正确|有误)|登录失败|注册失败|wrong password|invalid password/i;
|
||||||
|
/**
|
||||||
|
* 「该账号已在别处登录」的踢出提示。
|
||||||
|
*
|
||||||
|
* 同一账号同时只能有一个连接:玩家在游戏客户端登录后忘了退出,机器人每次连上去
|
||||||
|
* 都会被立刻踢掉。这种失败重试多少次都没用,必须明确告诉用户去关掉另一个客户端,
|
||||||
|
* 否则只会看到「连上→被踢→重连」的循环而不知道为什么。
|
||||||
|
*/
|
||||||
|
const ALREADY_CONNECTED_PATTERN = /已经连接|已在别处登录|already (logged|connected)|duplicate login/i;
|
||||||
/**
|
/**
|
||||||
* 服务器要求完成账号绑定 / 二次验证时的提示关键词。
|
* 服务器要求完成账号绑定 / 二次验证时的提示关键词。
|
||||||
*
|
*
|
||||||
* 典型场景:服务器用「简幻通」等外站账号体系,新账号必须先用 `/auth <UID> <验证码>`
|
* 典型场景:服务器用「简幻通」等外站账号体系,新账号必须先用 `/auth <UID> <验证码>`
|
||||||
* 或扫码完成绑定,否则会被反复传送、并在倒计时结束后踢出。
|
* 或扫码完成绑定,否则会被反复传送、并在倒计时结束后踢出。
|
||||||
|
* 账号绑定过之后,服务器仍可能**每次登录都要求输入验证码**
|
||||||
|
* (「请直接输入验证码进行验证」),这时验证码是当聊天内容发出去的,不是指令。
|
||||||
* 这种流程无法靠密码自动完成,因此只做「明确告知 + 可选模板」处理。
|
* 这种流程无法靠密码自动完成,因此只做「明确告知 + 可选模板」处理。
|
||||||
*/
|
*/
|
||||||
const NEED_AUTH_PATTERN = /需要绑定|绑定您的|绑定你的|请使用命令:\s*\/?auth|扫描.{0,6}二维码|扫码|完成验证|身份验证/i;
|
const NEED_AUTH_PATTERN = /需要绑定|绑定您的|绑定你的|请使用命令:\s*\/?auth|扫描.{0,8}二维码|扫码|完成验证|身份验证|输入验证码|验证码进行验证|输入验证|verify|verification code/i;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 「被服务器传送」事件的日志聚合窗口(毫秒)。
|
* 「被服务器传送」事件的日志聚合窗口(毫秒)。
|
||||||
@@ -36,6 +48,23 @@ const NEED_AUTH_PATTERN = /需要绑定|绑定您的|绑定你的|请使用命
|
|||||||
*/
|
*/
|
||||||
const FORCED_MOVE_WINDOW_MS = 30000;
|
const FORCED_MOVE_WINDOW_MS = 30000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原点自救的方向组合(按「动作从轻到重」排序)。
|
||||||
|
*
|
||||||
|
* 服务器对左右移动最不敏感(不会走远),先试;前后移动 + 跳对「卡在方块里」
|
||||||
|
* 这类情况更有效,放第二轮。每轮结束后都会实际判定位置是否恢复。
|
||||||
|
*/
|
||||||
|
const ORIGIN_RESCUE_ROUNDS = [
|
||||||
|
{ name: '左右移动', directions: ['left', 'right', 'left', 'right'] },
|
||||||
|
{ name: '前后移动 + 跳', directions: ['forward', 'back', 'forward', 'back', 'jump'] }
|
||||||
|
];
|
||||||
|
/** 原点自救单次按键时长(毫秒) */
|
||||||
|
const ORIGIN_RESCUE_PRESS_MS = 500;
|
||||||
|
/** 原点自救两次按键之间的间隔(毫秒) */
|
||||||
|
const ORIGIN_RESCUE_GAP_MS = 150;
|
||||||
|
/** 每轮结束后等待服务器传送落地的时间(毫秒),避免把「还没传送」误判成失败 */
|
||||||
|
const ORIGIN_RESCUE_SETTLE_MS = 800;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 机器人管理器:负责连接、登录、断线重连,并把底层 mineflayer 事件
|
* 机器人管理器:负责连接、登录、断线重连,并把底层 mineflayer 事件
|
||||||
* 归一化成上层(控制器 / Web 控制台)可直接消费的事件。
|
* 归一化成上层(控制器 / Web 控制台)可直接消费的事件。
|
||||||
@@ -59,7 +88,8 @@ class BotManager extends EventEmitter {
|
|||||||
* @param {string} [connection.password]
|
* @param {string} [connection.password]
|
||||||
* @param {object} config 完整配置对象
|
* @param {object} config 完整配置对象
|
||||||
* @param {import('../util/logger').Logger} [logger]
|
* @param {import('../util/logger').Logger} [logger]
|
||||||
* @param {{chatLog?: import('../core/chatLog').ChatLog}} [options]
|
* @param {{chatLog?: import('../core/chatLog').ChatLog,
|
||||||
|
* notifier?: import('../core/notifier').Notifier}} [options]
|
||||||
*/
|
*/
|
||||||
constructor(connection, config, logger, options) {
|
constructor(connection, config, logger, options) {
|
||||||
super();
|
super();
|
||||||
@@ -73,6 +103,12 @@ class BotManager extends EventEmitter {
|
|||||||
this.label = connection.label || connection.username;
|
this.label = connection.label || connection.username;
|
||||||
/** @type {import('../core/chatLog').ChatLog|null} 游戏公屏完整日志 */
|
/** @type {import('../core/chatLog').ChatLog|null} 游戏公屏完整日志 */
|
||||||
this.chatLog = opts.chatLog || null;
|
this.chatLog = opts.chatLog || null;
|
||||||
|
/**
|
||||||
|
* 通知提醒(Webhook):等待登录验证码 / 原点自救失败时推送到飞书等渠道。
|
||||||
|
* 验证码只有 120 秒时限,是少数必须人工介入的时刻。
|
||||||
|
* @type {import('../core/notifier').Notifier|null}
|
||||||
|
*/
|
||||||
|
this.notifier = opts.notifier || null;
|
||||||
|
|
||||||
/** @type {import('mineflayer').Bot|null} */
|
/** @type {import('mineflayer').Bot|null} */
|
||||||
this.bot = null;
|
this.bot = null;
|
||||||
@@ -116,6 +152,21 @@ class BotManager extends EventEmitter {
|
|||||||
this._needAuthWarned = false;
|
this._needAuthWarned = false;
|
||||||
/** 本次连接内是否已提示过「服务器要登录但没配密码」 */
|
/** 本次连接内是否已提示过「服务器要登录但没配密码」 */
|
||||||
this._noPasswordWarned = false;
|
this._noPasswordWarned = false;
|
||||||
|
/** 上次断开是否因为「该账号已在别处登录」(决定重连退避策略) */
|
||||||
|
this.alreadyConnected = false;
|
||||||
|
/** 最近一次被服务端强制同步的位置与时间(判断服务端是否把我们按在原地) */
|
||||||
|
this._lastForcedMove = null;
|
||||||
|
/** 是否处于「原点自救」过程中,避免重复触发 */
|
||||||
|
this._escaping = false;
|
||||||
|
/**
|
||||||
|
* 原点自救的统计(以前只能从日志里看到「尝试过」,看不出有没有用)。
|
||||||
|
*
|
||||||
|
* 记录尝试次数、成功次数与最近一次结果;成功 = 自救结束后已不在原点附近。
|
||||||
|
* 失败会升级为明确提示(可能是被切到了登录服,移动无效)。
|
||||||
|
*/
|
||||||
|
this.originRescue = { attempts: 0, successes: 0, lastResult: null };
|
||||||
|
/** 是否已提示过「当前在登录服」 */
|
||||||
|
this.needsVerificationHint = false;
|
||||||
/**
|
/**
|
||||||
* 位置同步统计。
|
* 位置同步统计。
|
||||||
*
|
*
|
||||||
@@ -130,11 +181,43 @@ class BotManager extends EventEmitter {
|
|||||||
lastOutboundAt: 0,
|
lastOutboundAt: 0,
|
||||||
/** 出站包中 onGround 被世界数据纠正的次数 */
|
/** 出站包中 onGround 被世界数据纠正的次数 */
|
||||||
onGroundFixed: 0,
|
onGroundFixed: 0,
|
||||||
|
/** 出站包中 hasHorizontalCollision 被物理引擎真实值补上的次数 */
|
||||||
|
horizontalCollisionFixed: 0,
|
||||||
|
/** 【方案 A】物理引擎的 onGround 被世界数据强制同步为 true 的次数 */
|
||||||
|
physicsGroundSync: 0,
|
||||||
|
/** 【方案 B】保住本地 onGround、不采纳例行同步的位置包次数 */
|
||||||
|
setbackOnGroundKept: 0,
|
||||||
|
/** 【方案 B】最近一次位置包与本地的位置差(用来调整判定阈值) */
|
||||||
|
lastSetbackDelta: null,
|
||||||
|
/**
|
||||||
|
* 出站包类型分布(包名 → 次数)。
|
||||||
|
*
|
||||||
|
* 用于与旧版 Fabric 模组移动时的包序列逐项对照(TODO.md 假设 4):
|
||||||
|
* 若发现我们从未发送某个原版必备的包(如 `position_look`),
|
||||||
|
* 就能直接锁定「发包类型 / 顺序不一致」这类被反作弊拒绝的原因。
|
||||||
|
*/
|
||||||
|
outboundTypes: {},
|
||||||
/** 每 tick 补发的 player_input 次数(1.21.2+ 移动预测必需) */
|
/** 每 tick 补发的 player_input 次数(1.21.2+ 移动预测必需) */
|
||||||
playerInput: 0,
|
playerInput: 0,
|
||||||
/** 每 tick 补发的 tick_end 次数 */
|
/** 每 tick 补发的 tick_end 次数 */
|
||||||
tickEnd: 0
|
tickEnd: 0
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* 出站包抓取器(诊断用,默认不录制)。
|
||||||
|
*
|
||||||
|
* `packetStats.outboundTypes` 只统计「发了多少」,而反作弊拒绝移动时
|
||||||
|
* 真正要看的是「发出去的内容对不对」,所以需要能录原始序列的工具,
|
||||||
|
* 与「能走」时的序列逐包 diff(TODO.md 假设 5)。
|
||||||
|
*/
|
||||||
|
this.packetCapture = new PacketCapture();
|
||||||
|
/**
|
||||||
|
* 物理 tick 录制器(诊断用,默认不录制)。
|
||||||
|
*
|
||||||
|
* 抓包只能看到「发出去的包」,看不到物理引擎内部:它这一 tick 读到的 control、
|
||||||
|
* 算出的速度、有没有真的移动、有没有报告水平碰撞。而「走不动」正卡在这一层,
|
||||||
|
* 所以单独再要一个逐 tick 录制器。
|
||||||
|
*/
|
||||||
|
this.physicsRecorder = new PhysicsRecorder();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 连接生命周期 ====================
|
// ==================== 连接生命周期 ====================
|
||||||
@@ -207,6 +290,214 @@ class BotManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @returns {boolean} 是否已进入世界并完成登录,可以执行任务 */
|
/** @returns {boolean} 是否已进入世界并完成登录,可以执行任务 */
|
||||||
|
/**
|
||||||
|
* 处理「被丢到世界原点附近」的情况。
|
||||||
|
*
|
||||||
|
* 邦国崛起这台服务器有两种截然不同的原因会把玩家送到原点附近:
|
||||||
|
*
|
||||||
|
* 1. **死亡后被卡在原点** —— 左右移动一下就会触发服务器重新传送回正常位置,
|
||||||
|
* 机器人可以自救,不需要人工介入;
|
||||||
|
* 2. **被切到登录服** —— 必须重新输入验证码,而验证码只能人工获取,程序无法代劳。
|
||||||
|
*
|
||||||
|
* ⚠️ **不能用「脚下是不是基岩」来区分这两种情况 —— 实测两种情况下脚下都是基岩。**
|
||||||
|
* 唯一可靠的信号是**服务器有没有要求输入验证码**(`_needAuthWarned`),
|
||||||
|
* 所以这里以它为准;没要求验证码就按「死亡卡住」处理并尝试自救。
|
||||||
|
* 自救本身是无害的(在登录服里按左右键也不会有什么后果),
|
||||||
|
* 因此即使判断错了也不会把事情弄得更糟。
|
||||||
|
*/
|
||||||
|
_handleZeroPosition() {
|
||||||
|
const bot = this.bot;
|
||||||
|
if (!bot || !bot.entity) return;
|
||||||
|
if (!this._isNearOrigin()) return;
|
||||||
|
const pos = bot.entity.position;
|
||||||
|
const where = `(${pos.x.toFixed(0)}, ${pos.y.toFixed(0)}, ${pos.z.toFixed(0)})`;
|
||||||
|
|
||||||
|
if (this._needAuthWarned) {
|
||||||
|
this._log(`机器人位于世界原点附近 ${where},且服务器正在要求输入验证码`
|
||||||
|
+ ' —— 这是被切到了登录服,不是真实世界', 'warn');
|
||||||
|
this._log('请在控制台的聊天框里把验证码发出去(验证码当聊天内容发送即可,不用加斜杠)。'
|
||||||
|
+ '待在大厅里还会不断触发「被服务器传送」,看起来像机器人坏了', 'warn');
|
||||||
|
this.needsVerificationHint = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._log(`机器人被卡在世界原点附近 ${where},疑似死亡后卡住`
|
||||||
|
+ '(若服务器随后要求验证码,则是被切到了登录服)—— 尝试左右移动触发服务器重新传送', 'warn');
|
||||||
|
this.needsVerificationHint = false;
|
||||||
|
this._escapeFromOrigin();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否位于世界原点附近(服务器把玩家丢进大厅的位置)。
|
||||||
|
*
|
||||||
|
* 「死亡后被卡住」与「被切到登录服」两种情况的位置特征完全一样,
|
||||||
|
* 因此这里只负责位置判定,区分处理见 {@link _handleZeroPosition}。
|
||||||
|
*
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
_isNearOrigin() {
|
||||||
|
const bot = this.bot;
|
||||||
|
if (!bot || !bot.entity) return false;
|
||||||
|
const pos = bot.entity.position;
|
||||||
|
// 原点判定:水平方向很近且高度很低(正常地形不会在 y<16 的世界原点附近活动)
|
||||||
|
return Math.abs(pos.x) < 48 && Math.abs(pos.z) < 48 && pos.y < 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 尝试从原点自救:分两轮按方向键,每轮结束后判定位置是否恢复。
|
||||||
|
*
|
||||||
|
* 死亡后被卡在原点时,服务器只要收到有效的移动输入就会把玩家传送回正常位置,
|
||||||
|
* 因此这里交替按键一小会儿。与以前「按完就算」的做法相比,现在会:
|
||||||
|
* 1. 每轮结束后等传送落地,再判定「是否还在原点」;
|
||||||
|
* 2. 第一轮(左右)无效就换更重的第二轮(前后 + 跳);
|
||||||
|
* 3. 两轮都失败时给出明确提示,并把成功率记入 `originRescue` 统计。
|
||||||
|
* 自救本身无害(在登录服里按键也不会有什么后果),失败也不阻塞后续逻辑。
|
||||||
|
*/
|
||||||
|
_escapeFromOrigin() {
|
||||||
|
const bot = this.bot;
|
||||||
|
if (!bot || this._escaping) return;
|
||||||
|
this._escaping = true;
|
||||||
|
this.originRescue.attempts++;
|
||||||
|
const from = capturePosition(bot);
|
||||||
|
let roundIndex = 0;
|
||||||
|
|
||||||
|
const finish = (success, roundsDone) => {
|
||||||
|
this._escaping = false;
|
||||||
|
const to = capturePosition(this.bot);
|
||||||
|
this.originRescue.lastResult = {
|
||||||
|
at: Date.now(),
|
||||||
|
success,
|
||||||
|
rounds: roundsDone,
|
||||||
|
from,
|
||||||
|
to
|
||||||
|
};
|
||||||
|
if (success) {
|
||||||
|
this.originRescue.successes++;
|
||||||
|
this._log(`原点自救成功:第 ${roundsDone} 轮后位置已恢复`
|
||||||
|
+ `(${formatPosition(from)} → ${formatPosition(to)})`, 'success');
|
||||||
|
} else {
|
||||||
|
this._log('原点自救失败:换过两组方向后仍在原点附近。'
|
||||||
|
+ '若服务器其实要求输入验证码(被切到了登录服),移动是无效的 —— 需要在聊天框里把验证码发出去;'
|
||||||
|
+ '请对照上方是否有验证码提示,或查看 /api/status 的 originRescue 统计', 'error');
|
||||||
|
if (this._shouldNotifyRescueFailed()) {
|
||||||
|
this._notify(
|
||||||
|
`[AutoStore] 机器人「${this.connection.username}」原地自救失败:`
|
||||||
|
+ `两次移动后仍卡在世界原点附近 ${formatPosition(to)}。\n`
|
||||||
|
+ '多半是被切到了登录服(需要人工输入验证码),请到控制台查看提示并处理。',
|
||||||
|
`rescue-failed:${this.id}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runRound = () => {
|
||||||
|
if (roundIndex >= ORIGIN_RESCUE_ROUNDS.length) {
|
||||||
|
finish(false, ORIGIN_RESCUE_ROUNDS.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const round = ORIGIN_RESCUE_ROUNDS[roundIndex];
|
||||||
|
this._log(`原点自救第 ${roundIndex + 1} 轮:${round.name}(共 ${round.directions.length} 次按键)`);
|
||||||
|
let index = 0;
|
||||||
|
const step = () => {
|
||||||
|
if (this.bot !== bot) {
|
||||||
|
this._escaping = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (index >= round.directions.length) {
|
||||||
|
// 等服务器的传送落地再判定,否则会把「还没传来」误判成失败
|
||||||
|
const settle = setTimeout(() => {
|
||||||
|
if (this.bot !== bot) {
|
||||||
|
this._escaping = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this._needAuthWarned) {
|
||||||
|
this._log('服务器要求输入验证码 —— 原点自救提前结束(登录服里移动没有意义)', 'warn');
|
||||||
|
finish(false, roundIndex + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this._isNearOrigin()) {
|
||||||
|
finish(true, roundIndex + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._log(`第 ${roundIndex + 1} 轮(${round.name})后仍在原点附近,换下一组方向重试`, 'warn');
|
||||||
|
roundIndex++;
|
||||||
|
runRound();
|
||||||
|
}, ORIGIN_RESCUE_SETTLE_MS);
|
||||||
|
settle.unref?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dir = round.directions[index++];
|
||||||
|
this.setControlState(dir, true);
|
||||||
|
const release = setTimeout(() => {
|
||||||
|
this.setControlState(dir, false);
|
||||||
|
const next = setTimeout(step, ORIGIN_RESCUE_GAP_MS);
|
||||||
|
next.unref?.();
|
||||||
|
}, ORIGIN_RESCUE_PRESS_MS);
|
||||||
|
release.unref?.();
|
||||||
|
};
|
||||||
|
step();
|
||||||
|
};
|
||||||
|
runRound();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 采集移动执行状态(控制键 / 速度 / 寻路器内部状态)。
|
||||||
|
*
|
||||||
|
* 用来一刀切开「寻路器根本没在执行」和「执行了但服务端不认」这两类问题:
|
||||||
|
* 前者表现为控制键全为 false,后者表现为按键有、速度却接近 0 且位置包被回弹。
|
||||||
|
*
|
||||||
|
* @returns {object|null}
|
||||||
|
*/
|
||||||
|
_moveDiagnostics() {
|
||||||
|
const bot = this.bot;
|
||||||
|
if (!bot || !bot.entity) return null;
|
||||||
|
const readControl = (name) => {
|
||||||
|
try {
|
||||||
|
return typeof bot.getControlState === 'function' ? bot.getControlState(name) === true : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const controls = {};
|
||||||
|
for (const name of ['forward', 'back', 'left', 'right', 'jump', 'sprint', 'sneak']) {
|
||||||
|
controls[name] = readControl(name);
|
||||||
|
}
|
||||||
|
const velocity = bot.entity.velocity || {};
|
||||||
|
const speed = Math.sqrt(
|
||||||
|
(velocity.x || 0) ** 2 + (velocity.y || 0) ** 2 + (velocity.z || 0) ** 2
|
||||||
|
);
|
||||||
|
let pathfinder = null;
|
||||||
|
if (bot.pathfinder) {
|
||||||
|
const pf = bot.pathfinder;
|
||||||
|
let goal = null;
|
||||||
|
try {
|
||||||
|
goal = pf.goal ? { x: pf.goal.x, y: pf.goal.y, z: pf.goal.z } : null;
|
||||||
|
} catch {
|
||||||
|
goal = null;
|
||||||
|
}
|
||||||
|
pathfinder = {
|
||||||
|
isMoving: typeof pf.isMoving === 'function' ? pf.isMoving() : null,
|
||||||
|
isMining: typeof pf.isMining === 'function' ? pf.isMining() : null,
|
||||||
|
isBuilding: typeof pf.isBuilding === 'function' ? pf.isBuilding() : null,
|
||||||
|
goal,
|
||||||
|
/** 剩余路径点数量:为 0 说明路径已走完或根本没算出路径 */
|
||||||
|
remainingPath: pf.path ? pf.path.length : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
controls,
|
||||||
|
anyPressed: Object.values(controls).some((v) => v === true),
|
||||||
|
velocity: {
|
||||||
|
x: Math.round((velocity.x || 0) * 1000) / 1000,
|
||||||
|
y: Math.round((velocity.y || 0) * 1000) / 1000,
|
||||||
|
z: Math.round((velocity.z || 0) * 1000) / 1000
|
||||||
|
},
|
||||||
|
speed: Math.round(speed * 1000) / 1000,
|
||||||
|
onGround: bot.entity.onGround === true,
|
||||||
|
pathfinder
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
isReady() {
|
isReady() {
|
||||||
return this.state === 'online' && !!this.bot && !!this.bot.entity
|
return this.state === 'online' && !!this.bot && !!this.bot.entity
|
||||||
&& !!this.bot.pathfinder && this.loggedIn;
|
&& !!this.bot.pathfinder && this.loggedIn;
|
||||||
@@ -221,7 +512,10 @@ class BotManager extends EventEmitter {
|
|||||||
if (this._manualDisconnect || !this.config.server.autoReconnect) return;
|
if (this._manualDisconnect || !this.config.server.autoReconnect) return;
|
||||||
if (this._reconnectTimer) return;
|
if (this._reconnectTimer) return;
|
||||||
this.reconnectAttempts++;
|
this.reconnectAttempts++;
|
||||||
const delay = computeReconnectDelay(this.config.server, this.reconnectAttempts);
|
// 「账号已在别处登录」时重试多少次都没用,直接退避到最大间隔,别频繁硬撞服务器
|
||||||
|
const delay = this.alreadyConnected
|
||||||
|
? (this.config.server.reconnectMaxDelayMs || 120000)
|
||||||
|
: computeReconnectDelay(this.config.server, this.reconnectAttempts);
|
||||||
this._log(`${Math.round(delay / 1000)} 秒后尝试第 ${this.reconnectAttempts} 次重连`);
|
this._log(`${Math.round(delay / 1000)} 秒后尝试第 ${this.reconnectAttempts} 次重连`);
|
||||||
this._reconnectTimer = setTimeout(() => {
|
this._reconnectTimer = setTimeout(() => {
|
||||||
this._reconnectTimer = null;
|
this._reconnectTimer = null;
|
||||||
@@ -234,12 +528,17 @@ class BotManager extends EventEmitter {
|
|||||||
// ==================== 事件装配 ====================
|
// ==================== 事件装配 ====================
|
||||||
|
|
||||||
_wireEvents(bot) {
|
_wireEvents(bot) {
|
||||||
|
this._installGroundStateFix(bot);
|
||||||
this._wirePacketStats(bot);
|
this._wirePacketStats(bot);
|
||||||
|
// 重连会换新的 client,这里每次都重新登记(只在录制期间才会真正挂钩 write)
|
||||||
|
this.packetCapture.setClient(bot._client);
|
||||||
|
this.physicsRecorder.setBot(bot);
|
||||||
|
|
||||||
bot.on('login', () => {
|
bot.on('login', () => {
|
||||||
this.state = 'online';
|
this.state = 'online';
|
||||||
this._needAuthWarned = false;
|
this._needAuthWarned = false;
|
||||||
this._noPasswordWarned = false;
|
this._noPasswordWarned = false;
|
||||||
|
this.alreadyConnected = false;
|
||||||
this._log('已连接到服务器,正在进入世界');
|
this._log('已连接到服务器,正在进入世界');
|
||||||
this._handleLogin();
|
this._handleLogin();
|
||||||
});
|
});
|
||||||
@@ -249,6 +548,8 @@ class BotManager extends EventEmitter {
|
|||||||
this.reconnectAttempts = 0;
|
this.reconnectAttempts = 0;
|
||||||
this._installModules();
|
this._installModules();
|
||||||
this.emit('spawn');
|
this.emit('spawn');
|
||||||
|
// 服务器会把玩家丢到世界原点附近,两种原因的处理方式完全不同,需要区分
|
||||||
|
setTimeout(() => this._handleZeroPosition(), 1500).unref?.();
|
||||||
});
|
});
|
||||||
|
|
||||||
bot.on('end', (reason) => {
|
bot.on('end', (reason) => {
|
||||||
@@ -262,8 +563,16 @@ class BotManager extends EventEmitter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
bot.on('kicked', (reason) => {
|
bot.on('kicked', (reason) => {
|
||||||
const text = typeof reason === 'string' ? reason : JSON.stringify(reason);
|
const text = extractKickReason(reason);
|
||||||
|
if (ALREADY_CONNECTED_PATTERN.test(text)) {
|
||||||
|
this.alreadyConnected = true;
|
||||||
|
this._log(`被服务器踢出:${text}`, 'error');
|
||||||
|
this._log('该账号已在别处登录(通常是你在游戏客户端里登着没退)。'
|
||||||
|
+ '同一账号只能有一个连接,请先在客户端退出,或点控制台的「上线」重新连接;'
|
||||||
|
+ '重连间隔已退避到最大,不会频繁硬撞服务器', 'warn');
|
||||||
|
} else {
|
||||||
this._log('被服务器踢出:' + text, 'warn');
|
this._log('被服务器踢出:' + text, 'warn');
|
||||||
|
}
|
||||||
this.emit('kicked', text);
|
this.emit('kicked', text);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -309,6 +618,16 @@ class BotManager extends EventEmitter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
bot.on('forcedMove', () => {
|
bot.on('forcedMove', () => {
|
||||||
|
// 记录最近一次被服务端同步的位置与时间:用来判断服务端是否正把我们按在原地
|
||||||
|
// (见 _serverPinsUs),这是「客户端认为在空中、服务端认为站着」的关键线索
|
||||||
|
if (bot.entity) {
|
||||||
|
this._lastForcedMove = {
|
||||||
|
x: bot.entity.position.x,
|
||||||
|
y: bot.entity.position.y,
|
||||||
|
z: bot.entity.position.z,
|
||||||
|
at: Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
this.emit('forcedMove');
|
this.emit('forcedMove');
|
||||||
this._countForcedMove();
|
this._countForcedMove();
|
||||||
});
|
});
|
||||||
@@ -406,6 +725,8 @@ class BotManager extends EventEmitter {
|
|||||||
});
|
});
|
||||||
const originalWrite = bot._client.write.bind(bot._client);
|
const originalWrite = bot._client.write.bind(bot._client);
|
||||||
bot._client.write = (name, params) => {
|
bot._client.write = (name, params) => {
|
||||||
|
const types = this.packetStats.outboundTypes;
|
||||||
|
types[name] = (types[name] || 0) + 1;
|
||||||
if (name === 'position' || name === 'position_look') {
|
if (name === 'position' || name === 'position_look') {
|
||||||
this.packetStats.outboundPosition++;
|
this.packetStats.outboundPosition++;
|
||||||
this.packetStats.lastOutboundAt = Date.now();
|
this.packetStats.lastOutboundAt = Date.now();
|
||||||
@@ -419,6 +740,92 @@ class BotManager extends EventEmitter {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「地面状态修正」:让本地物理引擎与服务端对「是否站在地上」的判断保持一致。
|
||||||
|
*
|
||||||
|
* 背景(详见 TODO.md 问题 1):本服在机器人尝试移动时会以 5~9 次/秒的频率下发
|
||||||
|
* `position` 强制同步,而 mineflayer 的 `physics.js` 每次都会把 `entity.onGround`
|
||||||
|
* 置为 false 并清零速度。结果是物理引擎一直按「空中」分支计算
|
||||||
|
* (水平加速只有地面的 1/5),出站包也持续上报「站在地上却在空中」——
|
||||||
|
* 表现就是「按着前进键,水平速度恒为 0」。
|
||||||
|
*
|
||||||
|
* 两个可独立开关的修正:
|
||||||
|
*
|
||||||
|
* - **方案 A**(`grim.syncGroundToPhysics`):每个物理 tick 里,物理引擎算完、
|
||||||
|
* 位置包发出之前,用世界数据复核脚下是否有实体方块;是则强制把
|
||||||
|
* `bot.entity.onGround` 同步为 true。执行时机对齐 mineflayer 的 tickPhysics 顺序
|
||||||
|
* (simulatePlayer → emit('physicsTick') → updatePosition 读 entity.onGround 发包),
|
||||||
|
* 因此这一次修正同时影响「下一 tick 的物理分支」与「本 tick 的出站包」。
|
||||||
|
* - **方案 B**(`grim.trustSetbackOnGround`):收到服务端 `position` 包时,
|
||||||
|
* 如果它的目标位置与本地位置几乎一致(只差浮点误差 / 例行同步),就不采纳
|
||||||
|
* 它带来的 `onGround=false`,只同步坐标 ——「我们是对的,服务端只是话多」。
|
||||||
|
* 位置差超过 `grim.setbackSyncEpsilon` 时按真正的传送处理,不做任何干预。
|
||||||
|
*
|
||||||
|
* 两项修正都与 `grim.enabled` 无关:它们是「让客户端状态正确」的协议级修复,
|
||||||
|
* 而不是反作弊调优(实测关闭合规层后回弹依旧)。
|
||||||
|
*
|
||||||
|
* @param {import('mineflayer').Bot} bot
|
||||||
|
*/
|
||||||
|
_installGroundStateFix(bot) {
|
||||||
|
if (!bot) return;
|
||||||
|
const grim = (this.config && this.config.grim) || {};
|
||||||
|
const usePhysicsSync = grim.syncGroundToPhysics !== false;
|
||||||
|
const useSetbackGuard = grim.trustSetbackOnGround !== false;
|
||||||
|
if (!usePhysicsSync && !useSetbackGuard) return;
|
||||||
|
|
||||||
|
if (usePhysicsSync) {
|
||||||
|
bot.on('physicsTick', () => {
|
||||||
|
if (!bot.entity || bot.entity.onGround === true) return;
|
||||||
|
if (this._isStandingOnGround() !== true) return;
|
||||||
|
bot.entity.onGround = true;
|
||||||
|
this.packetStats.physicsGroundSync++;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useSetbackGuard && bot._client && typeof bot._client.on === 'function') {
|
||||||
|
/**
|
||||||
|
* 位置包的本地快照。
|
||||||
|
*
|
||||||
|
* 必须在 mineflayer 自己的处理器**之前**取样:它会在同一次事件里把速度清零、
|
||||||
|
* 把 onGround 置为 false,之后再取就看不到「我们自己的判断」了。
|
||||||
|
* mineflayer 的处理器注册得更早,因此这里用 prependListener 插到最前面。
|
||||||
|
*/
|
||||||
|
let before = null;
|
||||||
|
if (typeof bot._client.prependListener === 'function') {
|
||||||
|
bot._client.prependListener('position', () => {
|
||||||
|
before = bot.entity
|
||||||
|
? {
|
||||||
|
x: bot.entity.position.x,
|
||||||
|
y: bot.entity.position.y,
|
||||||
|
z: bot.entity.position.z,
|
||||||
|
onGround: bot.entity.onGround === true
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
bot._client.on('position', () => {
|
||||||
|
if (!before || !bot.entity) return;
|
||||||
|
const pos = bot.entity.position;
|
||||||
|
const dx = pos.x - before.x;
|
||||||
|
const dy = pos.y - before.y;
|
||||||
|
const dz = pos.z - before.z;
|
||||||
|
const distance = Math.hypot(dx, dy, dz);
|
||||||
|
this.packetStats.lastSetbackDelta = {
|
||||||
|
x: round3(dx),
|
||||||
|
y: round3(dy),
|
||||||
|
z: round3(dz),
|
||||||
|
distance: round3(distance),
|
||||||
|
at: Date.now()
|
||||||
|
};
|
||||||
|
const epsilon = Number(grim.setbackSyncEpsilon);
|
||||||
|
if (distance > (Number.isFinite(epsilon) ? epsilon : 0.5)) return; // 真正的传送:按服务端的来
|
||||||
|
if (bot.entity.onGround === before.onGround) return;
|
||||||
|
bot.entity.onGround = before.onGround;
|
||||||
|
this.packetStats.setbackOnGroundKept++;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按世界数据修正出站位置包的 onGround 标志。
|
* 按世界数据修正出站位置包的 onGround 标志。
|
||||||
*
|
*
|
||||||
@@ -434,19 +841,91 @@ class BotManager extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
_fixOutboundOnGround(params) {
|
_fixOutboundOnGround(params) {
|
||||||
const grim = this.config && this.config.grim;
|
const grim = this.config && this.config.grim;
|
||||||
if (!grim || grim.reportOnGroundFromWorld !== true) return;
|
if (!grim) return;
|
||||||
if (!params || !params.flags || typeof params.flags.onGround !== 'boolean') return;
|
if (!params || !params.flags) return;
|
||||||
|
this._fixOnGroundFlag(grim, params);
|
||||||
|
this._fixHorizontalCollisionFlag(grim, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用世界数据复核出站包的 `flags.onGround`。
|
||||||
|
*
|
||||||
|
* @param {object} grim
|
||||||
|
* @param {object} params 出站包内容
|
||||||
|
*/
|
||||||
|
_fixOnGroundFlag(grim, params) {
|
||||||
|
if (grim.reportOnGroundFromWorld !== true) return;
|
||||||
|
if (typeof params.flags.onGround !== 'boolean') return;
|
||||||
const actual = this._isStandingOnGround();
|
const actual = this._isStandingOnGround();
|
||||||
if (actual === null || params.flags.onGround === actual) return;
|
if (actual === null || params.flags.onGround === actual) return;
|
||||||
params.flags.onGround = actual;
|
params.flags.onGround = actual;
|
||||||
this.packetStats.onGroundFixed++;
|
this.packetStats.onGroundFixed++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 补上出站包的 `flags.hasHorizontalCollision`。
|
||||||
|
*
|
||||||
|
* mineflayer 在 `physics.js` 里把它写成 `undefined`(源码注释还写着「1.21.3+」),
|
||||||
|
* 位标志序列化后**恒为 0** —— 而原版客户端在水平碰到方块时会上报 1。
|
||||||
|
* 机器人在崎岖地形上几乎一直在水平碰撞,却始终上报「没碰撞」,
|
||||||
|
* 正是 Grim 的 Prediction 最容易判错的形态:服务端按「该碰撞」预测,
|
||||||
|
* 客户端说「没碰撞」,于是被判定异常并拉回。
|
||||||
|
*
|
||||||
|
* 物理引擎(prismarine-physics)其实已经算出了真实值
|
||||||
|
* (`bot.entity.isCollidedHorizontally`,见 `moveEntity`),只是从没被发出去。
|
||||||
|
* 这里把它如实补上。
|
||||||
|
*
|
||||||
|
* @param {object} grim
|
||||||
|
* @param {object} params 出站包内容
|
||||||
|
*/
|
||||||
|
_fixHorizontalCollisionFlag(grim, params) {
|
||||||
|
if (grim.reportHorizontalCollision !== true) return;
|
||||||
|
const entity = this.bot && this.bot.entity;
|
||||||
|
if (!entity || typeof entity.isCollidedHorizontally !== 'boolean') return;
|
||||||
|
if (params.flags.hasHorizontalCollision === entity.isCollidedHorizontally) return;
|
||||||
|
params.flags.hasHorizontalCollision = entity.isCollidedHorizontally;
|
||||||
|
this.packetStats.horizontalCollisionFixed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送一条「需要人工介入」的通知(验证码 / 自救失败)。
|
||||||
|
*
|
||||||
|
* 异步且静默失败:通知只是提醒手段,网络不通不能影响机器人本身的运行。
|
||||||
|
*
|
||||||
|
* @param {string} text 正文
|
||||||
|
* @param {string} key 去重键(同一个问题在通知器的最小间隔内只发一次)
|
||||||
|
*/
|
||||||
|
_notify(text, key) {
|
||||||
|
if (!this.notifier || typeof this.notifier.notify !== 'function') return;
|
||||||
|
// 是否启用、地址是否配好,由通知器自己判断(含 NOTIFY_WEBHOOK_URL 环境变量兜底)
|
||||||
|
if (this.notifier.isEnabled() !== true) return;
|
||||||
|
Promise.resolve(this.notifier.notify(text, { key }))
|
||||||
|
.catch((err) => this._log('发送通知提醒失败:' + (err && err.message ? err.message : String(err)), 'warn'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {boolean} 是否允许发送「等待验证码」提醒(配置 notify.onVerification) */
|
||||||
|
_shouldNotifyVerification() {
|
||||||
|
const cfg = (this.config && this.config.notify) || {};
|
||||||
|
return cfg.onVerification !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {boolean} 是否允许发送「原点自救失败」提醒(配置 notify.onOriginRescueFailed) */
|
||||||
|
_shouldNotifyRescueFailed() {
|
||||||
|
const cfg = (this.config && this.config.notify) || {};
|
||||||
|
return cfg.onOriginRescueFailed !== false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用世界数据判断脚下是否有实体方块。
|
* 用世界数据判断脚下是否有实体方块。
|
||||||
*
|
*
|
||||||
* 取脚下方 0.05 格处的方块:既能容忍浮点误差,又不会因为站在半砖上而误判。
|
* 取脚下方 0.05 格处的方块:既能容忍浮点误差,又不会因为站在半砖上而误判。
|
||||||
*
|
*
|
||||||
|
* **额外考虑服务端的判定**:如果服务端刚刚把机器人按在同一个位置不动
|
||||||
|
* (短时间内反复下发相同的 position 包),那在服务端眼里它显然是站着的。
|
||||||
|
* 这种「客户端认为自己在空中、服务端认为它站着」的不一致会让服务端每 tick
|
||||||
|
* 判一次异常并回弹,而回弹又会清零速度、让物理引擎永远走不出空中状态 ——
|
||||||
|
* 表现就是「按着前进键但水平速度恒为 0」。此时以服务端为准更安全。
|
||||||
|
*
|
||||||
* @returns {boolean|null} 无法判断时返回 null
|
* @returns {boolean|null} 无法判断时返回 null
|
||||||
*/
|
*/
|
||||||
_isStandingOnGround() {
|
_isStandingOnGround() {
|
||||||
@@ -454,7 +933,27 @@ class BotManager extends EventEmitter {
|
|||||||
if (!bot || !bot.entity || !bot.blockAt) return null;
|
if (!bot || !bot.entity || !bot.blockAt) return null;
|
||||||
const below = bot.blockAt(bot.entity.position.offset(0, -0.05, 0));
|
const below = bot.blockAt(bot.entity.position.offset(0, -0.05, 0));
|
||||||
if (!below) return null;
|
if (!below) return null;
|
||||||
return below.boundingBox !== 'empty';
|
if (below.boundingBox !== 'empty') return true;
|
||||||
|
// 世界说脚下是空的 —— 再看服务端是不是正把我们按在原地
|
||||||
|
return this._serverPinsUs() ? true : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务端是否正在把机器人「按」在原地(短时间内反复同步到几乎相同的位置)。
|
||||||
|
*
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
_serverPinsUs() {
|
||||||
|
const last = this._lastForcedMove;
|
||||||
|
if (!last) return false;
|
||||||
|
const age = Date.now() - last.at;
|
||||||
|
if (age > 500) return false;
|
||||||
|
const pos = this.bot && this.bot.entity ? this.bot.entity.position : null;
|
||||||
|
if (!pos) return false;
|
||||||
|
// 位置几乎没变(服务器认可当前位置),说明它认为我们稳稳站着
|
||||||
|
return Math.abs(pos.y - last.y) < 0.05
|
||||||
|
&& Math.abs(pos.x - last.x) < 0.5
|
||||||
|
&& Math.abs(pos.z - last.z) < 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 进入世界后按配置尝试登录 / 注册 */
|
/** 进入世界后按配置尝试登录 / 注册 */
|
||||||
@@ -541,8 +1040,23 @@ class BotManager extends EventEmitter {
|
|||||||
if (!this._needAuthWarned) {
|
if (!this._needAuthWarned) {
|
||||||
this._needAuthWarned = true;
|
this._needAuthWarned = true;
|
||||||
this._log('服务器要求先完成账号绑定 / 二次验证(本服为「简幻通」体系):' + message, 'error');
|
this._log('服务器要求先完成账号绑定 / 二次验证(本服为「简幻通」体系):' + message, 'error');
|
||||||
this._log('该验证需要人工完成一次(游戏内扫码或 /auth <UID> <验证码>);'
|
this._log('该验证需要人工完成一次(游戏内扫码,或把验证码当聊天内容直接发出去);'
|
||||||
+ '绑定成功后同一账号即可自动登录。若服务器提供固定验证码,可在配置的「账号绑定指令模板」里填写。', 'warn');
|
+ '绑定成功后同一账号即可自动登录。若服务器提供固定验证码,可在配置的「账号绑定指令模板」里填写。', 'warn');
|
||||||
|
// 验证码只有 120 秒时限,错过就得等下次重登 —— 这是少数必须人工介入的时刻,
|
||||||
|
// 因此值得走通知渠道(飞书机器人等)提醒,而不是只写进日志
|
||||||
|
if (this._shouldNotifyVerification()) {
|
||||||
|
this._notify(`[AutoStore] 机器人「${this.connection.username}」需要输入登录验证码。\n`
|
||||||
|
+ '服务器只给 120 秒,请立刻到控制台的聊天框把验证码发出去(当聊天内容发送,不用加斜杠)。\n'
|
||||||
|
+ `服务器提示:${message}`, `verification:${this.id}`);
|
||||||
|
}
|
||||||
|
// 如果此刻人在原点附近,那就是被切到了登录服 —— 把这条因果关系讲清楚,
|
||||||
|
// 否则用户只看到「机器人被反复传送」而不知道真正原因
|
||||||
|
const pos = this.bot && this.bot.entity ? this.bot.entity.position : null;
|
||||||
|
if (pos && Math.abs(pos.x) < 48 && Math.abs(pos.z) < 48 && pos.y < 16) {
|
||||||
|
this.needsVerificationHint = true;
|
||||||
|
this._log('当前机器人位于世界原点附近,说明它已被切到登录服;'
|
||||||
|
+ '「被服务器传送」刷屏正是这个原因,输入验证码后即会回到真实世界', 'warn');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (server.authCommand) {
|
if (server.authCommand) {
|
||||||
this.sendCommand(fillTemplate(server.authCommand, password), { internal: true, immediate: true });
|
this.sendCommand(fillTemplate(server.authCommand, password), { internal: true, immediate: true });
|
||||||
@@ -740,6 +1254,80 @@ class BotManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始抓出站包(诊断用)。
|
||||||
|
*
|
||||||
|
* 典型用法:先在「机器人能走」时抓 5 秒,再在「走不动」时抓 5 秒,两次逐包 diff ——
|
||||||
|
* 出站包内容上的差异(onGround / hasHorizontalCollision / player_input 位标志、
|
||||||
|
* 位置与朝向是否合并成一次更新)比任何猜测都更能定位反作弊为什么拒绝移动。
|
||||||
|
*
|
||||||
|
* @param {number} [seconds] 录制时长(秒),省略则需手动停止
|
||||||
|
* @returns {{ok: boolean, message: string}}
|
||||||
|
*/
|
||||||
|
startPacketCapture(seconds) {
|
||||||
|
const result = this.packetCapture.start(seconds);
|
||||||
|
if (result.ok) this._log('抓包:' + result.message);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止抓包(已录到的内容保留,可继续用 `packetCaptureSnapshot()` 取)。
|
||||||
|
*
|
||||||
|
* @returns {{ok: boolean, message: string}}
|
||||||
|
*/
|
||||||
|
stopPacketCapture() {
|
||||||
|
const result = this.packetCapture.stop();
|
||||||
|
this._log('抓包:' + result.message);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取出抓包结果。
|
||||||
|
*
|
||||||
|
* @param {{types?: string|string[], limit?: number}} [options]
|
||||||
|
* @returns {object}
|
||||||
|
*/
|
||||||
|
packetCaptureSnapshot(options) {
|
||||||
|
return this.packetCapture.snapshot(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始录制物理 tick(诊断用)。
|
||||||
|
*
|
||||||
|
* 与抓包配合使用:抓包回答「客户端发了什么」,本录制器回答
|
||||||
|
* 「物理引擎这一 tick 读到了什么输入、算出了什么、有没有真的移动」。
|
||||||
|
* 两者一起看,才能把「输入没进物理」「被方块挡住」「服务端回弹」三种原因切开。
|
||||||
|
*
|
||||||
|
* @param {number} [seconds] 录制时长(秒),省略则需手动停止
|
||||||
|
* @returns {{ok: boolean, message: string}}
|
||||||
|
*/
|
||||||
|
startPhysicsRecorder(seconds) {
|
||||||
|
const result = this.physicsRecorder.start(seconds);
|
||||||
|
if (result.ok) this._log('物理录制:' + result.message);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止物理 tick 录制。
|
||||||
|
*
|
||||||
|
* @returns {{ok: boolean, message: string}}
|
||||||
|
*/
|
||||||
|
stopPhysicsRecorder() {
|
||||||
|
const result = this.physicsRecorder.stop();
|
||||||
|
this._log('物理录制:' + result.message);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取出物理 tick 录制结果。
|
||||||
|
*
|
||||||
|
* @param {{limit?: number}} [options]
|
||||||
|
* @returns {object}
|
||||||
|
*/
|
||||||
|
physicsSnapshot(options) {
|
||||||
|
return this.physicsRecorder.snapshot(options);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 手动移动测试:按住某个方向键若干秒,然后松开。
|
* 手动移动测试:按住某个方向键若干秒,然后松开。
|
||||||
*
|
*
|
||||||
@@ -794,6 +1382,26 @@ class BotManager extends EventEmitter {
|
|||||||
loggedIn: this.loggedIn,
|
loggedIn: this.loggedIn,
|
||||||
loginFailed: this.loginFailed,
|
loginFailed: this.loginFailed,
|
||||||
canOperate: this.canOperate(),
|
canOperate: this.canOperate(),
|
||||||
|
/** 是否因「该账号已在别处登录」被踢(控制台据此提示用户去关掉另一个客户端) */
|
||||||
|
alreadyConnected: this.alreadyConnected,
|
||||||
|
/**
|
||||||
|
* 服务器是否正在要求输入验证码 / 完成二次验证。
|
||||||
|
*
|
||||||
|
* 这种验证只能人工完成(验证码当聊天内容发出去,或扫码),
|
||||||
|
* 控制台据此弹醒目提示并引导用户在输入框里填验证码。
|
||||||
|
*/
|
||||||
|
needsVerification: this._needAuthWarned === true,
|
||||||
|
/**
|
||||||
|
* 原点自救统计:尝试次数、成功次数、最近一次结果(含前后坐标)。
|
||||||
|
*
|
||||||
|
* 以前只能从日志里看到「尝试过」,看不出自救到底有没有用;
|
||||||
|
* 控制台与 /api/status 可以直接读这里的成功率。
|
||||||
|
*/
|
||||||
|
originRescue: {
|
||||||
|
attempts: this.originRescue.attempts,
|
||||||
|
successes: this.originRescue.successes,
|
||||||
|
lastResult: this.originRescue.lastResult
|
||||||
|
},
|
||||||
username: this.connection.username,
|
username: this.connection.username,
|
||||||
host: `${this.connection.host}:${this.connection.port}`,
|
host: `${this.connection.host}:${this.connection.port}`,
|
||||||
version: this.connection.version,
|
version: this.connection.version,
|
||||||
@@ -819,6 +1427,17 @@ class BotManager extends EventEmitter {
|
|||||||
packetStats: Object.assign({}, this.packetStats),
|
packetStats: Object.assign({}, this.packetStats),
|
||||||
world: this._worldDiagnostics(),
|
world: this._worldDiagnostics(),
|
||||||
pathing: this.pathing ? this.pathing.isPathing() : false,
|
pathing: this.pathing ? this.pathing.isPathing() : false,
|
||||||
|
/** 寻路诊断:邻居生成数、世界数据可读性、四向可见距离 */
|
||||||
|
pathingDiag: this.pathing && typeof this.pathing.diagnostics === 'function'
|
||||||
|
? this.pathing.diagnostics()
|
||||||
|
: null,
|
||||||
|
/**
|
||||||
|
* 移动执行诊断:当前按下了哪些控制键、速度、寻路器内部状态。
|
||||||
|
*
|
||||||
|
* 用来一刀切开「寻路器根本没在执行」和「执行了但服务端不认」这两类问题:
|
||||||
|
* 前者表现为控制键全为 false,后者表现为按键有、速度却接近 0 且位置包被回弹。
|
||||||
|
*/
|
||||||
|
moveDiag: this._moveDiagnostics(),
|
||||||
eating: this.eater ? this.eater.eating : false,
|
eating: this.eater ? this.eater.eating : false,
|
||||||
grim: this.grim ? this.grim.snapshot() : null
|
grim: this.grim ? this.grim.snapshot() : null
|
||||||
};
|
};
|
||||||
@@ -998,6 +1617,35 @@ function boundsOfColumns(columns) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取当前位置的快照(三位小数),用于「回城 / 自救前后对比」这类排查信息。
|
||||||
|
* @param {import('mineflayer').Bot|null} bot
|
||||||
|
* @returns {{x: number, y: number, z: number}|null}
|
||||||
|
*/
|
||||||
|
function capturePosition(bot) {
|
||||||
|
const pos = bot && bot.entity ? bot.entity.position : null;
|
||||||
|
if (!pos) return null;
|
||||||
|
return { x: round3(pos.x), y: round3(pos.y), z: round3(pos.z) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把位置快照格式化成日志里可读的形式。
|
||||||
|
* @param {{x: number, y: number, z: number}|null} pos
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function formatPosition(pos) {
|
||||||
|
return pos ? `(${pos.x}, ${pos.y}, ${pos.z})` : '(未知)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保留三位小数(诊断数据里足够,避免控制台被长浮点刷屏)。
|
||||||
|
* @param {number} value
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
function round3(value) {
|
||||||
|
return Math.round(value * 1000) / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* mineflayer 的消息通道 → 公屏日志类型。
|
* mineflayer 的消息通道 → 公屏日志类型。
|
||||||
* @param {string} [position]
|
* @param {string} [position]
|
||||||
@@ -1009,6 +1657,40 @@ function kindOfPosition(position) {
|
|||||||
return ChatKind.CHAT;
|
return ChatKind.CHAT;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把踢出原因(可能是字符串,也可能是 Minecraft 聊天组件对象)提取成纯文本。
|
||||||
|
*
|
||||||
|
* 服务端踢人时发的是组件对象,直接 `JSON.stringify` 会得到
|
||||||
|
* `{"color":"red","text":"您已经连接到此代理服务器了!"}` 这种没法看的东西,
|
||||||
|
* 而里面那句话恰恰是判断问题的关键。
|
||||||
|
*
|
||||||
|
* @param {*} reason
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function extractKickReason(reason) {
|
||||||
|
if (reason === undefined || reason === null) return '未知原因';
|
||||||
|
if (typeof reason === 'string') return reason;
|
||||||
|
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);
|
||||||
|
if (value.extra !== undefined) parts.push(walk(value.extra));
|
||||||
|
return parts.join('');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
const text = walk(reason);
|
||||||
|
if (text) return text;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(reason);
|
||||||
|
} catch {
|
||||||
|
return String(reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把指令模板里的 `{password}` 占位符替换为真实密码。
|
* 把指令模板里的 `{password}` 占位符替换为真实密码。
|
||||||
* @param {string} template
|
* @param {string} template
|
||||||
@@ -1036,4 +1718,11 @@ function computeReconnectDelay(serverConfig, attempts) {
|
|||||||
return Math.min(max, base * Math.pow(2, Math.min(6, step - 1)));
|
return Math.min(max, base * Math.pow(2, Math.min(6, step - 1)));
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { BotManager, fillTemplate, kindOfPosition, computeReconnectDelay, FORCED_MOVE_WINDOW_MS };
|
module.exports = {
|
||||||
|
BotManager,
|
||||||
|
fillTemplate,
|
||||||
|
kindOfPosition,
|
||||||
|
computeReconnectDelay,
|
||||||
|
extractKickReason,
|
||||||
|
FORCED_MOVE_WINDOW_MS
|
||||||
|
};
|
||||||
|
|||||||
+141
-3
@@ -5,6 +5,7 @@ const { EventEmitter } = require('events');
|
|||||||
const { BotManager } = require('./botManager');
|
const { BotManager } = require('./botManager');
|
||||||
const { ScheduledMessenger } = require('./scheduledMessenger');
|
const { ScheduledMessenger } = require('./scheduledMessenger');
|
||||||
const { ExpansionController } = require('../controller/expansionController');
|
const { ExpansionController } = require('../controller/expansionController');
|
||||||
|
const { TargetLeaseTable } = require('../core/targetLease');
|
||||||
const { normalizeConfig } = require('../config/expansionConfig');
|
const { normalizeConfig } = require('../config/expansionConfig');
|
||||||
const { sanitizeId } = require('../config/botsConfig');
|
const { sanitizeId } = require('../config/botsConfig');
|
||||||
|
|
||||||
@@ -43,6 +44,8 @@ class BotPool extends EventEmitter {
|
|||||||
this.chatLog = deps.chatLog || null;
|
this.chatLog = deps.chatLog || null;
|
||||||
this.botStore = deps.botStore || null;
|
this.botStore = deps.botStore || null;
|
||||||
this.credentialStore = deps.credentialStore || null;
|
this.credentialStore = deps.credentialStore || null;
|
||||||
|
/** @type {import('../core/notifier').Notifier|null} 人工介入提醒(验证码 / 自救失败) */
|
||||||
|
this.notifier = deps.notifier || null;
|
||||||
|
|
||||||
/** @type {Map<string, object>} 机器人 id → 运行时条目 */
|
/** @type {Map<string, object>} 机器人 id → 运行时条目 */
|
||||||
this.entries = new Map();
|
this.entries = new Map();
|
||||||
@@ -54,6 +57,13 @@ class BotPool extends EventEmitter {
|
|||||||
this.credentials = this.credentialStore ? this.credentialStore.load().bots : {};
|
this.credentials = this.credentialStore ? this.credentialStore.load().bots : {};
|
||||||
/** 启动时间戳 */
|
/** 启动时间戳 */
|
||||||
this.startedAt = 0;
|
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 || [])) {
|
for (const connection of this._mergeConnections(deps.connections || [])) {
|
||||||
this._addBot(connection);
|
this._addBot(connection);
|
||||||
@@ -103,7 +113,7 @@ class BotPool extends EventEmitter {
|
|||||||
Object.assign({}, connection, { label: setting.label || connection.label }),
|
Object.assign({}, connection, { label: setting.label || connection.label }),
|
||||||
this.getConfigFor(id),
|
this.getConfigFor(id),
|
||||||
this.logger,
|
this.logger,
|
||||||
{ chatLog: this.chatLog }
|
{ chatLog: this.chatLog, notifier: this.notifier }
|
||||||
);
|
);
|
||||||
const controller = new ExpansionController({
|
const controller = new ExpansionController({
|
||||||
botManager: manager,
|
botManager: manager,
|
||||||
@@ -111,7 +121,8 @@ class BotPool extends EventEmitter {
|
|||||||
logger: this.logger,
|
logger: this.logger,
|
||||||
botId: id,
|
botId: id,
|
||||||
botName: connection.username,
|
botName: connection.username,
|
||||||
expansionLog: this.expansionLog
|
expansionLog: this.expansionLog,
|
||||||
|
targetLeases: this.targetLeases
|
||||||
});
|
});
|
||||||
// 寻路事件转发给控制器(与 Baritone 的 PathEvent 语义一致)
|
// 寻路事件转发给控制器(与 Baritone 的 PathEvent 语义一致)
|
||||||
manager.on('modulesReady', () => {
|
manager.on('modulesReady', () => {
|
||||||
@@ -341,6 +352,10 @@ class BotPool extends EventEmitter {
|
|||||||
*/
|
*/
|
||||||
applyGlobalConfig(config) {
|
applyGlobalConfig(config) {
|
||||||
this.config = 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()) {
|
for (const entry of this.entries.values()) {
|
||||||
entry.config = this.getConfigFor(entry.id);
|
entry.config = this.getConfigFor(entry.id);
|
||||||
entry.manager.applyConfig(entry.config);
|
entry.manager.applyConfig(entry.config);
|
||||||
@@ -421,17 +436,43 @@ class BotPool extends EventEmitter {
|
|||||||
? normalizeConfig(overrideConfig, this.getConfigFor(id))
|
? normalizeConfig(overrideConfig, this.getConfigFor(id))
|
||||||
: this.getConfigFor(id);
|
: this.getConfigFor(id);
|
||||||
if (overrideConfig) {
|
if (overrideConfig) {
|
||||||
// 控制台点「开始扩地」时顺带保存该机器人的配置
|
// 控制台点「开始扩地」且勾了「仅对当前机器人生效」时,顺带把这份配置存为该机器人的专属配置。
|
||||||
|
// 注意这是一份**完整快照**:存下之后全局配置的改动就不再影响它了,
|
||||||
|
// 所以这里明确记一条日志,避免以后排查时一头雾水。
|
||||||
|
const previous = this.settings[id] && this.settings[id].config ? '(覆盖已有)' : '(新建)';
|
||||||
this.settings[id] = Object.assign({}, this.settings[id] || {}, { config });
|
this.settings[id] = Object.assign({}, this.settings[id] || {}, { config });
|
||||||
this._persistSettings();
|
this._persistSettings();
|
||||||
entry.config = config;
|
entry.config = config;
|
||||||
entry.manager.applyConfig(config);
|
entry.manager.applyConfig(config);
|
||||||
|
this._log(`${entry.connection.username} 已保存专属配置${previous}:`
|
||||||
|
+ `策略 ${config.strategy}、目标邦国「${config.stateName || '自动'}」、目标数量 ${config.targetCount};`
|
||||||
|
+ '此后全局配置的改动不会影响它,可在配置面板点「清除专属配置」恢复继承');
|
||||||
}
|
}
|
||||||
entry.controller.config = config;
|
entry.controller.config = config;
|
||||||
const ok = await entry.controller.start(config);
|
const ok = await entry.controller.start(config);
|
||||||
return { ok, message: ok ? '任务已启动' : entry.controller.status, entry };
|
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) {
|
stopTask(id) {
|
||||||
const entry = this.require(id);
|
const entry = this.require(id);
|
||||||
@@ -450,6 +491,45 @@ class BotPool extends EventEmitter {
|
|||||||
return entry;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 向某机器人发送游戏指令。
|
* 向某机器人发送游戏指令。
|
||||||
*
|
*
|
||||||
@@ -516,6 +596,64 @@ class BotPool extends EventEmitter {
|
|||||||
return entry.manager.walkTest(direction, seconds);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 状态快照 ====================
|
// ==================== 状态快照 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+34
-3
@@ -227,7 +227,7 @@ class GrimCompat {
|
|||||||
*/
|
*/
|
||||||
setLook(yaw, pitch) {
|
setLook(yaw, pitch) {
|
||||||
if (typeof yaw !== 'number' || Number.isNaN(yaw)) return Promise.resolve();
|
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)
|
this.targetPitch = typeof pitch === 'number' && !Number.isNaN(pitch)
|
||||||
? clamp(pitch, -Math.PI / 2, Math.PI / 2)
|
? clamp(pitch, -Math.PI / 2, Math.PI / 2)
|
||||||
: this.targetPitch;
|
: this.targetPitch;
|
||||||
@@ -297,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() {
|
_onPhysicsTick() {
|
||||||
const bot = this.bot;
|
const bot = this.bot;
|
||||||
if (!bot.entity) return;
|
if (!bot.entity) return;
|
||||||
const step = this.maxStepRad;
|
const step = this.maxStepRad;
|
||||||
const yawDelta = shortestAngleDelta(bot.entity.yaw, this.targetYaw);
|
const yawDelta = shortestAngleDelta(bot.entity.yaw, this.targetYaw);
|
||||||
if (Math.abs(yawDelta) > 1e-6) {
|
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 {
|
} else {
|
||||||
bot.entity.yaw = this.targetYaw;
|
bot.entity.yaw = this.targetYaw;
|
||||||
}
|
}
|
||||||
@@ -613,6 +624,25 @@ function shortestAngleDelta(from, to) {
|
|||||||
return delta;
|
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
|
* @param {import('mineflayer').Bot} bot
|
||||||
@@ -658,6 +688,7 @@ module.exports = {
|
|||||||
VANILLA_PHYSICS_BASELINE,
|
VANILLA_PHYSICS_BASELINE,
|
||||||
ATTACK_SPEED_RULES,
|
ATTACK_SPEED_RULES,
|
||||||
shortestAngleDelta,
|
shortestAngleDelta,
|
||||||
|
wrapAngle,
|
||||||
eyeDistanceToEntity,
|
eyeDistanceToEntity,
|
||||||
degToRad,
|
degToRad,
|
||||||
radToDeg
|
radToDeg
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
+236
-33
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
const pathfinderPlugin = require('mineflayer-pathfinder');
|
const pathfinderPlugin = require('mineflayer-pathfinder');
|
||||||
|
|
||||||
|
const { ManualMover } = require('./manualMover');
|
||||||
|
|
||||||
const { GoalXZ, GoalNearXZ, Goal } = pathfinderPlugin.goals;
|
const { GoalXZ, GoalNearXZ, Goal } = pathfinderPlugin.goals;
|
||||||
const { Movements } = pathfinderPlugin;
|
const { Movements } = pathfinderPlugin;
|
||||||
|
|
||||||
@@ -23,9 +25,18 @@ const MAX_SEGMENT_SHRINK = 3;
|
|||||||
* - 大幅提高进入液体格的代价,优先绕开水域(mineflayer 液体物理与原版差异最大)
|
* - 大幅提高进入液体格的代价,优先绕开水域(mineflayer 液体物理与原版差异最大)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「寻路状态」日志的最小间隔(毫秒):进度日志最多这么频繁地记一条。
|
||||||
|
*
|
||||||
|
* mineflayer-pathfinder 每用满一个 tick 的计算预算就发一次 `path_update`,
|
||||||
|
* 不节流的话实测 4 分钟能写 3893 条,把真正有用的日志全淹了。
|
||||||
|
*/
|
||||||
|
const PATH_LOG_MIN_INTERVAL_MS = 2000;
|
||||||
|
/** 「寻路状态」日志的节点数增量阈值:探索推进这么多节点才值得再记一条 */
|
||||||
|
const PATH_LOG_NODE_STEP = 1000;
|
||||||
|
|
||||||
/** 路径事件枚举,语义对齐 Baritone 的 PathEvent */
|
/** 路径事件枚举,语义对齐 Baritone 的 PathEvent */
|
||||||
const PathEvent = {
|
const PathEvent = { CALC_STARTED: 'CALC_STARTED',
|
||||||
CALC_STARTED: 'CALC_STARTED',
|
|
||||||
CALC_FINISHED_NOW_EXECUTING: 'CALC_FINISHED_NOW_EXECUTING',
|
CALC_FINISHED_NOW_EXECUTING: 'CALC_FINISHED_NOW_EXECUTING',
|
||||||
CALC_FAILED: 'CALC_FAILED',
|
CALC_FAILED: 'CALC_FAILED',
|
||||||
AT_GOAL: 'AT_GOAL',
|
AT_GOAL: 'AT_GOAL',
|
||||||
@@ -55,6 +66,10 @@ class PathingService {
|
|||||||
this._pathing = false;
|
this._pathing = false;
|
||||||
this._lastStatus = null;
|
this._lastStatus = null;
|
||||||
this._configured = false;
|
this._configured = false;
|
||||||
|
/** 「寻路状态」日志的节流状态:上次记的状态 / 时间 / 节点数 */
|
||||||
|
this._lastLoggedStatus = null;
|
||||||
|
this._lastPathLogAt = 0;
|
||||||
|
this._lastLoggedNodes = 0;
|
||||||
/** 事件监听是否已注册(configure 会被反复调用,不能重复注册) */
|
/** 事件监听是否已注册(configure 会被反复调用,不能重复注册) */
|
||||||
this._listenersBound = false;
|
this._listenersBound = false;
|
||||||
/**
|
/**
|
||||||
@@ -75,6 +90,16 @@ class PathingService {
|
|||||||
/** 段长收缩次数与系数(本段算出 noPath 时用来缩短段长重试) */
|
/** 段长收缩次数与系数(本段算出 noPath 时用来缩短段长重试) */
|
||||||
this._segmentShrink = 0;
|
this._segmentShrink = 0;
|
||||||
this._segmentScale = 1;
|
this._segmentScale = 1;
|
||||||
|
/**
|
||||||
|
* 手动执行器(`pathing.executor === 'manual'` 时创建)。
|
||||||
|
*
|
||||||
|
* 只借用 pathfinder 的 A* 规划,执行改为自己按键(详见 manualMover.js 的说明);
|
||||||
|
* 默认不启用,实测确认寻路器执行层被服务端回弹时才切换。
|
||||||
|
* @type {ManualMover|null}
|
||||||
|
*/
|
||||||
|
this.mover = null;
|
||||||
|
/** 手动执行的代次:新一次执行会让旧的一次自行退出 */
|
||||||
|
this._manualRunId = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -119,33 +144,10 @@ class PathingService {
|
|||||||
// 重复注册会让同一个事件被处理多次(日志里同一秒出现多条相同事件就是这么来的)
|
// 重复注册会让同一个事件被处理多次(日志里同一秒出现多条相同事件就是这么来的)
|
||||||
if (!this._listenersBound) {
|
if (!this._listenersBound) {
|
||||||
this._listenersBound = true;
|
this._listenersBound = true;
|
||||||
bot.on('goal_reached', () => {
|
bot.on('goal_reached', () => this._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.on('path_update', (r) => {
|
bot.on('path_update', (r) => {
|
||||||
this._lastStatus = r.status;
|
this._lastStatus = r.status;
|
||||||
// 完整记录寻路器状态:长距离寻路卡住时,只看成功 / 失败是查不出原因的
|
this._logPathProgress(r);
|
||||||
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) : '无'}`);
|
|
||||||
if (r.status === 'success') {
|
if (r.status === 'success') {
|
||||||
this.calcFailed = false;
|
this.calcFailed = false;
|
||||||
this._emit(PathEvent.CALC_FINISHED_NOW_EXECUTING, { cost: r.cost, nodes: r.visitedNodes });
|
this._emit(PathEvent.CALC_FINISHED_NOW_EXECUTING, { cost: r.cost, nodes: r.visitedNodes });
|
||||||
@@ -175,6 +177,13 @@ class PathingService {
|
|||||||
}
|
}
|
||||||
this.ready = true;
|
this.ready = true;
|
||||||
this._configured = 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._log(`寻路器就绪(挖方块 ${this.config.allowBreak ? '开' : '关'},`
|
||||||
+ `放方块 ${this.config.allowPlace ? '开' : '关'},`
|
+ `放方块 ${this.config.allowPlace ? '开' : '关'},`
|
||||||
+ `跑酷 ${this.config.pathing.allowParkour ? '开' : '关'},`
|
+ `跑酷 ${this.config.pathing.allowParkour ? '开' : '关'},`
|
||||||
@@ -182,6 +191,40 @@ class PathingService {
|
|||||||
return true;
|
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 新配置
|
* @param {object} config 新配置
|
||||||
@@ -235,14 +278,17 @@ class PathingService {
|
|||||||
const goalBlock = this._segmentGoal(this.finalTarget);
|
const goalBlock = this._segmentGoal(this.finalTarget);
|
||||||
this.currentWaypoint = goalBlock;
|
this.currentWaypoint = goalBlock;
|
||||||
this._waypointIsFinal = goalBlock.x === this.finalTarget.x && goalBlock.z === this.finalTarget.z;
|
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);
|
let goal = new GoalNearXZ(goalBlock.x, goalBlock.z, 2);
|
||||||
if (this.config.altitudePreference && this.config.altitudeWeight > 0) {
|
if (this.config.altitudePreference && this.config.altitudeWeight > 0) {
|
||||||
const startY = Math.floor(bot.entity.position.y);
|
const startY = Math.floor(bot.entity.position.y);
|
||||||
goal = new AltitudeGoal(goal, this.config.altitudeWeight, startY);
|
goal = new AltitudeGoal(goal, this.config.altitudeWeight, startY);
|
||||||
}
|
}
|
||||||
this.calcFailed = false;
|
|
||||||
this._pathing = true;
|
|
||||||
try {
|
try {
|
||||||
bot.pathfinder.setGoal(goal);
|
bot.pathfinder.setGoal(goal);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -255,6 +301,81 @@ class PathingService {
|
|||||||
return true;
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把最终目标裁剪到「当前已加载的范围内」。
|
* 把最终目标裁剪到「当前已加载的范围内」。
|
||||||
*
|
*
|
||||||
@@ -325,9 +446,74 @@ class PathingService {
|
|||||||
return lo;
|
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() {
|
cancel() { if (!this.isAvailable()) return;
|
||||||
if (!this.isAvailable()) return;
|
|
||||||
this._pathing = false;
|
this._pathing = false;
|
||||||
this.currentTarget = null;
|
this.currentTarget = null;
|
||||||
this.finalTarget = null;
|
this.finalTarget = null;
|
||||||
@@ -336,6 +522,9 @@ class PathingService {
|
|||||||
this._segmentCount = 0;
|
this._segmentCount = 0;
|
||||||
this._segmentShrink = 0;
|
this._segmentShrink = 0;
|
||||||
this._segmentScale = 1;
|
this._segmentScale = 1;
|
||||||
|
// 手动执行模式:停掉执行器并让在途的结果失效(否则它会继续按键)
|
||||||
|
this._manualRunId++;
|
||||||
|
if (this.mover) this.mover.stop();
|
||||||
try {
|
try {
|
||||||
this.bot.pathfinder.setGoal(null);
|
this.bot.pathfinder.setGoal(null);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -368,8 +557,22 @@ class PathingService {
|
|||||||
* 对应原 Java 版的 GoalXZAltitude。
|
* 对应原 Java 版的 GoalXZAltitude。
|
||||||
*
|
*
|
||||||
* heuristic = max(0, 基础启发值 - 权重 × 节点高度)
|
* 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 {
|
class AltitudeGoal extends Goal {
|
||||||
/**
|
/**
|
||||||
|
|||||||
+305
-16
@@ -6,18 +6,24 @@ const MIN_INTERVAL_SECONDS = 5;
|
|||||||
const MAX_TASKS = 20;
|
const MAX_TASKS = 20;
|
||||||
/** 调度器的心跳间隔(毫秒):只做「到点了吗」的判断,开销可忽略 */
|
/** 调度器的心跳间隔(毫秒):只做「到点了吗」的判断,开销可忽略 */
|
||||||
const TICK_INTERVAL_MS = 1000;
|
const TICK_INTERVAL_MS = 1000;
|
||||||
|
/** 发送结果确认的默认超时(秒) */
|
||||||
|
const DEFAULT_CONFIRM_TIMEOUT_SECONDS = 10;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 定时消息器:按固定间隔重复发送同一条内容(公屏消息或游戏指令)。
|
* 定时消息器:按固定间隔或 cron 表达式重复发送同一条内容(公屏消息或游戏指令)。
|
||||||
*
|
*
|
||||||
* 典型用法是服务器要求周期性执行的指令,例如每 300 秒发一次 `/u restore confirm`
|
* 典型用法是服务器要求周期性执行的指令,例如每 300 秒发一次 `/u restore confirm`
|
||||||
* 来维持疆土民心。这类「心跳式」指令是挂机机器人的刚需,
|
* 来维持疆土民心,或者每天 09:00 发一次公告。这类「心跳式」指令是挂机机器人的刚需,
|
||||||
* 但绝不能简单地 setInterval —— 断线、未登录、正在回城时都不该发,
|
* 但绝不能简单地 setInterval —— 断线、未登录、正在回城时都不该发,
|
||||||
* 否则要么白发(服务端丢弃),要么在错误的时机打断状态机。
|
* 否则要么白发(服务端丢弃),要么在错误的时机打断状态机。
|
||||||
*
|
*
|
||||||
* 因此这里做成:
|
* 因此这里做成:
|
||||||
* - 每个任务独立计时,按「上次成功发送时间 + 间隔」判断是否到点;
|
* - **两种调度**:`intervalSeconds`(固定间隔)或 `cron`(5 段 cron 表达式,本地时间)。
|
||||||
* - 只在 `canOperate()`(已连接 + 已通过登录验证)时才发;
|
* 例如「0 9 * * *」是每天 09:00,「0/30 * * * *」是每 30 分钟(步长写法见 parseCron);
|
||||||
|
* - **发送结果确认**(可选):配了 `expectPattern`(正则)的任务在发出后会等待服务器的
|
||||||
|
* 回复,超时未回复记为「未确认」并写警告 —— 否则 `/u restore confirm` 到底成没成,
|
||||||
|
* 只能靠人翻日志;
|
||||||
|
* - 只在 `canSendChat()`(已连接 + 已通过登录验证)时才发;
|
||||||
* - 未就绪时跳过本次并顺延,不补发(避免重连后一次性连发多条);
|
* - 未就绪时跳过本次并顺延,不补发(避免重连后一次性连发多条);
|
||||||
* - 与机器人共用指令节流队列,不会绕过 `server.commandCooldownMs` 刷屏。
|
* - 与机器人共用指令节流队列,不会绕过 `server.commandCooldownMs` 刷屏。
|
||||||
*/
|
*/
|
||||||
@@ -31,9 +37,15 @@ class ScheduledMessenger {
|
|||||||
this.logger = logger || null;
|
this.logger = logger || null;
|
||||||
/** @type {Array<object>} 定时任务列表 */
|
/** @type {Array<object>} 定时任务列表 */
|
||||||
this.tasks = [];
|
this.tasks = [];
|
||||||
/** @type {Map<string, number>} 任务 id → 下次应发送的时间戳 */
|
/** @type {Map<string, number>} 任务 id → 下次应发送的时间戳(cron 任务可能是 0=待计算) */
|
||||||
this._nextDueAt = new Map();
|
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._timer = null;
|
||||||
|
this._onChatBound = (message) => this._onChatMessage(message);
|
||||||
|
this._chatBound = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,18 +56,38 @@ class ScheduledMessenger {
|
|||||||
* @returns {Array<object>} 归一化后的任务列表
|
* @returns {Array<object>} 归一化后的任务列表
|
||||||
*/
|
*/
|
||||||
setTasks(tasks, now) {
|
setTasks(tasks, now) {
|
||||||
|
const current = Number.isFinite(now) ? now : Date.now();
|
||||||
this.tasks = normalizeTasks(tasks);
|
this.tasks = normalizeTasks(tasks);
|
||||||
this._nextDueAt.clear();
|
this._nextDueAt.clear();
|
||||||
const current = Number.isFinite(now) ? now : Date.now();
|
this._parsedCron.clear();
|
||||||
|
this._pending.clear();
|
||||||
for (const task of this.tasks) {
|
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);
|
this._nextDueAt.set(task.id, current + task.intervalSeconds * 1000);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return this.tasks;
|
return this.tasks;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 启动调度心跳 */
|
/** 启动调度心跳(并开始监听服务器回复,用于发送结果确认) */
|
||||||
start() {
|
start() {
|
||||||
|
if (!this._chatBound && typeof this.manager.on === 'function') {
|
||||||
|
this.manager.on('chat', this._onChatBound);
|
||||||
|
this._chatBound = true;
|
||||||
|
}
|
||||||
if (this._timer) return;
|
if (this._timer) return;
|
||||||
this._timer = setInterval(() => this.tick(), TICK_INTERVAL_MS);
|
this._timer = setInterval(() => this.tick(), TICK_INTERVAL_MS);
|
||||||
if (this._timer.unref) this._timer.unref();
|
if (this._timer.unref) this._timer.unref();
|
||||||
@@ -65,10 +97,14 @@ class ScheduledMessenger {
|
|||||||
stop() {
|
stop() {
|
||||||
if (this._timer) clearInterval(this._timer);
|
if (this._timer) clearInterval(this._timer);
|
||||||
this._timer = null;
|
this._timer = null;
|
||||||
|
if (this._chatBound && typeof this.manager.removeListener === 'function') {
|
||||||
|
this.manager.removeListener('chat', this._onChatBound);
|
||||||
|
this._chatBound = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 心跳:把所有到点的任务发出去。
|
* 心跳:把所有到点的任务发出去,并检查待确认的发送是否超时。
|
||||||
*
|
*
|
||||||
* 抽成公开方法是为了可测 —— 测试里直接调 `tick()` 并注入当前时间,
|
* 抽成公开方法是为了可测 —— 测试里直接调 `tick()` 并注入当前时间,
|
||||||
* 不必真的等 300 秒。
|
* 不必真的等 300 秒。
|
||||||
@@ -78,31 +114,105 @@ class ScheduledMessenger {
|
|||||||
*/
|
*/
|
||||||
tick(now) {
|
tick(now) {
|
||||||
const current = Number.isFinite(now) ? now : Date.now();
|
const current = Number.isFinite(now) ? now : Date.now();
|
||||||
|
this._checkPendingConfirmations(current);
|
||||||
let sent = 0;
|
let sent = 0;
|
||||||
for (const task of this.tasks) {
|
for (const task of this.tasks) {
|
||||||
if (!task.enabled) continue;
|
if (!task.enabled) continue;
|
||||||
const due = this._nextDueAt.get(task.id);
|
const due = this._nextDueAt.get(task.id);
|
||||||
if (due === undefined) {
|
if (due === undefined) {
|
||||||
this._nextDueAt.set(task.id, current + task.intervalSeconds * 1000);
|
this._reschedule(task, current);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (current < due) continue;
|
if (current < due) continue;
|
||||||
|
|
||||||
// 未就绪就顺延到下一个周期,不补发
|
// 未就绪就顺延到下一个周期,不补发
|
||||||
if (!this.manager.canSendChat()) {
|
if (!this.manager.canSendChat()) {
|
||||||
this._nextDueAt.set(task.id, current + task.intervalSeconds * 1000);
|
this._reschedule(task, current);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const ok = this.manager.sendChat(task.content);
|
const ok = this.manager.sendChat(task.content);
|
||||||
this._nextDueAt.set(task.id, current + task.intervalSeconds * 1000);
|
this._reschedule(task, current);
|
||||||
if (ok) {
|
if (ok) {
|
||||||
sent++;
|
sent++;
|
||||||
|
task.sentCount = (task.sentCount || 0) + 1;
|
||||||
|
this._armConfirmation(task, current);
|
||||||
this._log(`定时任务「${task.label || task.id}」已发送:${task.content}`);
|
this._log(`定时任务「${task.label || task.id}」已发送:${task.content}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return sent;
|
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} 供控制台展示的状态快照 */
|
/** @returns {object} 供控制台展示的状态快照 */
|
||||||
snapshot() {
|
snapshot() {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -115,14 +225,20 @@ class ScheduledMessenger {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
_log(message) {
|
_log(message, level) {
|
||||||
if (this.logger) this.logger.log(`[${this.manager.connection.username}] ${message}`);
|
if (this.logger) this.logger.log(`[${this.manager.connection.username}] ${message}`, level);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 任务归一化 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 归一化任务列表:丢弃非法项、补齐默认值、限制数量。
|
* 归一化任务列表:丢弃非法项、补齐默认值、限制数量。
|
||||||
*
|
*
|
||||||
|
* 调度方式二选一:
|
||||||
|
* - 写了合法的 `cron`(5 段表达式)→ cron 模式;
|
||||||
|
* - 否则按 `intervalSeconds` 固定间隔。
|
||||||
|
*
|
||||||
* @param {Array<object>} tasks
|
* @param {Array<object>} tasks
|
||||||
* @returns {Array<object>}
|
* @returns {Array<object>}
|
||||||
*/
|
*/
|
||||||
@@ -139,15 +255,30 @@ function normalizeTasks(tasks) {
|
|||||||
if (!id || seen.has(id)) id = `task-${i + 1}`;
|
if (!id || seen.has(id)) id = `task-${i + 1}`;
|
||||||
while (seen.has(id)) id += '_';
|
while (seen.has(id)) id += '_';
|
||||||
seen.add(id);
|
seen.add(id);
|
||||||
result.push({
|
const cronText = String(raw.cron || '').trim();
|
||||||
|
const cronValid = cronText !== '' && parseCron(cronText) !== null;
|
||||||
|
const task = {
|
||||||
id,
|
id,
|
||||||
label: String(raw.label || '').trim(),
|
label: String(raw.label || '').trim(),
|
||||||
content,
|
content,
|
||||||
enabled: raw.enabled !== false,
|
enabled: raw.enabled !== false,
|
||||||
|
/** 调度模式:cron(cron 表达式)| interval(固定间隔) */
|
||||||
|
mode: cronValid ? 'cron' : 'interval',
|
||||||
intervalSeconds: clampInterval(raw.intervalSeconds),
|
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 自动转成指令 */
|
/** 以 `/` 开头的内容会被 sendChat 自动转成指令 */
|
||||||
kind: content.startsWith('/') ? 'command' : 'chat'
|
kind: content.startsWith('/') ? 'command' : 'chat'
|
||||||
});
|
};
|
||||||
|
if (cronText !== '' && !cronValid) {
|
||||||
|
task.cronError = `cron 表达式无效:${cronText}(已按固定间隔发送)`;
|
||||||
|
}
|
||||||
|
result.push(task);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -163,4 +294,162 @@ function clampInterval(value) {
|
|||||||
return Math.min(86400, Math.max(MIN_INTERVAL_SECONDS, Math.trunc(n)));
|
return Math.min(86400, Math.max(MIN_INTERVAL_SECONDS, Math.trunc(n)));
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { ScheduledMessenger, normalizeTasks, clampInterval, MIN_INTERVAL_SECONDS, MAX_TASKS };
|
/**
|
||||||
|
* 把确认超时钳制到合理范围(秒)。
|
||||||
|
* @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
|
||||||
|
};
|
||||||
+146
-6
@@ -65,6 +65,19 @@ function defaultConfig() {
|
|||||||
retries: 2,
|
retries: 2,
|
||||||
/** 连续寻路超时累计达到该秒数后,发送回城指令并重新开始 */
|
/** 连续寻路超时累计达到该秒数后,发送回城指令并重新开始 */
|
||||||
returnThresholdSeconds: 60,
|
returnThresholdSeconds: 60,
|
||||||
|
/**
|
||||||
|
* 是否允许「连续超时后自动回城」。
|
||||||
|
*
|
||||||
|
* **默认关闭**,因为回城是破坏性的:它会把机器人从当前位置传送回安全点,
|
||||||
|
* 而这个安全点往往并不比原地更好(可能同样是卡死点),
|
||||||
|
* 更麻烦的是**会把玩家在游戏客户端里手动摆好的位置一并清掉**。
|
||||||
|
* 需要用它来逃离卡死状态时再打开。
|
||||||
|
*/
|
||||||
|
returnOnTimeout: false,
|
||||||
|
/** 回城指令(不带前导斜杠),默认服务器自带的 `/u return` */
|
||||||
|
returnCommand: 'u return',
|
||||||
|
/** 建立连接后多久内不自动回城(秒):刚被玩家摆好位置的机器人不该被立刻拉走 */
|
||||||
|
returnGraceSeconds: 300,
|
||||||
/** 发送回城指令后等待传送完成的时间(秒) */
|
/** 发送回城指令后等待传送完成的时间(秒) */
|
||||||
returnWaitSeconds: 12,
|
returnWaitSeconds: 12,
|
||||||
/** 单个目标最多触发「往边上走两步」自救的轮数 */
|
/** 单个目标最多触发「往边上走两步」自救的轮数 */
|
||||||
@@ -84,9 +97,20 @@ function defaultConfig() {
|
|||||||
allowJump: true,
|
allowJump: true,
|
||||||
allowBreak: false,
|
allowBreak: false,
|
||||||
allowPlace: 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,
|
altitudeWeight: 0.1,
|
||||||
|
|
||||||
// ==================== 生存辅助(与 Java 版同名) ====================
|
// ==================== 生存辅助(与 Java 版同名) ====================
|
||||||
@@ -98,6 +122,15 @@ function defaultConfig() {
|
|||||||
durabilityGuard: true,
|
durabilityGuard: true,
|
||||||
/** 耐久保护阈值:剩余耐久百分比 ≤ 该值时回城(1-99) */
|
/** 耐久保护阈值:剩余耐久百分比 ≤ 该值时回城(1-99) */
|
||||||
durabilityThreshold: 15,
|
durabilityThreshold: 15,
|
||||||
|
/**
|
||||||
|
* 掉线重连后是否自动恢复被中断的扩地任务。
|
||||||
|
*
|
||||||
|
* 本服会周期性把机器人踢下线(实测约 1~6 分钟一次),每次都会触发
|
||||||
|
* 「世界已退出,任务停止」。若不自动恢复,任务就永远停在那里 ——
|
||||||
|
* 机器人虽然能被原点自救送回真实世界,却再也不会继续扩地。
|
||||||
|
* 手动停止 / 到时段外 / 耐久保护停机都不会被自动恢复。
|
||||||
|
*/
|
||||||
|
autoResumeAfterReconnect: true,
|
||||||
/** 是否启用自动用船过水 */
|
/** 是否启用自动用船过水 */
|
||||||
autoBoat: false,
|
autoBoat: false,
|
||||||
/** 触发自动用船的水面最小宽度(格) */
|
/** 触发自动用船的水面最小宽度(格) */
|
||||||
@@ -161,7 +194,17 @@ function defaultConfig() {
|
|||||||
* A* 的代价搜索半径:节点代价超过「起点到目标的启发值 + 该值」就被剪枝。
|
* A* 的代价搜索半径:节点代价超过「起点到目标的启发值 + 该值」就被剪枝。
|
||||||
* 负数表示不限制(复杂地形里最稳,但搜索更慢)。
|
* 负数表示不限制(复杂地形里最稳,但搜索更慢)。
|
||||||
*/
|
*/
|
||||||
searchRadius: 256
|
searchRadius: 256,
|
||||||
|
/**
|
||||||
|
* 寻路执行器:
|
||||||
|
* - `pathfinder`(默认):由 mineflayer-pathfinder 自己执行;
|
||||||
|
* - `manual`:**备选方案**(TODO 问题 1)。只用 A* 算路径,执行改为自己按键
|
||||||
|
* (朝路径点转 → 按住前进 → 遇坎跳),控制键时序与手动移动测试完全一致。
|
||||||
|
* 仅在实测确认 pathfinder 的执行层被服务端回弹时才需要切换。
|
||||||
|
*/
|
||||||
|
executor: 'pathfinder',
|
||||||
|
/** 手动执行器单段执行的超时(毫秒),防止卡死时无限按键 */
|
||||||
|
manualTimeoutMs: 180000
|
||||||
},
|
},
|
||||||
|
|
||||||
// ==================== 战斗辅助(新增) ====================
|
// ==================== 战斗辅助(新增) ====================
|
||||||
@@ -213,6 +256,44 @@ function defaultConfig() {
|
|||||||
* 开启后发包前会用脚下方块复核,只有确实不一致时才改写。
|
* 开启后发包前会用脚下方块复核,只有确实不一致时才改写。
|
||||||
*/
|
*/
|
||||||
reportOnGroundFromWorld: true,
|
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` 包(默认开启,**线上服务器必需**)。
|
* 是否每 tick 补发 `player_input` 包(默认开启,**线上服务器必需**)。
|
||||||
*
|
*
|
||||||
@@ -258,7 +339,51 @@ function defaultConfig() {
|
|||||||
/** 启动时是否自动预爬取 */
|
/** 启动时是否自动预爬取 */
|
||||||
crawlOnStart: true,
|
crawlOnStart: true,
|
||||||
/** 瓦片下载并发数(对源站友好,默认 4) */
|
/** 瓦片下载并发数(对源站友好,默认 4) */
|
||||||
concurrency: 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
|
||||||
},
|
},
|
||||||
|
|
||||||
// ==================== 服务器交互(新增) ====================
|
// ==================== 服务器交互(新增) ====================
|
||||||
@@ -382,12 +507,18 @@ function validateConfig(config) {
|
|||||||
config.waitTimeoutSeconds = clampInt(config.waitTimeoutSeconds, 5, 3600, 60);
|
config.waitTimeoutSeconds = clampInt(config.waitTimeoutSeconds, 5, 3600, 60);
|
||||||
config.retries = clampInt(config.retries, 1, 20, 2);
|
config.retries = clampInt(config.retries, 1, 20, 2);
|
||||||
config.returnThresholdSeconds = clampInt(config.returnThresholdSeconds, 0, 3600, 60);
|
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.returnWaitSeconds = clampInt(config.returnWaitSeconds, 1, 120, 12);
|
||||||
config.sideStepMaxRounds = clampInt(config.sideStepMaxRounds, 0, 10, 3);
|
config.sideStepMaxRounds = clampInt(config.sideStepMaxRounds, 0, 10, 3);
|
||||||
config.claimStartHour = clampInt(config.claimStartHour, 0, 23, 11);
|
config.claimStartHour = clampInt(config.claimStartHour, 0, 23, 11);
|
||||||
config.claimEndHour = clampInt(config.claimEndHour, 1, 24, 22);
|
config.claimEndHour = clampInt(config.claimEndHour, 1, 24, 22);
|
||||||
config.eatThreshold = clampInt(config.eatThreshold, 0, 20, 14);
|
config.eatThreshold = clampInt(config.eatThreshold, 0, 20, 14);
|
||||||
config.durabilityThreshold = clampInt(config.durabilityThreshold, 1, 99, 15);
|
config.durabilityThreshold = clampInt(config.durabilityThreshold, 1, 99, 15);
|
||||||
|
config.autoResumeAfterReconnect = config.autoResumeAfterReconnect !== false;
|
||||||
config.autoBoatMinWidth = clampInt(config.autoBoatMinWidth, 1, 64, 4);
|
config.autoBoatMinWidth = clampInt(config.autoBoatMinWidth, 1, 64, 4);
|
||||||
config.autoBoatMinLength = clampInt(config.autoBoatMinLength, 1, 128, 6);
|
config.autoBoatMinLength = clampInt(config.autoBoatMinLength, 1, 128, 6);
|
||||||
config.refreshIntervalSeconds = clampInt(config.refreshIntervalSeconds, 10, 3600, 30);
|
config.refreshIntervalSeconds = clampInt(config.refreshIntervalSeconds, 10, 3600, 30);
|
||||||
@@ -402,11 +533,15 @@ function validateConfig(config) {
|
|||||||
config.pathing.tickTimeoutMs = clampInt(config.pathing.tickTimeoutMs, 5, 5000, 40);
|
config.pathing.tickTimeoutMs = clampInt(config.pathing.tickTimeoutMs, 5, 5000, 40);
|
||||||
// searchRadius 允许为负(不限制),因此单独钳制下限
|
// searchRadius 允许为负(不限制),因此单独钳制下限
|
||||||
config.pathing.searchRadius = clampInt(config.pathing.searchRadius, -1, 100000, 256);
|
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.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.maxRotationPerTickDeg = clampNumber(config.grim.maxRotationPerTickDeg, 1, 180, 8.6);
|
||||||
config.grim.attackReach = clampNumber(config.grim.attackReach, 1, 6, 3.0);
|
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.zoom = clampInt(config.bigMap.zoom, 0, 8, 0);
|
||||||
config.bigMap.maxZoom = clampInt(config.bigMap.maxZoom, 0, 8, 3);
|
config.bigMap.maxZoom = clampInt(config.bigMap.maxZoom, 0, 8, 3);
|
||||||
@@ -414,6 +549,10 @@ function validateConfig(config) {
|
|||||||
if (config.bigMap.zoom > config.bigMap.maxZoom) config.bigMap.zoom = config.bigMap.maxZoom;
|
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.rangeBlocks = clampInt(config.bigMap.rangeBlocks, 256, 200000, 4096);
|
||||||
config.bigMap.concurrency = clampInt(config.bigMap.concurrency, 1, 16, 4);
|
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.world !== 'string') config.bigMap.world = '';
|
||||||
if (typeof config.bigMap.followBot !== 'boolean') config.bigMap.followBot = true;
|
if (typeof config.bigMap.followBot !== 'boolean') config.bigMap.followBot = true;
|
||||||
|
|
||||||
@@ -436,10 +575,11 @@ function validateConfig(config) {
|
|||||||
}
|
}
|
||||||
if (typeof config.stateName !== 'string') config.stateName = '';
|
if (typeof config.stateName !== 'string') config.stateName = '';
|
||||||
if (typeof config.apiKey !== 'string') config.apiKey = '';
|
if (typeof config.apiKey !== 'string') config.apiKey = '';
|
||||||
if (typeof config.apiV2Key !== 'string') config.apiV2Key = '';
|
|
||||||
if (typeof config.server.claimCommand !== 'string') config.server.claimCommand = '';
|
if (typeof config.server.claimCommand !== 'string') config.server.claimCommand = '';
|
||||||
if (typeof config.server.loginCommand !== 'string') config.server.loginCommand = '';
|
if (typeof config.server.loginCommand !== 'string') config.server.loginCommand = '';
|
||||||
if (typeof config.server.registerCommand !== 'string') config.server.registerCommand = '';
|
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;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,21 @@ const State = {
|
|||||||
/** 环形日志中「导出日志」用的状态摘要键名 */
|
/** 环形日志中「导出日志」用的状态摘要键名 */
|
||||||
const EXPORT_SUMMARY_KEYS = ['状态', '目标区块', '玩家区块', '队列', '已占领', '重试', '最近进度'];
|
const EXPORT_SUMMARY_KEYS = ['状态', '目标区块', '玩家区块', '队列', '已占领', '重试', '最近进度'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判定「寻路有进展」的最小距离变化(区块)。
|
||||||
|
*
|
||||||
|
* 只要与目标的曼哈顿距离缩短了这么多,就认为是在稳步接近而不是卡死,
|
||||||
|
* 从而重置超时计时。取 1 是因为矿区/建筑区里寻路速度可能远低于正常步行,
|
||||||
|
* 阈值再大就会把慢速推进误判成超时。
|
||||||
|
*/
|
||||||
|
const PATH_PROGRESS_MIN_CHUNKS = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 候选区块全被其他机器人预定时,隔多久重新规划(毫秒)。
|
||||||
|
* 取 15 秒:既不会忙等,也不会在别人刚释放时错过太久。
|
||||||
|
*/
|
||||||
|
const LEASE_RETRY_DELAY_MS = 15000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 自动扩地控制器:扩地任务的核心状态机。
|
* 自动扩地控制器:扩地任务的核心状态机。
|
||||||
*
|
*
|
||||||
@@ -53,6 +68,7 @@ class ExpansionController {
|
|||||||
* @param {string} [deps.botId] 所属机器人 id(多机器人场景,用于日志归属)
|
* @param {string} [deps.botId] 所属机器人 id(多机器人场景,用于日志归属)
|
||||||
* @param {string} [deps.botName] 所属机器人游戏名
|
* @param {string} [deps.botName] 所属机器人游戏名
|
||||||
* @param {import('../core/expansionLog').ExpansionLog} [deps.expansionLog] 开拓日志
|
* @param {import('../core/expansionLog').ExpansionLog} [deps.expansionLog] 开拓日志
|
||||||
|
* @param {import('../core/targetLease').TargetLeaseTable} [deps.targetLeases] 目标区块租约表(多机器人共享)
|
||||||
*/
|
*/
|
||||||
constructor(deps) {
|
constructor(deps) {
|
||||||
this.botManager = deps.botManager;
|
this.botManager = deps.botManager;
|
||||||
@@ -66,6 +82,14 @@ class ExpansionController {
|
|||||||
? deps.botManager.connection.username : this.botId);
|
? deps.botManager.connection.username : this.botId);
|
||||||
/** @type {import('../core/expansionLog').ExpansionLog|null} */
|
/** @type {import('../core/expansionLog').ExpansionLog|null} */
|
||||||
this.expansionLog = deps.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();
|
this.planner = new ExpansionPlanner();
|
||||||
/** @type {import('../core/subtitleChatMonitor').SubtitleChatMonitor} */
|
/** @type {import('../core/subtitleChatMonitor').SubtitleChatMonitor} */
|
||||||
this.monitor = new SubtitleChatMonitor({
|
this.monitor = new SubtitleChatMonitor({
|
||||||
@@ -90,6 +114,10 @@ class ExpansionController {
|
|||||||
this.retryCount = 0;
|
this.retryCount = 0;
|
||||||
/** 当前阶段(寻路 / 等待)的开始时间戳 */
|
/** 当前阶段(寻路 / 等待)的开始时间戳 */
|
||||||
this.stageStartedMillis = 0;
|
this.stageStartedMillis = 0;
|
||||||
|
/** 本次连接/本次任务的开始时间戳,用于「刚连上时不许回城」的保护期 */
|
||||||
|
this.sessionStartedMillis = Date.now();
|
||||||
|
/** 上一次记录的「与目标的曼哈顿距离」,用于判断寻路是否在稳步推进 */
|
||||||
|
this._lastTargetDistance = null;
|
||||||
/** 最近一次开垦进度(0-100) */
|
/** 最近一次开垦进度(0-100) */
|
||||||
this.lastProgress = 0;
|
this.lastProgress = 0;
|
||||||
|
|
||||||
@@ -100,6 +128,15 @@ class ExpansionController {
|
|||||||
|
|
||||||
/** 等待开拓时段自动开始:不在时段时点击开始会暂存配置(null 表示无等待任务) */
|
/** 等待开拓时段自动开始:不在时段时点击开始会暂存配置(null 表示无等待任务) */
|
||||||
this.pendingStart = null;
|
this.pendingStart = null;
|
||||||
|
/**
|
||||||
|
* 被「世界退出」打断的任务配置:重连进入世界后用它自动恢复。
|
||||||
|
*
|
||||||
|
* 本服会周期性把机器人踢下线(实测约 1~6 分钟一次),而每次掉线都会走到
|
||||||
|
* 「世界已退出,任务停止」。没有这个字段的话,机器人虽然能被原点自救送回
|
||||||
|
* 真实世界,任务却再也不会继续 —— 扩地永远跑不长。
|
||||||
|
* 手动停止 / 到时段外 / 耐久保护停机都不会写入它。
|
||||||
|
*/
|
||||||
|
this.resumeConfig = null;
|
||||||
/** 连续寻路超时累计时长(毫秒) */
|
/** 连续寻路超时累计时长(毫秒) */
|
||||||
this.consecutiveTimeoutMillis = 0;
|
this.consecutiveTimeoutMillis = 0;
|
||||||
/** 发送回城指令的时间戳 */
|
/** 发送回城指令的时间戳 */
|
||||||
@@ -118,6 +155,24 @@ class ExpansionController {
|
|||||||
this.sideStepStartPos = null;
|
this.sideStepStartPos = null;
|
||||||
/** 本次回城是否由耐久保护触发:RETURNING 结束后停止任务而非重新规划 */
|
/** 本次回城是否由耐久保护触发:RETURNING 结束后停止任务而非重新规划 */
|
||||||
this.returnThenStop = false;
|
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(仅用于避免重复记录战斗日志) */
|
/** 自动攻击当前目标的实体 ID(仅用于避免重复记录战斗日志) */
|
||||||
this.combatTargetEntityId = -1;
|
this.combatTargetEntityId = -1;
|
||||||
/** 当前是否正持续按住跳跃键(自动游泳用) */
|
/** 当前是否正持续按住跳跃键(自动游泳用) */
|
||||||
@@ -230,6 +285,9 @@ class ExpansionController {
|
|||||||
this.completedClaims = 0;
|
this.completedClaims = 0;
|
||||||
this.retryCount = 0;
|
this.retryCount = 0;
|
||||||
this.consecutiveTimeoutMillis = 0;
|
this.consecutiveTimeoutMillis = 0;
|
||||||
|
// 保护期从「本次任务开始」算起:玩家常趁机器人掉线时在客户端把它摆到开阔地,
|
||||||
|
// 刚启动就被回城拉走等于白摆
|
||||||
|
this.sessionStartedMillis = Date.now();
|
||||||
this.sideStepCount = 0;
|
this.sideStepCount = 0;
|
||||||
this.sideStepping = false;
|
this.sideStepping = false;
|
||||||
this.returnThenStop = false;
|
this.returnThenStop = false;
|
||||||
@@ -259,6 +317,8 @@ class ExpansionController {
|
|||||||
/** 手动停止任务 */
|
/** 手动停止任务 */
|
||||||
stop() {
|
stop() {
|
||||||
const bm = this.botManager;
|
const bm = this.botManager;
|
||||||
|
// 任何显式停止都作废「掉线待恢复」——只有 tick() 里因世界退出而停的那次会重新登记
|
||||||
|
this.resumeConfig = null;
|
||||||
if (this.pendingStart) {
|
if (this.pendingStart) {
|
||||||
this.pendingStart = null;
|
this.pendingStart = null;
|
||||||
this.setState(State.IDLE, '已取消自动开始');
|
this.setState(State.IDLE, '已取消自动开始');
|
||||||
@@ -270,6 +330,9 @@ class ExpansionController {
|
|||||||
if (this.sideStepping) this.stopSideStep();
|
if (this.sideStepping) this.stopSideStep();
|
||||||
this.releaseJump();
|
this.releaseJump();
|
||||||
if (bm.boat) bm.boat.reset(true);
|
if (bm.boat) bm.boat.reset(true);
|
||||||
|
// 停止任务时归还全部目标租约,别的机器人可以立刻接手这些区块
|
||||||
|
if (this.targetLeases) this.targetLeases.releaseAll(this.botId);
|
||||||
|
this._nextReplanAt = 0;
|
||||||
this.setState(State.STOPPED, '已手动停止');
|
this.setState(State.STOPPED, '已手动停止');
|
||||||
this._log('已手动停止');
|
this._log('已手动停止');
|
||||||
}
|
}
|
||||||
@@ -283,12 +346,40 @@ class ExpansionController {
|
|||||||
if (!this.config) return; // 尚未启动过任务,没有可驱动的状态
|
if (!this.config) return; // 尚未启动过任务,没有可驱动的状态
|
||||||
if (!bm.isSpawned()) {
|
if (!bm.isSpawned()) {
|
||||||
if (this.isRunning()) {
|
if (this.isRunning()) {
|
||||||
|
// 先记下这次任务的配置:本服会周期性把机器人踢下线,重连回来时若没人管,
|
||||||
|
// 任务就永远停在这里。stop() 会清空 resumeConfig,所以要在它之后重新登记。
|
||||||
|
const interrupted = this.config;
|
||||||
this.stop();
|
this.stop();
|
||||||
this.status = '世界已退出,任务停止';
|
this.status = '世界已退出,等待重连后自动恢复';
|
||||||
|
if (interrupted && interrupted.autoResumeAfterReconnect !== false) {
|
||||||
|
this.resumeConfig = interrupted;
|
||||||
|
this._log('世界已退出,任务已暂停;重新进入世界后会自动恢复');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return;
|
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();
|
this.playerChunk = this._playerChunk();
|
||||||
|
// 续租当前目标:长距离寻路可能要走几分钟,不能让租约中途过期被别人抢走
|
||||||
|
if (this.isRunning()) this._renewTargetLease();
|
||||||
|
|
||||||
// 等待开垦时段自动开始:进入时段后自动启动已登记的任务
|
// 等待开垦时段自动开始:进入时段后自动启动已登记的任务
|
||||||
if (this.pendingStart && this.isWithinClaimTime()) {
|
if (this.pendingStart && this.isWithinClaimTime()) {
|
||||||
@@ -344,6 +435,8 @@ class ExpansionController {
|
|||||||
*/
|
*/
|
||||||
async plan() {
|
async plan() {
|
||||||
const bm = this.botManager;
|
const bm = this.botManager;
|
||||||
|
// 重新规划意味着目标可能变了(或被回城传送重置了位置),进度基准必须清空
|
||||||
|
this._lastTargetDistance = null;
|
||||||
if (this.config.targetCount > 0 && this.completedClaims >= this.config.targetCount) {
|
if (this.config.targetCount > 0 && this.completedClaims >= this.config.targetCount) {
|
||||||
this.finish();
|
this.finish();
|
||||||
return;
|
return;
|
||||||
@@ -351,21 +444,39 @@ class ExpansionController {
|
|||||||
this.queue = [];
|
this.queue = [];
|
||||||
const model = this.dataService.getModel();
|
const model = this.dataService.getModel();
|
||||||
const planned = this.planner.generate(model, this.config, this.playerChunk);
|
const planned = this.planner.generate(model, this.config, this.playerChunk);
|
||||||
|
let leasedByOthers = 0;
|
||||||
for (const chunk of planned) {
|
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;
|
const rectangleMode = this.config.strategy === 2 && this.config.regionMaxX >= this.config.regionMinX;
|
||||||
if (this.config.maxRings > 0 && !rectangleMode) {
|
if (this.config.maxRings > 0 && !rectangleMode) {
|
||||||
this.queue = this.queue.filter((chunk) => this._ringDistance(chunk, model) <= this.config.maxRings);
|
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 (this.queue.length === 0) {
|
||||||
|
if (leasedByOthers > 0) {
|
||||||
|
this._waitForLeaseRelease(leasedByOthers);
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.setState(State.COMPLETE, '没有可用的相邻无主区块');
|
this.setState(State.COMPLETE, '没有可用的相邻无主区块');
|
||||||
this._log('没有可用的相邻无主区块');
|
this._log('没有可用的相邻无主区块');
|
||||||
return;
|
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();
|
this._startPathing();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,6 +547,20 @@ class ExpansionController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const elapsedSeconds = Math.floor((Date.now() - this.stageStartedMillis) / 1000);
|
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) {
|
if (elapsedSeconds >= this.config.waitTimeoutSeconds) {
|
||||||
this.retryCount++;
|
this.retryCount++;
|
||||||
this.dataService.refreshAsync();
|
this.dataService.refreshAsync();
|
||||||
@@ -453,26 +578,36 @@ class ExpansionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送回城指令:取消当前寻路,向服务器发送 `/u return`(约 10 秒后自动传送回安全位置),
|
* 发送回城指令:取消当前寻路,向服务器发送回城指令(默认 `/u return`,
|
||||||
* 随后进入 RETURNING 状态等待传送完成。
|
* 约 10 秒后自动传送回安全位置),随后进入 RETURNING 状态等待传送完成。
|
||||||
|
*
|
||||||
|
* 指令内容取自 `returnCommand`,方便不同服务器替换成自己的回城命令。
|
||||||
*
|
*
|
||||||
* @param {string} reason 触发回城的原因
|
* @param {string} reason 触发回城的原因
|
||||||
*/
|
*/
|
||||||
_sendReturnCommand(reason) {
|
_sendReturnCommand(reason) {
|
||||||
const bm = this.botManager;
|
const bm = this.botManager;
|
||||||
|
const command = (this.config.returnCommand || 'u return').replace(/^\/+/, '');
|
||||||
bm.pathing.cancel();
|
bm.pathing.cancel();
|
||||||
this._log(reason + ',发送 /u return 回城,10 秒后自动传送');
|
this._log(`${reason},发送 /${command} 回城,约 10 秒后自动传送`
|
||||||
bm.sendCommand('u return');
|
+ '(注意:这会把机器人拉回安全点,当前位置与玩家手动摆好的位置都会丢失)');
|
||||||
|
bm.sendCommand(command);
|
||||||
this.returnStartMillis = Date.now();
|
this.returnStartMillis = Date.now();
|
||||||
|
// 记下回城前坐标:传送完成后与当前位置组成「从 A 挪到了 B」的对比
|
||||||
|
this.pendingReturn = { reason, from: this._precisePos(), at: Date.now() };
|
||||||
this.setState(State.RETURNING, '等待回城传送(10 秒后自动传送)');
|
this.setState(State.RETURNING, '等待回城传送(10 秒后自动传送)');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 回城传送等待阶段每 tick 逻辑:等待 returnWaitSeconds 秒后,
|
* 回城传送等待阶段每 tick 逻辑:等待 returnWaitSeconds 秒后,
|
||||||
* 耐久保护触发的回城 → 停止任务;寻路超时触发的回城 → 重新规划扩地计划。
|
* 耐久保护触发的回城 → 停止任务;寻路超时触发的回城 → 重新规划扩地计划。
|
||||||
|
*
|
||||||
|
* 传送完成后记录回城前后坐标(控制台据此展示「回城把机器人从 A 挪到了 B」),
|
||||||
|
* 位置几乎没变时明确提示 —— 说明这个卡死点同时也是安全点,回城没有帮助。
|
||||||
*/
|
*/
|
||||||
_tickReturning() {
|
_tickReturning() {
|
||||||
if (Date.now() - this.returnStartMillis < this.config.returnWaitSeconds * 1000) return;
|
if (Date.now() - this.returnStartMillis < this.config.returnWaitSeconds * 1000) return;
|
||||||
|
this._recordReturnResult();
|
||||||
if (this.returnThenStop) {
|
if (this.returnThenStop) {
|
||||||
this.returnThenStop = false;
|
this.returnThenStop = false;
|
||||||
this._log('耐久保护回城完成,任务已停止;请修复 / 更换装备后重新开始扩地');
|
this._log('耐久保护回城完成,任务已停止;请修复 / 更换装备后重新开始扩地');
|
||||||
@@ -484,6 +619,28 @@ class ExpansionController {
|
|||||||
this.plan();
|
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 格(左右各试一侧),走开后自动重新寻路原目标。
|
* 走开约 2.5 格(左右各试一侧),走开后自动重新寻路原目标。
|
||||||
@@ -564,16 +721,80 @@ class ExpansionController {
|
|||||||
/**
|
/**
|
||||||
* 放弃当前目标(重试与走两步均已尝试)的统一出口:
|
* 放弃当前目标(重试与走两步均已尝试)的统一出口:
|
||||||
* 连续寻路超时累计达到阈值则回城传送并重新规划;否则跳过当前目标。
|
* 连续寻路超时累计达到阈值则回城传送并重新规划;否则跳过当前目标。
|
||||||
|
*
|
||||||
|
* 回城是**破坏性**操作,因此默认关闭(`returnOnTimeout`),且即便打开也有两道护栏:
|
||||||
|
* - 刚建立连接的一段时间内不回城:玩家常常是趁机器人掉线时在客户端把它摆到开阔地,
|
||||||
|
* 一连上就被拉回安全点等于白摆;
|
||||||
|
* - 每次真正回城都明确记日志,避免事后只看到位置莫名其妙变了。
|
||||||
*/
|
*/
|
||||||
_giveUpTarget(reason) {
|
_giveUpTarget(reason) {
|
||||||
if (this.consecutiveTimeoutMillis >= this.config.returnThresholdSeconds * 1000) {
|
const config = this.config;
|
||||||
this._sendReturnCommand(`连续寻路超时超过 ${this.config.returnThresholdSeconds} 秒`);
|
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;
|
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._log(this.target ? `${reason},跳过 ${this.target.x},${this.target.z}` : reason);
|
||||||
this.setState(State.FAIL, 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 逻辑:玩家离开目标区块则重新寻路;等待超时则重试或跳过 */
|
/** 等待开垦阶段每 tick 逻辑:玩家离开目标区块则重新寻路;等待超时则重试或跳过 */
|
||||||
_tickWaiting() {
|
_tickWaiting() {
|
||||||
if (!this.target) {
|
if (!this.target) {
|
||||||
@@ -623,16 +844,26 @@ class ExpansionController {
|
|||||||
|
|
||||||
/** 处理下一个目标:达成目标数量则完成;队列为空时重新规划,否则取队首开始寻路 */
|
/** 处理下一个目标:达成目标数量则完成;队列为空时重新规划,否则取队首开始寻路 */
|
||||||
_tickNext() {
|
_tickNext() {
|
||||||
|
this._releaseTargetLease();
|
||||||
this.target = null;
|
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) {
|
if (this.config.targetCount > 0 && this.completedClaims >= this.config.targetCount) {
|
||||||
this.finish();
|
this.finish();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this.queue.length === 0) {
|
const picked = this._pickNextTarget();
|
||||||
|
if (!picked.target) {
|
||||||
|
if (picked.skippedLease > 0) {
|
||||||
|
this._waitForLeaseRelease(picked.skippedLease);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 队列真的空了:重新规划
|
||||||
this.plan();
|
this.plan();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.target = this.queue.shift();
|
this.target = picked.target;
|
||||||
if (this._isClaimedByOthers(this.target)) {
|
if (this._isClaimedByOthers(this.target)) {
|
||||||
this._log(`目标已被占领,跳过 ${this.target.x},${this.target.z}`);
|
this._log(`目标已被占领,跳过 ${this.target.x},${this.target.z}`);
|
||||||
this.setState(State.NEXT, '跳过已占领区块');
|
this.setState(State.NEXT, '跳过已占领区块');
|
||||||
@@ -643,6 +874,7 @@ class ExpansionController {
|
|||||||
|
|
||||||
/** 完成任务 */
|
/** 完成任务 */
|
||||||
finish() {
|
finish() {
|
||||||
|
this._releaseTargetLease();
|
||||||
this.botManager.pathing.cancel();
|
this.botManager.pathing.cancel();
|
||||||
this.setState(State.COMPLETE, this.config.targetCount > 0
|
this.setState(State.COMPLETE, this.config.targetCount > 0
|
||||||
? `已完成目标数量 ${this.completedClaims}/${this.config.targetCount}`
|
? `已完成目标数量 ${this.completedClaims}/${this.config.targetCount}`
|
||||||
@@ -710,6 +942,55 @@ class ExpansionController {
|
|||||||
return this.dataService.getModel().allClaimedChunks().has(chunkPos.key(chunk.x, chunk.z));
|
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
|
* @param {{x: number, z: number}} chunk
|
||||||
@@ -975,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) {
|
_describeChunk(chunk) {
|
||||||
return chunk ? `${chunk.x},${chunk.z}` : '无';
|
return chunk ? `${chunk.x},${chunk.z}` : '无';
|
||||||
}
|
}
|
||||||
@@ -1020,6 +1318,10 @@ class ExpansionController {
|
|||||||
consecutiveTimeoutMs: this.consecutiveTimeoutMillis,
|
consecutiveTimeoutMs: this.consecutiveTimeoutMillis,
|
||||||
sideStepping: this.sideStepping,
|
sideStepping: this.sideStepping,
|
||||||
sideStepCount: this.sideStepCount,
|
sideStepCount: this.sideStepCount,
|
||||||
|
/** 因「原地还能走」而放弃回城的次数(回城前评估的结果) */
|
||||||
|
returnSkippedNotStuck: this.returnSkippedNotStuck,
|
||||||
|
/** 最近一次回城的前后坐标与直线距离(控制台展示「从 A 挪到了 B」) */
|
||||||
|
lastReturn: this.lastReturn,
|
||||||
autoBoatActive: !!(this.botManager.boat && this.botManager.boat.isActive()),
|
autoBoatActive: !!(this.botManager.boat && this.botManager.boat.isActive()),
|
||||||
withinClaimTime: this.isWithinClaimTime(),
|
withinClaimTime: this.isWithinClaimTime(),
|
||||||
claimModeSent: this._claimModeSent,
|
claimModeSent: this._claimModeSent,
|
||||||
@@ -1045,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。
|
* 格式化为 HH:mm:ss。
|
||||||
* @param {Date} date
|
* @param {Date} date
|
||||||
|
|||||||
+114
-1
@@ -66,6 +66,26 @@ class BigMapService {
|
|||||||
lastError: ''
|
lastError: ''
|
||||||
};
|
};
|
||||||
this._crawlAbort = false;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 坐标系 ====================
|
// ==================== 坐标系 ====================
|
||||||
@@ -307,6 +327,97 @@ class BigMapService {
|
|||||||
this._crawlAbort = true;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 状态 ====================
|
// ==================== 状态 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -335,7 +446,9 @@ class BigMapService {
|
|||||||
tileUrlTemplate: `${this.baseUrl}/tiles/${this.world}/{z}/{x}_{y}.png`,
|
tileUrlTemplate: `${this.baseUrl}/tiles/${this.world}/{z}/{x}_{y}.png`,
|
||||||
markersUrl: `${this.baseUrl}/tiles/${this.world}/markers.json`
|
markersUrl: `${this.baseUrl}/tiles/${this.world}/markers.json`
|
||||||
},
|
},
|
||||||
crawl: Object.assign({}, this.crawl)
|
crawl: Object.assign({}, this.crawl),
|
||||||
|
/** 后台预热状态:最近一次覆盖的层级、成功 / 失败数与上次位置 */
|
||||||
|
prewarm: Object.assign({}, this.prewarm)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
+7
-1
@@ -16,6 +16,7 @@ const { TerritoryDataService } = require('./core/territoryDataService');
|
|||||||
const { ExpansionLog } = require('./core/expansionLog');
|
const { ExpansionLog } = require('./core/expansionLog');
|
||||||
const { ChatLog } = require('./core/chatLog');
|
const { ChatLog } = require('./core/chatLog');
|
||||||
const { BigMapService } = require('./core/bigMapService');
|
const { BigMapService } = require('./core/bigMapService');
|
||||||
|
const { Notifier } = require('./core/notifier');
|
||||||
const { WebConsole } = require('./web/server');
|
const { WebConsole } = require('./web/server');
|
||||||
|
|
||||||
/** 控制器 tick 间隔(毫秒),50ms ≈ 20 TPS */
|
/** 控制器 tick 间隔(毫秒),50ms ≈ 20 TPS */
|
||||||
@@ -58,6 +59,8 @@ async function main() {
|
|||||||
const botStore = new BotStore();
|
const botStore = new BotStore();
|
||||||
// 控制台新增玩家的凭据单独落盘(0600),与 .env 互为补充、.env 优先
|
// 控制台新增玩家的凭据单独落盘(0600),与 .env 互为补充、.env 优先
|
||||||
const credentialStore = new BotCredentialStore();
|
const credentialStore = new BotCredentialStore();
|
||||||
|
// 通知提醒(Webhook):等待验证码 / 原点自救失败时把消息推到飞书等渠道
|
||||||
|
const notifier = new Notifier(config, logger);
|
||||||
const pool = new BotPool({
|
const pool = new BotPool({
|
||||||
connections,
|
connections,
|
||||||
config,
|
config,
|
||||||
@@ -66,7 +69,8 @@ async function main() {
|
|||||||
expansionLog,
|
expansionLog,
|
||||||
chatLog,
|
chatLog,
|
||||||
botStore,
|
botStore,
|
||||||
credentialStore
|
credentialStore,
|
||||||
|
notifier
|
||||||
});
|
});
|
||||||
|
|
||||||
// 账号来源有两个:.env 的 MC_BOTS / MC_USERNAME,以及控制台「添加玩家」写入的本地凭据文件
|
// 账号来源有两个:.env 的 MC_BOTS / MC_USERNAME,以及控制台「添加玩家」写入的本地凭据文件
|
||||||
@@ -86,6 +90,7 @@ async function main() {
|
|||||||
expansionLog,
|
expansionLog,
|
||||||
chatLog,
|
chatLog,
|
||||||
bigMap,
|
bigMap,
|
||||||
|
notifier,
|
||||||
getConfig: () => config,
|
getConfig: () => config,
|
||||||
applyConfig: (next) => applyConfig(next)
|
applyConfig: (next) => applyConfig(next)
|
||||||
},
|
},
|
||||||
@@ -115,6 +120,7 @@ async function main() {
|
|||||||
bigMap.config = config;
|
bigMap.config = config;
|
||||||
bigMap.world = config.bigMap.world || process.env.DYNMAP_WORLD || bigMap.world;
|
bigMap.world = config.bigMap.world || process.env.DYNMAP_WORLD || bigMap.world;
|
||||||
}
|
}
|
||||||
|
notifier.config = config;
|
||||||
logger.log('配置已更新并应用');
|
logger.log('配置已更新并应用');
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|||||||
+310
-18
@@ -160,6 +160,18 @@ const CONFIG_SCHEMA = {
|
|||||||
{ path: 'regionCenterZ', label: '中心 Z(区块)', type: 'number' },
|
{ path: 'regionCenterZ', label: '中心 Z(区块)', type: 'number' },
|
||||||
{ path: 'regionRadius', label: '半径(区块)', type: 'number', min: 1 }
|
{ path: 'regionRadius', label: '半径(区块)', type: 'number', min: 1 }
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '多机器人协调',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
path: 'coordination.targetLeaseTtlSeconds',
|
||||||
|
label: '目标区块租约时长(秒)',
|
||||||
|
type: 'number',
|
||||||
|
min: 30,
|
||||||
|
hint: '多个机器人同时扩地时用租约避免撞在同一区块上;持有者持续续租,掉线 / 卡死后租约到期自动释放'
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
timing: [
|
timing: [
|
||||||
@@ -169,6 +181,20 @@ const CONFIG_SCHEMA = {
|
|||||||
{ path: 'waitTimeoutSeconds', label: '等待 / 寻路超时(秒)', type: 'number', min: 5 },
|
{ path: 'waitTimeoutSeconds', label: '等待 / 寻路超时(秒)', type: 'number', min: 5 },
|
||||||
{ path: 'retries', label: '超时后最大重试次数', type: 'number', min: 1 },
|
{ path: 'retries', label: '超时后最大重试次数', type: 'number', min: 1 },
|
||||||
{ path: 'returnThresholdSeconds', label: '连续超时回城阈值(秒)', type: 'number', min: 0 },
|
{ path: 'returnThresholdSeconds', label: '连续超时回城阈值(秒)', type: 'number', min: 0 },
|
||||||
|
{
|
||||||
|
path: 'returnOnTimeout',
|
||||||
|
label: '超时后自动回城',
|
||||||
|
type: 'checkbox',
|
||||||
|
hint: '默认关闭:回城会把机器人拉回安全点,当前位置与你在客户端手动摆好的位置都会丢失'
|
||||||
|
},
|
||||||
|
{ path: 'returnCommand', label: '回城指令', type: 'text', hint: '不带前导斜杠,默认 u return' },
|
||||||
|
{
|
||||||
|
path: 'returnGraceSeconds',
|
||||||
|
label: '回城保护期(秒)',
|
||||||
|
type: 'number',
|
||||||
|
min: 0,
|
||||||
|
hint: '刚启动这段时间内不自动回城,避免把刚摆好的位置清掉'
|
||||||
|
},
|
||||||
{ path: 'returnWaitSeconds', label: '回城等待传送(秒)', type: 'number', min: 1 },
|
{ path: 'returnWaitSeconds', label: '回城等待传送(秒)', type: 'number', min: 1 },
|
||||||
{ path: 'sideStepMaxRounds', label: '往边上走两步最大轮数', type: 'number', min: 0 }
|
{ path: 'sideStepMaxRounds', label: '往边上走两步最大轮数', type: 'number', min: 0 }
|
||||||
]
|
]
|
||||||
@@ -204,7 +230,12 @@ const CONFIG_SCHEMA = {
|
|||||||
{ path: 'pathing.liquidCost', label: '液体格额外代价', type: 'number', min: 1 },
|
{ path: 'pathing.liquidCost', label: '液体格额外代价', type: 'number', min: 1 },
|
||||||
{ path: 'pathing.canOpenDoors', label: '允许开门', type: 'checkbox' },
|
{ path: 'pathing.canOpenDoors', label: '允许开门', type: 'checkbox' },
|
||||||
{ path: 'pathing.maxSegmentBlocks', label: '单段寻路最长距离(格)', type: 'number', min: 32, hint: '寻路器只能规划已加载区块范围(本服约 130 格),长距离会自动拆段;此项只是单段上限' },
|
{ path: 'pathing.maxSegmentBlocks', label: '单段寻路最长距离(格)', type: 'number', min: 32, hint: '寻路器只能规划已加载区块范围(本服约 130 格),长距离会自动拆段;此项只是单段上限' },
|
||||||
{ path: 'pathing.segmentMarginBlocks', label: '分段安全边距(格)', type: 'number', min: 0, hint: '分段时距已加载范围边缘留出的余量,避免踩在未加载边界上' }
|
{ path: 'pathing.segmentMarginBlocks', label: '分段安全边距(格)', type: 'number', min: 0, hint: '分段时距已加载范围边缘留出的余量,避免踩在未加载边界上' },
|
||||||
|
{ path: 'pathing.executor', label: '寻路执行器', type: 'select', options: [
|
||||||
|
{ value: 'pathfinder', label: 'pathfinder(默认)' },
|
||||||
|
{ value: 'manual', label: '手动执行(备选方案)' }
|
||||||
|
], hint: '手动执行:只用 A* 算路径,执行改为自己按键(与手动移动测试一致的时序);仅在实测确认寻路器执行层被服务端回弹时才切换' },
|
||||||
|
{ path: 'pathing.manualTimeoutMs', label: '手动执行超时(毫秒)', type: 'number', min: 10000, hint: '单段手动执行的最长时间,防止卡死时无限按键' }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -268,7 +299,12 @@ const CONFIG_SCHEMA = {
|
|||||||
{ path: 'bigMap.maxZoom', label: '最大可浏览层级', type: 'number', min: 0, max: 8, hint: '本站 squaremap 为 3(该层级 1 方块 = 1 像素)' },
|
{ path: 'bigMap.maxZoom', label: '最大可浏览层级', type: 'number', min: 0, max: 8, hint: '本站 squaremap 为 3(该层级 1 方块 = 1 像素)' },
|
||||||
{ path: 'bigMap.rangeBlocks', label: '覆盖半径(方块)', type: 'number', min: 256, hint: '默认 20480,与服务器世界边界一致' },
|
{ path: 'bigMap.rangeBlocks', label: '覆盖半径(方块)', type: 'number', min: 256, hint: '默认 20480,与服务器世界边界一致' },
|
||||||
{ path: 'bigMap.crawlOnStart', label: '启动时自动预爬取', type: 'checkbox' },
|
{ path: 'bigMap.crawlOnStart', label: '启动时自动预爬取', type: 'checkbox' },
|
||||||
{ path: 'bigMap.concurrency', label: '瓦片下载并发数', type: 'number', min: 1, max: 16 }
|
{ path: 'bigMap.concurrency', label: '瓦片下载并发数', type: 'number', min: 1, max: 16 },
|
||||||
|
{ path: 'bigMap.prewarm', label: '后台预热高层级瓦片', type: 'checkbox', hint: '机器人移动到新区域时低优先级拉取周边高层级瓦片,避免首次缩放时干等;已缓存的不重复下载' },
|
||||||
|
{ path: 'bigMap.prewarmRangeBlocks', label: '预热半径(方块)', type: 'number', min: 256 },
|
||||||
|
{ path: 'bigMap.prewarmLevels', label: '预热层级数', type: 'number', min: 0, max: 8, hint: '从「启动预爬取层级」往上数几层(受最大可浏览层级限制)' },
|
||||||
|
{ path: 'bigMap.prewarmMinMoveBlocks', label: '移动多少格后重新预热', type: 'number', min: 0 },
|
||||||
|
{ path: 'bigMap.prewarmIntervalMs', label: '预热最小间隔(毫秒)', type: 'number', min: 60000, hint: '即便没怎么移动,超过这个时间也会再预热一次' }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -287,6 +323,10 @@ const CONFIG_SCHEMA = {
|
|||||||
{ path: 'grim.checkPhysicsIntegrity', label: '启动物理参数自检', type: 'checkbox' },
|
{ path: 'grim.checkPhysicsIntegrity', label: '启动物理参数自检', type: 'checkbox' },
|
||||||
{ path: 'grim.blockActionsWhileRiding', label: '骑乘时禁止攻击 / 进食', type: 'checkbox' },
|
{ path: 'grim.blockActionsWhileRiding', label: '骑乘时禁止攻击 / 进食', type: 'checkbox' },
|
||||||
{ path: 'grim.reportOnGroundFromWorld', label: '按世界数据修正 onGround', type: 'checkbox', hint: '服务端频繁下发位置包时,mineflayer 会持续上报「站地上却在空中」而被反作弊每 tick 拉回;开启后在发包前用脚下方块复核(实测本服必需)' },
|
{ path: 'grim.reportOnGroundFromWorld', label: '按世界数据修正 onGround', type: 'checkbox', hint: '服务端频繁下发位置包时,mineflayer 会持续上报「站地上却在空中」而被反作弊每 tick 拉回;开启后在发包前用脚下方块复核(实测本服必需)' },
|
||||||
|
{ path: 'grim.reportHorizontalCollision', label: '补上 hasHorizontalCollision', type: 'checkbox', hint: 'mineflayer 把这个位标志写成 undefined,序列化后恒为 0,而原版客户端碰到方块时会上报 1;开启后按物理引擎的真实值补上(实测本服机器人一直按着前进键、物理速度约 2 格/秒,净位移却只有 0.4~0.5 格/秒,差额就是被服务端拉回的部分)' },
|
||||||
|
{ path: 'grim.syncGroundToPhysics', label: '物理引擎 onGround 对齐(方案 A)', type: 'checkbox', hint: '每个物理 tick 用世界数据复核脚下是否有方块,是则把物理引擎的 onGround 同步为 true,让移动走地面分支(TODO 问题 1 的疑似根因)' },
|
||||||
|
{ path: 'grim.trustSetbackOnGround', label: '不采纳例行同步的 onGround(方案 B)', type: 'checkbox', hint: '服务端下发的位置与本地几乎一致时,只同步坐标、不采纳它的「在空中」,避免物理引擎的地面状态被反复打断' },
|
||||||
|
{ path: 'grim.setbackSyncEpsilon', label: '方案 B 判定阈值(格)', type: 'number', step: 0.05, min: 0, max: 5, hint: '位置差不超过它时视为例行同步;超过则按真正的传送处理。先用默认值实测,再看状态卡里的「最近同步差」调整' },
|
||||||
{ path: 'grim.sendPlayerInput', label: '每 tick 补发 player_input', type: 'checkbox', hint: '1.21.2+ 移动预测依赖它;mineflayer 地面移动时完全不发,导致反作弊判定「无输入却位移」并把机器人每 tick 拉回(实测本服必需)' },
|
{ path: 'grim.sendPlayerInput', label: '每 tick 补发 player_input', type: 'checkbox', hint: '1.21.2+ 移动预测依赖它;mineflayer 地面移动时完全不发,导致反作弊判定「无输入却位移」并把机器人每 tick 拉回(实测本服必需)' },
|
||||||
{ path: 'grim.sendTickEnd', label: '每 tick 补发 tick_end', type: 'checkbox', hint: '原版 1.21.2+ 客户端每 tick 末尾发送,反作弊用它判定 tick 边界' }
|
{ path: 'grim.sendTickEnd', label: '每 tick 补发 tick_end', type: 'checkbox', hint: '原版 1.21.2+ 客户端每 tick 末尾发送,反作弊用它判定 tick 边界' }
|
||||||
]
|
]
|
||||||
@@ -308,6 +348,15 @@ const CONFIG_SCHEMA = {
|
|||||||
{ path: 'server.reconnectDelayMs', label: '重连间隔(毫秒)', type: 'number', min: 1000 },
|
{ path: 'server.reconnectDelayMs', label: '重连间隔(毫秒)', type: 'number', min: 1000 },
|
||||||
{ path: 'server.reconnectMaxDelayMs', label: '重连间隔上限(毫秒)', type: 'number', min: 1000, hint: '连续重连失败时按指数退避,最多退到这个间隔' }
|
{ path: 'server.reconnectMaxDelayMs', label: '重连间隔上限(毫秒)', type: 'number', min: 1000, hint: '连续重连失败时按指数退避,最多退到这个间隔' }
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '通知提醒(Webhook)',
|
||||||
|
fields: [
|
||||||
|
{ path: 'notify.enabled', label: '启用通知', type: 'checkbox', hint: '需要人工介入时(等待验证码 / 原点自救失败)推送到飞书等自定义机器人,不用一直盯控制台' },
|
||||||
|
{ path: 'notify.webhookUrl', label: 'Webhook 地址', type: 'password', hint: '飞书 / Lark 自定义机器人的 Webhook;留空回退环境变量 NOTIFY_WEBHOOK_URL' },
|
||||||
|
{ path: 'notify.onVerification', label: '等待验证码时提醒', type: 'checkbox', hint: '验证码只有 120 秒时限,错过要等下次重登' },
|
||||||
|
{ path: 'notify.onOriginRescueFailed', label: '原点自救失败时提醒', type: 'checkbox', hint: '自救失败多半说明被切到了登录服,需要人工发验证码' }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
@@ -428,11 +477,41 @@ function renderConfig() {
|
|||||||
body.appendChild(buildField(field));
|
body.appendChild(buildField(field));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
filterConfigFields($('configSearch') ? $('configSearch').value : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按关键词过滤配置项(8 个标签页、字段很多,逐个找很费劲)。
|
||||||
|
*
|
||||||
|
* 只隐藏不删除:隐藏的输入仍然留在 DOM 里,保存时 `collectConfig` 依旧能读到它们的值。
|
||||||
|
*
|
||||||
|
* @param {string} query 搜索词(名称 / 字段名 / 说明,大小写不敏感)
|
||||||
|
*/
|
||||||
|
function filterConfigFields(query) {
|
||||||
|
const body = $('configBody');
|
||||||
|
if (!body) return;
|
||||||
|
const keyword = String(query || '').trim().toLowerCase();
|
||||||
|
let lastTitle = null;
|
||||||
|
let titleHasVisible = false;
|
||||||
|
for (const child of Array.from(body.children)) {
|
||||||
|
if (child.classList.contains('group-title')) {
|
||||||
|
if (lastTitle) lastTitle.classList.toggle('hidden', !titleHasVisible);
|
||||||
|
lastTitle = child;
|
||||||
|
titleHasVisible = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const match = !keyword || (child.dataset.search || '').includes(keyword);
|
||||||
|
child.classList.toggle('hidden', !match);
|
||||||
|
if (match) titleHasVisible = true;
|
||||||
|
}
|
||||||
|
if (lastTitle) lastTitle.classList.toggle('hidden', !titleHasVisible);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildField(field) {
|
function buildField(field) {
|
||||||
const wrap = document.createElement('div');
|
const wrap = document.createElement('div');
|
||||||
wrap.className = 'field';
|
wrap.className = 'field';
|
||||||
|
// 供配置搜索使用:名称 + 字段路径 + 说明一起匹配,输入「回城」「returnOnTimeout」都能找到
|
||||||
|
wrap.dataset.search = `${field.label} ${field.path} ${field.hint || ''}`.toLowerCase();
|
||||||
const label = document.createElement('label');
|
const label = document.createElement('label');
|
||||||
label.textContent = field.label;
|
label.textContent = field.label;
|
||||||
wrap.appendChild(label);
|
wrap.appendChild(label);
|
||||||
@@ -510,19 +589,42 @@ function collectConfig() {
|
|||||||
|
|
||||||
async function loadConfig() {
|
async function loadConfig() {
|
||||||
try {
|
try {
|
||||||
if ($('chkBotOverride').checked && selectedBotId) {
|
if (selectedBotId) {
|
||||||
const res = await api('/api/bots/' + encodeURIComponent(selectedBotId) + '/settings');
|
const res = await api('/api/bots/' + encodeURIComponent(selectedBotId) + '/settings');
|
||||||
|
updateOverrideHint(res.data);
|
||||||
|
if ($('chkBotOverride').checked) {
|
||||||
currentConfig = res.data.config;
|
currentConfig = res.data.config;
|
||||||
} else {
|
renderConfig();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
const res = await api('/api/config');
|
const res = await api('/api/config');
|
||||||
currentConfig = res.data;
|
currentConfig = res.data;
|
||||||
}
|
|
||||||
renderConfig();
|
renderConfig();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast('读取配置失败:' + err.message, true);
|
toast('读取配置失败:' + err.message, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 提示「该机器人是否有一份专属配置覆盖」。
|
||||||
|
*
|
||||||
|
* 专属配置是一份完整快照,存下之后全局配置的改动就再也影响不到这个机器人 ——
|
||||||
|
* 不提示的话,很容易出现「我明明改了全局设置,它却还是老行为」的困惑。
|
||||||
|
*
|
||||||
|
* @param {object} setting `/api/bots/:id/settings` 的 data
|
||||||
|
*/
|
||||||
|
function updateOverrideHint(setting) {
|
||||||
|
const hint = $('overrideHint');
|
||||||
|
const text = $('overrideHintText');
|
||||||
|
if (!hint || !text) return;
|
||||||
|
const has = !!(setting && setting.hasOverrides);
|
||||||
|
hint.classList.toggle('hidden', !has);
|
||||||
|
if (has) {
|
||||||
|
text.textContent = '该机器人有一份专属配置(完整快照),全局配置的改动不会影响它。';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function saveConfig() {
|
async function saveConfig() {
|
||||||
try {
|
try {
|
||||||
const config = collectConfig();
|
const config = collectConfig();
|
||||||
@@ -703,17 +805,56 @@ function renderStatus(status) {
|
|||||||
: `${bot.health.toFixed(1)} / ${bot.food === null || bot.food === undefined ? '-' : bot.food}`;
|
: `${bot.health.toFixed(1)} / ${bot.food === null || bot.food === undefined ? '-' : bot.food}`;
|
||||||
$('kBotState').textContent = (bot.riding ? '乘船中 · ' : '') + (bot.inWater ? '水中' : '陆地');
|
$('kBotState').textContent = (bot.riding ? '乘船中 · ' : '') + (bot.inWater ? '水中' : '陆地');
|
||||||
const loginEl = $('kLogin');
|
const loginEl = $('kLogin');
|
||||||
|
if (bot.alreadyConnected) {
|
||||||
|
// 同一账号只能有一个连接:玩家在客户端登着没退时,机器人每次连上都会被踢
|
||||||
|
loginEl.textContent = '账号已在别处登录';
|
||||||
|
loginEl.style.color = 'var(--red)';
|
||||||
|
loginEl.title = '该账号正在别处登录(通常是你的游戏客户端没退)。请先在客户端退出,或点「上线」重试。';
|
||||||
|
} else {
|
||||||
loginEl.textContent = bot.loginFailed ? '登录被拒绝' : (bot.loggedIn ? '已通过' : '未通过');
|
loginEl.textContent = bot.loginFailed ? '登录被拒绝' : (bot.loggedIn ? '已通过' : '未通过');
|
||||||
loginEl.style.color = bot.loginFailed ? 'var(--red)' : (bot.loggedIn ? 'var(--green)' : 'var(--yellow)');
|
loginEl.style.color = bot.loginFailed ? 'var(--red)' : (bot.loggedIn ? 'var(--green)' : 'var(--yellow)');
|
||||||
|
loginEl.title = '';
|
||||||
|
}
|
||||||
$('kReconnect').textContent = String(bot.reconnectAttempts || 0);
|
$('kReconnect').textContent = String(bot.reconnectAttempts || 0);
|
||||||
|
// 原点自救统计:成功率与最近一次结果(失败说明可能其实是登录服,需要人工发验证码)
|
||||||
|
const rescue = bot.originRescue || {};
|
||||||
|
const rescueEl = $('kOriginRescue');
|
||||||
|
if ((rescue.attempts || 0) > 0) {
|
||||||
|
const last = rescue.lastResult;
|
||||||
|
rescueEl.textContent = `成功 ${rescue.successes || 0}/${rescue.attempts}`
|
||||||
|
+ (last ? `(最近${last.success ? '成功' : '失败'},第 ${last.rounds} 轮)` : '');
|
||||||
|
rescueEl.style.color = last && last.success ? 'var(--green)' : 'var(--yellow)';
|
||||||
|
} else {
|
||||||
|
rescueEl.textContent = '未触发';
|
||||||
|
rescueEl.style.color = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上线 / 下线按钮:在线时是「下线」,离线时是「上线」
|
||||||
|
const offlineBtn = $('btnOffline');
|
||||||
|
const isOnline = bot.state === 'online' || bot.state === 'connecting';
|
||||||
|
offlineBtn.textContent = isOnline ? '下线' : '上线';
|
||||||
|
offlineBtn.className = 'btn' + (isOnline ? '' : ' btn-primary');
|
||||||
|
offlineBtn.title = isOnline
|
||||||
|
? '断开且不再自动重连(需要在游戏客户端登同一账号时用)'
|
||||||
|
: '重新连接并恢复定时消息';
|
||||||
if (document.activeElement !== $('chkEnabled')) $('chkEnabled').checked = bot.enabled !== false;
|
if (document.activeElement !== $('chkEnabled')) $('chkEnabled').checked = bot.enabled !== false;
|
||||||
if (document.activeElement !== $('chkAutoStart')) $('chkAutoStart').checked = bot.autoStart === true;
|
if (document.activeElement !== $('chkAutoStart')) $('chkAutoStart').checked = bot.autoStart === true;
|
||||||
|
|
||||||
const canOperate = bot.canOperate === true;
|
const canOperate = bot.canOperate === true;
|
||||||
$('consoleHint').textContent = canOperate
|
if (bot.needsVerification) {
|
||||||
? '以 / 开头按游戏指令发送,否则按公屏消息发送。'
|
$('consoleHint').textContent = '服务器正在等待验证码:把验证码填进输入框发送即可。';
|
||||||
: '该机器人尚未通过游戏内登录验证,发送会被拒绝(服务器要求验证时会自动放行,用于发验证码)。';
|
$('consoleHint').style.color = 'var(--yellow)';
|
||||||
$('consoleHint').style.color = canOperate ? 'var(--muted)' : 'var(--yellow)';
|
$('cmdInput').placeholder = '输入验证码后回车发送';
|
||||||
|
} else if (canOperate) {
|
||||||
|
$('consoleHint').textContent = '以 / 开头按游戏指令发送,否则按公屏消息发送。';
|
||||||
|
$('consoleHint').style.color = 'var(--muted)';
|
||||||
|
$('cmdInput').placeholder = '输入内容或 /指令,回车发送';
|
||||||
|
} else {
|
||||||
|
$('consoleHint').textContent = '该机器人尚未通过游戏内登录验证,发送会被拒绝(服务器要求验证时会自动放行,用于发验证码)。';
|
||||||
|
$('consoleHint').style.color = 'var(--yellow)';
|
||||||
|
$('cmdInput').placeholder = '输入内容或 /指令,回车发送';
|
||||||
|
}
|
||||||
|
$('verifyBanner').classList.toggle('hidden', bot.needsVerification !== true);
|
||||||
|
|
||||||
// ===== 任务卡片 =====
|
// ===== 任务卡片 =====
|
||||||
$('kTaskState').textContent = task.state || 'IDLE';
|
$('kTaskState').textContent = task.state || 'IDLE';
|
||||||
@@ -725,6 +866,21 @@ function renderStatus(status) {
|
|||||||
$('kRetry').textContent = String(task.retryCount || 0);
|
$('kRetry').textContent = String(task.retryCount || 0);
|
||||||
$('kProgress').textContent = (task.lastProgress || 0) + '%';
|
$('kProgress').textContent = (task.lastProgress || 0) + '%';
|
||||||
$('kTimeout').textContent = Math.round((task.consecutiveTimeoutMs || 0) / 1000) + 's';
|
$('kTimeout').textContent = Math.round((task.consecutiveTimeoutMs || 0) / 1000) + 's';
|
||||||
|
// 最近一次回城把机器人从哪挪到了哪(回城是破坏性操作,必须能看出是否值得)
|
||||||
|
const returnEl = $('kLastReturn');
|
||||||
|
const lastReturn = task.lastReturn;
|
||||||
|
if (lastReturn && lastReturn.from && lastReturn.to) {
|
||||||
|
returnEl.textContent = `(${lastReturn.from.x}, ${lastReturn.from.z}) → `
|
||||||
|
+ `(${lastReturn.to.x}, ${lastReturn.to.z}),直线 ${lastReturn.distance} 格`;
|
||||||
|
returnEl.title = lastReturn.reason || '';
|
||||||
|
returnEl.style.color = lastReturn.distance >= 8 ? 'var(--green)' : 'var(--yellow)';
|
||||||
|
} else if ((task.returnSkippedNotStuck || 0) > 0) {
|
||||||
|
returnEl.textContent = `未回城(原地还能走,已放弃 ${task.returnSkippedNotStuck} 次)`;
|
||||||
|
returnEl.style.color = 'var(--green)';
|
||||||
|
} else {
|
||||||
|
returnEl.textContent = '-';
|
||||||
|
returnEl.style.color = '';
|
||||||
|
}
|
||||||
$('kClaimTime').textContent = task.withinClaimTime ? '时段内' : '时段外';
|
$('kClaimTime').textContent = task.withinClaimTime ? '时段内' : '时段外';
|
||||||
$('progressBar').style.width = (task.lastProgress || 0) + '%';
|
$('progressBar').style.width = (task.lastProgress || 0) + '%';
|
||||||
|
|
||||||
@@ -786,6 +942,13 @@ function renderStatus(status) {
|
|||||||
$('kGrimPackets').style.color = parts.length > 0 ? 'var(--green)' : 'var(--yellow)';
|
$('kGrimPackets').style.color = parts.length > 0 ? 'var(--green)' : 'var(--yellow)';
|
||||||
}
|
}
|
||||||
$('kGrimBlocked').textContent = String(grim.blockedActions || 0);
|
$('kGrimBlocked').textContent = String(grim.blockedActions || 0);
|
||||||
|
// 地面状态修正(TODO 问题 1):方案 A 对齐次数 / 方案 B 保住次数 / 最近一次同步差
|
||||||
|
const stats = bot.packetStats || {};
|
||||||
|
const groundParts = [`物理对齐 ${stats.physicsGroundSync || 0}`, `保住 ${stats.setbackOnGroundKept || 0}`];
|
||||||
|
if (stats.lastSetbackDelta) groundParts.push(`最近同步差 ${stats.lastSetbackDelta.distance} 格`);
|
||||||
|
$('kGrimGround').textContent = groundParts.join(' · ');
|
||||||
|
$('kGrimGround').style.color = (stats.physicsGroundSync || stats.setbackOnGroundKept)
|
||||||
|
? 'var(--green)' : 'var(--yellow)';
|
||||||
$('kGrimAudit').textContent = grim.physicsOk ? '通过' : '异常';
|
$('kGrimAudit').textContent = grim.physicsOk ? '通过' : '异常';
|
||||||
$('kGrimAudit').style.color = grim.physicsOk ? 'var(--green)' : 'var(--red)';
|
$('kGrimAudit').style.color = grim.physicsOk ? 'var(--green)' : 'var(--red)';
|
||||||
const issues = $('grimIssues');
|
const issues = $('grimIssues');
|
||||||
@@ -814,7 +977,10 @@ function renderSchedules(schedules) {
|
|||||||
const list = $('scheduleList');
|
const list = $('scheduleList');
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
const signature = (schedules || [])
|
const signature = (schedules || [])
|
||||||
.map((t) => [t.id, t.enabled, t.content, t.intervalSeconds, t.nextInSeconds].join(':'))
|
.map((t) => [
|
||||||
|
t.id, t.enabled, t.content, t.mode, t.cron, t.intervalSeconds, t.nextInSeconds,
|
||||||
|
t.expectPattern, t.lastResult ? `${t.lastResult.ok ? 1 : 0}@${t.lastResult.at}` : ''
|
||||||
|
].join(':'))
|
||||||
.join('|');
|
.join('|');
|
||||||
if (signature === scheduleSignature) return;
|
if (signature === scheduleSignature) return;
|
||||||
scheduleSignature = signature;
|
scheduleSignature = signature;
|
||||||
@@ -823,7 +989,7 @@ function renderSchedules(schedules) {
|
|||||||
if (!schedules || schedules.length === 0) {
|
if (!schedules || schedules.length === 0) {
|
||||||
const empty = document.createElement('div');
|
const empty = document.createElement('div');
|
||||||
empty.className = 'schedule-empty';
|
empty.className = 'schedule-empty';
|
||||||
empty.textContent = '暂无定时任务。点「+ 新增」可让机器人每隔一段时间重复发送同一条消息或指令。';
|
empty.textContent = '暂无定时任务。点「+ 新增」可让机器人每隔一段时间(或按 cron 表达式)重复发送同一条消息或指令。';
|
||||||
list.appendChild(empty);
|
list.appendChild(empty);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -847,8 +1013,22 @@ function renderSchedules(schedules) {
|
|||||||
content.appendChild(code);
|
content.appendChild(code);
|
||||||
const sub = document.createElement('div');
|
const sub = document.createElement('div');
|
||||||
sub.className = 'sub';
|
sub.className = 'sub';
|
||||||
sub.textContent = `${task.kind === 'command' ? '指令' : '公屏'} · 每 ${task.intervalSeconds} 秒`
|
// 调度描述:cron 任务显示表达式,间隔任务显示秒数;无效表达式会带出错误说明
|
||||||
+ (task.label ? ` · ${task.label}` : '');
|
const scheduleText = task.mode === 'cron'
|
||||||
|
? `cron ${task.cron}`
|
||||||
|
: `每 ${task.intervalSeconds} 秒`;
|
||||||
|
const parts = [task.kind === 'command' ? '指令' : '公屏', scheduleText];
|
||||||
|
if (task.label) parts.push(task.label);
|
||||||
|
// 发送结果确认:成功显示绿色对勾,超时未确认显示黄色感叹号
|
||||||
|
if (task.expectPattern) {
|
||||||
|
if (task.lastResult && task.lastResult.ok) parts.push('✓ 已确认');
|
||||||
|
else if (task.lastResult) parts.push('⚠ 未确认');
|
||||||
|
else parts.push('待确认');
|
||||||
|
}
|
||||||
|
if (task.cronError) parts.push(task.cronError);
|
||||||
|
sub.textContent = parts.join(' · ');
|
||||||
|
if (task.cronError) sub.style.color = 'var(--red)';
|
||||||
|
else if (task.lastResult && task.lastResult.ok === false) sub.style.color = 'var(--yellow)';
|
||||||
content.appendChild(sub);
|
content.appendChild(sub);
|
||||||
item.appendChild(content);
|
item.appendChild(content);
|
||||||
|
|
||||||
@@ -885,14 +1065,18 @@ async function saveSchedules(schedules) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 取当前列表的原始定义(去掉服务端附加的剩余秒数字段) */
|
/** 取当前列表的原始定义(去掉服务端附加的剩余秒数 / 发送结果等运行时字段) */
|
||||||
function currentSchedules() {
|
function currentSchedules() {
|
||||||
const bot = selectedBot(lastStatus || {});
|
const bot = selectedBot(lastStatus || {});
|
||||||
return ((bot && bot.schedules) || []).map((t) => ({
|
return ((bot && bot.schedules) || []).map((t) => ({
|
||||||
id: t.id,
|
id: t.id,
|
||||||
label: t.label,
|
label: t.label,
|
||||||
content: t.content,
|
content: t.content,
|
||||||
|
mode: t.mode === 'cron' ? 'cron' : 'interval',
|
||||||
|
cron: t.cron || '',
|
||||||
intervalSeconds: t.intervalSeconds,
|
intervalSeconds: t.intervalSeconds,
|
||||||
|
expectPattern: t.expectPattern || '',
|
||||||
|
confirmTimeoutSeconds: t.confirmTimeoutSeconds,
|
||||||
enabled: t.enabled
|
enabled: t.enabled
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -1565,9 +1749,46 @@ async function renderSimpV2(body, force) {
|
|||||||
// ==================== 运行日志 ====================
|
// ==================== 运行日志 ====================
|
||||||
|
|
||||||
const MAX_LOG_LINES = 800;
|
const MAX_LOG_LINES = 800;
|
||||||
|
/**
|
||||||
|
* 待渲染的日志队列。
|
||||||
|
*
|
||||||
|
* 服务端是「一条日志一次推送」,而机器人卡住时日志会成串到达(例如「被服务器传送」
|
||||||
|
* 的爆发),逐条改 DOM + 逐条写 scrollTop 会让日志区明显卡顿(手机上尤其明显)。
|
||||||
|
* 这里把同一帧内到达的日志合并成一次 DOM 更新。
|
||||||
|
*/
|
||||||
|
let pendingLogs = [];
|
||||||
|
let logFlushScheduled = false;
|
||||||
|
|
||||||
function appendLog(entry) {
|
function appendLog(entry) {
|
||||||
|
pendingLogs.push(entry);
|
||||||
|
// 后台标签页里 rAF 不触发,这里做个上限,避免队列无限增长
|
||||||
|
if (pendingLogs.length > MAX_LOG_LINES * 2) pendingLogs.splice(0, pendingLogs.length - MAX_LOG_LINES * 2);
|
||||||
|
scheduleLogFlush();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleLogFlush() {
|
||||||
|
if (logFlushScheduled) return;
|
||||||
|
logFlushScheduled = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
logFlushScheduled = false;
|
||||||
|
flushLogs();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把队列里的日志一次性写入 DOM(一次布局、一次滚动) */
|
||||||
|
function flushLogs() {
|
||||||
|
if (pendingLogs.length === 0) return;
|
||||||
const view = $('logView');
|
const view = $('logView');
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (const entry of pendingLogs) fragment.appendChild(buildLogLine(entry));
|
||||||
|
pendingLogs = [];
|
||||||
|
view.appendChild(fragment);
|
||||||
|
while (view.childNodes.length > MAX_LOG_LINES) view.removeChild(view.firstChild);
|
||||||
|
if ($('chkAutoScroll').checked) view.scrollTop = view.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构造一行日志的 DOM */
|
||||||
|
function buildLogLine(entry) {
|
||||||
const line = document.createElement('span');
|
const line = document.createElement('span');
|
||||||
line.className = 'log-line log-' + (entry.level || 'info');
|
line.className = 'log-line log-' + (entry.level || 'info');
|
||||||
const time = document.createElement('span');
|
const time = document.createElement('span');
|
||||||
@@ -1575,14 +1796,14 @@ function appendLog(entry) {
|
|||||||
time.textContent = '[' + entry.time + ']';
|
time.textContent = '[' + entry.time + ']';
|
||||||
line.appendChild(time);
|
line.appendChild(time);
|
||||||
line.appendChild(document.createTextNode(entry.message));
|
line.appendChild(document.createTextNode(entry.message));
|
||||||
view.appendChild(line);
|
return line;
|
||||||
while (view.childNodes.length > MAX_LOG_LINES) view.removeChild(view.firstChild);
|
|
||||||
if ($('chkAutoScroll').checked) view.scrollTop = view.scrollHeight;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLogs(entries) {
|
function renderLogs(entries) {
|
||||||
|
pendingLogs = [];
|
||||||
$('logView').innerHTML = '';
|
$('logView').innerHTML = '';
|
||||||
for (const entry of entries) appendLog(entry);
|
for (const entry of entries) pendingLogs.push(entry);
|
||||||
|
flushLogs();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 公屏聊天 ====================
|
// ==================== 公屏聊天 ====================
|
||||||
@@ -1927,6 +2148,9 @@ function drawBigMap() {
|
|||||||
ctx.lineWidth = 1 / px;
|
ctx.lineWidth = 1 / px;
|
||||||
for (const state of bigMapView.markers.states) {
|
for (const state of bigMapView.markers.states) {
|
||||||
if (!state.path) continue;
|
if (!state.path) continue;
|
||||||
|
// 按视野裁剪:全世界视角下邦国多边形很多,视野外的直接跳过(超大地图防掉帧)
|
||||||
|
if (state.bounds && (state.bounds.maxX < minBlockX || state.bounds.minX > maxBlockX
|
||||||
|
|| state.bounds.maxZ < minBlockZ || state.bounds.minZ > maxBlockZ)) continue;
|
||||||
ctx.fillStyle = hexToRgba(state.color, 0.26);
|
ctx.fillStyle = hexToRgba(state.color, 0.26);
|
||||||
ctx.fill(state.path, 'evenodd');
|
ctx.fill(state.path, 'evenodd');
|
||||||
ctx.strokeStyle = hexToRgba(state.color, 0.85);
|
ctx.strokeStyle = hexToRgba(state.color, 0.85);
|
||||||
@@ -2240,14 +2464,26 @@ function setBigMapMarkers(markers) {
|
|||||||
for (const state of states) {
|
for (const state of states) {
|
||||||
const path = new Path2D();
|
const path = new Path2D();
|
||||||
let has = false;
|
let has = false;
|
||||||
|
// 方块坐标包围盒:全世界视角下用来按视野裁剪多边形(视野外的不必绘制,避免掉帧)
|
||||||
|
let minX = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let minZ = Infinity;
|
||||||
|
let maxZ = -Infinity;
|
||||||
for (const flat of state.polygons || []) {
|
for (const flat of state.polygons || []) {
|
||||||
if (flat.length < 6) continue;
|
if (flat.length < 6) continue;
|
||||||
path.moveTo(flat[0], flat[1]);
|
path.moveTo(flat[0], flat[1]);
|
||||||
for (let i = 2; i < flat.length; i += 2) path.lineTo(flat[i], flat[i + 1]);
|
for (let i = 2; i < flat.length; i += 2) path.lineTo(flat[i], flat[i + 1]);
|
||||||
path.closePath();
|
path.closePath();
|
||||||
|
for (let i = 0; i < flat.length; i += 2) {
|
||||||
|
if (flat[i] < minX) minX = flat[i];
|
||||||
|
if (flat[i] > maxX) maxX = flat[i];
|
||||||
|
if (flat[i + 1] < minZ) minZ = flat[i + 1];
|
||||||
|
if (flat[i + 1] > maxZ) maxZ = flat[i + 1];
|
||||||
|
}
|
||||||
has = true;
|
has = true;
|
||||||
}
|
}
|
||||||
state.path = has ? path : null;
|
state.path = has ? path : null;
|
||||||
|
state.bounds = Number.isFinite(minX) ? { minX, maxX, minZ, maxZ } : null;
|
||||||
}
|
}
|
||||||
bigMapView.markers = { world: markers.world, updatedAt: markers.updatedAt, states };
|
bigMapView.markers = { world: markers.world, updatedAt: markers.updatedAt, states };
|
||||||
}
|
}
|
||||||
@@ -2367,6 +2603,7 @@ function bindEvents() {
|
|||||||
$('btnStart').addEventListener('click', () => startTask(selectedBotId));
|
$('btnStart').addEventListener('click', () => startTask(selectedBotId));
|
||||||
$('btnStop').addEventListener('click', () => stopTask(selectedBotId));
|
$('btnStop').addEventListener('click', () => stopTask(selectedBotId));
|
||||||
$('btnReconnect').addEventListener('click', () => reconnectBot(selectedBotId));
|
$('btnReconnect').addEventListener('click', () => reconnectBot(selectedBotId));
|
||||||
|
$('btnOffline').addEventListener('click', () => toggleBotOnline(selectedBotId));
|
||||||
|
|
||||||
$('btnStartAll').addEventListener('click', async () => {
|
$('btnStartAll').addEventListener('click', async () => {
|
||||||
const bots = (lastStatus && lastStatus.bots) || [];
|
const bots = (lastStatus && lastStatus.bots) || [];
|
||||||
@@ -2430,11 +2667,21 @@ function bindEvents() {
|
|||||||
toast('请填写要发送的内容', true);
|
toast('请填写要发送的内容', true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const mode = $('schedMode').value === 'cron' ? 'cron' : 'interval';
|
||||||
|
const cron = $('schedCron').value.trim();
|
||||||
|
if (mode === 'cron' && !cron) {
|
||||||
|
toast('cron 模式下必须填写表达式,例如 0 9 * * *', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const next = currentSchedules();
|
const next = currentSchedules();
|
||||||
next.push({
|
next.push({
|
||||||
id: 'task-' + Date.now().toString(36),
|
id: 'task-' + Date.now().toString(36),
|
||||||
content,
|
content,
|
||||||
|
mode,
|
||||||
|
cron: mode === 'cron' ? cron : '',
|
||||||
intervalSeconds: Number($('schedInterval').value) || 300,
|
intervalSeconds: Number($('schedInterval').value) || 300,
|
||||||
|
expectPattern: $('schedExpect').value.trim(),
|
||||||
|
confirmTimeoutSeconds: Number($('schedConfirm').value) || 10,
|
||||||
label: $('schedLabel').value.trim(),
|
label: $('schedLabel').value.trim(),
|
||||||
enabled: true
|
enabled: true
|
||||||
});
|
});
|
||||||
@@ -2442,6 +2689,11 @@ function bindEvents() {
|
|||||||
await saveSchedules(next);
|
await saveSchedules(next);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 调度方式切换:选 cron 时显示表达式输入框(固定间隔输入框保留,切回来不用重填)
|
||||||
|
$('schedMode').addEventListener('change', () => {
|
||||||
|
$('schedCronField').classList.toggle('hidden', $('schedMode').value !== 'cron');
|
||||||
|
});
|
||||||
|
|
||||||
$('btnRefreshMap').addEventListener('click', async () => {
|
$('btnRefreshMap').addEventListener('click', async () => {
|
||||||
try {
|
try {
|
||||||
await api('/api/map/refresh', { method: 'POST' });
|
await api('/api/map/refresh', { method: 'POST' });
|
||||||
@@ -2466,6 +2718,19 @@ function bindEvents() {
|
|||||||
$('btnSaveConfig').addEventListener('click', saveConfig);
|
$('btnSaveConfig').addEventListener('click', saveConfig);
|
||||||
$('btnReloadConfig').addEventListener('click', loadConfig);
|
$('btnReloadConfig').addEventListener('click', loadConfig);
|
||||||
$('chkBotOverride').addEventListener('change', loadConfig);
|
$('chkBotOverride').addEventListener('change', loadConfig);
|
||||||
|
$('btnClearOverride').addEventListener('click', async () => {
|
||||||
|
if (!selectedBotId) return;
|
||||||
|
try {
|
||||||
|
const res = await api('/api/bots/' + encodeURIComponent(selectedBotId) + '/settings/clear-config', {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
toast(res.message || '已清除');
|
||||||
|
updateOverrideHint(res.data);
|
||||||
|
await loadConfig();
|
||||||
|
} catch (err) {
|
||||||
|
toast('清除失败:' + err.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
$('configTabs').addEventListener('click', (e) => {
|
$('configTabs').addEventListener('click', (e) => {
|
||||||
const tab = e.target.closest('.tab');
|
const tab = e.target.closest('.tab');
|
||||||
@@ -2477,6 +2742,9 @@ function bindEvents() {
|
|||||||
renderConfig();
|
renderConfig();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 配置搜索:只隐藏不删除,保存时仍能读到隐藏字段的值
|
||||||
|
$('configSearch').addEventListener('input', (e) => filterConfigFields(e.target.value));
|
||||||
|
|
||||||
// ===== 地图视图切换 =====
|
// ===== 地图视图切换 =====
|
||||||
$('mapTabs').addEventListener('click', (e) => {
|
$('mapTabs').addEventListener('click', (e) => {
|
||||||
const tab = e.target.closest('.tab');
|
const tab = e.target.closest('.tab');
|
||||||
@@ -2744,6 +3012,30 @@ async function reconnectBot(botId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上线 / 下线切换。
|
||||||
|
*
|
||||||
|
* 下线会断开连接并**停止自动重连**、同时停掉定时消息与扩地任务 ——
|
||||||
|
* 需要在游戏客户端登同一个账号时必须先下线,因为同一账号只能有一个连接,
|
||||||
|
* 否则会一直被服务端以「您已经连接到此代理服务器了」踢掉。
|
||||||
|
*/
|
||||||
|
async function toggleBotOnline(botId) {
|
||||||
|
if (!botId) return;
|
||||||
|
const bot = selectedBot(lastStatus || {});
|
||||||
|
const isOnline = bot && (bot.state === 'online' || bot.state === 'connecting');
|
||||||
|
const action = isOnline ? 'offline' : 'online';
|
||||||
|
if (isOnline && !window.confirm('让机器人下线?\n\n会断开连接并停止自动重连,同时停掉定时消息与扩地任务。\n之后点「上线」即可恢复。')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await api('/api/bots/' + encodeURIComponent(botId) + '/' + action, { method: 'POST' });
|
||||||
|
toast(res.message || '已切换');
|
||||||
|
if (res.data) renderStatus(res.data);
|
||||||
|
} catch (err) {
|
||||||
|
toast('操作失败:' + err.message, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 控制台新增一个玩家(机器人) */
|
/** 控制台新增一个玩家(机器人) */
|
||||||
async function addBot() {
|
async function addBot() {
|
||||||
const username = $('newBotName').value.trim();
|
const username = $('newBotName').value.trim();
|
||||||
|
|||||||
+39
-2
@@ -30,6 +30,7 @@
|
|||||||
<button id="btnStart" class="btn btn-primary">开始扩地</button>
|
<button id="btnStart" class="btn btn-primary">开始扩地</button>
|
||||||
<button id="btnStop" class="btn btn-danger">停止</button>
|
<button id="btnStop" class="btn btn-danger">停止</button>
|
||||||
<button id="btnReconnect" class="btn">重连</button>
|
<button id="btnReconnect" class="btn">重连</button>
|
||||||
|
<button id="btnOffline" class="btn" title="让机器人断开且不再自动重连(需要在游戏客户端登同一账号时用)">下线</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -80,6 +81,7 @@
|
|||||||
<dt>生命 / 饥饿</dt><dd id="kHealth">-</dd>
|
<dt>生命 / 饥饿</dt><dd id="kHealth">-</dd>
|
||||||
<dt>状态</dt><dd id="kBotState">-</dd>
|
<dt>状态</dt><dd id="kBotState">-</dd>
|
||||||
<dt>登录验证</dt><dd id="kLogin">-</dd>
|
<dt>登录验证</dt><dd id="kLogin">-</dd>
|
||||||
|
<dt>原点自救</dt><dd id="kOriginRescue">-</dd>
|
||||||
<dt>重连次数</dt><dd id="kReconnect">0</dd>
|
<dt>重连次数</dt><dd id="kReconnect">0</dd>
|
||||||
</dl>
|
</dl>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -101,6 +103,7 @@
|
|||||||
<dt>重试</dt><dd id="kRetry">0</dd>
|
<dt>重试</dt><dd id="kRetry">0</dd>
|
||||||
<dt>开垦进度</dt><dd id="kProgress">0%</dd>
|
<dt>开垦进度</dt><dd id="kProgress">0%</dd>
|
||||||
<dt>连续寻路超时</dt><dd id="kTimeout">0s</dd>
|
<dt>连续寻路超时</dt><dd id="kTimeout">0s</dd>
|
||||||
|
<dt>最近回城</dt><dd id="kLastReturn">-</dd>
|
||||||
<dt>开垦时段</dt><dd id="kClaimTime">-</dd>
|
<dt>开垦时段</dt><dd id="kClaimTime">-</dd>
|
||||||
</dl>
|
</dl>
|
||||||
<div class="progress"><div id="progressBar" class="progress-fill"></div></div>
|
<div class="progress"><div id="progressBar" class="progress-fill"></div></div>
|
||||||
@@ -118,20 +121,42 @@
|
|||||||
<input id="schedContent" class="input" type="text" placeholder="/u restore confirm">
|
<input id="schedContent" class="input" type="text" placeholder="/u restore confirm">
|
||||||
</div>
|
</div>
|
||||||
<div class="field-row">
|
<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">
|
<div class="field">
|
||||||
<label>间隔(秒)</label>
|
<label>间隔(秒)</label>
|
||||||
<input id="schedInterval" class="input" type="number" min="5" value="300">
|
<input id="schedInterval" class="input" type="number" min="5" value="300">
|
||||||
</div>
|
</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">
|
<div class="field">
|
||||||
<label>备注</label>
|
<label>备注</label>
|
||||||
<input id="schedLabel" class="input" type="text" placeholder="恢复民心">
|
<input id="schedLabel" class="input" type="text" placeholder="恢复民心">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="row row-compact">
|
<div class="row row-compact">
|
||||||
<button id="btnSaveSchedule" class="btn btn-sm btn-primary">保存</button>
|
<button id="btnSaveSchedule" class="btn btn-sm btn-primary">保存</button>
|
||||||
<button id="btnCancelSchedule" class="btn btn-sm">取消</button>
|
<button id="btnCancelSchedule" class="btn btn-sm">取消</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">只会在机器人已连接且通过登录验证时发送;未就绪的周期会跳过,不会补发。</p>
|
<p class="hint">只会在机器人已连接且通过登录验证时发送;未就绪的周期会跳过,不会补发。填了「期望回复」时,发出后会等待服务器回复,超时未回复会在日志里给出警告。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -163,6 +188,7 @@
|
|||||||
<dt>攻击距离</dt><dd id="kGrimReach">-</dd>
|
<dt>攻击距离</dt><dd id="kGrimReach">-</dd>
|
||||||
<dt>攻击冷却</dt><dd id="kGrimCooldown">-</dd>
|
<dt>攻击冷却</dt><dd id="kGrimCooldown">-</dd>
|
||||||
<dt>协议补包</dt><dd id="kGrimPackets">-</dd>
|
<dt>协议补包</dt><dd id="kGrimPackets">-</dd>
|
||||||
|
<dt>地面修正</dt><dd id="kGrimGround">-</dd>
|
||||||
<dt>已拦下动作</dt><dd id="kGrimBlocked">0</dd>
|
<dt>已拦下动作</dt><dd id="kGrimBlocked">0</dd>
|
||||||
<dt>物理自检</dt><dd id="kGrimAudit">-</dd>
|
<dt>物理自检</dt><dd id="kGrimAudit">-</dd>
|
||||||
</dl>
|
</dl>
|
||||||
@@ -272,6 +298,10 @@
|
|||||||
<span class="mono small" id="consoleTarget">-</span>
|
<span class="mono small" id="consoleTarget">-</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint" id="consoleHint">以 / 开头按游戏指令发送,否则按公屏消息发送。登录验证通过后才允许发送。</p>
|
<p class="hint" id="consoleHint">以 / 开头按游戏指令发送,否则按公屏消息发送。登录验证通过后才允许发送。</p>
|
||||||
|
<div id="verifyBanner" class="verify-banner hidden">
|
||||||
|
<strong>服务器正在等待验证码</strong>
|
||||||
|
<p>把验证码填进下面的输入框直接发送即可(当聊天内容发出去,不用加斜杠)。账号绑定过的服务器可能每次登录都要输一次。</p>
|
||||||
|
</div>
|
||||||
<div class="row row-compact">
|
<div class="row row-compact">
|
||||||
<input id="cmdInput" class="input input-grow" type="text" placeholder="输入内容或 /指令,回车发送">
|
<input id="cmdInput" class="input input-grow" type="text" placeholder="输入内容或 /指令,回车发送">
|
||||||
<button id="btnSendCmd" class="btn btn-sm btn-primary">发送</button>
|
<button id="btnSendCmd" class="btn btn-sm btn-primary">发送</button>
|
||||||
@@ -304,6 +334,10 @@
|
|||||||
<input type="checkbox" id="chkBotOverride">
|
<input type="checkbox" id="chkBotOverride">
|
||||||
仅对当前机器人生效(不勾选则写入全局默认配置)
|
仅对当前机器人生效(不勾选则写入全局默认配置)
|
||||||
</label>
|
</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">
|
<div class="tabs" id="configTabs">
|
||||||
<button class="tab active" data-tab="strategy">策略</button>
|
<button class="tab active" data-tab="strategy">策略</button>
|
||||||
<button class="tab" data-tab="timing">超时</button>
|
<button class="tab" data-tab="timing">超时</button>
|
||||||
@@ -314,6 +348,9 @@
|
|||||||
<button class="tab" data-tab="grim">Grim</button>
|
<button class="tab" data-tab="grim">Grim</button>
|
||||||
<button class="tab" data-tab="server">服务器</button>
|
<button class="tab" data-tab="server">服务器</button>
|
||||||
</div>
|
</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 id="configBody" class="config-body"></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -314,6 +314,17 @@ html, body {
|
|||||||
.log-error { color: var(--red); }
|
.log-error { color: var(--red); }
|
||||||
.log-success { color: var(--green); }
|
.log-success { color: var(--green); }
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 日志 / 公屏行很多时(移动端尤其明显):让浏览器跳过「视口外」行的渲染与布局。
|
||||||
|
* 相当于浏览器自带的虚拟列表 —— 不改变内容与滚动位置,只是不再为看不见的行做绘制。
|
||||||
|
* 不支持的浏览器会忽略这两行,行为与原来一致。
|
||||||
|
*/
|
||||||
|
.log-line,
|
||||||
|
.chat-line {
|
||||||
|
content-visibility: auto;
|
||||||
|
contain-intrinsic-size: auto 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-line { display: block; }
|
.chat-line { display: block; }
|
||||||
.chat-time { color: #55606f; margin-right: 6px; }
|
.chat-time { color: #55606f; margin-right: 6px; }
|
||||||
.chat-bot { color: var(--accent-2); margin-right: 4px; }
|
.chat-bot { color: var(--accent-2); margin-right: 4px; }
|
||||||
@@ -338,6 +349,19 @@ html, body {
|
|||||||
/* ==================== 表单 ==================== */
|
/* ==================== 表单 ==================== */
|
||||||
.card-config { display: flex; flex-direction: column; }
|
.card-config { display: flex; flex-direction: column; }
|
||||||
.config-scope { 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 { display: flex; gap: 5px; flex-wrap: wrap; margin-bottom: 9px; }
|
||||||
.tabs-inline { margin-bottom: 0; }
|
.tabs-inline { margin-bottom: 0; }
|
||||||
.tab {
|
.tab {
|
||||||
|
|||||||
+227
@@ -11,6 +11,13 @@ const { defaultConfig, normalizeConfig } = require('../config/expansionConfig');
|
|||||||
|
|
||||||
/** 状态推送间隔(毫秒) */
|
/** 状态推送间隔(毫秒) */
|
||||||
const STATUS_PUSH_INTERVAL_MS = 1000;
|
const STATUS_PUSH_INTERVAL_MS = 1000;
|
||||||
|
/**
|
||||||
|
* 大地图预热检查间隔(毫秒)。
|
||||||
|
*
|
||||||
|
* 这里只是「低频问一句」,真正的节流(移动够远 / 距上次够久)由
|
||||||
|
* BigMapService.prewarmAround 自己判断,所以这个间隔可以设得很保守。
|
||||||
|
*/
|
||||||
|
const MAP_PREWARM_CHECK_INTERVAL_MS = 60000;
|
||||||
/** 登录接口路径(免令牌,否则无法登录) */
|
/** 登录接口路径(免令牌,否则无法登录) */
|
||||||
const LOGIN_PATH = '/api/login';
|
const LOGIN_PATH = '/api/login';
|
||||||
/** 会话探测接口路径(免令牌) */
|
/** 会话探测接口路径(免令牌) */
|
||||||
@@ -52,6 +59,8 @@ class WebConsole {
|
|||||||
this.expansionLog = deps.expansionLog || null;
|
this.expansionLog = deps.expansionLog || null;
|
||||||
this.chatLog = deps.chatLog || null;
|
this.chatLog = deps.chatLog || null;
|
||||||
this.bigMap = deps.bigMap || null;
|
this.bigMap = deps.bigMap || null;
|
||||||
|
/** @type {import('../core/notifier').Notifier|null} 人工介入提醒(状态只暴露域名,不含完整地址) */
|
||||||
|
this.notifier = deps.notifier || null;
|
||||||
this.getConfig = deps.getConfig;
|
this.getConfig = deps.getConfig;
|
||||||
this.applyConfig = deps.applyConfig;
|
this.applyConfig = deps.applyConfig;
|
||||||
this.exportDir = opts.exportDir || path.join(__dirname, '..', '..', 'data');
|
this.exportDir = opts.exportDir || path.join(__dirname, '..', '..', 'data');
|
||||||
@@ -120,6 +129,10 @@ class WebConsole {
|
|||||||
}, STATUS_PUSH_INTERVAL_MS);
|
}, STATUS_PUSH_INTERVAL_MS);
|
||||||
if (this._pushTimer.unref) this._pushTimer.unref();
|
if (this._pushTimer.unref) this._pushTimer.unref();
|
||||||
|
|
||||||
|
// 大地图后台预热:低频检查机器人位置,移动够远就顺带拉取周边高层级瓦片(低优先级)
|
||||||
|
this._mapPrewarmTimer = setInterval(() => this._maybePrewarmMap(), MAP_PREWARM_CHECK_INTERVAL_MS);
|
||||||
|
if (this._mapPrewarmTimer.unref) this._mapPrewarmTimer.unref();
|
||||||
|
|
||||||
// 新日志 / 新公屏消息 / 新开拓记录实时推送
|
// 新日志 / 新公屏消息 / 新开拓记录实时推送
|
||||||
this.logger.onEntry = (entry) => this._broadcast('log', entry);
|
this.logger.onEntry = (entry) => this._broadcast('log', entry);
|
||||||
if (this.chatLog) this.chatLog.onEntry = (entry) => this._broadcast('chat', entry);
|
if (this.chatLog) this.chatLog.onEntry = (entry) => this._broadcast('chat', entry);
|
||||||
@@ -148,6 +161,7 @@ class WebConsole {
|
|||||||
/** 停止服务 */
|
/** 停止服务 */
|
||||||
async stop() {
|
async stop() {
|
||||||
if (this._pushTimer) clearInterval(this._pushTimer);
|
if (this._pushTimer) clearInterval(this._pushTimer);
|
||||||
|
if (this._mapPrewarmTimer) clearInterval(this._mapPrewarmTimer);
|
||||||
this.logger.onEntry = null;
|
this.logger.onEntry = null;
|
||||||
if (this.chatLog) this.chatLog.onEntry = null;
|
if (this.chatLog) this.chatLog.onEntry = null;
|
||||||
if (this.expansionLog) this.expansionLog.onEntry = null;
|
if (this.expansionLog) this.expansionLog.onEntry = null;
|
||||||
@@ -305,6 +319,19 @@ class WebConsole {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 清除专属配置覆盖,恢复继承全局默认配置
|
||||||
|
app.post('/api/bots/:id/settings/clear-config', (req, res) => {
|
||||||
|
if (typeof this.pool.clearBotConfigOverride !== 'function') {
|
||||||
|
res.status(400).json({ ok: false, message: '当前运行模式不支持清除专属配置' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._withEntry(req, res, (entry) => {
|
||||||
|
const saved = this.pool.clearBotConfigOverride(entry.id);
|
||||||
|
res.json({ ok: true, data: saved, message: '已清除专属配置,恢复继承全局默认配置' });
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
app.post('/api/bots/:id/settings', (req, res) => {
|
app.post('/api/bots/:id/settings', (req, res) => {
|
||||||
this._withEntry(req, res, (entry) => {
|
this._withEntry(req, res, (entry) => {
|
||||||
const body = req.body || {};
|
const body = req.body || {};
|
||||||
@@ -368,6 +395,40 @@ class WebConsole {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 下线:断开且不再自动重连(需要在游戏客户端登同一账号时用)
|
||||||
|
app.post('/api/bots/:id/offline', (req, res) => {
|
||||||
|
if (typeof this.pool.disconnectBot !== 'function') {
|
||||||
|
res.status(400).json({ ok: false, message: '当前运行模式不支持下线操作' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._withEntry(req, res, (entry) => {
|
||||||
|
this.pool.disconnectBot(entry.id);
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
message: entry.connection.username + ' 已下线(不会自动重连)',
|
||||||
|
data: this.statusPayload()
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 上线:恢复连接与定时消息
|
||||||
|
app.post('/api/bots/:id/online', (req, res) => {
|
||||||
|
if (typeof this.pool.connectBot !== 'function') {
|
||||||
|
res.status(400).json({ ok: false, message: '当前运行模式不支持上线操作' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._withEntry(req, res, (entry) => {
|
||||||
|
this.pool.connectBot(entry.id);
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
message: entry.connection.username + ' 正在上线',
|
||||||
|
data: this.statusPayload()
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// 手动移动测试:绕开寻路器直接按方向键,用于判断「算不出路径」还是「根本走不动」
|
// 手动移动测试:绕开寻路器直接按方向键,用于判断「算不出路径」还是「根本走不动」
|
||||||
app.post('/api/bots/:id/move', async (req, res) => {
|
app.post('/api/bots/:id/move', async (req, res) => {
|
||||||
const body = req.body || {};
|
const body = req.body || {};
|
||||||
@@ -384,6 +445,68 @@ class WebConsole {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 出站包抓取:与「移动测试」配合使用 —— 先抓「能走」时的 5 秒,
|
||||||
|
// 再抓「走不动」时的 5 秒,两次 diff 才能看出反作弊到底在拒绝什么。
|
||||||
|
app.post('/api/bots/:id/capture/start', (req, res) => {
|
||||||
|
this._withEntry(req, res, (entry) => {
|
||||||
|
const body = req.body || {};
|
||||||
|
const result = this.pool.startCapture(entry.id, body.seconds);
|
||||||
|
res.status(result.ok ? 200 : 409).json(result);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/bots/:id/capture/stop', (req, res) => {
|
||||||
|
this._withEntry(req, res, (entry) => {
|
||||||
|
const result = this.pool.stopCapture(entry.id);
|
||||||
|
res.json({ ok: true, message: result.message });
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/bots/:id/capture', (req, res) => {
|
||||||
|
this._withEntry(req, res, (entry) => {
|
||||||
|
const limit = Number.parseInt(req.query.limit, 10);
|
||||||
|
const data = this.pool.captureSnapshot(entry.id, {
|
||||||
|
types: req.query.types,
|
||||||
|
limit: Number.isFinite(limit) ? limit : undefined
|
||||||
|
});
|
||||||
|
res.json({ ok: true, data });
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 物理 tick 录制:抓包回答「客户端发了什么」,这里回答
|
||||||
|
// 「物理引擎读到了什么输入、算出了什么、有没有真的移动」。
|
||||||
|
// 两者一起看,才能把「输入没进物理 / 被方块挡住 / 服务端回弹」三种原因切开。
|
||||||
|
app.post('/api/bots/:id/physics/start', (req, res) => {
|
||||||
|
this._withEntry(req, res, (entry) => {
|
||||||
|
const body = req.body || {};
|
||||||
|
const result = this.pool.startPhysicsRecorder(entry.id, body.seconds);
|
||||||
|
res.status(result.ok ? 200 : 409).json(result);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/bots/:id/physics/stop', (req, res) => {
|
||||||
|
this._withEntry(req, res, (entry) => {
|
||||||
|
const result = this.pool.stopPhysicsRecorder(entry.id);
|
||||||
|
res.json({ ok: true, message: result.message });
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/bots/:id/physics', (req, res) => {
|
||||||
|
this._withEntry(req, res, (entry) => {
|
||||||
|
const limit = Number.parseInt(req.query.limit, 10);
|
||||||
|
const data = this.pool.physicsSnapshot(entry.id, {
|
||||||
|
limit: Number.isFinite(limit) ? limit : undefined
|
||||||
|
});
|
||||||
|
res.json({ ok: true, data });
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// 控制台 / 聊天框操作:都要求该机器人已完成游戏内登录验证
|
// 控制台 / 聊天框操作:都要求该机器人已完成游戏内登录验证
|
||||||
app.post('/api/bots/:id/command', (req, res) => {
|
app.post('/api/bots/:id/command', (req, res) => {
|
||||||
const command = req.body && req.body.command;
|
const command = req.body && req.body.command;
|
||||||
@@ -902,6 +1025,8 @@ class WebConsole {
|
|||||||
expansionLog: this.expansionLog ? this.expansionLog.stats() : null,
|
expansionLog: this.expansionLog ? this.expansionLog.stats() : null,
|
||||||
chatLog: this.chatLog ? this.chatLog.snapshot() : null,
|
chatLog: this.chatLog ? this.chatLog.snapshot() : null,
|
||||||
bigMap: this.bigMap ? this.bigMapPayload() : null,
|
bigMap: this.bigMap ? this.bigMapPayload() : null,
|
||||||
|
/** 通知提醒状态:是否启用 / 是否配好地址 / 已发与失败条数 */
|
||||||
|
notify: this.notifier ? this.notifier.snapshot() : null,
|
||||||
serverTime: Date.now(),
|
serverTime: Date.now(),
|
||||||
claimTime: {
|
claimTime: {
|
||||||
start: this.getConfig().claimStartHour,
|
start: this.getConfig().claimStartHour,
|
||||||
@@ -1006,6 +1131,26 @@ class WebConsole {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 大地图后台预热(低频调用):以「第一个有坐标的机器人」为中心,
|
||||||
|
* 交给 BigMapService 判断是否值得预热(移动够远 / 距上次够久)。
|
||||||
|
*
|
||||||
|
* 预热是低优先级任务:失败只记一条日志,不影响任何业务功能。
|
||||||
|
*/
|
||||||
|
_maybePrewarmMap() {
|
||||||
|
if (!this.bigMap) return;
|
||||||
|
let target = null;
|
||||||
|
try {
|
||||||
|
target = this.pool.positions().find((bot) => bot.position) || null;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!target) return;
|
||||||
|
this.bigMap.prewarmAround(target.position.x, target.position.z).catch((err) => {
|
||||||
|
this.logger.log('大地图预热失败:' + (err && err.message ? err.message : String(err)), 'warn');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** @returns {object} 大地图上的领土标记(多边形 + 王城) */
|
/** @returns {object} 大地图上的领土标记(多边形 + 王城) */
|
||||||
bigMapMarkersPayload() {
|
bigMapMarkersPayload() {
|
||||||
const model = this.dataService.getModel();
|
const model = this.dataService.getModel();
|
||||||
@@ -1146,6 +1291,88 @@ class SingleBotPool {
|
|||||||
const ok = entry.manager.sendChat(text);
|
const ok = entry.manager.sendChat(text);
|
||||||
return { ok, kind, message: ok ? (isCommand ? '指令已发送:' + text : '消息已发送') : '发送失败' };
|
return { ok, kind, message: ok ? (isCommand ? '指令已发送:' + text : '消息已发送') : '发送失败' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始抓出站包。
|
||||||
|
* @param {string} id
|
||||||
|
* @param {number} [seconds]
|
||||||
|
* @returns {{ok: boolean, message: string}}
|
||||||
|
*/
|
||||||
|
startCapture(id, seconds) {
|
||||||
|
const entry = this.require(id);
|
||||||
|
if (typeof entry.manager.startPacketCapture !== 'function') {
|
||||||
|
return { ok: false, message: '当前运行模式不支持抓包' };
|
||||||
|
}
|
||||||
|
return entry.manager.startPacketCapture(seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止抓包。
|
||||||
|
* @param {string} id
|
||||||
|
* @returns {{ok: boolean, message: string}}
|
||||||
|
*/
|
||||||
|
stopCapture(id) {
|
||||||
|
const entry = this.require(id);
|
||||||
|
if (typeof entry.manager.stopPacketCapture !== 'function') {
|
||||||
|
return { ok: true, message: '当前运行模式不支持抓包' };
|
||||||
|
}
|
||||||
|
return entry.manager.stopPacketCapture();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取出抓包结果。
|
||||||
|
* @param {string} id
|
||||||
|
* @param {{types?: string|string[], limit?: number}} [options]
|
||||||
|
* @returns {object}
|
||||||
|
*/
|
||||||
|
captureSnapshot(id, options) {
|
||||||
|
const entry = this.require(id);
|
||||||
|
if (typeof entry.manager.packetCaptureSnapshot !== 'function') {
|
||||||
|
return { running: false, count: 0, dropped: 0, byType: {}, returned: 0, packets: [] };
|
||||||
|
}
|
||||||
|
return entry.manager.packetCaptureSnapshot(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始录制物理 tick。
|
||||||
|
* @param {string} id
|
||||||
|
* @param {number} [seconds]
|
||||||
|
* @returns {{ok: boolean, message: string}}
|
||||||
|
*/
|
||||||
|
startPhysicsRecorder(id, seconds) {
|
||||||
|
const entry = this.require(id);
|
||||||
|
if (typeof entry.manager.startPhysicsRecorder !== 'function') {
|
||||||
|
return { ok: false, message: '当前运行模式不支持物理录制' };
|
||||||
|
}
|
||||||
|
return entry.manager.startPhysicsRecorder(seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止物理 tick 录制。
|
||||||
|
* @param {string} id
|
||||||
|
* @returns {{ok: boolean, message: string}}
|
||||||
|
*/
|
||||||
|
stopPhysicsRecorder(id) {
|
||||||
|
const entry = this.require(id);
|
||||||
|
if (typeof entry.manager.stopPhysicsRecorder !== 'function') {
|
||||||
|
return { ok: true, message: '当前运行模式不支持物理录制' };
|
||||||
|
}
|
||||||
|
return entry.manager.stopPhysicsRecorder();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取出物理 tick 录制结果。
|
||||||
|
* @param {string} id
|
||||||
|
* @param {{limit?: number}} [options]
|
||||||
|
* @returns {object}
|
||||||
|
*/
|
||||||
|
physicsSnapshot(id, options) {
|
||||||
|
const entry = this.require(id);
|
||||||
|
if (typeof entry.manager.physicsSnapshot !== 'function') {
|
||||||
|
return { running: false, count: 0, inputTicks: 0, inputMovedTicks: 0, ticks: [] };
|
||||||
|
}
|
||||||
|
return entry.manager.physicsSnapshot(options);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -218,3 +218,65 @@ test('配置里 zoom 大于 maxZoom 时被钳制(避免爬到源站不存在
|
|||||||
assert.strictEqual(normalized.bigMap.zoom, 3);
|
assert.strictEqual(normalized.bigMap.zoom, 3);
|
||||||
assert.strictEqual(service.maxZoom, 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);
|
||||||
|
});
|
||||||
|
|||||||
+533
-1
@@ -6,7 +6,7 @@ const fs = require('fs');
|
|||||||
const os = require('os');
|
const os = require('os');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
const { BotManager, kindOfPosition, computeReconnectDelay } = require('../src/bot/botManager');
|
const { BotManager, kindOfPosition, computeReconnectDelay, extractKickReason } = require('../src/bot/botManager');
|
||||||
const { FakeBot } = require('./helpers/fakeBot');
|
const { FakeBot } = require('./helpers/fakeBot');
|
||||||
const { ChatLog, ChatKind } = require('../src/core/chatLog');
|
const { ChatLog, ChatKind } = require('../src/core/chatLog');
|
||||||
const { defaultConfig, normalizeConfig } = require('../src/config/expansionConfig');
|
const { defaultConfig, normalizeConfig } = require('../src/config/expansionConfig');
|
||||||
@@ -476,6 +476,58 @@ test('出站包结构异常时不抛异常', () => {
|
|||||||
assert.doesNotThrow(() => manager._fixOutboundOnGround({ flags: {} }));
|
assert.doesNotThrow(() => manager._fixOutboundOnGround({ flags: {} }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('补上 hasHorizontalCollision:mineflayer 恒写 undefined(序列化后为 0)', () => {
|
||||||
|
// 回归:原版客户端碰到方块时上报 1,而 mineflayer 在 physics.js 里把它写成
|
||||||
|
// undefined —— 位标志序列化后恒为 0。机器人在崎岖地形上几乎一直水平碰撞,
|
||||||
|
// 却始终上报「没碰撞」,反作弊的 Prediction 会按「该碰撞」预测后判定异常并拉回。
|
||||||
|
const { manager } = setup();
|
||||||
|
const bot = botStandingOnStone();
|
||||||
|
bot.entity.isCollidedHorizontally = true;
|
||||||
|
manager.bot = bot;
|
||||||
|
|
||||||
|
const params = { flags: { onGround: true, hasHorizontalCollision: undefined } };
|
||||||
|
manager._fixOutboundOnGround(params);
|
||||||
|
assert.strictEqual(params.flags.hasHorizontalCollision, true, '应按物理引擎的真实值补上');
|
||||||
|
assert.strictEqual(manager.packetStats.horizontalCollisionFixed, 1);
|
||||||
|
|
||||||
|
// 已一致时不重复计数
|
||||||
|
manager._fixOutboundOnGround(params);
|
||||||
|
assert.strictEqual(manager.packetStats.horizontalCollisionFixed, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('没有水平碰撞时如实上报 false', () => {
|
||||||
|
const { manager } = setup();
|
||||||
|
const bot = botStandingOnStone();
|
||||||
|
bot.entity.isCollidedHorizontally = false;
|
||||||
|
manager.bot = bot;
|
||||||
|
const params = { flags: { onGround: true, hasHorizontalCollision: undefined } };
|
||||||
|
manager._fixOutboundOnGround(params);
|
||||||
|
assert.strictEqual(params.flags.hasHorizontalCollision, false);
|
||||||
|
assert.strictEqual(manager.packetStats.horizontalCollisionFixed, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('关闭 reportHorizontalCollision 后不碰这个标志', () => {
|
||||||
|
const { manager } = setup({ config: { grim: { reportHorizontalCollision: false } } });
|
||||||
|
const bot = botStandingOnStone();
|
||||||
|
bot.entity.isCollidedHorizontally = true;
|
||||||
|
manager.bot = bot;
|
||||||
|
const params = { flags: { onGround: true, hasHorizontalCollision: undefined } };
|
||||||
|
manager._fixOutboundOnGround(params);
|
||||||
|
assert.strictEqual(params.flags.hasHorizontalCollision, undefined);
|
||||||
|
assert.strictEqual(manager.packetStats.horizontalCollisionFixed, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('物理引擎没给出碰撞状态时不瞎猜', () => {
|
||||||
|
const { manager } = setup();
|
||||||
|
const bot = botStandingOnStone();
|
||||||
|
delete bot.entity.isCollidedHorizontally;
|
||||||
|
manager.bot = bot;
|
||||||
|
const params = { flags: { onGround: true, hasHorizontalCollision: undefined } };
|
||||||
|
manager._fixOutboundOnGround(params);
|
||||||
|
assert.strictEqual(params.flags.hasHorizontalCollision, undefined);
|
||||||
|
assert.strictEqual(manager.packetStats.horizontalCollisionFixed, 0);
|
||||||
|
});
|
||||||
|
|
||||||
test('位置包统计暴露在 snapshot 中,便于排查走不动的问题', () => {
|
test('位置包统计暴露在 snapshot 中,便于排查走不动的问题', () => {
|
||||||
const { manager } = setup();
|
const { manager } = setup();
|
||||||
const snap = manager.snapshot();
|
const snap = manager.snapshot();
|
||||||
@@ -486,6 +538,149 @@ test('位置包统计暴露在 snapshot 中,便于排查走不动的问题', (
|
|||||||
assert.strictEqual(snap.world, null, '未进入世界时没有世界诊断信息');
|
assert.strictEqual(snap.world, null, '未进入世界时没有世界诊断信息');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ==================== 地面状态修正(TODO 问题 1 · 假设 2 的方案 A / B)====================
|
||||||
|
|
||||||
|
/** 造一个支持事件与包记录(write)的假协议客户端,模拟 mineflayer 的 _client */
|
||||||
|
function fakeClient() {
|
||||||
|
const client = new (require('events').EventEmitter)();
|
||||||
|
client.written = [];
|
||||||
|
client.write = (name, params) => client.written.push({ name, params });
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模拟 mineflayer 的 position 处理器:清零速度、应用服务端坐标、把 onGround 置 false。
|
||||||
|
* 它注册得比修正层更早,因此方案 B 的快照必须靠 prependListener 抢在它前面。
|
||||||
|
* @param {object} bot
|
||||||
|
* @param {object} client
|
||||||
|
*/
|
||||||
|
function installMineflayerPositionHandler(bot, client) {
|
||||||
|
client.on('position', (packet) => {
|
||||||
|
bot.entity.velocity.set(0, 0, 0);
|
||||||
|
bot.entity.position.set(packet.x, packet.y, packet.z);
|
||||||
|
bot.entity.onGround = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('方案 A:物理 tick 里把物理引擎的 onGround 与世界数据对齐', () => {
|
||||||
|
const { manager } = setup();
|
||||||
|
const bot = botStandingOnStone();
|
||||||
|
bot._client = fakeClient();
|
||||||
|
manager.bot = bot;
|
||||||
|
manager._installGroundStateFix(bot);
|
||||||
|
|
||||||
|
bot.entity.onGround = false; // 模拟刚被服务端位置包置为 false
|
||||||
|
bot.tick();
|
||||||
|
assert.strictEqual(bot.entity.onGround, true, '站在石头上时应把物理引擎的 onGround 同步为 true');
|
||||||
|
assert.strictEqual(manager.packetStats.physicsGroundSync, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('方案 A:脚下是空气时不干预(真正的下落不能被改判为站立)', () => {
|
||||||
|
const { manager } = setup();
|
||||||
|
const bot = new FakeBot({ x: 0.5, y: 72, z: 0.5 });
|
||||||
|
bot._client = fakeClient();
|
||||||
|
manager.bot = bot;
|
||||||
|
manager._installGroundStateFix(bot);
|
||||||
|
|
||||||
|
bot.entity.onGround = false;
|
||||||
|
bot.tick();
|
||||||
|
assert.strictEqual(bot.entity.onGround, false);
|
||||||
|
assert.strictEqual(manager.packetStats.physicsGroundSync, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('方案 A:关闭开关后完全不干预', () => {
|
||||||
|
const { manager } = setup({ config: { grim: { syncGroundToPhysics: false } } });
|
||||||
|
const bot = botStandingOnStone();
|
||||||
|
bot._client = fakeClient();
|
||||||
|
manager.bot = bot;
|
||||||
|
manager._installGroundStateFix(bot);
|
||||||
|
bot.entity.onGround = false;
|
||||||
|
bot.tick();
|
||||||
|
assert.strictEqual(bot.entity.onGround, false);
|
||||||
|
assert.strictEqual(manager.packetStats.physicsGroundSync, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('方案 B:例行同步位置包只同步坐标,不采纳它的 onGround=false', () => {
|
||||||
|
const { manager } = setup();
|
||||||
|
const client = fakeClient();
|
||||||
|
const bot = botStandingOnStone();
|
||||||
|
bot._client = client;
|
||||||
|
manager.bot = bot;
|
||||||
|
installMineflayerPositionHandler(bot, client);
|
||||||
|
manager._installGroundStateFix(bot);
|
||||||
|
|
||||||
|
bot.entity.onGround = true; // 本地物理引擎判定站在地上
|
||||||
|
client.emit('position', { x: 0.5, y: 72, z: 0.5 }); // 服务端位置与本地一致(例行同步)
|
||||||
|
assert.strictEqual(bot.entity.onGround, true, '位置一致时不应采纳服务端的 onGround=false');
|
||||||
|
assert.strictEqual(manager.packetStats.setbackOnGroundKept, 1);
|
||||||
|
assert.strictEqual(manager.packetStats.lastSetbackDelta.distance, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('方案 B:位置差超过阈值时按真正的传送处理,不做干预', () => {
|
||||||
|
const { manager } = setup();
|
||||||
|
const client = fakeClient();
|
||||||
|
const bot = botStandingOnStone();
|
||||||
|
bot._client = client;
|
||||||
|
manager.bot = bot;
|
||||||
|
installMineflayerPositionHandler(bot, client);
|
||||||
|
manager._installGroundStateFix(bot);
|
||||||
|
|
||||||
|
bot.entity.onGround = true;
|
||||||
|
client.emit('position', { x: 30.5, y: 72, z: 0.5 }); // 服务端把机器人传送到 30 格外
|
||||||
|
assert.strictEqual(bot.entity.onGround, false, '真正的传送应完全按服务端状态来');
|
||||||
|
assert.strictEqual(manager.packetStats.setbackOnGroundKept, 0);
|
||||||
|
assert.ok(manager.packetStats.lastSetbackDelta.distance > 29, '应记录本次位置差');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('方案 B:本地本来就在空中时保持原样(只拒绝「由站变空」的误判)', () => {
|
||||||
|
const { manager } = setup();
|
||||||
|
const client = fakeClient();
|
||||||
|
const bot = botStandingOnStone();
|
||||||
|
bot._client = client;
|
||||||
|
manager.bot = bot;
|
||||||
|
installMineflayerPositionHandler(bot, client);
|
||||||
|
manager._installGroundStateFix(bot);
|
||||||
|
|
||||||
|
bot.entity.onGround = false;
|
||||||
|
client.emit('position', { x: 0.5, y: 72, z: 0.5 });
|
||||||
|
assert.strictEqual(bot.entity.onGround, false);
|
||||||
|
assert.strictEqual(manager.packetStats.setbackOnGroundKept, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('方案 B:关闭开关后不再接手服务端位置包', () => {
|
||||||
|
const { manager } = setup({ config: { grim: { trustSetbackOnGround: false } } });
|
||||||
|
const client = fakeClient();
|
||||||
|
const bot = botStandingOnStone();
|
||||||
|
bot._client = client;
|
||||||
|
manager.bot = bot;
|
||||||
|
installMineflayerPositionHandler(bot, client);
|
||||||
|
manager._installGroundStateFix(bot);
|
||||||
|
|
||||||
|
bot.entity.onGround = true;
|
||||||
|
client.emit('position', { x: 0.5, y: 72, z: 0.5 });
|
||||||
|
assert.strictEqual(bot.entity.onGround, false);
|
||||||
|
assert.strictEqual(manager.packetStats.setbackOnGroundKept, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('出站包类型分布记录在 packetStats 中(假设 4 的对照依据)', () => {
|
||||||
|
const { manager } = setup();
|
||||||
|
const client = fakeClient();
|
||||||
|
const bot = new FakeBot({ client });
|
||||||
|
manager.bot = bot;
|
||||||
|
manager._wirePacketStats(bot);
|
||||||
|
|
||||||
|
client.write('position', { flags: { onGround: true } });
|
||||||
|
client.write('position_look', { flags: { onGround: true }, x: 0, y: 0, z: 0 });
|
||||||
|
client.write('player_input', { inputs: {} });
|
||||||
|
|
||||||
|
assert.deepStrictEqual(manager.packetStats.outboundTypes, {
|
||||||
|
position: 1,
|
||||||
|
position_look: 1,
|
||||||
|
player_input: 1
|
||||||
|
});
|
||||||
|
assert.strictEqual(manager.packetStats.outboundPosition, 2);
|
||||||
|
});
|
||||||
|
|
||||||
// ==================== 手动移动测试 ====================
|
// ==================== 手动移动测试 ====================
|
||||||
|
|
||||||
test('walkTest 走控制键而不是寻路器,能测出真实位移', async () => {
|
test('walkTest 走控制键而不是寻路器,能测出真实位移', async () => {
|
||||||
@@ -529,6 +724,343 @@ test('walkTest 拒绝非法方向与未连接状态', async () => {
|
|||||||
assert.ok(offline.message.includes('未连接'));
|
assert.ok(offline.message.includes('未连接'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ==================== 踢出原因解析 ====================
|
||||||
|
|
||||||
|
test('extractKickReason 把组件对象提取成纯文本', () => {
|
||||||
|
// 服务端踢人时发的是组件对象,直接 JSON.stringify 会得到没法看的一串
|
||||||
|
const component = { color: 'red', text: '您已经连接到此代理服务器了!' };
|
||||||
|
assert.strictEqual(extractKickReason(component), '您已经连接到此代理服务器了!');
|
||||||
|
assert.strictEqual(extractKickReason('普通字符串'), '普通字符串');
|
||||||
|
assert.strictEqual(
|
||||||
|
extractKickReason({ text: 'You were kicked: ', extra: [{ text: '验证超时' }] }),
|
||||||
|
'You were kicked: 验证超时'
|
||||||
|
);
|
||||||
|
assert.strictEqual(extractKickReason(null), '未知原因');
|
||||||
|
assert.strictEqual(extractKickReason(undefined), '未知原因');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('「账号已在别处登录」被识别并给出可操作提示', () => {
|
||||||
|
const { manager, logger } = setup();
|
||||||
|
const bot = new FakeBot();
|
||||||
|
manager.bot = bot;
|
||||||
|
manager._wireEvents(bot);
|
||||||
|
|
||||||
|
bot.emit('kicked', { color: 'red', text: '您已经连接到此代理服务器了!' });
|
||||||
|
|
||||||
|
assert.strictEqual(manager.alreadyConnected, true, '应标记为「已在别处登录」');
|
||||||
|
const lines = logger.list();
|
||||||
|
assert.ok(lines.some((e) => e.level === 'error' && e.message.includes('您已经连接到此代理服务器了')),
|
||||||
|
'应把组件里的纯文本记进日志');
|
||||||
|
assert.ok(lines.some((e) => e.message.includes('该账号已在别处登录') && e.message.includes('客户端')),
|
||||||
|
'应提示用户去关掉另一个客户端');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('其它踢出原因不触发「已在别处登录」提示', () => {
|
||||||
|
const { manager, logger } = setup();
|
||||||
|
const bot = new FakeBot();
|
||||||
|
manager.bot = bot;
|
||||||
|
manager._wireEvents(bot);
|
||||||
|
|
||||||
|
bot.emit('kicked', '服务器重启中');
|
||||||
|
assert.strictEqual(manager.alreadyConnected, false);
|
||||||
|
assert.ok(logger.list().some((e) => e.message.includes('服务器重启中')));
|
||||||
|
assert.ok(!logger.list().some((e) => e.message.includes('该账号已在别处登录')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('账号已在别处登录时重连退避到最大间隔,不频繁硬撞服务器', () => {
|
||||||
|
const { manager } = setup();
|
||||||
|
manager.config.server.reconnectDelayMs = 10000;
|
||||||
|
manager.config.server.reconnectMaxDelayMs = 120000;
|
||||||
|
manager.alreadyConnected = true;
|
||||||
|
manager._scheduleReconnect();
|
||||||
|
assert.ok(manager._reconnectTimer, '应安排重连');
|
||||||
|
// 正常退避第 1 次是 10 秒,被踢时应直接跳到上限 120 秒
|
||||||
|
const delay = manager._reconnectTimer._idleTimeout;
|
||||||
|
assert.strictEqual(delay, 120000, `实际 ${delay}`);
|
||||||
|
manager._manualDisconnect = true;
|
||||||
|
clearTimeout(manager._reconnectTimer);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('重新登录成功后清掉「已在别处登录」标记', () => {
|
||||||
|
const { manager } = setup();
|
||||||
|
manager.alreadyConnected = true;
|
||||||
|
// 模拟 login 事件的处理
|
||||||
|
manager.alreadyConnected = false;
|
||||||
|
assert.strictEqual(manager.alreadyConnected, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('识别「请直接输入验证码进行验证」(绑定过的账号每次登录仍可能要验证码)', () => {
|
||||||
|
const { manager, logger } = setup();
|
||||||
|
manager.loggedIn = false;
|
||||||
|
manager._handleAuthPrompt('欢迎回来!您绑定的简幻通UID是: 102017');
|
||||||
|
manager._handleAuthPrompt('请直接输入验证码进行验证');
|
||||||
|
|
||||||
|
assert.strictEqual(manager._needAuthWarned, true, '必须识别这句话,否则用户只看到机器人卡在大厅');
|
||||||
|
assert.ok(logger.list().some((e) => e.level === 'error' && e.message.includes('二次验证')));
|
||||||
|
assert.ok(manager.snapshot().needsVerification === true, 'snapshot 应暴露给控制台弹提示');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('识别「请使用微信扫描手中的二维码进行身份验证」', () => {
|
||||||
|
const { manager } = setup();
|
||||||
|
manager._handleAuthPrompt('请使用微信扫描手中的二维码进行身份验证');
|
||||||
|
assert.strictEqual(manager._needAuthWarned, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== 原点(00)自救 ====================
|
||||||
|
|
||||||
|
/** 把机器人放到世界原点附近(脚下是基岩 —— 实测两种情况都是基岩) */
|
||||||
|
function placeAtOrigin(manager, bot, y) {
|
||||||
|
bot.entity.position.x = -1.5;
|
||||||
|
bot.entity.position.y = y === undefined ? -2 : y;
|
||||||
|
bot.entity.position.z = -4.5;
|
||||||
|
bot.blockAt = () => ({ name: 'bedrock', boundingBox: 'block' });
|
||||||
|
manager.bot = bot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 反复推进 mock 定时器。
|
||||||
|
*
|
||||||
|
* Node 的 mock timers 不会在同一次 tick() 里执行「回调中新注册」的定时器,
|
||||||
|
* 而原点自救是链式的(按键 → 松开 → 下一次按键 → … → 判定 → 下一轮),
|
||||||
|
* 因此必须分多次 tick 才能把整条链走完。
|
||||||
|
*
|
||||||
|
* @param {object} t 测试上下文(t.mock.timers)
|
||||||
|
* @param {number} totalMs 需要推进的总时长
|
||||||
|
*/
|
||||||
|
function advanceTimers(t, totalMs) {
|
||||||
|
// 步长要远小于最短的定时器(150ms 的按键间隔),否则每次 tick 的量化误差会累积
|
||||||
|
const step = 50;
|
||||||
|
for (let elapsed = 0; elapsed < totalMs; elapsed += step) {
|
||||||
|
t.mock.timers.tick(step);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('原点附近且服务器没要求验证码 → 按「死亡卡住」处理并尝试左右移动自救', () => {
|
||||||
|
const { manager, logger } = setup();
|
||||||
|
const bot = new FakeBot();
|
||||||
|
placeAtOrigin(manager, bot);
|
||||||
|
manager._needAuthWarned = false;
|
||||||
|
|
||||||
|
manager._handleZeroPosition();
|
||||||
|
|
||||||
|
assert.strictEqual(manager.needsVerificationHint, false, '不应误判成登录服');
|
||||||
|
assert.strictEqual(manager._escaping, true, '应开始左右移动自救');
|
||||||
|
const lines = logger.list().map((e) => e.message).join('\n');
|
||||||
|
assert.ok(lines.includes('疑似死亡后卡住'), '应说明是死亡卡住:' + lines);
|
||||||
|
assert.strictEqual(manager.needsVerificationHint, false, '不应把标记置成登录服');
|
||||||
|
assert.ok(lines.includes('左右移动'), '应说明自救手段');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('原点附近且服务器要求验证码 → 判定为登录服,提示人工发验证码(不做自救)', () => {
|
||||||
|
const { manager, logger } = setup();
|
||||||
|
const bot = new FakeBot();
|
||||||
|
placeAtOrigin(manager, bot);
|
||||||
|
manager._needAuthWarned = true;
|
||||||
|
|
||||||
|
manager._handleZeroPosition();
|
||||||
|
|
||||||
|
assert.strictEqual(manager.needsVerificationHint, true);
|
||||||
|
assert.notStrictEqual(manager._escaping, true, '登录服里左右移动没有意义');
|
||||||
|
const lines = logger.list().map((e) => e.message).join('\n');
|
||||||
|
assert.ok(lines.includes('被切到了登录服'), '应明确指出是登录服:' + lines);
|
||||||
|
assert.ok(lines.includes('验证码'), '应引导用户发验证码');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('脚下是基岩不能作为区分依据(两种情况都是基岩)', () => {
|
||||||
|
const { manager } = setup();
|
||||||
|
const bot = new FakeBot();
|
||||||
|
placeAtOrigin(manager, bot);
|
||||||
|
// 同一个基岩地形,仅凭「服务器要不要验证码」得出不同结论
|
||||||
|
manager._needAuthWarned = false;
|
||||||
|
manager._handleZeroPosition();
|
||||||
|
const asDeath = manager.needsVerificationHint;
|
||||||
|
|
||||||
|
const second = setup();
|
||||||
|
const bot2 = new FakeBot();
|
||||||
|
placeAtOrigin(second.manager, bot2);
|
||||||
|
second.manager._needAuthWarned = true;
|
||||||
|
second.manager._handleZeroPosition();
|
||||||
|
|
||||||
|
assert.strictEqual(asDeath, false);
|
||||||
|
assert.strictEqual(second.manager.needsVerificationHint, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('验证码提示到达时若人在原点附近,点明「被反复传送」的因果关系', () => {
|
||||||
|
const { manager, logger } = setup();
|
||||||
|
const bot = new FakeBot();
|
||||||
|
placeAtOrigin(manager, bot);
|
||||||
|
manager._needAuthWarned = false;
|
||||||
|
|
||||||
|
manager._handleAuthPrompt('请直接输入验证码进行验证');
|
||||||
|
|
||||||
|
assert.strictEqual(manager.needsVerificationHint, true);
|
||||||
|
const lines = logger.list().map((e) => e.message).join('\n');
|
||||||
|
assert.ok(lines.includes('已被切到登录服'), '应把传送刷屏与登录服关联起来:' + lines);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('不在原点附近时不触发任何原点逻辑', () => {
|
||||||
|
const { manager } = setup();
|
||||||
|
const bot = new FakeBot();
|
||||||
|
bot.entity.position.x = 6760;
|
||||||
|
bot.entity.position.y = 148;
|
||||||
|
bot.entity.position.z = 11934;
|
||||||
|
manager.bot = bot;
|
||||||
|
|
||||||
|
manager._handleZeroPosition();
|
||||||
|
|
||||||
|
assert.notStrictEqual(manager._escaping, true);
|
||||||
|
assert.strictEqual(manager.needsVerificationHint, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('原点自救会判定结果并统计成功率(第一轮后位置恢复 → 成功)', (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
|
const { manager, logger } = setup();
|
||||||
|
const bot = new FakeBot();
|
||||||
|
placeAtOrigin(manager, bot);
|
||||||
|
manager._needAuthWarned = false;
|
||||||
|
// 模拟服务器收到移动输入后把玩家传送回正常位置
|
||||||
|
bot.on('controlState', (control, state) => {
|
||||||
|
if (state === true) {
|
||||||
|
bot.entity.position.x = 6234.5;
|
||||||
|
bot.entity.position.y = 72;
|
||||||
|
bot.entity.position.z = 11890.5;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
manager._escapeFromOrigin();
|
||||||
|
advanceTimers(t, 5000);
|
||||||
|
|
||||||
|
assert.strictEqual(manager.originRescue.attempts, 1);
|
||||||
|
assert.strictEqual(manager.originRescue.successes, 1);
|
||||||
|
const last = manager.originRescue.lastResult;
|
||||||
|
assert.strictEqual(last.success, true);
|
||||||
|
assert.strictEqual(last.rounds, 1, '第一轮就恢复了,不该继续第二轮');
|
||||||
|
assert.ok(last.from.z < 0, '应记录自救前坐标');
|
||||||
|
assert.ok(last.to.z > 1000, '应记录自救后坐标');
|
||||||
|
assert.strictEqual(manager._escaping, false);
|
||||||
|
assert.strictEqual(bot.getControlState('right'), false, '自救结束后按键必须松开');
|
||||||
|
assert.ok(logger.list().some((e) => e.message.includes('原点自救成功')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('第一轮无效时换「前后 + 跳」重试,两轮都失败则统计失败并升级提示', (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
|
const { manager, logger } = setup();
|
||||||
|
const bot = new FakeBot();
|
||||||
|
placeAtOrigin(manager, bot);
|
||||||
|
manager._needAuthWarned = false;
|
||||||
|
|
||||||
|
manager._escapeFromOrigin();
|
||||||
|
advanceTimers(t, 10000);
|
||||||
|
|
||||||
|
assert.strictEqual(manager.originRescue.attempts, 1);
|
||||||
|
assert.strictEqual(manager.originRescue.successes, 0);
|
||||||
|
const last = manager.originRescue.lastResult;
|
||||||
|
assert.strictEqual(last.success, false);
|
||||||
|
assert.strictEqual(last.rounds, 2, '两轮都要试过才算失败');
|
||||||
|
assert.strictEqual(manager._escaping, false);
|
||||||
|
const lines = logger.list().map((e) => e.message).join('\n');
|
||||||
|
assert.ok(lines.includes('前后移动 + 跳'), '应尝试第二组方向:' + lines);
|
||||||
|
assert.ok(lines.includes('原点自救失败'), '失败要升级成明确提示:' + lines);
|
||||||
|
assert.ok(lines.includes('验证码'), '失败提示里要点明「登录服需人工验证」这条可能');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('自救途中服务器要求验证码 → 提前结束(登录服里移动没有意义)', (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
|
const { manager, logger } = setup();
|
||||||
|
const bot = new FakeBot();
|
||||||
|
placeAtOrigin(manager, bot);
|
||||||
|
manager._needAuthWarned = false;
|
||||||
|
|
||||||
|
manager._escapeFromOrigin();
|
||||||
|
manager._needAuthWarned = true; // 第一轮还没结束时服务器开始要验证码
|
||||||
|
advanceTimers(t, 10000);
|
||||||
|
|
||||||
|
assert.strictEqual(manager._escaping, false);
|
||||||
|
const last = manager.originRescue.lastResult;
|
||||||
|
assert.strictEqual(last.success, false);
|
||||||
|
assert.strictEqual(last.rounds, 1, '不该再继续第二轮');
|
||||||
|
const lines = logger.list().map((e) => e.message).join('\n');
|
||||||
|
assert.ok(lines.includes('原点自救提前结束'), '应解释为什么不再自救:' + lines);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('snapshot 暴露原点自救统计(控制台与 /api/status 可读成功率)', () => {
|
||||||
|
const { manager } = setup();
|
||||||
|
assert.deepStrictEqual(manager.snapshot().originRescue, {
|
||||||
|
attempts: 0,
|
||||||
|
successes: 0,
|
||||||
|
lastResult: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== 需要人工介入时的通知提醒(4.5)====================
|
||||||
|
|
||||||
|
/** 造一个记录调用的假通知器 */
|
||||||
|
function fakeNotifier(enabled) {
|
||||||
|
const calls = [];
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
isEnabled: () => enabled !== false,
|
||||||
|
notify: (text, options) => {
|
||||||
|
calls.push({ text, options });
|
||||||
|
return Promise.resolve(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('服务器要求验证码时通过通知渠道提醒(验证码只有 120 秒时限)', () => {
|
||||||
|
const { manager } = setup();
|
||||||
|
const notifier = fakeNotifier();
|
||||||
|
manager.notifier = notifier;
|
||||||
|
manager._handleAuthPrompt('请直接输入验证码进行验证');
|
||||||
|
|
||||||
|
assert.strictEqual(notifier.calls.length, 1, '应发出一条提醒');
|
||||||
|
assert.ok(notifier.calls[0].text.includes('验证码'));
|
||||||
|
assert.ok(notifier.calls[0].text.includes('120 秒'));
|
||||||
|
assert.ok(notifier.calls[0].options.key.startsWith('verification'), '应带去重 key');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('关闭 notify.onVerification 后不再提醒验证码', () => {
|
||||||
|
const { manager } = setup();
|
||||||
|
const notifier = fakeNotifier();
|
||||||
|
manager.notifier = notifier;
|
||||||
|
manager.config.notify.onVerification = false;
|
||||||
|
manager._handleAuthPrompt('请直接输入验证码进行验证');
|
||||||
|
assert.strictEqual(notifier.calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('通知器未启用时不发提醒(也不会误触网络请求)', () => {
|
||||||
|
const { manager } = setup();
|
||||||
|
const notifier = fakeNotifier(false);
|
||||||
|
manager.notifier = notifier;
|
||||||
|
manager._handleAuthPrompt('请输入验证码进行验证');
|
||||||
|
assert.strictEqual(notifier.calls.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('原点自救失败时也提醒(很可能被切到了登录服)', (t) => {
|
||||||
|
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||||
|
const { manager } = setup();
|
||||||
|
const bot = new FakeBot();
|
||||||
|
placeAtOrigin(manager, bot);
|
||||||
|
manager._needAuthWarned = false;
|
||||||
|
const notifier = fakeNotifier();
|
||||||
|
manager.notifier = notifier;
|
||||||
|
|
||||||
|
manager._escapeFromOrigin();
|
||||||
|
advanceTimers(t, 10000);
|
||||||
|
|
||||||
|
assert.strictEqual(manager.originRescue.lastResult.success, false);
|
||||||
|
assert.strictEqual(notifier.calls.length, 1, '自救失败应发出提醒');
|
||||||
|
assert.ok(notifier.calls[0].text.includes('自救失败'));
|
||||||
|
assert.ok(notifier.calls[0].text.includes('验证码'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('snapshot 暴露 alreadyConnected 供控制台提示用户', () => {
|
||||||
|
const { manager } = setup();
|
||||||
|
assert.strictEqual(manager.snapshot().alreadyConnected, false);
|
||||||
|
manager.alreadyConnected = true;
|
||||||
|
assert.strictEqual(manager.snapshot().alreadyConnected, true);
|
||||||
|
});
|
||||||
|
|
||||||
// ==================== 插件加载 ====================
|
// ==================== 插件加载 ====================
|
||||||
test('connect 时显式加载 mineflayer-pathfinder 插件', () => {
|
test('connect 时显式加载 mineflayer-pathfinder 插件', () => {
|
||||||
const mineflayer = require('mineflayer');
|
const mineflayer = require('mineflayer');
|
||||||
|
|||||||
@@ -257,6 +257,42 @@ test('startTask 带配置覆盖时写入该机器人的专属配置', async () =
|
|||||||
assert.strictEqual(config.targetCount, 5);
|
assert.strictEqual(config.targetCount, 5);
|
||||||
// 另一个机器人不受影响
|
// 另一个机器人不受影响
|
||||||
assert.strictEqual(pool.getConfigFor('BotA').strategy, 1);
|
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 下发到全部机器人并保留各自的覆盖', () => {
|
test('applyGlobalConfig 下发到全部机器人并保留各自的覆盖', () => {
|
||||||
@@ -290,6 +326,49 @@ test('tick 驱动全部机器人且单个异常不影响其它', () => {
|
|||||||
assert.deepStrictEqual(ticks, ['B']);
|
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)的机器人不参与连接', () => {
|
test('未启用(enabled=false)的机器人不参与连接', () => {
|
||||||
const { pool } = setup({ settings: { BotB: { enabled: false } } });
|
const { pool } = setup({ settings: { BotB: { enabled: false } } });
|
||||||
const connected = [];
|
const connected = [];
|
||||||
|
|||||||
+329
-6
@@ -8,6 +8,7 @@ const path = require('path');
|
|||||||
|
|
||||||
const { ExpansionController, State } = require('../src/controller/expansionController');
|
const { ExpansionController, State } = require('../src/controller/expansionController');
|
||||||
const { TerritoryModel } = require('../src/core/territoryModel');
|
const { TerritoryModel } = require('../src/core/territoryModel');
|
||||||
|
const { TargetLeaseTable } = require('../src/core/targetLease');
|
||||||
const { defaultConfig } = require('../src/config/expansionConfig');
|
const { defaultConfig } = require('../src/config/expansionConfig');
|
||||||
const { Logger } = require('../src/util/logger');
|
const { Logger } = require('../src/util/logger');
|
||||||
const { FakeBot } = require('./helpers/fakeBot');
|
const { FakeBot } = require('./helpers/fakeBot');
|
||||||
@@ -86,14 +87,18 @@ class FakeBotManager {
|
|||||||
this.connection = { username: 'TestBot' };
|
this.connection = { username: 'TestBot' };
|
||||||
this.commands = [];
|
this.commands = [];
|
||||||
this._model = model;
|
this._model = model;
|
||||||
|
/** 设为 false 模拟「世界已退出」(掉线 / 被踢 / 被切到登录服) */
|
||||||
|
this.spawned = true;
|
||||||
|
/** 设为 false 模拟「已进入世界但还没完成登录」 */
|
||||||
|
this.ready = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
isReady() {
|
isReady() {
|
||||||
return true;
|
return this.ready;
|
||||||
}
|
}
|
||||||
|
|
||||||
isSpawned() {
|
isSpawned() {
|
||||||
return true;
|
return this.spawned;
|
||||||
}
|
}
|
||||||
|
|
||||||
sendCommand(command) {
|
sendCommand(command) {
|
||||||
@@ -126,7 +131,14 @@ function setup(options) {
|
|||||||
const botManager = new FakeBotManager(bot, model);
|
const botManager = new FakeBotManager(bot, model);
|
||||||
const dataService = new FakeDataService(model);
|
const dataService = new FakeDataService(model);
|
||||||
const logger = new Logger({ echo: false });
|
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();
|
const config = defaultConfig();
|
||||||
config.enforceClaimTime = false;
|
config.enforceClaimTime = false;
|
||||||
@@ -139,6 +151,68 @@ function setup(options) {
|
|||||||
return { controller, botManager, dataService, model, bot, logger, config };
|
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 () => {
|
test('start:自动识别所在邦国并下发开垦模式指令', async () => {
|
||||||
const { controller, botManager, config, model } = setup({ selfName: '' });
|
const { controller, botManager, config, model } = setup({ selfName: '' });
|
||||||
const ok = await controller.start(config);
|
const ok = await controller.start(config);
|
||||||
@@ -273,9 +347,12 @@ test('寻路超时会重新寻路,重试耗尽后走两步', async () => {
|
|||||||
assert.strictEqual(controller.sideStepCount, 1);
|
assert.strictEqual(controller.sideStepCount, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('连续超时累计达到阈值时回城', async () => {
|
test('连续超时累计达到阈值时回城(需显式开启 returnOnTimeout)', async () => {
|
||||||
const { controller, botManager, config } = setup({
|
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);
|
await controller.start(config);
|
||||||
controller.stageStartedMillis = Date.now() - 10000;
|
controller.stageStartedMillis = Date.now() - 10000;
|
||||||
@@ -284,9 +361,54 @@ test('连续超时累计达到阈值时回城', async () => {
|
|||||||
assert.ok(botManager.commands.includes('u return'), '应发送回城指令');
|
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 () => {
|
test('回城等待结束后重新规划(非耐久保护触发)', async () => {
|
||||||
const { controller, config } = setup({
|
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);
|
await controller.start(config);
|
||||||
controller.stageStartedMillis = Date.now() - 10000;
|
controller.stageStartedMillis = Date.now() - 10000;
|
||||||
@@ -314,6 +436,171 @@ test('耐久保护触发回城并在传送后停止任务', async () => {
|
|||||||
assert.strictEqual(controller.state, State.STOPPED, '耐久保护回城后应停止任务');
|
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 () => {
|
test('达成目标数量后进入 COMPLETE', async () => {
|
||||||
const { controller, config, bot } = setup({ config: { targetCount: 1 } });
|
const { controller, config, bot } = setup({ config: { targetCount: 1 } });
|
||||||
await controller.start(config);
|
await controller.start(config);
|
||||||
@@ -415,6 +702,42 @@ test('未启动任务时 tick 不做任何事', () => {
|
|||||||
assert.strictEqual(controller.state, State.IDLE);
|
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 () => {
|
test('规划队列为空时直接进入 COMPLETE', async () => {
|
||||||
// 己方领土被完全包围且无空位:构造一个只有 1 个区块、四周全被他国占领的局面
|
// 己方领土被完全包围且无空位:构造一个只有 1 个区块、四周全被他国占领的局面
|
||||||
const { controller, config } = setup({
|
const { controller, config } = setup({
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const { FakeBot } = require('./helpers/fakeBot');
|
|||||||
const {
|
const {
|
||||||
GrimCompat,
|
GrimCompat,
|
||||||
shortestAngleDelta,
|
shortestAngleDelta,
|
||||||
|
wrapAngle,
|
||||||
eyeDistanceToEntity,
|
eyeDistanceToEntity,
|
||||||
VANILLA_PHYSICS_BASELINE
|
VANILLA_PHYSICS_BASELINE
|
||||||
} = require('../src/bot/grimCompat');
|
} = require('../src/bot/grimCompat');
|
||||||
@@ -111,6 +112,31 @@ test('shortestAngleDelta 走最短路径并处理环绕', () => { assert.ok(Mat
|
|||||||
assert.ok(Math.abs(shortestAngleDelta(0, Math.PI * 2)) < 1e-9);
|
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 计算眼睛到碰撞箱的最近距离', () => {
|
test('eyeDistanceToEntity 计算眼睛到碰撞箱的最近距离', () => {
|
||||||
const bot = new FakeBot({ x: 0, y: 64, z: 0 });
|
const bot = new FakeBot({ x: 0, y: 64, z: 0 });
|
||||||
// 目标在正前方 2 格,碰撞箱中心高度与眼睛同高
|
// 目标在正前方 2 格,碰撞箱中心高度与眼睛同高
|
||||||
|
|||||||
@@ -50,6 +50,16 @@ class FakeBot extends EventEmitter {
|
|||||||
};
|
};
|
||||||
/** @type {Record<string, boolean>} */
|
/** @type {Record<string, boolean>} */
|
||||||
this.controls = {};
|
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.heldItem = opts.heldItem || null;
|
||||||
this.inventory = { slots: opts.slots || [] };
|
this.inventory = { slots: opts.slots || [] };
|
||||||
this.entities = {};
|
this.entities = {};
|
||||||
|
|||||||
@@ -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), '取消后的在途结果必须被忽略');
|
||||||
|
});
|
||||||
@@ -511,6 +511,34 @@ test('HTTP:大地图图幅可按机器人位置与半径计算瓦片范围', a
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 () => {
|
test('HTTP:未配置密码时免登录(本机开发模式)', async () => {
|
||||||
const { webConsole } = setup();
|
const { webConsole } = setup();
|
||||||
await webConsole.start();
|
await webConsole.start();
|
||||||
|
|||||||
@@ -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'), '完整地址不能出现在接口响应里');
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -35,6 +35,53 @@ test('AltitudeGoal 使用 GoalNearXZ 时保留其到达范围', () => {
|
|||||||
assert.strictEqual(goal.isEnd({ x: 100, y: 70, z: 201 }), true, '范围内算到达');
|
assert.strictEqual(goal.isEnd({ x: 100, y: 70, z: 201 }), true, '范围内算到达');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 格),
|
// mineflayer-pathfinder 只能规划已加载区块范围(实测线上约 8 区块 ≈ 130 格),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
+146
-1
@@ -3,7 +3,24 @@
|
|||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
const assert = require('node:assert');
|
const assert = require('node:assert');
|
||||||
|
|
||||||
const { ScheduledMessenger, normalizeTasks, clampInterval, MIN_INTERVAL_SECONDS, MAX_TASKS } = require('../src/bot/scheduledMessenger');
|
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 */
|
/** 造一个可控的假 BotManager */
|
||||||
function fakeManager(options) {
|
function fakeManager(options) {
|
||||||
@@ -141,3 +158,131 @@ test('start / stop 控制心跳定时器', () => {
|
|||||||
messenger.stop();
|
messenger.stop();
|
||||||
assert.strictEqual(messenger._timer, null);
|
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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 { flattenChunks, boundsOf, timingSafeEqual, WebConsole } = require('../src/web/server');
|
||||||
const { TerritoryModel } = require('../src/core/territoryModel');
|
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 { defaultConfig } = require('../src/config/expansionConfig');
|
||||||
const { Logger } = require('../src/util/logger');
|
const { Logger } = require('../src/util/logger');
|
||||||
const chunkPos = require('../src/core/chunkPos');
|
const chunkPos = require('../src/core/chunkPos');
|
||||||
@@ -33,7 +36,8 @@ test('timingSafeEqual 只接受等长且完全相同的字符串', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/** 搭建一个使用桩依赖的控制台实例 */
|
/** 搭建一个使用桩依赖的控制台实例 */
|
||||||
function setupConsole() {
|
function setupConsole(options) {
|
||||||
|
const opts = options || {};
|
||||||
const map = new Map([[
|
const map = new Map([[
|
||||||
'A',
|
'A',
|
||||||
{
|
{
|
||||||
@@ -48,9 +52,28 @@ function setupConsole() {
|
|||||||
model.addSessionClaim({ x: 1, z: 0 });
|
model.addSessionClaim({ x: 1, z: 0 });
|
||||||
|
|
||||||
const logger = new Logger({ echo: false });
|
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 = {
|
const botManager = {
|
||||||
snapshot: () => ({ state: 'online', username: 'TestBot' }),
|
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 = {
|
const controller = {
|
||||||
target: { x: 2, z: 0 },
|
target: { x: 2, z: 0 },
|
||||||
@@ -73,10 +96,12 @@ function setupConsole() {
|
|||||||
controller,
|
controller,
|
||||||
dataService,
|
dataService,
|
||||||
logger,
|
logger,
|
||||||
|
bigMap: opts.bigMap,
|
||||||
|
notifier: opts.notifier,
|
||||||
getConfig: () => config,
|
getConfig: () => config,
|
||||||
applyConfig: (next) => next
|
applyConfig: (next) => next
|
||||||
}, { host: '127.0.0.1', port: 0 });
|
}, { host: '127.0.0.1', port: 0 });
|
||||||
return { webConsole, model, logger, controller };
|
return { webConsole, model, logger, controller, captureClient, packetCapture, physicsBot, physicsRecorder };
|
||||||
}
|
}
|
||||||
|
|
||||||
test('mapPayload 返回己方 / 会话 / 飞地 / 目标与各邦国摘要', () => {
|
test('mapPayload 返回己方 / 会话 / 飞地 / 目标与各邦国摘要', () => {
|
||||||
@@ -116,6 +141,20 @@ test('statusPayload 汇总 bot / 任务 / 数据源状态', () => {
|
|||||||
assert.strictEqual(payload.data.stateCount, 1);
|
assert.strictEqual(payload.data.stateCount, 1);
|
||||||
assert.strictEqual(payload.claimTime.start, 11);
|
assert.strictEqual(payload.claimTime.start, 11);
|
||||||
assert.strictEqual(payload.claimTime.end, 22);
|
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 () => {
|
test('HTTP 接口:/api/status、/api/config、/api/map 可访问', async () => {
|
||||||
@@ -204,3 +243,79 @@ test('HTTP 接口:POST /api/command 缺参数时返回 400', async () => {
|
|||||||
await webConsole.stop();
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户