From 51664c55b6b0a94358c9914e0503a5e2bee12963 Mon Sep 17 00:00:00 2001 From: lyizai <3945107889@qq.com> Date: Mon, 28 Sep 2026 22:46:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=201.7.0=20=E8=90=BD=E5=9C=B0=20TODO=20?= =?UTF-8?q?=E5=85=A8=E9=83=A8=E5=BE=85=E5=8A=9E=EF=BC=88=E5=9C=B0=E9=9D=A2?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20/=20=E6=89=8B=E5=8A=A8=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E5=99=A8=20/=20=E8=87=AA=E6=95=91=20/=20=E5=9B=9E=E5=9F=8E=20/?= =?UTF-8?q?=20=E7=A7=9F=E7=BA=A6=20/=20=E9=80=9A=E7=9F=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 TODO.md 的优先级把待办全部落地为代码,详见 CHANGELOG 的 [1.7.0]。 - 「走不动」攻关:地面状态修正(syncGroundToPhysics / trustSetbackOnGround)、 出站包类型分布、备选执行器 manualMover(只借 A* 规划、自己按键执行) - 原点自救分两轮并统计成败;回城前评估「原地是否更差」并记录前后坐标 - 新增目标区块租约表(targetLease)避免多机器人撞同一块 - 定时消息支持 cron 与「期望回复」确认;新增人工介入 Webhook 通知(notifier) - 大地图后台预热与视野裁剪;控制台日志按帧批量写入、配置面板支持搜索 顺带修掉两个影响部署的细节: - 部署脚本(deploy.js / ssh-helper.js)改为也能从 .env 读 SSH 凭据 —— 在命令行传密码会留在 shell 历史与进程列表里,写进已被忽略的 .env 更安全; 显式环境变量仍然优先。.env.example 补上占位符并说明这些只在本机用。 - package.json / package-lock.json 版本对齐到 1.7.0(此前停留在 1.1.0, 与 CHANGELOG 的语义化版本声明脱节)。 测试:368 → 436 个用例,`npm test` 与 `npm run check` 全绿。 --- .env.example | 18 ++ CHANGELOG.md | 55 +++++ README.md | 109 +++++++-- TODO.md | 238 +++++++++--------- package-lock.json | 4 +- package.json | 2 +- scripts/deploy.js | 17 +- scripts/ssh-helper.js | 8 +- src/bot/botManager.js | 337 ++++++++++++++++++++++++-- src/bot/botPool.js | 19 +- src/bot/manualMover.js | 304 +++++++++++++++++++++++ src/bot/pathingService.js | 128 ++++++++-- src/bot/scheduledMessenger.js | 325 +++++++++++++++++++++++-- src/config/expansionConfig.js | 93 ++++++- src/controller/expansionController.js | 232 +++++++++++++++++- src/core/bigMapService.js | 115 ++++++++- src/core/notifier.js | 140 +++++++++++ src/core/targetLease.js | 160 ++++++++++++ src/index.js | 8 +- src/web/public/app.js | 211 +++++++++++++++- src/web/public/index.html | 34 ++- src/web/public/style.css | 11 + src/web/server.js | 36 +++ test/bigMapService.test.js | 62 +++++ test/botManager.test.js | 303 +++++++++++++++++++++++ test/expansionController.test.js | 169 ++++++++++++- test/manualMover.test.js | 290 ++++++++++++++++++++++ test/notifier.test.js | 109 +++++++++ test/scheduledMessenger.test.js | 147 ++++++++++- test/targetLease.test.js | 83 +++++++ test/webServer.test.js | 19 +- 31 files changed, 3535 insertions(+), 251 deletions(-) create mode 100644 src/bot/manualMover.js create mode 100644 src/core/notifier.js create mode 100644 src/core/targetLease.js create mode 100644 test/manualMover.test.js create mode 100644 test/notifier.test.js create mode 100644 test/targetLease.test.js diff --git a/.env.example b/.env.example index f12316a..30a834e 100644 --- a/.env.example +++ b/.env.example @@ -46,3 +46,21 @@ SIMP_API_KEY= # https://bgjq.simpfun.cn/api-v2/ # 配额很小(末影龙仅 1 次/分钟),程序只按需查询、不做轮询 SIMP_API_V2_KEY= + +# ==================== 人工介入提醒(可选) ==================== +# 等待登录验证码 / 原点自救失败时推送提醒(默认按飞书、Lark 自定义机器人的文本消息格式) +# 用法:控制台「服务器 → 通知提醒」勾选「启用通知」并填地址;地址留空时回退本环境变量 +# 注意:Webhook 地址等同于「任何人都能往群里发消息」的凭据,不要提交进仓库 +NOTIFY_WEBHOOK_URL= + +# ==================== 部署(可选,只在本地开发机用) ==================== +# 只有 `npm run deploy` / `node scripts/ssh-helper.js` 会读这几个变量; +# 程序本体(src/)不读它们,所以服务器上的 .env 不需要填。 +# 放在这里是为了不把 SSH 密码写进命令行(会留在 shell 历史与进程列表里)。 +SSH_HOST= +SSH_PORT= +SSH_USER=root +SSH_PASS= +# 部署目录与健康检查端口(留空则用脚本默认值 /opt/autostore-bot 与 78) +DEPLOY_APP= +DEPLOY_PORT= diff --git a/CHANGELOG.md b/CHANGELOG.md index 46d4b19..cf7b5e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,61 @@ 本项目遵循 [约定式提交](https://www.conventionalcommits.org/zh-hans/) 与 [语义化版本](https://semver.org/lang/zh-CN/)。 +## [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 围绕「机器人为什么走不动」做了一轮系统排查,并给破坏性操作加上护栏。 diff --git a/README.md b/README.md index 10ba8c6..ef837a4 100644 --- a/README.md +++ b/README.md @@ -36,11 +36,13 @@ npm start # 打开 http://127.0.0.1:3081 | 日志导出 | ✅ | 网页按钮导出到 `data/` | | SimpCore 开放 API 第四数据源 | ✅ | 低频轮询 + 手动全服拉取 | | Grim 反作弊适配 | ✅ 新增 | 见下方专章 | -| **多机器人同时挂机** | ✅ 新增 | 一个进程挂多个账号,各自独立连接 / 登录 / 扩地,配置可独立覆盖 | +| **多机器人同时挂机** | ✅ 新增 | 一个进程挂多个账号,各自独立连接 / 登录 / 扩地,配置可独立覆盖;**目标区块带租约**,不会撞在同一块上 | | **开拓日志** | ✅ 新增 | 逐条记录开拓玩家、邦国、区块、方块坐标、时间、判定来源,落盘可导出 CSV | | **游戏公屏完整日志 + 聊天框** | ✅ 新增 | 全部公屏 / 系统消息按机器人分文件留档,控制台可直接发公屏消息 | -| **大地图** | ✅ 新增 | 爬取服务器地图站点瓦片与领土标记,一张图上看全部机器人坐标 | +| **大地图** | ✅ 新增 | 爬取服务器地图站点瓦片与领土标记,一张图上看全部机器人坐标;高层级瓦片后台预热 | | **登录验证门禁** | ✅ 新增 | 游戏内登录 + 控制台登录双重校验,通过后才允许操作机器人与发送消息 | +| **定时消息(间隔 / cron)** | ✅ 新增 | 固定间隔或 cron 表达式定时发送,可选「期望回复」确认发送是否真的生效 | +| **人工介入提醒(Webhook)** | ✅ 新增 | 等待验证码 / 原点自救失败时推送到飞书等自定义机器人,不必一直盯控制台 | --- @@ -76,6 +78,11 @@ MC_PASSWORD=xxxxxx - **共享一份领土模型**:`markers.json` 是全服数据(约 160KB / 11.5 万区块), 没必要每个机器人各拉一遍,因此数据服务是单例、共享给全部控制器。 +- **目标区块带租约**(`src/core/targetLease.js`):领土模型是全局共享的,但「谁要去占哪一块」 + 原先只存在各自的队列里,彼此看不见 —— 多机器人很容易撞在同一批候选区块上。 + 现在抢到的机器人独占该区块(其余机器人跳过),持有者持续续租, + 掉线 / 卡死后租约到期自动失效,不会永久锁死区块; + 候选区块全被预定时会等 15 秒再规划,而不是提前判「没有可用区块」。 - **邦国归属按机器人解析**:`ExpansionController` 在 `start()` 时把识别到的邦国固化到 `controller.stateName` 并写回本次任务配置,规划器不再读全局 `selfStateName` (否则会被最后启动的机器人覆盖,导致 A 机器人按 B 的邦国规划)。 @@ -158,12 +165,17 @@ squaremap 前端用 `L.CRS.Simple` + `tileSize: 512`,瓦片路径为 | 层级 | 每瓦片方块数 | 全世界瓦片数 | 策略 | | --- | --- | --- | --- | | 0 | 4096 | 10×10 = 100 | 启动时自动预爬取(以机器人位置为中心,只爬当前图幅内) | -| 1 | 2048 | 20×20 = 400 | 按需拉取 + 永久缓存 | -| 2 | 1024 | 40×40 = 1600 | 按需拉取 + 永久缓存 | -| 3 | 512 | 80×80 = 6400 | 按需拉取 + 永久缓存 | +| 1 | 2048 | 20×20 = 400 | 按需拉取 + **后台预热** + 永久缓存 | +| 2 | 1024 | 40×40 = 1600 | 按需拉取 + **后台预热** + 永久缓存 | +| 3 | 512 | 80×80 = 6400 | 按需拉取 + **后台预热** + 永久缓存 | +- **后台预热**:机器人移动到新区域(默认超过 512 格)时,用低并发(批量爬取并发的一半) + 顺带把周边高层级瓦片拉下来,避免首次缩放时干等;距上次预热超过 10 分钟也会补一次。 + 已缓存的瓦片只会做一次 `statSync`,不会重复下载;正在批量爬取时预热自动让路 - 磁盘缓存:`data/mapcache///_.png`,命中即不再回源 - 瓦片由后端代理(`/api/bigmap/tile/:z/:x/:y`),避免浏览器直连地图站点的跨域问题 +- 领土多边形按**视野裁剪**绘制(预编译路径时顺带记包围盒,视野外的直接跳过), + 全世界视角 + 全部邦国也不会掉帧 - 领土以 `markers.json` 的**原始多边形**绘制(预编译成 `Path2D`,按 even-odd 填充保留国中孔洞), 叠加本次会话新占领区块、王城与**全部机器人坐标(含名字标签)** - 操作:滚轮缩放、拖拽平移、图幅切换、「爬取周边」/「回到机器人」一键定位 @@ -235,6 +247,11 @@ V2 的接口级配额很小(末影龙只有 **1 次/分钟**),因此 V2 若该服务器提供固定验证码,可在配置的「账号绑定 / 二次验证模板」(`server.authCommand`)里填写 - **失败信息可操作**:发送失败会区分「未连接」「登录被服务器拒绝」「未通过登录验证」三种情况, 而不是笼统报一句「发送失败」 +- **人工介入提醒(Webhook)**:验证码只有 120 秒时限,错过就得等下次重登。 + 在控制台「服务器 → 通知提醒」勾选「启用通知」并填 Webhook 地址(地址也可走环境变量 + `NOTIFY_WEBHOOK_URL`)后,服务器要求验证码时会推送一条提醒到飞书 / Lark 自定义机器人; + 原文里带上服务器提示与处理步骤。同一问题默认 60 秒内只推一次,避免重连循环里反复轰炸; + 接口里只暴露 Webhook 域名,不会把带凭证的完整地址打进响应 **控制台侧**(`src/web/server.js`) @@ -252,17 +269,25 @@ V2 的接口级配额很小(末影龙只有 **1 次/分钟**),因此 V2 ### 0. 一键更新(已装好之后日常用这个) ```powershell -# Windows PowerShell —— 凭据走环境变量,脚本里不写死密码 -$env:SSH_HOST='103.236.99.177'; $env:SSH_PORT='33851' -$env:SSH_USER='root'; $env:SSH_PASS='<服务器密码>' +# 推荐:把 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 "<命令>"`。 @@ -442,6 +467,23 @@ Grim 的 Prediction 检查正是逐 tick 复算玩家位置并与上报位置比 | `liquidCost` | 200(avoid 模式) | mineflayer 液体物理与原版差异最大,优先绕开水域 | | `allowFreeMotion` | 关闭 | 禁止自由运动 | +### 6. 地面状态修正与手动执行器(「走不动」的两级应对) + +服务端频繁下发 `position` 包时,mineflayer 会把 `entity.onGround` 置为 false 并清零速度, +物理引擎于是长期走「空中」分支 —— 表现就是「按着前进键、水平速度恒为 0」。为此有两级措施: + +| 措施 | 配置 | 说明 | +| --- | --- | --- | +| 出站包复核 | `grim.reportOnGroundFromWorld` | 发包前用脚下方块复核 `onGround` | +| **方案 A**:物理引擎对齐 | `grim.syncGroundToPhysics` | 每个物理 tick 在「物理算完、发包之前」把 `entity.onGround` 与世界数据对齐,让移动回到地面分支 | +| **方案 B**:不信例行同步 | `grim.trustSetbackOnGround` + `grim.setbackSyncEpsilon` | 服务端位置与本地几乎一致(例行同步)时只采纳坐标、保留我们自己的 `onGround` | +| **备选执行器** | `pathing.executor: 'manual'` | 只用 A* 算路径,执行改为自己按键(转向 → 前进 → 卡坎跳),时序与「移动测试」的手动按键完全一致 | + +方案 A / B 与 `grim.enabled` 无关(实测关闭合规层后回弹依旧)。 +控制台「Grim 合规」卡片会显示 `物理对齐 / 保住 / 最近同步差` 三个计数, +用它们可以判断修正是否真的生效;`/api/status` 的 `packetStats` 里还有出站包类型分布, +便于与旧模组抓包逐项对照。 + --- ## 与原模组的行为差异(重要) @@ -555,7 +597,19 @@ npm start ### 定时重复发送消息 挂机时常需要周期性发同一条指令(例如每 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`: @@ -624,9 +678,11 @@ npm start | 寻路 | 疾跑 / 游泳 / 跳跃 / 挖方块 / 放方块、高度偏好、跑酷、搭塔、下落高度、液体代价 | | 生存 | 自动进食、自动攻击、耐久保护、战斗筛选、水域策略、自动用船 | | 数据 | 地图刷新间隔、SimpCore API | -| 大地图 | 开关、世界名、预爬取层级、最大层级、**图幅半径、是否跟随机器人**、启动预爬取、下载并发数 | -| Grim | 合规层全部阈值 + 协议补包开关(见上) | -| 服务器 | 开垦模式指令、登录 / 注册模板、登录确认超时、指令节流、重连 | +| 大地图 | 开关、世界名、预爬取层级、最大层级、**图幅半径、是否跟随机器人**、启动预爬取、下载并发数、**后台预热(开关 / 半径 / 层级 / 移动阈值 / 最小间隔)** | +| 协调 | 目标区块租约时长(多机器人避免撞车) | +| Grim | 合规层全部阈值 + 协议补包开关 + **地面状态修正(方案 A / B)**(见上) | +| 服务器 | 开垦模式指令、登录 / 注册 / 绑定模板、登录确认超时、指令节流、重连 | +| 通知 | Webhook 开关与地址(`NOTIFY_WEBHOOK_URL` 兜底)、验证码提醒、自救失败提醒 | --- @@ -640,7 +696,7 @@ npm run dev # 文件变更自动重启 ### 测试覆盖 -242 个测试用例,覆盖: +436 个测试用例,覆盖: - 区块坐标编解码(含负数与边界) - 领土模型:飞地识别、会话占领、按邦国名取飞地与己方区块、API 数据合并、多边形保留 @@ -648,19 +704,23 @@ npm run dev # 文件变更自动重启 按 `config.stateName` 解析己方邦国 - markers.json 解析:奇偶填充孔洞、王城换算、颜色解析、多边形扁平化 - 字幕 / 聊天解析:进度、带坐标成功、通用成功、模糊匹配 -- Grim 合规层:旋转限速、动作互斥、攻击冷却表、距离 / 视线校验、物理自检 +- Grim 合规层:旋转限速、动作互斥、攻击冷却表、距离 / 视线校验、物理自检、 + **地面状态修正(方案 A / B 的正反用例)**、出站包类型分布 - 战斗筛选:中立生物豁免、条件敌对、猪灵金甲判定、耐久保护 -- 寻路服务:Grim 约束是否真正施加到 Movements、高度偏好目标 +- 寻路服务:Grim 约束是否真正施加到 Movements、高度偏好目标、**手动执行器(沿路点行进、跳坎、卡住终止、取消、分段重试)** - 控制器状态机:完整扩地流程、超时重试、走两步、回城、耐久保护停止、完成、 - 多机器人邦国归属隔离、开拓日志写入与去重 + 多机器人邦国归属隔离、开拓日志写入与去重、**回城前「原地是否更差」评估与前后坐标**、 + **目标租约的挑选 / 续租 / 归还** - 多机器人:凭据解析(JSON / 逗号分隔 / 兜底)、机器人池组装、配置覆盖与持久化、 - 密码不落配置文件、未登录时拒绝发送、全局配置下发保留各自覆盖 + 密码不落配置文件、未登录时拒绝发送、全局配置下发保留各自覆盖、**目标区块租约表** - 机器人登录门禁:就绪判定、登录成功 / 失败 / 已登录提示、注册与登录自动补发、 - 确认兜底超时、公屏消息类型映射 + 确认兜底超时、公屏消息类型映射、**原点自救(判定成败 / 换方向重试 / 提前结束 / 通知提醒)** - 开拓日志:字段记录、筛选、统计、JSONL 落盘与重启恢复、损坏行容错、环形缓冲、CSV 导出 - 公屏日志:按机器人隔离、类型 / 关键字过滤、分文件落盘、文件名安全化、文本导出 -- 大地图:坐标系换算(含对源站实测标定的王城用例)、**以机器人为中心的图幅换算**、瓦片范围、缓存命中、批量爬取、缓存清理 -- 定时消息:内容归一化与间隔钳制、到点发送与按周期重复、未就绪时顺延不补发、多任务独立计时、整体替换重置计时 +- 大地图:坐标系换算(含对源站实测标定的王城用例)、**以机器人为中心的图幅换算**、瓦片范围、缓存命中、批量爬取、缓存清理、**后台预热的节流与层级** +- 定时消息:内容归一化与间隔钳制、到点发送与按周期重复、未就绪时顺延不补发、多任务独立计时、整体替换重置计时、 + **cron 解析与下次时刻计算(步长 / 区间 / 星期 / 稀缺表达式 / 非法回退)**、**发送结果确认(命中 / 超时 / 未配置)** +- 通知提醒:启用判定、飞书文本格式、去重节流、环境变量兜底、失败不抛异常、不泄露完整地址 - Web 控制台:地图负载、单 / 多机器人接口、聊天与开拓日志接口、定时任务接口、 图幅与中心计算、瓦片代理、令牌与登录鉴权、WebSocket 握手鉴权 @@ -683,13 +743,16 @@ src/ │ ├── subtitleChatMonitor.js 字幕 / 聊天解析与模糊匹配 │ ├── expansionLog.js ★ 开拓日志(玩家 / 邦国 / 区块 / 时间 / 来源) │ ├── chatLog.js ★ 游戏公屏完整日志(按机器人分文件) -│ └── bigMapService.js ★ 大地图瓦片爬取与缓存 +│ ├── bigMapService.js ★ 大地图瓦片爬取与缓存 / 后台预热 +│ ├── targetLease.js ★ 目标区块租约表(多机器人避免撞车) +│ └── notifier.js ★ 人工介入提醒(飞书等 Webhook) ├── bot/ │ ├── botPool.js ★ 机器人池:同时挂多个玩家 -│ ├── botManager.js 连接、登录验证、公屏留档、指令 / 聊天发送 -│ ├── scheduledMessenger.js ★ 定时重复发送消息 / 指令 +│ ├── botManager.js 连接、登录验证、公屏留档、指令 / 聊天发送、地面状态修正 +│ ├── scheduledMessenger.js ★ 定时发送:固定间隔 / cron + 发送结果确认 │ ├── grimCompat.js ★ Grim 反作弊合规层 + 协议补包(player_input / tick_end) │ ├── pathingService.js 寻路服务(替代 Baritone) +│ ├── manualMover.js ★ 手动执行器(只借用 A* 规划,自己按键执行) │ ├── autoCombat.js 战斗筛选与耐久检查 │ ├── autoEat.js 自动进食 │ ├── autoBoat.js 自动用船(默认关闭) diff --git a/TODO.md b/TODO.md index f428efc..57c830a 100644 --- a/TODO.md +++ b/TODO.md @@ -12,7 +12,7 @@ | 项 | 值 | | --- | --- | | 技术栈 | Node.js ≥ 22 + mineflayer 4.39.0 + mineflayer-pathfinder 2.4.5 | -| 测试 | `npm test`(368 个用例全绿) | +| 测试 | `npm test`(436 个用例全绿) | | 语法与前端 id 自检 | `npm run check` | | 本地启动 | `npm start`(控制台 http://127.0.0.1:3080/) | | 服务器 | `root@103.236.99.177:33851`,部署目录 `/opt/autostore-bot` | @@ -28,6 +28,9 @@ > ⚠️ 上面的临时目录会被系统清理。若已丢失,它只作为历史参考 —— > 不影响当前项目运行,但排查移动问题时很有价值,建议接手后先备份一份到仓库外。 +> 📌 **本轮(1.7.0)已把本文档里的待办全部落地为代码** —— 详见 CHANGELOG 的 [1.7.0]。 +> 下面每节都标注了「已实现 / 待实测」,未实测的部分需要部署到服务器后按验证方法确认。 + --- ## 0.1 🚀 部署方法(完整步骤) @@ -35,13 +38,18 @@ ### 方式一:一键部署(推荐) ```powershell -# Windows PowerShell -$env:SSH_HOST='103.236.99.177'; $env:SSH_PORT='33851' -$env:SSH_USER='root'; $env:SSH_PASS='<服务器密码>' +# 推荐:把 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 ``` @@ -50,6 +58,7 @@ SSH_HOST=103.236.99.177 SSH_PORT=33851 SSH_USER=root SSH_PASS='<密码>' npm run 轮询确认控制台返回 200。任一步失败都会明确报错。 可选环境变量:`DEPLOY_APP`(默认 `/opt/autostore-bot`)、`DEPLOY_PORT`(默认 `78`)。 +`SSH_*` 与 `DEPLOY_*` **只在本机生效**,服务器上的 `.env` 不需要填。 ### 方式二:手动部署 @@ -114,7 +123,7 @@ 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`),权限 0600,**绝不进仓库** | +| `/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 | @@ -132,15 +141,14 @@ ls -1d /opt/autostore-bot/backup-* 2>/dev/null | tail -3 --- -## 1. 🔴 最大问题:寻路器驱动的移动会被服务端持续回弹 +## 1. 🟡 「机器人走不动」:本轮已实现两套修正 + 备选执行器,**待线上实测确认** -### 现象 +### 现象回顾 - **手动按键移动完全正常**:通过控制台「移动测试」按 forward 2 秒,实测位移 4~8 格,位置保持。 - **寻路器驱动的移动被回弹**:一旦 `bot.pathfinder.setGoal()` 开始执行, 服务端以 **5~9 次/秒** 的频率强制同步位置(`forcedMove`),机器人几乎原地不动。 -- 偶尔能挪动一点,但速度只有 **0.3 格/秒**(正常步行 4.3 格/秒), - 所以几百格外的目标在超时前根本走不到。 +- 偶尔能挪动一点,但速度只有 **0.3 格/秒**(正常步行 4.3 格/秒)。 ### 已确认正常的部分(不要再怀疑这些) @@ -148,68 +156,45 @@ ls -1d /opt/autostore-bot/backup-* 2>/dev/null | tail -3 | --- | --- | --- | | `player_input` 补包 | ✅ 每 physicsTick 都发(~20/s),bitflags 正确 | `packetStats.playerInput` 持续增长 | | `tick_end` 补包 | ✅ 每帧发送 | `grim.protocolPackets.tickEndSent` | -| `onGround` 标志 | ✅ 出站包已按世界数据复核 | 实测空闲时 `onGround=true`,世界脚下确实是 stone | +| `onGround` 标志(出站包) | ✅ 已按世界数据复核 | `packetStats.onGroundFixed` | | 世界数据可读性 | ✅ `world.getBlock` 正常,四向可见 115~125 格 | `pathingDiag.visibleDistance` | | 寻路器本身 | ✅ 在开阔地能找到路径 | 实测「已探索 3633 节点 → success,代价 113.8」 | -| 服务端回弹是否持续 | ❌ **只在机器人尝试移动时出现**;空闲时入站位置包为 0 | 12 秒采样:空闲 +0,寻路中 +105 | +| 服务端回弹是否持续 | ❌ **只在机器人尝试移动时出现** | 12 秒采样:空闲 +0,寻路中 +105 | | 关闭疾跑 | 试过,**无改善** | `allowSprint=false` 后仍是 4.8 次/秒回弹 | -| **关闭合规层(grim.enabled=false)** | 试过,**无改善** | A/B 实测:开启 6.0 次/秒 vs 关闭 5.5 次/秒,机器人位移都是 0 格 | +| 关闭合规层(`grim.enabled=false`) | 试过,**无改善** | A/B 实测 6.0 vs 5.5 次/秒,位移都是 0 | | 世界数据是否错误 | ❌ **不是原因** | 实测脚下 `stone`,`blockBelow` 正确 | -### 🎯 最关键的一条线索:水平速度恒为 0 +### 🎯 关键线索(未变) -用 `/api/status` 的 `moveDiag` 字段采集到的实测数据: +用 `/api/status` 的 `moveDiag` 采集到的实测数据: -**空闲时** -```json -{ "controls": { "forward": false, "sprint": false, ... }, "anyPressed": false, - "velocity": { "x": 0, "y": -0.078, "z": 0 }, "speed": 0.078, "onGround": true, - "pathfinder": { "isMoving": false, "goal": null } } -``` +- 寻路时 `forward`/`sprint` 都按下、`isMoving=true`,但**水平速度恒为 0**; +- `velocity.y = -0.078` 恰是自由落体第一 tick 的重力值 —— 说明速度每 tick 被清零; +- `bot.entity.onGround` 被服务端位置包反复置为 false(mineflayer 的 `physics.js` + 在处理 `position` 包时无条件置 false),物理引擎因此一直走「空中」分支。 -**寻路中** -```json -{ "controls": { "forward": true, "sprint": true, ... }, "anyPressed": true, - "velocity": { "x": 0, "y": -0.078, "z": 0 }, "speed": 0.078, "onGround": false, - "pathfinder": { "isMoving": true, "goal": {...} } } -``` +### ✅ 本轮已实现的代码(`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. **寻路器确实在执行** —— `forward`/`sprint` 都按下去了,`isMoving=true`。 - 所以不是「寻路器没跑」,问题在更底层。 -2. **水平速度恒为 0**,而 `velocity.y = -0.078` 恰好是**自由落体第一 tick** 的重力值。 - 这说明速度**每 tick 都被清零**,机器人永远积累不起速度。 -3. **`onGround` 被服务端的 position 包反复置为 false**: - mineflayer 的 `physics.js` 在处理服务端 `position` 包时会把 `entity.onGround` 置为 false, - 而本服下发位置包的频率(5~6 次/秒)远高于物理引擎把它改回来的速度。 - 结果是:**机器人站在石头上,但物理引擎一直认为自己在空中**, - 于是水平加速走的是「空中控制」分支,而位置包又把速度清零,两者叠加就是「按着前进键却纹丝不动」。 -4. 已尝试的缓解:`_isStandingOnGround()` 里加了「服务端是否正把我们按在原地」的判断 - (`_serverPinsUs()`),出站包的 `onGround` 已经能正确上报。 - **但 `bot.entity.onGround`(物理引擎内部值)仍然是 false**,所以物理计算依旧按空中处理 —— - 这可能就是还差的那一步。 +**请按下面的顺序实测**: -### 排查建议(按优先级,已按实测结果更新) +1. 部署后先在控制台确认「Grim 合规」卡片里的 `地面修正` 一行出现非零计数 + (`物理对齐 N · 保住 M · 最近同步差 X 格`); +2. 用「移动测试」确认手动移动仍然正常; +3. 开始扩地,观察 `moveDiag.speed` 是否 > 0.1、`moveDiag.onGround` 是否稳定为 true、 + 「被服务器传送另有」的计数是否下降、以及是否真的能走完一段路; +4. 若仍有回弹 → 在配置「寻路 → 寻路执行器」切到 **手动执行(备选方案)**, + 再观察一次(它只借用 A* 规划,执行层完全是我们自己的按键时序); +5. 若两者都不行 → 回到下面「未完成的排查方向」,用 `packetStats.outboundTypes` + + 旧模组抓包做逐项 diff(假设 5)。 -#### 假设 1:`GrimCompat` 对 `bot.look` 的拦截 ❌ **已排除** - -A/B 实测 grim 开/关对回弹频率没有影响(6.0 vs 5.5 次/秒),机器人位移都是 0。不要再往这个方向查。 - -#### 假设 2:`bot.entity.onGround` 被位置包置 false(**最可疑,优先测**) - -这是目前唯一能解释「按键有、速度恒 0」的机制。方向: - -- **方案 A**:在 `physicsTick` 里,当出站包已判定「脚下有方块」时, - 把 `bot.entity.onGround` 也强制同步为 true,让物理引擎走地面分支。 - (注意:物理引擎每 tick 会自己重算这个值,所以需要在它之后、发送位置包之前介入, - 或者直接监听并覆盖。需要读 mineflayer `lib/plugins/physics.js` 确认执行顺序。) -- **方案 B**:降低服务端下发位置包的频率对我们的影响 —— - 例如在收到 `position` 包时,如果位置与我们自己的预测几乎一致(只差浮点误差), - **不采纳它的 onGround=false**,只同步坐标。 - 这比方案 A 更贴近「我们是对的、服务端只是话多」的实际情况。 -- **验证**:改完后看 `moveDiag.speed` 是否变成 0.1 以上、`moveDiag.onGround` 是否稳定为 true, - 以及 `被服务器传送另有` 的计数是否下降。 +### 未完成的排查方向(按优先级) #### 假设 3:跳跃 / 跑酷的物理预测不匹配 @@ -217,115 +202,111 @@ Grim 的 Prediction 检查对**跳跃**最严格(起跳时机、跳跃高度 mineflayer 的 `physics.js` 在这些细节上与原版有已知偏差。 **验证方法**:把路径限制成纯平地,禁用一切跳跃: + ```json {"allowSprint": false, "pathing": {"allowParkour": false, "allow1by1towers": false, "maxDropDown": 1}} ``` + 若回弹消失 → 需要在 `GrimCompat` 里拦下 `jump` 控制状态,改用更保守的移动策略。 -#### 假设 4:发包顺序 / 包类型与原版不一致 - -旧模组的调试指令 `!tp` 额外发的是 **`position_look`**(同时带位置与朝向)。 -需要实测确认:mineflayer 在朝向变化时到底发的是 `position` 还是 `position_look`, -以及服务端是否因为「朝向变了却只发 position」而判定异常。 - -**验证方法**:在 `GrimCompat` 里统计出站包类型分布, -与旧模组移动时的包序列逐项对照(旧模组源码见上文路径)。 - -#### 假设 5:直接抓包对照 +#### 假设 5:直接抓包对照(最彻底) 用 `scripts/check-protocol.js` 的思路扩展一个抓包脚本, -分别在「旧模组移动」和「mineflayer 寻路移动」时记录 5 秒的完整出站包序列, -diff 出差异。这是最彻底也最费时的办法,但能一次性定位问题。 +分别在「旧模组移动」和「mineflayer 寻路移动」时记录 5 秒的**完整出站包序列**,diff 出差异。 +`packetStats.outboundTypes` 已经给出包类型分布作为起点。 -### 💡 备选方案:绕开 mineflayer-pathfinder - -既然**手动控制状态是有效的**,可以考虑不依赖 pathfinder 的执行层,自己写一个简单的转向控制器: - -1. 用 `bot.pathfinder` **只算路径**(`bot.pathfinder.getPathTo()` 拿到路径点数组,不执行); -2. 自己实现执行:朝下一个路径点 `bot.look` → 按住 `forward` → 到位后切下一个点; -3. 遇到 1 格高的坎就按 `jump`,遇到障碍就重新算路径。 - -这样能完全控制发包与朝向的时序,也便于在 `GrimCompat` 里做逐 tick 审计。 -工作量中等,但比继续调 pathfinder 的参数更可能出结果。 +> 提示:`scripts/check-protocol.js` 里读取 `node_modules` 的路径用了 +> `path.join(__dirname, 'node_modules', ...)`,在 `scripts/` 下是拿不到的 —— +> 顺手修一下(应为 `path.join(__dirname, '..', 'node_modules', ...)`)。 --- -## 2. 🟡 「被传送到原点」的两种原因(已在代码里区分,但可继续完善) +## 2. ✅ 原点自救(已实现判定与统计,实测成功率待观察) -服务器把玩家丢到世界原点附近有两种完全不同的原因,**以前在日志里长得一样**, -经常被误判成「机器人坏了」: +服务器把玩家丢到世界原点附近有两种完全不同的原因,**以前在日志里长得一样**: | 原因 | 判断依据 | 处理方式 | 现状 | | --- | --- | --- | --- | -| 死亡后被卡住 | **服务器没有要求验证码** | **左右移动即可触发服务器重新传送**,可自动自救 | ✅ `BotManager._escapeFromOrigin()` | -| 被切到登录服 | **服务器要求输入验证码** | **必须重新输入验证码**,只能人工完成 | ✅ 已识别并提示,控制台有醒目横幅 | +| 死亡后被卡住 | **服务器没有要求验证码** | 左右 / 前后移动即可触发服务器重新传送 | ✅ 两轮自救 + 成败判定 | +| 被切到登录服 | **服务器要求输入验证码** | **必须重新输入验证码**,只能人工完成 | ✅ 已识别并提示,支持 Webhook 提醒 | > ⚠️ **不要用「脚下是不是基岩」来区分这两种情况 —— 实测两种情况下脚下都是基岩。** > 唯一可靠的信号是**服务器有没有发「请直接输入验证码进行验证」这类提示**。 -> 代码里以 `_needAuthWarned` 为准;没要求验证码就按「死亡卡住」处理并尝试自救 -> (自救在两种情况下都是无害的,所以判断错了也不会把事情弄得更糟)。 -**待完善**:自救的成功率没有统计。可以记录「自救后位置是否恢复」, -失败时再升级为提示用户;也可以尝试更多方向组合(前/后/跳)。 +**本轮改进**:分两轮(左右 → 前后 + 跳),每轮结束后等 800ms 让传送落地再判定「位置是否恢复」; +统计 `attempts / successes / lastResult`(`/api/status` 与控制台「连接」卡片可见); +两轮都失败时升级为明确提示,并支持 Webhook 提醒。 +**待观察**:线上实际成功率(看 `originRescue.successes / attempts`), +若长期为 0,说明该服务器对移动输入不再响应,应把重心放在验证码流程上。 --- -## 3. 🟡 自动回城(`/u return`)已加护栏,但仍可改进 +## 3. ✅ 自动回城(已加「原地是否更差」评估与前后坐标) 回城是**破坏性**操作:它把机器人拉回安全点,会**清掉玩家在游戏客户端里手动摆好的位置**。 -项目里曾因此反复把机器人从玩家摆好的位置拉走,排查了很久。 -现状(已修): -- `returnOnTimeout` **默认关闭**; -- 即便打开,`returnGraceSeconds`(默认 300 秒)内不执行回城; -- 回城指令可配置(`returnCommand`,默认 `u return`); -- 每次回城都明确记日志,说明「会丢失当前位置」。 +现状(1.7.0 之后): -**待改进**: -- 回城前先判断「原地是否真的更差」——例如用 `pathingDiag.neighborCount` 判断 - 当前位置是否还有可走方向;如果邻居数正常,回城毫无意义。 -- 记录回城前后的坐标,在控制台上展示「回城把机器人从 A 挪到了 B」, - 让用户能判断这次回城是否值得。 +- `returnOnTimeout` **默认关闭**;即便打开,`returnGraceSeconds`(默认 300 秒)内不执行; +- **回城前先评估**:`pathing.diagnostics().neighborCount` 为 0(确实无路可走)才回城, + 否则记为「原地还能走」并跳过目标 —— 回城只会白白丢掉位置; +- **回城后会记录前后坐标与直线距离**,日志写「回城结果:机器人从 (A) 挪到了 (B)(直线 X 格)」, + 控制台「任务」卡片显示 `最近回城`;位置几乎没变时明确提示回城没有帮助。 --- -## 4. 🟢 其它已知但优先级较低的问题 +## 4. ✅ 其它已知问题(本轮都已落地) -### 4.1 大地图 -- 目前只有层级 0 会批量预爬取,层级 1~3 靠用户缩放时按需拉取,首次浏览较慢。 -- 可以考虑后台低优先级预热:机器人移动到新区域时,顺带爬取周边高层级瓦片。 -- 领土多边形在缩放较大时仍是全量绘制,超大地图(全世界视角 + 全部邦国)可能掉帧, - 可以按视野做裁剪。 +### 4.1 大地图 ✅ -### 4.2 控制台 -- 移动端布局已适配,但**日志区在手机上滚动性能一般**(每行都是 DOM 节点)。 - 数据量大时建议改虚拟列表,或限制保留行数。 -- 配置面板有 8 个标签页、字段很多,可以加搜索框。 +- 层级 1~3 现在有**后台预热**:机器人移动超过 `bigMap.prewarmMinMoveBlocks`(默认 512 格) + 或距上次超过 `prewarmIntervalMs`(默认 10 分钟)时,用一半并发把周边瓦片补下来; +- 领土多边形按**视野裁剪**绘制,全世界视角不再全量绘制。 -### 4.3 多机器人 -- 领土模型是全局共享的,多机器人同时扩地时对同一批候选区块的协调依赖 - `_isClaimedByOthers`,但没有真正的分布式锁;高并发时可能撞车。 -- 建议给「目标区块」加一个带租约的占用表。 +### 4.2 控制台 ✅ -### 4.4 定时消息 -- 只支持固定间隔,没有「cron 式」或「每天某时刻」的调度。 -- 没有发送结果确认:`/u restore confirm` 发出去后是否成功,目前只靠人看日志。 +- 日志改为**按帧批量写入**(一次布局、一次滚动),并用 `content-visibility: auto` + 让浏览器跳过视口外行的渲染(等价于浏览器自带的虚拟列表,不改变内容与滚动位置); +- 配置面板新增**搜索框**:按名称 / 字段名 / 说明过滤,隐藏不删除(保存时仍能读到隐藏字段的值)。 -### 4.5 登录验证 -- 验证码只能人工获取与输入(服务器设计如此)。 - 可以在「等待验证码」时通过通知渠道(如飞书机器人)提醒用户,避免错过 120 秒时限。 +### 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 @@ -338,6 +319,9 @@ 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 ``` ### 判断「机器人是不是卡在登录服」 @@ -347,8 +331,8 @@ 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"` **不能**用来判断 —— 实测死亡卡住和登录服两种情况脚下都是基岩 +- 位置靠近原点且 `needsVerification == false` → 多半是死亡后卡住,机器人会自动自救(并记录成败) +- ⚠️ `world.blockBelow == "bedrock"` **不能**用来判断 —— 实测两种情况脚下都是基岩 --- @@ -360,3 +344,5 @@ curl -s -H "X-Auth-Token: $PW" http://103.236.99.177:78/api/status \ - 注释与文档用中文,解释「为什么」而不是「做了什么」。 - 每个 `fix` 都应补一个能复现原问题的测试;本项目测试在 `test/`,用 `node:test`。 - 改前端记得跑 `npm run check`,它会校验 `app.js` 引用的元素 id 在 `index.html` 里都存在。 +- ⚠️ 写块注释时注意:**中文解释里不要出现 `*/` 这两个字符**(例如 cron 步长写法), + 它会提前结束注释并导致语法错误(本项目已踩过一次)。 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 3755015..612e8a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "autostore-bot", - "version": "1.1.0", + "version": "1.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "autostore-bot", - "version": "1.1.0", + "version": "1.7.0", "license": "MIT", "dependencies": { "chalk": "^4.1.2", diff --git a/package.json b/package.json index d25c9d3..380736c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "autostore-bot", - "version": "1.1.0", + "version": "1.7.0", "description": "AutoStore Bot —— Minecraft 自动扩地机器人(mineflayer 重写版,多机器人挂机,Web 控制台,开拓日志,大地图,Grim 反作弊适配)", "main": "src/index.js", "scripts": { diff --git a/scripts/deploy.js b/scripts/deploy.js index 9074c67..1c0e13e 100644 --- a/scripts/deploy.js +++ b/scripts/deploy.js @@ -3,11 +3,15 @@ /** * 一键部署:打包 → 上传 → 远程执行 update.sh → 校验服务已起来。 * - * 用法(凭据全部走环境变量,脚本里不写死任何密钥): + * 用法(凭据全部走环境变量或 .env,脚本里不写死任何密钥): * + * # 推荐:把 SSH_HOST / SSH_PORT / SSH_USER / SSH_PASS 写进 .env(已被 .gitignore 忽略) + * npm run deploy + * + * # 或者临时用环境变量覆盖(.env 里的值仍然作为兜底) * # Windows PowerShell - * $env:SSH_HOST='103.236.99.177'; $env:SSH_PORT='33851' - * $env:SSH_USER='root'; $env:SSH_PASS='<密码>' + * $env:SSH_HOST='<服务器 IP>'; $env:SSH_PORT='<端口>' + * $env:SSH_USER='root'; $env:SSH_PASS='<密码>' * node scripts/deploy.js * * # Linux / macOS @@ -27,13 +31,18 @@ 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 环境变量'); + console.error('缺少 SSH_HOST / SSH_PASS。请写进项目根目录的 .env(参考 .env.example),' + + '或用环境变量临时传入。'); process.exit(2); } diff --git a/scripts/ssh-helper.js b/scripts/ssh-helper.js index ce9affa..f33029b 100644 --- a/scripts/ssh-helper.js +++ b/scripts/ssh-helper.js @@ -13,7 +13,7 @@ * node scripts/ssh-helper.js exec "<命令>" * node scripts/ssh-helper.js put <本地文件> <远程路径> * - * 环境变量: + * 环境变量(也可以写进项目根目录的 .env,那里已被 .gitignore 忽略): * SSH_HOST 必填 * SSH_PASS 必填 * SSH_PORT 可选,默认 22 @@ -25,6 +25,9 @@ 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')); @@ -39,7 +42,8 @@ const USER = process.env.SSH_USER || 'root'; const PASS = process.env.SSH_PASS; if (!HOST || !PASS) { - console.error('缺少 SSH_HOST / SSH_PASS 环境变量'); + console.error('缺少 SSH_HOST / SSH_PASS。请写进项目根目录的 .env(参考 .env.example),' + + '或用环境变量临时传入。'); process.exit(2); } diff --git a/src/bot/botManager.js b/src/bot/botManager.js index 7c30c11..dc0727a 100644 --- a/src/bot/botManager.js +++ b/src/bot/botManager.js @@ -46,6 +46,23 @@ const NEED_AUTH_PATTERN = /需要绑定|绑定您的|绑定你的|请使用命 */ 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 事件 * 归一化成上层(控制器 / Web 控制台)可直接消费的事件。 @@ -69,7 +86,8 @@ class BotManager extends EventEmitter { * @param {string} [connection.password] * @param {object} config 完整配置对象 * @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) { super(); @@ -83,6 +101,12 @@ class BotManager extends EventEmitter { this.label = connection.label || connection.username; /** @type {import('../core/chatLog').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} */ this.bot = null; @@ -132,6 +156,13 @@ class BotManager extends EventEmitter { this._lastForcedMove = null; /** 是否处于「原点自救」过程中,避免重复触发 */ this._escaping = false; + /** + * 原点自救的统计(以前只能从日志里看到「尝试过」,看不出有没有用)。 + * + * 记录尝试次数、成功次数与最近一次结果;成功 = 自救结束后已不在原点附近。 + * 失败会升级为明确提示(可能是被切到了登录服,移动无效)。 + */ + this.originRescue = { attempts: 0, successes: 0, lastResult: null }; /** 是否已提示过「当前在登录服」 */ this.needsVerificationHint = false; /** @@ -148,6 +179,20 @@ class BotManager extends EventEmitter { lastOutboundAt: 0, /** 出站包中 onGround 被世界数据纠正的次数 */ onGroundFixed: 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+ 移动预测必需) */ playerInput: 0, /** 每 tick 补发的 tick_end 次数 */ @@ -243,11 +288,8 @@ class BotManager extends EventEmitter { _handleZeroPosition() { const bot = this.bot; if (!bot || !bot.entity) return; + if (!this._isNearOrigin()) return; const pos = bot.entity.position; - // 原点判定:水平方向很近且高度很低(正常地形不会在 y<16 的世界原点附近活动) - const nearOrigin = Math.abs(pos.x) < 48 && Math.abs(pos.z) < 48 && pos.y < 16; - if (!nearOrigin) return; - const where = `(${pos.x.toFixed(0)}, ${pos.y.toFixed(0)}, ${pos.z.toFixed(0)})`; if (this._needAuthWarned) { @@ -266,35 +308,116 @@ class BotManager extends EventEmitter { } /** - * 左右移动若干次以触发服务器的「重新传送」。 + * 是否位于世界原点附近(服务器把玩家丢进大厅的位置)。 + * + * 「死亡后被卡住」与「被切到登录服」两种情况的位置特征完全一样, + * 因此这里只负责位置判定,区分处理见 {@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; - const directions = ['left', 'right', 'left', 'right']; - let index = 0; - const step = () => { - if (!this.bot || this.bot !== bot) { - this._escaping = false; - return; + 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}` + ); + } } - if (index >= directions.length) { - this._escaping = false; - this._log('左右移动自救结束(若位置已恢复则属正常)'); - return; - } - const dir = directions[index++]; - this.setControlState(dir, true); - setTimeout(() => { - this.setControlState(dir, false); - setTimeout(step, 150).unref?.(); - }, 500).unref?.(); }; - step(); + + 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(); } /** @@ -385,6 +508,7 @@ class BotManager extends EventEmitter { // ==================== 事件装配 ==================== _wireEvents(bot) { + this._installGroundStateFix(bot); this._wirePacketStats(bot); bot.on('login', () => { @@ -578,6 +702,8 @@ class BotManager extends EventEmitter { }); const originalWrite = bot._client.write.bind(bot._client); bot._client.write = (name, params) => { + const types = this.packetStats.outboundTypes; + types[name] = (types[name] || 0) + 1; if (name === 'position' || name === 'position_look') { this.packetStats.outboundPosition++; this.packetStats.lastOutboundAt = Date.now(); @@ -591,6 +717,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 标志。 * @@ -614,6 +826,34 @@ class BotManager extends EventEmitter { this.packetStats.onGroundFixed++; } + /** + * 发送一条「需要人工介入」的通知(验证码 / 自救失败)。 + * + * 异步且静默失败:通知只是提醒手段,网络不通不能影响机器人本身的运行。 + * + * @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; + } + /** * 用世界数据判断脚下是否有实体方块。 * @@ -741,6 +981,13 @@ class BotManager extends EventEmitter { this._log('服务器要求先完成账号绑定 / 二次验证(本服为「简幻通」体系):' + message, 'error'); this._log('该验证需要人工完成一次(游戏内扫码,或把验证码当聊天内容直接发出去);' + '绑定成功后同一账号即可自动登录。若服务器提供固定验证码,可在配置的「账号绑定指令模板」里填写。', '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; @@ -1009,6 +1256,17 @@ class BotManager extends EventEmitter { * 控制台据此弹醒目提示并引导用户在输入框里填验证码。 */ needsVerification: this._needAuthWarned === true, + /** + * 原点自救统计:尝试次数、成功次数、最近一次结果(含前后坐标)。 + * + * 以前只能从日志里看到「尝试过」,看不出自救到底有没有用; + * 控制台与 /api/status 可以直接读这里的成功率。 + */ + originRescue: { + attempts: this.originRescue.attempts, + successes: this.originRescue.successes, + lastResult: this.originRescue.lastResult + }, username: this.connection.username, host: `${this.connection.host}:${this.connection.port}`, version: this.connection.version, @@ -1224,6 +1482,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 的消息通道 → 公屏日志类型。 * @param {string} [position] diff --git a/src/bot/botPool.js b/src/bot/botPool.js index 1302d1a..b5d4fff 100644 --- a/src/bot/botPool.js +++ b/src/bot/botPool.js @@ -5,6 +5,7 @@ const { EventEmitter } = require('events'); const { BotManager } = require('./botManager'); const { ScheduledMessenger } = require('./scheduledMessenger'); const { ExpansionController } = require('../controller/expansionController'); +const { TargetLeaseTable } = require('../core/targetLease'); const { normalizeConfig } = require('../config/expansionConfig'); const { sanitizeId } = require('../config/botsConfig'); @@ -43,6 +44,8 @@ class BotPool extends EventEmitter { this.chatLog = deps.chatLog || null; this.botStore = deps.botStore || null; this.credentialStore = deps.credentialStore || null; + /** @type {import('../core/notifier').Notifier|null} 人工介入提醒(验证码 / 自救失败) */ + this.notifier = deps.notifier || null; /** @type {Map} 机器人 id → 运行时条目 */ this.entries = new Map(); @@ -54,6 +57,13 @@ class BotPool extends EventEmitter { this.credentials = this.credentialStore ? this.credentialStore.load().bots : {}; /** 启动时间戳 */ this.startedAt = 0; + /** + * 目标区块租约表:多机器人共享,避免同时扩地时撞在同一批候选区块上 + * (详见 core/targetLease.js)。 + */ + this.targetLeases = new TargetLeaseTable({ + ttlMs: (this.config.coordination ? this.config.coordination.targetLeaseTtlSeconds : 180) * 1000 + }); for (const connection of this._mergeConnections(deps.connections || [])) { this._addBot(connection); @@ -103,7 +113,7 @@ class BotPool extends EventEmitter { Object.assign({}, connection, { label: setting.label || connection.label }), this.getConfigFor(id), this.logger, - { chatLog: this.chatLog } + { chatLog: this.chatLog, notifier: this.notifier } ); const controller = new ExpansionController({ botManager: manager, @@ -111,7 +121,8 @@ class BotPool extends EventEmitter { logger: this.logger, botId: id, botName: connection.username, - expansionLog: this.expansionLog + expansionLog: this.expansionLog, + targetLeases: this.targetLeases }); // 寻路事件转发给控制器(与 Baritone 的 PathEvent 语义一致) manager.on('modulesReady', () => { @@ -341,6 +352,10 @@ class BotPool extends EventEmitter { */ applyGlobalConfig(config) { this.config = config; + // 租约时长可以热改(新配置对新租约生效;已发出的租约仍按原时长到期) + if (this.targetLeases && config.coordination) { + this.targetLeases.ttlMs = Math.max(1, Number(config.coordination.targetLeaseTtlSeconds) || 180) * 1000; + } for (const entry of this.entries.values()) { entry.config = this.getConfigFor(entry.id); entry.manager.applyConfig(entry.config); diff --git a/src/bot/manualMover.js b/src/bot/manualMover.js new file mode 100644 index 0000000..1ae6d45 --- /dev/null +++ b/src/bot/manualMover.js @@ -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|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|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} 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 +}; \ No newline at end of file diff --git a/src/bot/pathingService.js b/src/bot/pathingService.js index 55732a9..9c0a720 100644 --- a/src/bot/pathingService.js +++ b/src/bot/pathingService.js @@ -2,6 +2,8 @@ const pathfinderPlugin = require('mineflayer-pathfinder'); +const { ManualMover } = require('./manualMover'); + const { GoalXZ, GoalNearXZ, Goal } = pathfinderPlugin.goals; const { Movements } = pathfinderPlugin; @@ -75,6 +77,16 @@ class PathingService { /** 段长收缩次数与系数(本段算出 noPath 时用来缩短段长重试) */ this._segmentShrink = 0; this._segmentScale = 1; + /** + * 手动执行器(`pathing.executor === 'manual'` 时创建)。 + * + * 只借用 pathfinder 的 A* 规划,执行改为自己按键(详见 manualMover.js 的说明); + * 默认不启用,实测确认寻路器执行层被服务端回弹时才切换。 + * @type {ManualMover|null} + */ + this.mover = null; + /** 手动执行的代次:新一次执行会让旧的一次自行退出 */ + this._manualRunId = 0; } /** @@ -119,25 +131,7 @@ class PathingService { // 重复注册会让同一个事件被处理多次(日志里同一秒出现多条相同事件就是这么来的) if (!this._listenersBound) { this._listenersBound = true; - bot.on('goal_reached', () => { - 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('goal_reached', () => this._onSegmentReached()); bot.on('path_update', (r) => { this._lastStatus = r.status; // 完整记录寻路器状态:长距离寻路卡住时,只看成功 / 失败是查不出原因的 @@ -175,6 +169,13 @@ class PathingService { } this.ready = true; this._configured = true; + // 手动执行模式:只保留 A* 规划能力,执行交给 ManualMover(备选方案,默认不启用) + if (this.config.pathing.executor === 'manual') { + this.mover = new ManualMover(bot, this.config, this.logger); + this._log('寻路执行器:手动模式(只借用 A* 算路径,自己按键执行)'); + } else { + this.mover = null; + } this._log(`寻路器就绪(挖方块 ${this.config.allowBreak ? '开' : '关'},` + `放方块 ${this.config.allowPlace ? '开' : '关'},` + `跑酷 ${this.config.pathing.allowParkour ? '开' : '关'},` @@ -235,14 +236,17 @@ class PathingService { const goalBlock = this._segmentGoal(this.finalTarget); this.currentWaypoint = goalBlock; this._waypointIsFinal = goalBlock.x === this.finalTarget.x && goalBlock.z === this.finalTarget.z; + this.calcFailed = false; + this._pathing = true; + + // 手动执行模式:同样只规划「这一段」,执行交给 ManualMover(不阻塞调用方) + if (this.mover) return this._startManualRun(goalBlock); let goal = new GoalNearXZ(goalBlock.x, goalBlock.z, 2); if (this.config.altitudePreference && this.config.altitudeWeight > 0) { const startY = Math.floor(bot.entity.position.y); goal = new AltitudeGoal(goal, this.config.altitudeWeight, startY); } - this.calcFailed = false; - this._pathing = true; try { bot.pathfinder.setGoal(goal); } catch (err) { @@ -255,6 +259,81 @@ class PathingService { return true; } + /** + * 启动一次手动执行(备选方案)。 + * + * 与 pathfinder 的执行保持同一套对外语义:走完一段 → `_onSegmentReached()`(会继续 + * 规划下一段或报到达);走不动 → 与 noPath 一样先缩短段长重试,再如实报失败。 + * + * @param {{x: number, z: number}} goalBlock + * @returns {boolean} + */ + _startManualRun(goalBlock) { + if (!this.mover) return false; + const runId = ++this._manualRunId; + this.mover.stop(); + this.mover.moveTo(goalBlock) + .then((result) => this._onManualOutcome(result, runId)) + .catch((err) => { + if (runId !== this._manualRunId) return; + this.calcFailed = true; + this._pathing = false; + this._log('手动执行异常:' + (err && err.message ? err.message : String(err))); + this._emit(PathEvent.CALC_FAILED, { target: this.currentTarget, error: err && err.message }); + }); + return true; + } + + /** + * 手动执行的结果处理。 + * @param {{ok: boolean, reason: string, waypoints: number, ticks: number}} result + * @param {number} runId + */ + _onManualOutcome(result, runId) { + if (runId !== this._manualRunId) return; // 已被取消或被新的一段取代 + if (result.ok) { + this._onSegmentReached(); + return; + } + if (result.reason === 'cancelled') return; + if (!this._waypointIsFinal && this.finalTarget && this._segmentShrink < MAX_SEGMENT_SHRINK) { + this._segmentShrink++; + this._segmentScale *= 0.6; + this._log(`手动执行中断(${result.reason}),缩短段长后重试(第 ${this._segmentShrink}/${MAX_SEGMENT_SHRINK} 次)`); + this._planSegment(); + return; + } + this.calcFailed = true; + this._pathing = false; + this._log(`手动执行失败:${result.reason}` + + `(走过 ${result.waypoints} 个路径点、${result.ticks} tick)`); + this._emit(PathEvent.CALC_FAILED, { target: this.currentTarget, reason: result.reason }); + } + + /** + * 到达当前段的终点:是最终目标就报到达,否则继续规划下一段。 + * pathfinder 的 `goal_reached` 与手动执行共用这一套逻辑。 + */ + _onSegmentReached() { + this._pathing = false; + if (this._waypointIsFinal || !this.finalTarget) { + this._emit(PathEvent.AT_GOAL, { target: this.currentTarget }); + return; + } + // 只走到了中途点:新区块已加载,继续朝最终目标规划下一段 + this._segmentCount++; + if (this._segmentCount > MAX_SEGMENTS) { + this._log(`分段寻路超过 ${MAX_SEGMENTS} 段仍未到达目标,判定为不可达`); + this.calcFailed = true; + this._emit(PathEvent.CALC_FAILED, { target: this.currentTarget, reason: 'too-many-segments' }); + return; + } + this._log(`已到达中途点 ${Math.round(this.currentWaypoint.x)},${Math.round(this.currentWaypoint.z)},` + + `继续朝目标 ${this.finalTarget.x},${this.finalTarget.z} 前进(第 ${this._segmentCount} 段)`); + this._emit(PathEvent.SEGMENT_REACHED, { segment: this._segmentCount, target: this.currentTarget }); + this._planSegment(); + } + /** * 把最终目标裁剪到「当前已加载的范围内」。 * @@ -377,6 +456,10 @@ class PathingService { 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)), @@ -397,6 +480,9 @@ class PathingService { this._segmentCount = 0; this._segmentShrink = 0; this._segmentScale = 1; + // 手动执行模式:停掉执行器并让在途的结果失效(否则它会继续按键) + this._manualRunId++; + if (this.mover) this.mover.stop(); try { this.bot.pathfinder.setGoal(null); } catch { diff --git a/src/bot/scheduledMessenger.js b/src/bot/scheduledMessenger.js index f8cf0b7..2f0a7e1 100644 --- a/src/bot/scheduledMessenger.js +++ b/src/bot/scheduledMessenger.js @@ -6,18 +6,24 @@ const MIN_INTERVAL_SECONDS = 5; const MAX_TASKS = 20; /** 调度器的心跳间隔(毫秒):只做「到点了吗」的判断,开销可忽略 */ const TICK_INTERVAL_MS = 1000; +/** 发送结果确认的默认超时(秒) */ +const DEFAULT_CONFIRM_TIMEOUT_SECONDS = 10; /** - * 定时消息器:按固定间隔重复发送同一条内容(公屏消息或游戏指令)。 + * 定时消息器:按固定间隔或 cron 表达式重复发送同一条内容(公屏消息或游戏指令)。 * * 典型用法是服务器要求周期性执行的指令,例如每 300 秒发一次 `/u restore confirm` - * 来维持疆土民心。这类「心跳式」指令是挂机机器人的刚需, + * 来维持疆土民心,或者每天 09:00 发一次公告。这类「心跳式」指令是挂机机器人的刚需, * 但绝不能简单地 setInterval —— 断线、未登录、正在回城时都不该发, * 否则要么白发(服务端丢弃),要么在错误的时机打断状态机。 * * 因此这里做成: - * - 每个任务独立计时,按「上次成功发送时间 + 间隔」判断是否到点; - * - 只在 `canOperate()`(已连接 + 已通过登录验证)时才发; + * - **两种调度**:`intervalSeconds`(固定间隔)或 `cron`(5 段 cron 表达式,本地时间)。 + * 例如「0 9 * * *」是每天 09:00,「0/30 * * * *」是每 30 分钟(步长写法见 parseCron); + * - **发送结果确认**(可选):配了 `expectPattern`(正则)的任务在发出后会等待服务器的 + * 回复,超时未回复记为「未确认」并写警告 —— 否则 `/u restore confirm` 到底成没成, + * 只能靠人翻日志; + * - 只在 `canSendChat()`(已连接 + 已通过登录验证)时才发; * - 未就绪时跳过本次并顺延,不补发(避免重连后一次性连发多条); * - 与机器人共用指令节流队列,不会绕过 `server.commandCooldownMs` 刷屏。 */ @@ -31,9 +37,15 @@ class ScheduledMessenger { this.logger = logger || null; /** @type {Array} 定时任务列表 */ this.tasks = []; - /** @type {Map} 任务 id → 下次应发送的时间戳 */ + /** @type {Map} 任务 id → 下次应发送的时间戳(cron 任务可能是 0=待计算) */ this._nextDueAt = new Map(); + /** @type {Map} 任务 id → 预编译的 cron 表达式 */ + this._parsedCron = new Map(); + /** @type {Map} 任务 id → 待确认的发送(等服务器的回复) */ + this._pending = new Map(); this._timer = null; + this._onChatBound = (message) => this._onChatMessage(message); + this._chatBound = false; } /** @@ -44,18 +56,38 @@ class ScheduledMessenger { * @returns {Array} 归一化后的任务列表 */ setTasks(tasks, now) { + const current = Number.isFinite(now) ? now : Date.now(); this.tasks = normalizeTasks(tasks); this._nextDueAt.clear(); - const current = Number.isFinite(now) ? now : Date.now(); + this._parsedCron.clear(); + this._pending.clear(); for (const task of this.tasks) { - // 首次不立即发送:给机器人留出进入世界与登录的时间 - this._nextDueAt.set(task.id, current + task.intervalSeconds * 1000); + if (task.mode === 'cron') { + const parsed = parseCron(task.cron); + this._parsedCron.set(task.id, parsed); + // cron 任务按「下一次匹配时刻」调度;表达式一年内无匹配时退回间隔模式 + const next = parsed ? nextCronTime(parsed, current) : null; + if (next === null) { + task.mode = 'interval'; + task.cronError = '表达式一年内没有匹配时刻,已按固定间隔发送'; + this._nextDueAt.set(task.id, current + task.intervalSeconds * 1000); + } else { + this._nextDueAt.set(task.id, next); + } + } else { + // 首次不立即发送:给机器人留出进入世界与登录的时间 + this._nextDueAt.set(task.id, current + task.intervalSeconds * 1000); + } } return this.tasks; } - /** 启动调度心跳 */ + /** 启动调度心跳(并开始监听服务器回复,用于发送结果确认) */ start() { + if (!this._chatBound && typeof this.manager.on === 'function') { + this.manager.on('chat', this._onChatBound); + this._chatBound = true; + } if (this._timer) return; this._timer = setInterval(() => this.tick(), TICK_INTERVAL_MS); if (this._timer.unref) this._timer.unref(); @@ -65,10 +97,14 @@ class ScheduledMessenger { stop() { if (this._timer) clearInterval(this._timer); this._timer = null; + if (this._chatBound && typeof this.manager.removeListener === 'function') { + this.manager.removeListener('chat', this._onChatBound); + this._chatBound = false; + } } /** - * 心跳:把所有到点的任务发出去。 + * 心跳:把所有到点的任务发出去,并检查待确认的发送是否超时。 * * 抽成公开方法是为了可测 —— 测试里直接调 `tick()` 并注入当前时间, * 不必真的等 300 秒。 @@ -78,31 +114,105 @@ class ScheduledMessenger { */ tick(now) { const current = Number.isFinite(now) ? now : Date.now(); + this._checkPendingConfirmations(current); let sent = 0; for (const task of this.tasks) { if (!task.enabled) continue; const due = this._nextDueAt.get(task.id); if (due === undefined) { - this._nextDueAt.set(task.id, current + task.intervalSeconds * 1000); + this._reschedule(task, current); continue; } if (current < due) continue; // 未就绪就顺延到下一个周期,不补发 if (!this.manager.canSendChat()) { - this._nextDueAt.set(task.id, current + task.intervalSeconds * 1000); + this._reschedule(task, current); continue; } const ok = this.manager.sendChat(task.content); - this._nextDueAt.set(task.id, current + task.intervalSeconds * 1000); + this._reschedule(task, current); if (ok) { sent++; + task.sentCount = (task.sentCount || 0) + 1; + this._armConfirmation(task, current); this._log(`定时任务「${task.label || task.id}」已发送:${task.content}`); } } return sent; } + /** + * 排下一次发送时间:cron 任务取下一个匹配时刻,间隔任务取 now + interval。 + * @param {object} task + * @param {number} now + */ + _reschedule(task, now) { + if (task.mode === 'cron') { + const parsed = this._parsedCron.get(task.id); + const next = parsed ? nextCronTime(parsed, now) : null; + if (next !== null) { + this._nextDueAt.set(task.id, next); + return; + } + // 理论上不会发生(setTasks 时已校验),兜底退回间隔模式 + task.mode = 'interval'; + } + this._nextDueAt.set(task.id, now + task.intervalSeconds * 1000); + } + + /** + * 登记一条「待确认」:只有配了 expectPattern 的任务才需要确认服务器是否响应。 + * @param {object} task + * @param {number} now + */ + _armConfirmation(task, now) { + if (!task.expectPattern) return; + this._pending.set(task.id, { + taskId: task.id, + pattern: buildPattern(task.expectPattern), + sentAt: now, + deadline: now + task.confirmTimeoutSeconds * 1000 + }); + } + + /** 待确认超时的任务记为「未确认」并写警告 */ + _checkPendingConfirmations(now) { + if (this._pending.size === 0) return; + for (const [taskId, pending] of Array.from(this._pending)) { + if (now < pending.deadline) continue; + this._pending.delete(taskId); + const task = this.tasks.find((t) => t.id === taskId); + if (!task) continue; + task.lastResult = { + at: pending.sentAt, + ok: false, + detail: `${task.confirmTimeoutSeconds} 秒内没有收到预期回复` + }; + this._log(`定时任务「${task.label || task.id}」发送后 ${task.confirmTimeoutSeconds} 秒内` + + `没有收到预期回复(${task.expectPattern})—— 指令可能没有生效`, 'warn'); + } + } + + /** + * 收到游戏消息:命中待确认任务的模式就记为「已确认生效」。 + * @param {string} message + */ + _onChatMessage(message) { + if (!message || this._pending.size === 0) return; + const text = String(message); + for (const [taskId, pending] of Array.from(this._pending)) { + if (!pending.pattern.test(text)) continue; + this._pending.delete(taskId); + const task = this.tasks.find((t) => t.id === taskId); + if (task) { + task.lastResult = { at: Date.now(), ok: true, detail: text.slice(0, 120) }; + this._log(`定时任务「${task.label || task.id}」已确认生效:${text.slice(0, 80)}`, 'success'); + } + break; + } + } + /** @returns {object} 供控制台展示的状态快照 */ snapshot() { const now = Date.now(); @@ -115,14 +225,20 @@ class ScheduledMessenger { }); } - _log(message) { - if (this.logger) this.logger.log(`[${this.manager.connection.username}] ${message}`); + _log(message, level) { + if (this.logger) this.logger.log(`[${this.manager.connection.username}] ${message}`, level); } } +// ==================== 任务归一化 ==================== + /** * 归一化任务列表:丢弃非法项、补齐默认值、限制数量。 * + * 调度方式二选一: + * - 写了合法的 `cron`(5 段表达式)→ cron 模式; + * - 否则按 `intervalSeconds` 固定间隔。 + * * @param {Array} tasks * @returns {Array} */ @@ -139,15 +255,30 @@ function normalizeTasks(tasks) { if (!id || seen.has(id)) id = `task-${i + 1}`; while (seen.has(id)) id += '_'; seen.add(id); - result.push({ + const cronText = String(raw.cron || '').trim(); + const cronValid = cronText !== '' && parseCron(cronText) !== null; + const task = { id, label: String(raw.label || '').trim(), content, enabled: raw.enabled !== false, + /** 调度模式:cron(cron 表达式)| interval(固定间隔) */ + mode: cronValid ? 'cron' : 'interval', intervalSeconds: clampInterval(raw.intervalSeconds), + cron: cronValid ? cronText : '', + /** 期望的服务器回复(正则):用于确认发送结果,留空表示不确认 */ + expectPattern: String(raw.expectPattern || '').trim(), + confirmTimeoutSeconds: clampConfirmTimeout(raw.confirmTimeoutSeconds), + /** 累计发送次数与最近一次发送结果(确认成功 / 超时未确认) */ + sentCount: Number.isFinite(raw.sentCount) ? raw.sentCount : 0, + lastResult: raw.lastResult || null, /** 以 `/` 开头的内容会被 sendChat 自动转成指令 */ kind: content.startsWith('/') ? 'command' : 'chat' - }); + }; + if (cronText !== '' && !cronValid) { + task.cronError = `cron 表达式无效:${cronText}(已按固定间隔发送)`; + } + result.push(task); } return result; } @@ -163,4 +294,162 @@ function clampInterval(value) { 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 +}; \ No newline at end of file diff --git a/src/config/expansionConfig.js b/src/config/expansionConfig.js index 422372a..be08a9b 100644 --- a/src/config/expansionConfig.js +++ b/src/config/expansionConfig.js @@ -174,7 +174,17 @@ function defaultConfig() { * A* 的代价搜索半径:节点代价超过「起点到目标的启发值 + 该值」就被剪枝。 * 负数表示不限制(复杂地形里最稳,但搜索更慢)。 */ - searchRadius: 256 + searchRadius: 256, + /** + * 寻路执行器: + * - `pathfinder`(默认):由 mineflayer-pathfinder 自己执行; + * - `manual`:**备选方案**(TODO 问题 1)。只用 A* 算路径,执行改为自己按键 + * (朝路径点转 → 按住前进 → 遇坎跳),控制键时序与手动移动测试完全一致。 + * 仅在实测确认 pathfinder 的执行层被服务端回弹时才需要切换。 + */ + executor: 'pathfinder', + /** 手动执行器单段执行的超时(毫秒),防止卡死时无限按键 */ + manualTimeoutMs: 180000 }, // ==================== 战斗辅助(新增) ==================== @@ -226,6 +236,30 @@ function defaultConfig() { * 开启后发包前会用脚下方块复核,只有确实不一致时才改写。 */ reportOnGroundFromWorld: 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` 包(默认开启,**线上服务器必需**)。 * @@ -271,7 +305,51 @@ function defaultConfig() { /** 启动时是否自动预爬取 */ crawlOnStart: true, /** 瓦片下载并发数(对源站友好,默认 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 }, // ==================== 服务器交互(新增) ==================== @@ -420,11 +498,15 @@ function validateConfig(config) { config.pathing.tickTimeoutMs = clampInt(config.pathing.tickTimeoutMs, 5, 5000, 40); // searchRadius 允许为负(不限制),因此单独钳制下限 config.pathing.searchRadius = clampInt(config.pathing.searchRadius, -1, 100000, 256); + if (config.pathing.executor !== 'manual') config.pathing.executor = 'pathfinder'; + config.pathing.manualTimeoutMs = clampInt(config.pathing.manualTimeoutMs, 10000, 3600000, 180000); config.combat.scanRange = clampNumber(config.combat.scanRange, 1, 32, 4.5); + config.coordination.targetLeaseTtlSeconds = clampInt(config.coordination.targetLeaseTtlSeconds, 30, 3600, 180); config.grim.maxRotationPerTickDeg = clampNumber(config.grim.maxRotationPerTickDeg, 1, 180, 8.6); config.grim.attackReach = clampNumber(config.grim.attackReach, 1, 6, 3.0); + config.grim.setbackSyncEpsilon = clampNumber(config.grim.setbackSyncEpsilon, 0, 5, 0.5); config.bigMap.zoom = clampInt(config.bigMap.zoom, 0, 8, 0); config.bigMap.maxZoom = clampInt(config.bigMap.maxZoom, 0, 8, 3); @@ -432,6 +514,10 @@ function validateConfig(config) { if (config.bigMap.zoom > config.bigMap.maxZoom) config.bigMap.zoom = config.bigMap.maxZoom; config.bigMap.rangeBlocks = clampInt(config.bigMap.rangeBlocks, 256, 200000, 4096); config.bigMap.concurrency = clampInt(config.bigMap.concurrency, 1, 16, 4); + config.bigMap.prewarmRangeBlocks = clampInt(config.bigMap.prewarmRangeBlocks, 256, 200000, 1024); + config.bigMap.prewarmLevels = clampInt(config.bigMap.prewarmLevels, 0, 8, 3); + config.bigMap.prewarmMinMoveBlocks = clampInt(config.bigMap.prewarmMinMoveBlocks, 0, 100000, 512); + config.bigMap.prewarmIntervalMs = clampInt(config.bigMap.prewarmIntervalMs, 60000, 86400000, 600000); if (typeof config.bigMap.world !== 'string') config.bigMap.world = ''; if (typeof config.bigMap.followBot !== 'boolean') config.bigMap.followBot = true; @@ -454,10 +540,11 @@ function validateConfig(config) { } if (typeof config.stateName !== 'string') config.stateName = ''; 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.loginCommand !== 'string') config.server.loginCommand = ''; if (typeof config.server.registerCommand !== 'string') config.server.registerCommand = ''; + if (typeof config.server.authCommand !== 'string') config.server.authCommand = ''; + if (typeof config.notify.webhookUrl !== 'string') config.notify.webhookUrl = ''; return config; } diff --git a/src/controller/expansionController.js b/src/controller/expansionController.js index 511175d..2f71a4e 100644 --- a/src/controller/expansionController.js +++ b/src/controller/expansionController.js @@ -44,6 +44,12 @@ const EXPORT_SUMMARY_KEYS = ['状态', '目标区块', '玩家区块', '队列', */ const PATH_PROGRESS_MIN_CHUNKS = 1; +/** + * 候选区块全被其他机器人预定时,隔多久重新规划(毫秒)。 + * 取 15 秒:既不会忙等,也不会在别人刚释放时错过太久。 + */ +const LEASE_RETRY_DELAY_MS = 15000; + /** * 自动扩地控制器:扩地任务的核心状态机。 * @@ -62,6 +68,7 @@ class ExpansionController { * @param {string} [deps.botId] 所属机器人 id(多机器人场景,用于日志归属) * @param {string} [deps.botName] 所属机器人游戏名 * @param {import('../core/expansionLog').ExpansionLog} [deps.expansionLog] 开拓日志 + * @param {import('../core/targetLease').TargetLeaseTable} [deps.targetLeases] 目标区块租约表(多机器人共享) */ constructor(deps) { this.botManager = deps.botManager; @@ -75,6 +82,14 @@ class ExpansionController { ? deps.botManager.connection.username : this.botId); /** @type {import('../core/expansionLog').ExpansionLog|null} */ this.expansionLog = deps.expansionLog || null; + /** + * 目标区块租约表(多机器人共享,可空 —— 单机器人场景不需要协调)。 + * 详见 core/targetLease.js:抢到的机器人独占区块,其余机器人跳过。 + * @type {import('../core/targetLease').TargetLeaseTable|null} + */ + this.targetLeases = deps.targetLeases || null; + /** 候选区块全被其他机器人预定时,下一次重新规划的时间戳(避免忙等) */ + this._nextReplanAt = 0; this.planner = new ExpansionPlanner(); /** @type {import('../core/subtitleChatMonitor').SubtitleChatMonitor} */ this.monitor = new SubtitleChatMonitor({ @@ -131,6 +146,24 @@ class ExpansionController { this.sideStepStartPos = null; /** 本次回城是否由耐久保护触发:RETURNING 结束后停止任务而非重新规划 */ this.returnThenStop = false; + /** + * 最近一次回城的前后坐标与距离。 + * + * 回城是破坏性操作(会把机器人拉回安全点、清掉玩家手动摆好的位置), + * 用户必须能在控制台上一眼看出「这次回城把机器人从哪挪到了哪」, + * 从而判断回城是否值得。 + * @type {{at: number, reason: string, from: object|null, to: object|null, distance: number|null}|null} + */ + this.lastReturn = null; + /** 待回城时记录的「回城前坐标」(传送完成后与当前位置组成前后对比) */ + this.pendingReturn = null; + /** + * 因「原地还能走」而放弃回城的次数。 + * + * 连续超时达到回城阈值并不代表卡死 —— 位置还能走时回城毫无意义, + * 只会白白丢掉当前位置,因此这种情况下改为跳过目标并记入这里。 + */ + this.returnSkippedNotStuck = 0; /** 自动攻击当前目标的实体 ID(仅用于避免重复记录战斗日志) */ this.combatTargetEntityId = -1; /** 当前是否正持续按住跳跃键(自动游泳用) */ @@ -286,6 +319,9 @@ class ExpansionController { if (this.sideStepping) this.stopSideStep(); this.releaseJump(); if (bm.boat) bm.boat.reset(true); + // 停止任务时归还全部目标租约,别的机器人可以立刻接手这些区块 + if (this.targetLeases) this.targetLeases.releaseAll(this.botId); + this._nextReplanAt = 0; this.setState(State.STOPPED, '已手动停止'); this._log('已手动停止'); } @@ -305,6 +341,8 @@ class ExpansionController { return; } this.playerChunk = this._playerChunk(); + // 续租当前目标:长距离寻路可能要走几分钟,不能让租约中途过期被别人抢走 + if (this.isRunning()) this._renewTargetLease(); // 等待开垦时段自动开始:进入时段后自动启动已登记的任务 if (this.pendingStart && this.isWithinClaimTime()) { @@ -369,21 +407,39 @@ class ExpansionController { this.queue = []; const model = this.dataService.getModel(); const planned = this.planner.generate(model, this.config, this.playerChunk); + let leasedByOthers = 0; for (const chunk of planned) { - if (!this._isClaimedByOthers(chunk)) this.queue.push(chunk); + if (this._isClaimedByOthers(chunk)) continue; + // 被其他机器人预定(租约未到期)的区块先不排队,等下轮规划再看 + if (this.targetLeases && this.targetLeases.isLeasedByOther(chunk, this.botId)) { + leasedByOthers++; + continue; + } + this.queue.push(chunk); } // 矩形区域扩地时不受扩圈上限限制(玩家圈选的矩形即为明确边界,需完整填充) const rectangleMode = this.config.strategy === 2 && this.config.regionMaxX >= this.config.regionMinX; if (this.config.maxRings > 0 && !rectangleMode) { this.queue = this.queue.filter((chunk) => this._ringDistance(chunk, model) <= this.config.maxRings); } - this._log(`规划完成,共 ${this.queue.length} 个候选区块`); + this._log(`规划完成,共 ${this.queue.length} 个候选区块` + + (leasedByOthers > 0 ? `(另有 ${leasedByOthers} 个被其他机器人预定)` : '')); if (this.queue.length === 0) { + if (leasedByOthers > 0) { + this._waitForLeaseRelease(leasedByOthers); + return; + } this.setState(State.COMPLETE, '没有可用的相邻无主区块'); this._log('没有可用的相邻无主区块'); return; } - this.target = this.queue.shift(); + const picked = this._pickNextTarget(); + if (!picked.target) { + this.setState(State.COMPLETE, '没有可用的相邻无主区块'); + this._log('没有可用的相邻无主区块'); + return; + } + this.target = picked.target; this._startPathing(); } @@ -500,15 +556,21 @@ class ExpansionController { + '(注意:这会把机器人拉回安全点,当前位置与玩家手动摆好的位置都会丢失)'); bm.sendCommand(command); this.returnStartMillis = Date.now(); + // 记下回城前坐标:传送完成后与当前位置组成「从 A 挪到了 B」的对比 + this.pendingReturn = { reason, from: this._precisePos(), at: Date.now() }; this.setState(State.RETURNING, '等待回城传送(10 秒后自动传送)'); } /** * 回城传送等待阶段每 tick 逻辑:等待 returnWaitSeconds 秒后, * 耐久保护触发的回城 → 停止任务;寻路超时触发的回城 → 重新规划扩地计划。 + * + * 传送完成后记录回城前后坐标(控制台据此展示「回城把机器人从 A 挪到了 B」), + * 位置几乎没变时明确提示 —— 说明这个卡死点同时也是安全点,回城没有帮助。 */ _tickReturning() { if (Date.now() - this.returnStartMillis < this.config.returnWaitSeconds * 1000) return; + this._recordReturnResult(); if (this.returnThenStop) { this.returnThenStop = false; this._log('耐久保护回城完成,任务已停止;请修复 / 更换装备后重新开始扩地'); @@ -520,6 +582,28 @@ class ExpansionController { this.plan(); } + /** + * 记录回城结果:前后坐标与直线距离,并写入明确日志。 + * 没有待记录的回城(`pendingReturn` 为空)时什么也不做。 + */ + _recordReturnResult() { + const pending = this.pendingReturn; + if (!pending) return; + this.pendingReturn = null; + const to = this._precisePos(); + const from = pending.from; + const distance = from && to + ? Math.round(Math.hypot(to.x - from.x, to.z - from.z) * 10) / 10 + : null; + this.lastReturn = { at: Date.now(), reason: pending.reason, from, to, distance }; + if (distance === null) return; + this._log(`回城结果:机器人从 ${formatPosition(from)} 挪到了 ${formatPosition(to)}(直线 ${distance} 格)`); + if (distance < 1) { + this._log('本次回城几乎没有改变位置 —— 说明卡死点同时也是安全点,回城没有帮助;' + + '可考虑关闭「超时自动回城」,避免白白丢掉手动摆好的位置', 'warn'); + } + } + /** * 目标寻路重试耗尽时的脱困策略入口:取消当前寻路,改为朝「目标连线垂直方向」 * 走开约 2.5 格(左右各试一侧),走开后自动重新寻路原目标。 @@ -616,6 +700,16 @@ class ExpansionController { this.setState(State.FAIL, reason + '(保护期内不回城)'); return; } + // 回城前先判断「原地是否真的更差」:位置还能走时回城毫无意义,只会白白丢位置 + const assess = this._assessReturnWorth(); + if (!assess.worth) { + this.returnSkippedNotStuck++; + this._log(`连续超时达阈值,但${assess.reason} —— 放弃回城,改为跳过当前目标` + + '(回城会把机器人拉回安全点,可能丢掉玩家摆好的位置)', 'warn'); + this.setState(State.FAIL, reason + '(原地还能走,未回城)'); + return; + } + this._log(`连续超时达阈值,${assess.reason}`, 'warn'); this._sendReturnCommand(`连续寻路超时超过 ${config.returnThresholdSeconds} 秒`); return; } @@ -626,6 +720,44 @@ class ExpansionController { this.setState(State.FAIL, reason); } + /** + * 回城前的「原地是否真的更差」评估。 + * + * 回城是破坏性操作:它把机器人拉回安全点,会清掉玩家手动摆好的位置。 + * 如果当前位置还能走(寻路器能从脚下节点生成邻居),说明只是走得慢 / 目标不可达, + * 回城不会带来任何好处 —— 这种情况应当跳过目标,而不是传送。 + * + * 判据取自 `pathing.diagnostics().neighborCount`(寻路器从脚下节点能生成的可走方向数): + * - `0`:完全无路可走,确实卡死,回城值得一试; + * - `≥1`:还能走,回城没有意义; + * - 拿不到诊断(未连接 / 寻路器未就绪):保守起见仍按卡死处理。 + * + * @returns {{worth: boolean, neighborCount: number|null, reason: string}} + */ + _assessReturnWorth() { + const bm = this.botManager; + let neighborCount = null; + try { + if (bm.pathing && typeof bm.pathing.diagnostics === 'function') { + const diag = bm.pathing.diagnostics(); + if (diag && typeof diag.neighborCount === 'number') neighborCount = diag.neighborCount; + } + } catch { + neighborCount = null; + } + if (neighborCount === null) { + return { worth: true, neighborCount, reason: '拿不到寻路诊断(无法判断原地是否能走),按卡死处理' }; + } + if (neighborCount >= 1) { + return { + worth: false, + neighborCount, + reason: `当前位置还有 ${neighborCount} 个可走方向(不是卡死),回城不会更好` + }; + } + return { worth: true, neighborCount, reason: `当前位置 0 个可走方向(疑似卡死),回城值得一试` }; + } + /** 等待开垦阶段每 tick 逻辑:玩家离开目标区块则重新寻路;等待超时则重试或跳过 */ _tickWaiting() { if (!this.target) { @@ -675,16 +807,26 @@ class ExpansionController { /** 处理下一个目标:达成目标数量则完成;队列为空时重新规划,否则取队首开始寻路 */ _tickNext() { + this._releaseTargetLease(); this.target = null; + // 候选区块正被其他机器人预定:等一会儿再重新规划(避免忙等,见 _waitForLeaseRelease) + if (this._nextReplanAt && Date.now() < this._nextReplanAt) return; + this._nextReplanAt = 0; if (this.config.targetCount > 0 && this.completedClaims >= this.config.targetCount) { this.finish(); return; } - if (this.queue.length === 0) { + const picked = this._pickNextTarget(); + if (!picked.target) { + if (picked.skippedLease > 0) { + this._waitForLeaseRelease(picked.skippedLease); + return; + } + // 队列真的空了:重新规划 this.plan(); return; } - this.target = this.queue.shift(); + this.target = picked.target; if (this._isClaimedByOthers(this.target)) { this._log(`目标已被占领,跳过 ${this.target.x},${this.target.z}`); this.setState(State.NEXT, '跳过已占领区块'); @@ -695,6 +837,7 @@ class ExpansionController { /** 完成任务 */ finish() { + this._releaseTargetLease(); this.botManager.pathing.cancel(); this.setState(State.COMPLETE, this.config.targetCount > 0 ? `已完成目标数量 ${this.completedClaims}/${this.config.targetCount}` @@ -762,6 +905,55 @@ class ExpansionController { return this.dataService.getModel().allClaimedChunks().has(chunkPos.key(chunk.x, chunk.z)); } + // ==================== 目标区块租约(多机器人协调) ==================== + + /** + * 从队列里取出下一个可用目标: + * 跳过已被占领的区块,以及被其他机器人租约占用的区块(谁先抢到谁去)。 + * + * @returns {{target: {x: number, z: number}|null, skippedLease: number}} + */ + _pickNextTarget() { + let skippedLease = 0; + while (this.queue.length > 0) { + const chunk = this.queue.shift(); + if (this._isClaimedByOthers(chunk)) continue; + if (this.targetLeases && !this.targetLeases.tryAcquire(chunk, this.botId)) { + skippedLease++; + this._log(`区块 ${chunk.x},${chunk.z} 已被其他机器人预定,跳过`); + continue; + } + return { target: chunk, skippedLease }; + } + return { target: null, skippedLease }; + } + + /** 续租当前目标(每个 tick 调用,保证长时间寻路时租约不过期) */ + _renewTargetLease() { + if (!this.targetLeases || !this.target) return; + this.targetLeases.renew(this.target, this.botId); + } + + /** 释放当前目标的租约(幂等:没有目标或没有租约表时什么都不做) */ + _releaseTargetLease() { + if (!this.targetLeases || !this.target) return; + this.targetLeases.release(this.target, this.botId); + } + + /** + * 候选区块暂时都被其他机器人预定时的处理:等一会儿再重新规划。 + * + * 直接判 COMPLETE 会让机器人在别人还在开垦时提前收工 —— 那些区块可能是 + * 别人的目标失败了才释放的,稍后再看更合理。这里只设一个时间戳, + * 由 {@link _tickNext} 的时间闸门控制,不做定时器,避免并发状态。 + */ + _waitForLeaseRelease(skippedLease) { + this._nextReplanAt = Date.now() + LEASE_RETRY_DELAY_MS; + this._log(`${skippedLease} 个候选区块正被其他机器人预定,` + + `${Math.round(LEASE_RETRY_DELAY_MS / 1000)} 秒后重新规划`, 'warn'); + this.setState(State.NEXT, '候选区块被其他机器人预定,等待释放'); + } + /** * 计算区块到己方领土的「圈数」(切比雪夫距离的最小值),用于过滤扩圈上限。 * @param {{x: number, z: number}} chunk @@ -1027,6 +1219,23 @@ class ExpansionController { }; } + /** + * @returns {{x: number, y: number, z: number}|null} 机器人当前位置(两位小数) + * + * 与 `_blockPos` 的区别:这里是精确坐标,用于「回城前后挪了多少」这种对比展示, + * 取整会把几格的位移抹掉。 + */ + _precisePos() { + const bot = this.botManager.bot; + if (!bot || !bot.entity) return null; + const pos = bot.entity.position; + return { + x: Math.round(pos.x * 100) / 100, + y: Math.round(pos.y * 100) / 100, + z: Math.round(pos.z * 100) / 100 + }; + } + _describeChunk(chunk) { return chunk ? `${chunk.x},${chunk.z}` : '无'; } @@ -1072,6 +1281,10 @@ class ExpansionController { consecutiveTimeoutMs: this.consecutiveTimeoutMillis, sideStepping: this.sideStepping, sideStepCount: this.sideStepCount, + /** 因「原地还能走」而放弃回城的次数(回城前评估的结果) */ + returnSkippedNotStuck: this.returnSkippedNotStuck, + /** 最近一次回城的前后坐标与直线距离(控制台展示「从 A 挪到了 B」) */ + lastReturn: this.lastReturn, autoBoatActive: !!(this.botManager.boat && this.botManager.boat.isActive()), withinClaimTime: this.isWithinClaimTime(), claimModeSent: this._claimModeSent, @@ -1097,6 +1310,15 @@ class ExpansionController { } } +/** + * 位置快照 → 日志里可读的文本。 + * @param {{x: number, y: number, z: number}|null} pos + * @returns {string} + */ +function formatPosition(pos) { + return pos ? `(${pos.x}, ${pos.y}, ${pos.z})` : '(未知)'; +} + /** * 格式化为 HH:mm:ss。 * @param {Date} date diff --git a/src/core/bigMapService.js b/src/core/bigMapService.js index e731b42..2c77e9a 100644 --- a/src/core/bigMapService.js +++ b/src/core/bigMapService.js @@ -66,6 +66,26 @@ class BigMapService { lastError: '' }; this._crawlAbort = false; + /** + * 后台预热状态(机器人移动到新区域后顺带拉取周边高层级瓦片)。 + * + * `lastCenterX/lastCenterZ` 用来判断「机器人是否移动够远」, + * `lastAt` 用来兜底「很久没预热就再预热一次」。 + */ + this.prewarm = { + running: false, + lastAt: 0, + lastCenterX: null, + lastCenterZ: null, + /** 最近一次预热覆盖的层级范围(展示用) */ + minZoom: 0, + maxZoom: 0, + total: 0, + done: 0, + failed: 0, + finishedAt: 0 + }; + this._prewarmAbort = false; } // ==================== 坐标系 ==================== @@ -307,6 +327,97 @@ class BigMapService { this._crawlAbort = true; } + // ==================== 后台预热 ==================== + + /** + * 后台低优先级预热:机器人移动到新区域后,顺带把周边的高层级瓦片拉下来。 + * + * 只有「启动预爬取层级」(默认 0,全世界 10×10 张)会批量爬,层级 1~3 原本 + * 要靠用户缩放时按需拉取 —— 首次浏览某个区域要等好几秒。预热就是用空闲带宽 + * 把「以机器人为中心、半径 prewarmRangeBlocks」的较高层级补下来。 + * + * 节流规则(避免频繁骚扰源站): + * - 已有批量爬取或预热在途 → 直接跳过; + * - 机器人移动不足 `prewarmMinMoveBlocks` 且距上次预热不足 `prewarmIntervalMs` → 跳过; + * - 并发取批量爬取的一半(低优先级),已缓存的瓦片只会做一次 statSync。 + * + * @param {number} centerBlockX + * @param {number} centerBlockZ + * @returns {Promise} 预热结果(跳过关时返回 `{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`, markersUrl: `${this.baseUrl}/tiles/${this.world}/markers.json` }, - crawl: Object.assign({}, this.crawl) + crawl: Object.assign({}, this.crawl), + /** 后台预热状态:最近一次覆盖的层级、成功 / 失败数与上次位置 */ + prewarm: Object.assign({}, this.prewarm) }; } diff --git a/src/core/notifier.js b/src/core/notifier.js new file mode 100644 index 0000000..1206e31 --- /dev/null +++ b/src/core/notifier.js @@ -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} 去重 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} 是否成功发出 + */ + 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 }; \ No newline at end of file diff --git a/src/core/targetLease.js b/src/core/targetLease.js new file mode 100644 index 0000000..22645de --- /dev/null +++ b/src/core/targetLease.js @@ -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} */ + 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 }; \ No newline at end of file diff --git a/src/index.js b/src/index.js index 4dee78c..f331d5f 100644 --- a/src/index.js +++ b/src/index.js @@ -16,6 +16,7 @@ const { TerritoryDataService } = require('./core/territoryDataService'); const { ExpansionLog } = require('./core/expansionLog'); const { ChatLog } = require('./core/chatLog'); const { BigMapService } = require('./core/bigMapService'); +const { Notifier } = require('./core/notifier'); const { WebConsole } = require('./web/server'); /** 控制器 tick 间隔(毫秒),50ms ≈ 20 TPS */ @@ -58,6 +59,8 @@ async function main() { const botStore = new BotStore(); // 控制台新增玩家的凭据单独落盘(0600),与 .env 互为补充、.env 优先 const credentialStore = new BotCredentialStore(); + // 通知提醒(Webhook):等待验证码 / 原点自救失败时把消息推到飞书等渠道 + const notifier = new Notifier(config, logger); const pool = new BotPool({ connections, config, @@ -66,7 +69,8 @@ async function main() { expansionLog, chatLog, botStore, - credentialStore + credentialStore, + notifier }); // 账号来源有两个:.env 的 MC_BOTS / MC_USERNAME,以及控制台「添加玩家」写入的本地凭据文件 @@ -86,6 +90,7 @@ async function main() { expansionLog, chatLog, bigMap, + notifier, getConfig: () => config, applyConfig: (next) => applyConfig(next) }, @@ -115,6 +120,7 @@ async function main() { bigMap.config = config; bigMap.world = config.bigMap.world || process.env.DYNMAP_WORLD || bigMap.world; } + notifier.config = config; logger.log('配置已更新并应用'); return config; } diff --git a/src/web/public/app.js b/src/web/public/app.js index 44fa557..dc253b4 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -160,6 +160,18 @@ const CONFIG_SCHEMA = { { path: 'regionCenterZ', label: '中心 Z(区块)', type: 'number' }, { path: 'regionRadius', label: '半径(区块)', type: 'number', min: 1 } ] + }, + { + title: '多机器人协调', + fields: [ + { + path: 'coordination.targetLeaseTtlSeconds', + label: '目标区块租约时长(秒)', + type: 'number', + min: 30, + hint: '多个机器人同时扩地时用租约避免撞在同一区块上;持有者持续续租,掉线 / 卡死后租约到期自动释放' + } + ] } ], timing: [ @@ -218,7 +230,12 @@ const CONFIG_SCHEMA = { { path: 'pathing.liquidCost', label: '液体格额外代价', type: 'number', min: 1 }, { path: 'pathing.canOpenDoors', label: '允许开门', type: 'checkbox' }, { 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: '单段手动执行的最长时间,防止卡死时无限按键' } ] } ], @@ -282,7 +299,12 @@ const CONFIG_SCHEMA = { { 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.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: '即便没怎么移动,超过这个时间也会再预热一次' } ] } ], @@ -301,6 +323,9 @@ const CONFIG_SCHEMA = { { path: 'grim.checkPhysicsIntegrity', label: '启动物理参数自检', type: 'checkbox' }, { path: 'grim.blockActionsWhileRiding', label: '骑乘时禁止攻击 / 进食', type: 'checkbox' }, { path: 'grim.reportOnGroundFromWorld', label: '按世界数据修正 onGround', type: 'checkbox', hint: '服务端频繁下发位置包时,mineflayer 会持续上报「站地上却在空中」而被反作弊每 tick 拉回;开启后在发包前用脚下方块复核(实测本服必需)' }, + { 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.sendTickEnd', label: '每 tick 补发 tick_end', type: 'checkbox', hint: '原版 1.21.2+ 客户端每 tick 末尾发送,反作弊用它判定 tick 边界' } ] @@ -322,6 +347,15 @@ const CONFIG_SCHEMA = { { path: 'server.reconnectDelayMs', label: '重连间隔(毫秒)', type: 'number', min: 1000 }, { 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: '自救失败多半说明被切到了登录服,需要人工发验证码' } + ] } ] }; @@ -442,11 +476,41 @@ function renderConfig() { 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) { const wrap = document.createElement('div'); wrap.className = 'field'; + // 供配置搜索使用:名称 + 字段路径 + 说明一起匹配,输入「回城」「returnOnTimeout」都能找到 + wrap.dataset.search = `${field.label} ${field.path} ${field.hint || ''}`.toLowerCase(); const label = document.createElement('label'); label.textContent = field.label; wrap.appendChild(label); @@ -751,6 +815,18 @@ function renderStatus(status) { loginEl.title = ''; } $('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'); @@ -789,6 +865,21 @@ function renderStatus(status) { $('kRetry').textContent = String(task.retryCount || 0); $('kProgress').textContent = (task.lastProgress || 0) + '%'; $('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 ? '时段内' : '时段外'; $('progressBar').style.width = (task.lastProgress || 0) + '%'; @@ -850,6 +941,13 @@ function renderStatus(status) { $('kGrimPackets').style.color = parts.length > 0 ? 'var(--green)' : 'var(--yellow)'; } $('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').style.color = grim.physicsOk ? 'var(--green)' : 'var(--red)'; const issues = $('grimIssues'); @@ -878,7 +976,10 @@ function renderSchedules(schedules) { const list = $('scheduleList'); if (!list) return; 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('|'); if (signature === scheduleSignature) return; scheduleSignature = signature; @@ -887,7 +988,7 @@ function renderSchedules(schedules) { if (!schedules || schedules.length === 0) { const empty = document.createElement('div'); empty.className = 'schedule-empty'; - empty.textContent = '暂无定时任务。点「+ 新增」可让机器人每隔一段时间重复发送同一条消息或指令。'; + empty.textContent = '暂无定时任务。点「+ 新增」可让机器人每隔一段时间(或按 cron 表达式)重复发送同一条消息或指令。'; list.appendChild(empty); return; } @@ -911,8 +1012,22 @@ function renderSchedules(schedules) { content.appendChild(code); const sub = document.createElement('div'); sub.className = 'sub'; - sub.textContent = `${task.kind === 'command' ? '指令' : '公屏'} · 每 ${task.intervalSeconds} 秒` - + (task.label ? ` · ${task.label}` : ''); + // 调度描述:cron 任务显示表达式,间隔任务显示秒数;无效表达式会带出错误说明 + 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); item.appendChild(content); @@ -949,14 +1064,18 @@ async function saveSchedules(schedules) { } } -/** 取当前列表的原始定义(去掉服务端附加的剩余秒数字段) */ +/** 取当前列表的原始定义(去掉服务端附加的剩余秒数 / 发送结果等运行时字段) */ function currentSchedules() { const bot = selectedBot(lastStatus || {}); return ((bot && bot.schedules) || []).map((t) => ({ id: t.id, label: t.label, content: t.content, + mode: t.mode === 'cron' ? 'cron' : 'interval', + cron: t.cron || '', intervalSeconds: t.intervalSeconds, + expectPattern: t.expectPattern || '', + confirmTimeoutSeconds: t.confirmTimeoutSeconds, enabled: t.enabled })); } @@ -1629,9 +1748,46 @@ async function renderSimpV2(body, force) { // ==================== 运行日志 ==================== const MAX_LOG_LINES = 800; +/** + * 待渲染的日志队列。 + * + * 服务端是「一条日志一次推送」,而机器人卡住时日志会成串到达(例如「被服务器传送」 + * 的爆发),逐条改 DOM + 逐条写 scrollTop 会让日志区明显卡顿(手机上尤其明显)。 + * 这里把同一帧内到达的日志合并成一次 DOM 更新。 + */ +let pendingLogs = []; +let logFlushScheduled = false; 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 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'); line.className = 'log-line log-' + (entry.level || 'info'); const time = document.createElement('span'); @@ -1639,14 +1795,14 @@ function appendLog(entry) { time.textContent = '[' + entry.time + ']'; line.appendChild(time); line.appendChild(document.createTextNode(entry.message)); - view.appendChild(line); - while (view.childNodes.length > MAX_LOG_LINES) view.removeChild(view.firstChild); - if ($('chkAutoScroll').checked) view.scrollTop = view.scrollHeight; + return line; } function renderLogs(entries) { + pendingLogs = []; $('logView').innerHTML = ''; - for (const entry of entries) appendLog(entry); + for (const entry of entries) pendingLogs.push(entry); + flushLogs(); } // ==================== 公屏聊天 ==================== @@ -1991,6 +2147,9 @@ function drawBigMap() { ctx.lineWidth = 1 / px; for (const state of bigMapView.markers.states) { 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.fill(state.path, 'evenodd'); ctx.strokeStyle = hexToRgba(state.color, 0.85); @@ -2304,14 +2463,26 @@ function setBigMapMarkers(markers) { for (const state of states) { const path = new Path2D(); let has = false; + // 方块坐标包围盒:全世界视角下用来按视野裁剪多边形(视野外的不必绘制,避免掉帧) + let minX = Infinity; + let maxX = -Infinity; + let minZ = Infinity; + let maxZ = -Infinity; for (const flat of state.polygons || []) { if (flat.length < 6) continue; path.moveTo(flat[0], flat[1]); for (let i = 2; i < flat.length; i += 2) path.lineTo(flat[i], flat[i + 1]); 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; } state.path = has ? path : null; + state.bounds = Number.isFinite(minX) ? { minX, maxX, minZ, maxZ } : null; } bigMapView.markers = { world: markers.world, updatedAt: markers.updatedAt, states }; } @@ -2495,11 +2666,21 @@ function bindEvents() { toast('请填写要发送的内容', true); 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(); next.push({ id: 'task-' + Date.now().toString(36), content, + mode, + cron: mode === 'cron' ? cron : '', intervalSeconds: Number($('schedInterval').value) || 300, + expectPattern: $('schedExpect').value.trim(), + confirmTimeoutSeconds: Number($('schedConfirm').value) || 10, label: $('schedLabel').value.trim(), enabled: true }); @@ -2507,6 +2688,11 @@ function bindEvents() { await saveSchedules(next); }); + // 调度方式切换:选 cron 时显示表达式输入框(固定间隔输入框保留,切回来不用重填) + $('schedMode').addEventListener('change', () => { + $('schedCronField').classList.toggle('hidden', $('schedMode').value !== 'cron'); + }); + $('btnRefreshMap').addEventListener('click', async () => { try { await api('/api/map/refresh', { method: 'POST' }); @@ -2555,6 +2741,9 @@ function bindEvents() { renderConfig(); }); + // 配置搜索:只隐藏不删除,保存时仍能读到隐藏字段的值 + $('configSearch').addEventListener('input', (e) => filterConfigFields(e.target.value)); + // ===== 地图视图切换 ===== $('mapTabs').addEventListener('click', (e) => { const tab = e.target.closest('.tab'); diff --git a/src/web/public/index.html b/src/web/public/index.html index bc31bf7..150af14 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -81,6 +81,7 @@
生命 / 饥饿
-
状态
-
登录验证
-
+
原点自救
-
重连次数
0
@@ -102,6 +103,7 @@
重试
0
开垦进度
0%
连续寻路超时
0s
+
最近回城
-
开垦时段
-
@@ -119,20 +121,42 @@
+
+ + +
+
+ +
- - + +
+
+ + +
+
+
+ +
-

只会在机器人已连接且通过登录验证时发送;未就绪的周期会跳过,不会补发。

+

只会在机器人已连接且通过登录验证时发送;未就绪的周期会跳过,不会补发。填了「期望回复」时,发出后会等待服务器回复,超时未回复会在日志里给出警告。

@@ -164,6 +188,7 @@
攻击距离
-
攻击冷却
-
协议补包
-
+
地面修正
-
已拦下动作
0
物理自检
-
@@ -323,6 +348,9 @@ +
diff --git a/src/web/public/style.css b/src/web/public/style.css index 7f175b6..0968d43 100644 --- a/src/web/public/style.css +++ b/src/web/public/style.css @@ -314,6 +314,17 @@ html, body { .log-error { color: var(--red); } .log-success { color: var(--green); } +/* + * 日志 / 公屏行很多时(移动端尤其明显):让浏览器跳过「视口外」行的渲染与布局。 + * 相当于浏览器自带的虚拟列表 —— 不改变内容与滚动位置,只是不再为看不见的行做绘制。 + * 不支持的浏览器会忽略这两行,行为与原来一致。 + */ +.log-line, +.chat-line { + content-visibility: auto; + contain-intrinsic-size: auto 20px; +} + .chat-line { display: block; } .chat-time { color: #55606f; margin-right: 6px; } .chat-bot { color: var(--accent-2); margin-right: 4px; } diff --git a/src/web/server.js b/src/web/server.js index b65e6d4..59d60cb 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -11,6 +11,13 @@ const { defaultConfig, normalizeConfig } = require('../config/expansionConfig'); /** 状态推送间隔(毫秒) */ const STATUS_PUSH_INTERVAL_MS = 1000; +/** + * 大地图预热检查间隔(毫秒)。 + * + * 这里只是「低频问一句」,真正的节流(移动够远 / 距上次够久)由 + * BigMapService.prewarmAround 自己判断,所以这个间隔可以设得很保守。 + */ +const MAP_PREWARM_CHECK_INTERVAL_MS = 60000; /** 登录接口路径(免令牌,否则无法登录) */ const LOGIN_PATH = '/api/login'; /** 会话探测接口路径(免令牌) */ @@ -52,6 +59,8 @@ class WebConsole { this.expansionLog = deps.expansionLog || null; this.chatLog = deps.chatLog || null; this.bigMap = deps.bigMap || null; + /** @type {import('../core/notifier').Notifier|null} 人工介入提醒(状态只暴露域名,不含完整地址) */ + this.notifier = deps.notifier || null; this.getConfig = deps.getConfig; this.applyConfig = deps.applyConfig; this.exportDir = opts.exportDir || path.join(__dirname, '..', '..', 'data'); @@ -120,6 +129,10 @@ class WebConsole { }, STATUS_PUSH_INTERVAL_MS); 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); if (this.chatLog) this.chatLog.onEntry = (entry) => this._broadcast('chat', entry); @@ -148,6 +161,7 @@ class WebConsole { /** 停止服务 */ async stop() { if (this._pushTimer) clearInterval(this._pushTimer); + if (this._mapPrewarmTimer) clearInterval(this._mapPrewarmTimer); this.logger.onEntry = null; if (this.chatLog) this.chatLog.onEntry = null; if (this.expansionLog) this.expansionLog.onEntry = null; @@ -949,6 +963,8 @@ class WebConsole { expansionLog: this.expansionLog ? this.expansionLog.stats() : null, chatLog: this.chatLog ? this.chatLog.snapshot() : null, bigMap: this.bigMap ? this.bigMapPayload() : null, + /** 通知提醒状态:是否启用 / 是否配好地址 / 已发与失败条数 */ + notify: this.notifier ? this.notifier.snapshot() : null, serverTime: Date.now(), claimTime: { start: this.getConfig().claimStartHour, @@ -1053,6 +1069,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} 大地图上的领土标记(多边形 + 王城) */ bigMapMarkersPayload() { const model = this.dataService.getModel(); diff --git a/test/bigMapService.test.js b/test/bigMapService.test.js index fd42ecd..aff9e3c 100644 --- a/test/bigMapService.test.js +++ b/test/bigMapService.test.js @@ -218,3 +218,65 @@ test('配置里 zoom 大于 maxZoom 时被钳制(避免爬到源站不存在 assert.strictEqual(normalized.bigMap.zoom, 3); assert.strictEqual(service.maxZoom, 3); }); + +// ==================== 后台预热(机器人移动后顺带补高层级瓦片)==================== + +test('prewarmAround 只预热比「启动预爬取层级」更高的层级', async () => { + const { service, calls } = setup({ bigMap: { zoom: 0, prewarmLevels: 2, prewarmRangeBlocks: 512 } }); + const result = await service.prewarmAround(0, 0); + assert.strictEqual(result.skipped, undefined, '首次调用应真正预热'); + assert.strictEqual(result.minZoom, 1); + assert.strictEqual(result.maxZoom, 2, '默认只预热上面两层'); + assert.ok(result.done > 0); + for (const url of calls) { + assert.ok(/\/tiles\/minecraft_overworld\/[12]\//.test(url), '不应预热层级 0(批量爬取负责):' + url); + } +}); + +test('prewarmAround 在机器人没怎么移动时跳过(避免频繁请求源站)', async () => { + const { service, calls } = setup({ bigMap: { prewarmMinMoveBlocks: 512, prewarmIntervalMs: 600000 } }); + await service.prewarmAround(1000, 1000); + const firstCalls = calls.length; + const again = await service.prewarmAround(1000, 1000); + assert.strictEqual(again.skipped, 'recent'); + assert.strictEqual(calls.length, firstCalls, '跳过时不应再发起任何下载'); + + // 移动够远之后应该重新预热 + const moved = await service.prewarmAround(2000, 2000); + assert.strictEqual(moved.skipped, undefined); + assert.ok(calls.length > firstCalls); +}); + +test('prewarmAround 可关闭,且批量爬取在途时让路', async () => { + const { service } = setup({ bigMap: { prewarm: false } }); + assert.strictEqual((await service.prewarmAround(0, 0)).skipped, 'disabled'); + + const second = setup().service; + second.crawl.running = true; + assert.strictEqual((await second.prewarmAround(0, 0)).skipped, 'busy'); +}); + +test('prewarmAround 覆盖半径越大、层级越多,需要的瓦片越多', async () => { + const { service: small } = setup({ bigMap: { prewarmLevels: 1, prewarmRangeBlocks: 256 } }); + const smallResult = await small.prewarmAround(0, 0); + const { service: big } = setup({ bigMap: { prewarmLevels: 2, prewarmRangeBlocks: 2048 } }); + const bigResult = await big.prewarmAround(0, 0); + assert.ok(bigResult.total > smallResult.total, `大图幅应预热更多瓦片:${bigResult.total} vs ${smallResult.total}`); +}); + +test('snapshot 暴露预热状态供控制台展示', async () => { + const { service } = setup(); + await service.prewarmAround(0, 0); + const snapshot = service.snapshot(); + assert.ok(snapshot.prewarm); + assert.strictEqual(snapshot.prewarm.running, false); + assert.ok(snapshot.prewarm.done > 0); + assert.strictEqual(snapshot.prewarm.lastCenterX, 0); +}); + +test('prewarmLevels 为 0 时不预热(只做启动批量爬取)', async () => { + const { service, calls } = setup({ bigMap: { prewarmLevels: 0 } }); + const result = await service.prewarmAround(0, 0); + assert.strictEqual(result.skipped, 'no-levels'); + assert.strictEqual(calls.length, 0); +}); diff --git a/test/botManager.test.js b/test/botManager.test.js index 12a4570..2a1f7f4 100644 --- a/test/botManager.test.js +++ b/test/botManager.test.js @@ -486,6 +486,149 @@ test('位置包统计暴露在 snapshot 中,便于排查走不动的问题', ( 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 () => { @@ -622,6 +765,24 @@ function placeAtOrigin(manager, bot, y) { 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(); @@ -699,6 +860,148 @@ test('不在原点附近时不触发任何原点逻辑', () => { 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); diff --git a/test/expansionController.test.js b/test/expansionController.test.js index a893f08..e2f7c0a 100644 --- a/test/expansionController.test.js +++ b/test/expansionController.test.js @@ -8,6 +8,7 @@ const path = require('path'); const { ExpansionController, State } = require('../src/controller/expansionController'); const { TerritoryModel } = require('../src/core/territoryModel'); +const { TargetLeaseTable } = require('../src/core/targetLease'); const { defaultConfig } = require('../src/config/expansionConfig'); const { Logger } = require('../src/util/logger'); const { FakeBot } = require('./helpers/fakeBot'); @@ -126,7 +127,14 @@ function setup(options) { const botManager = new FakeBotManager(bot, model); const dataService = new FakeDataService(model); const logger = new Logger({ echo: false }); - const controller = new ExpansionController({ botManager, dataService, logger }); + const controller = new ExpansionController({ + botManager, + dataService, + logger, + botId: opts.botId, + botName: opts.botName, + targetLeases: opts.targetLeases + }); const config = defaultConfig(); config.enforceClaimTime = false; @@ -362,6 +370,165 @@ test('耐久保护触发回城并在传送后停止任务', async () => { assert.strictEqual(controller.state, State.STOPPED, '耐久保护回城后应停止任务'); }); +// ==================== 多机器人目标租约(4.3)==================== + +test('多机器人共享租约表:不会挑到同一个目标区块', async () => { + const table = new TargetLeaseTable(); + const first = setup({ targetLeases: table, botId: 'BotA' }); + const second = setup({ targetLeases: table, botId: 'BotB' }); + await first.controller.start(first.config); + await second.controller.start(second.config); + + assert.ok(first.controller.target, 'A 应有目标'); + assert.ok(second.controller.target, 'B 也应有目标'); + assert.notDeepStrictEqual(second.controller.target, first.controller.target, + '两个机器人不应挑到同一区块'); + assert.strictEqual(table.isLeasedByOther(first.controller.target, 'BotB'), true, + 'A 的目标应对外可见(B 不会再挑它)'); +}); + +test('停止任务时归还租约,其他机器人立刻可以接手', async () => { + const table = new TargetLeaseTable(); + const { controller, config } = setup({ targetLeases: table, botId: 'BotA' }); + await controller.start(config); + const target = controller.target; + assert.ok(target); + controller.stop(); + assert.strictEqual(table.isLeasedByOther(target, 'BotB'), false, '停止后应释放租约'); + assert.strictEqual(table.size, 0, 'A 不应留下任何占用'); +}); + +test('运行中持续续租:租约不会在长距离寻路中途过期', async () => { + const table = new TargetLeaseTable({ ttlMs: 40 }); + const { controller, config } = setup({ targetLeases: table, botId: 'BotA' }); + await controller.start(config); + const target = controller.target; + + await new Promise((resolve) => setTimeout(resolve, 25)); + controller.tick(); // 续租 + await new Promise((resolve) => setTimeout(resolve, 25)); + // 已经超过原始 TTL(40ms),但续租过,仍应归 A + assert.strictEqual(table.tryAcquire(target, 'BotB'), false, '续租后不应被抢走'); +}); + +test('候选区块全被其他机器人预定时等待释放,而不是判「没有可用区块」', async () => { + const table = new TargetLeaseTable(); + const first = setup({ targetLeases: table, botId: 'BotA' }); + const second = setup({ targetLeases: table, botId: 'BotB' }); + // A 先把 B 的所有候选区块都占了(模拟 A 的队列很长) + await first.controller.start(first.config); + const model = second.model; + for (const chunk of [...model.state('A').chunks, ...first.controller.queue, first.controller.target] + .filter(Boolean)) { + const c = typeof chunk === 'string' + ? { x: chunkPos.decode(chunk).x, z: chunkPos.decode(chunk).z } + : chunk; + table.tryAcquire(c, 'BotA'); + } + + await second.controller.start(second.config); + assert.strictEqual(second.controller.state, State.NEXT, '应进入等待而不是 COMPLETE'); + assert.ok(second.controller._nextReplanAt > Date.now(), '应登记稍后重新规划'); + const lines = second.logger.list().map((e) => e.message).join('\n'); + assert.ok(lines.includes('正被其他机器人预定'), '应说明为什么等待:' + lines); +}); + +test('连续超时达阈值但原地还能走 → 不回城,改为跳过目标', async () => { + const { controller, botManager, config } = setup({ + config: { + waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0, + returnThresholdSeconds: 1, returnOnTimeout: true, returnGraceSeconds: 0 + } + }); + botManager.pathing.diagnostics = () => ({ neighborCount: 3 }); + await controller.start(config); + controller.stageStartedMillis = Date.now() - 10000; + controller.tick(); + + assert.notStrictEqual(controller.state, State.RETURNING, '原地还能走时不应回城'); + assert.ok(!botManager.commands.includes('u return'), '不应发送回城指令'); + assert.strictEqual(controller.returnSkippedNotStuck, 1, '应记下「放弃回城」的次数'); + assert.strictEqual(controller.lastReturn, null, '没有回城就不该有回城记录'); +}); + +test('连续超时达阈值且原地无路可走 → 回城', async () => { + const { controller, botManager, config } = setup({ + config: { + waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0, + returnThresholdSeconds: 1, returnOnTimeout: true, returnGraceSeconds: 0 + } + }); + botManager.pathing.diagnostics = () => ({ neighborCount: 0 }); + await controller.start(config); + controller.stageStartedMillis = Date.now() - 10000; + controller.tick(); + + assert.strictEqual(controller.state, State.RETURNING, '确实卡死时应回城'); + assert.ok(botManager.commands.includes('u return')); + assert.strictEqual(controller.returnSkippedNotStuck, 0); +}); + +test('拿不到寻路诊断时保守按卡死处理(仍会回城)', async () => { + const { controller, botManager, config } = setup({ + config: { + waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0, + returnThresholdSeconds: 1, returnOnTimeout: true, returnGraceSeconds: 0 + } + }); + // FakePathing 本来就没有 diagnostics 方法:模拟「寻路器未就绪,判断不了」 + await controller.start(config); + controller.stageStartedMillis = Date.now() - 10000; + controller.tick(); + assert.strictEqual(controller.state, State.RETURNING); +}); + +test('回城完成后记录前后坐标与直线距离(控制台展示「从 A 挪到了 B」)', async () => { + const { controller, botManager, bot, logger, config } = setup({ + config: { + waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0, + returnThresholdSeconds: 1, returnOnTimeout: true, returnGraceSeconds: 0, returnWaitSeconds: 1 + } + }); + botManager.pathing.diagnostics = () => ({ neighborCount: 0 }); + await controller.start(config); + controller.stageStartedMillis = Date.now() - 10000; + controller.tick(); + assert.strictEqual(controller.state, State.RETURNING); + + // 模拟服务器把玩家传送到安全点 + bot.entity.position.x = 1000; + bot.entity.position.y = 70; + bot.entity.position.z = 2000; + controller.returnStartMillis = Date.now() - 5000; + controller.tick(); + + const last = controller.lastReturn; + assert.ok(last, '应记录回城结果'); + assert.deepStrictEqual(last.from, { x: 8, y: 64, z: 8 }, '应记录回城前坐标'); + assert.deepStrictEqual(last.to, { x: 1000, y: 70, z: 2000 }, '应记录回城后坐标'); + assert.ok(last.distance > 2000, `应记录直线距离,实际 ${last.distance}`); + assert.ok(logger.list().some((e) => e.message.includes('回城结果')), '应输出「从 A 挪到了 B」日志'); + assert.ok(controller.snapshot().lastReturn, 'snapshot 应暴露给控制台'); +}); + +test('回城几乎没有改变位置时给出明确提示', async () => { + const { controller, botManager, logger, config } = setup({ + config: { + waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0, + returnThresholdSeconds: 1, returnOnTimeout: true, returnGraceSeconds: 0, returnWaitSeconds: 1 + } + }); + botManager.pathing.diagnostics = () => ({ neighborCount: 0 }); + await controller.start(config); + controller.stageStartedMillis = Date.now() - 10000; + controller.tick(); + controller.returnStartMillis = Date.now() - 5000; + controller.tick(); // 位置没变(模拟安全点就是原地) + + assert.strictEqual(controller.lastReturn.distance, 0); + assert.ok(logger.list().some((e) => e.message.includes('几乎没有改变位置')), '应提示回城没有帮助'); +}); + test('达成目标数量后进入 COMPLETE', async () => { const { controller, config, bot } = setup({ config: { targetCount: 1 } }); await controller.start(config); diff --git a/test/manualMover.test.js b/test/manualMover.test.js new file mode 100644 index 0000000..892bb62 --- /dev/null +++ b/test/manualMover.test.js @@ -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), '取消后的在途结果必须被忽略'); +}); \ No newline at end of file diff --git a/test/notifier.test.js b/test/notifier.test.js new file mode 100644 index 0000000..deec7a7 --- /dev/null +++ b/test/notifier.test.js @@ -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'), '完整地址不能出现在接口响应里'); +}); \ No newline at end of file diff --git a/test/scheduledMessenger.test.js b/test/scheduledMessenger.test.js index 351cc9c..dda6808 100644 --- a/test/scheduledMessenger.test.js +++ b/test/scheduledMessenger.test.js @@ -3,7 +3,24 @@ const test = require('node:test'); 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 */ function fakeManager(options) { @@ -141,3 +158,131 @@ test('start / stop 控制心跳定时器', () => { messenger.stop(); assert.strictEqual(messenger._timer, null); }); + +// ==================== cron 式调度 ==================== + +test('parseCron 接受常用写法,拒绝非法表达式', () => { + assert.ok(parseCron('0 9 * * *'), '每天 09:00'); + assert.ok(parseCron('0/30 * * * *'), '每 30 分钟'); + assert.ok(parseCron('0 12 * * 1-5'), '工作日 12:00'); + assert.ok(parseCron('5,35 8-18 * * *'), '区间 + 列表'); + assert.strictEqual(parseCron('0 9 * *'), null, '段数不足应拒绝'); + assert.strictEqual(parseCron('99 9 * * *'), null, '越界应拒绝'); + assert.strictEqual(parseCron('0 9 * * 9'), null, '周只能是 0-6'); + assert.strictEqual(parseCron(''), null); +}); + +test('cron 任务只在匹配时刻发送,且不会同一天重复发', () => { + const manager = fakeManager(); + const messenger = new ScheduledMessenger(manager, null); + const now = new Date(2026, 8, 28, 8, 0, 0); // 2026-09-28 08:00(本地时间) + messenger.setTasks([{ id: 'daily', content: '早安公告', cron: '0 9 * * *' }], now.getTime()); + + messenger.tick(new Date(2026, 8, 28, 8, 59, 0).getTime()); + assert.strictEqual(manager.sent.length, 0, '09:00 之前不该发'); + messenger.tick(new Date(2026, 8, 28, 9, 0, 10).getTime()); + assert.strictEqual(manager.sent.length, 1, '09:00 到点应发送'); + messenger.tick(new Date(2026, 8, 28, 9, 1, 0).getTime()); + assert.strictEqual(manager.sent.length, 1, '同一天不应重复发'); + + const snap = messenger.snapshot()[0]; + assert.strictEqual(snap.mode, 'cron'); + assert.strictEqual(snap.cron, '0 9 * * *'); + assert.strictEqual(new Date(snap.nextDueAt).getDate(), 29, '下一次应是明天 09:00'); + assert.strictEqual(new Date(snap.nextDueAt).getHours(), 9); +}); + +test('cron 支持星期范围:工作日表达式会跳过周末', () => { + const manager = fakeManager(); + const messenger = new ScheduledMessenger(manager, null); + const saturday = new Date(2026, 8, 26, 13, 0, 0); // 2026-09-26 周六 + assert.strictEqual(saturday.getDay(), 6); + messenger.setTasks([{ id: 'workday', content: 'x', cron: '0 12 * * 1-5' }], saturday.getTime()); + const due = new Date(messenger.snapshot()[0].nextDueAt); + assert.strictEqual(due.getDay(), 1, '应跳到周一'); + assert.strictEqual(due.getDate(), 28); +}); + +test('cron 步长写法:每 30 分钟', () => { + const manager = fakeManager(); + const messenger = new ScheduledMessenger(manager, null); + const now = new Date(2026, 8, 28, 8, 1, 0); + messenger.setTasks([{ id: 'half', content: 'x', cron: '0/30 * * * *' }], now.getTime()); + const due = new Date(messenger.snapshot()[0].nextDueAt); + assert.strictEqual(due.getHours(), 8); + assert.strictEqual(due.getMinutes(), 30, '下一次应是 08:30'); +}); + +test('无效 cron 表达式回退到固定间隔,并说明原因', () => { + const tasks = normalizeTasks([{ content: 'x', cron: '99 99 * * *', intervalSeconds: 60 }]); + assert.strictEqual(tasks[0].mode, 'interval', '非法表达式不能静默吞掉,应按间隔发送'); + assert.ok(tasks[0].cronError.includes('无效'), '要给出可见的原因:' + tasks[0].cronError); + assert.strictEqual(tasks[0].cron, '', '非法表达式不应被保留'); +}); + +test('nextCronTime 能跳到下一个月(稀缺表达式)', () => { + const parsed = parseCron('0 0 1 * *'); // 每月 1 日 00:00 + const from = new Date(2026, 8, 28, 12, 0, 0).getTime(); + const next = new Date(nextCronTime(parsed, from)); + assert.strictEqual(next.getMonth(), 9, '应跳到 10 月'); + assert.strictEqual(next.getDate(), 1); +}); + +// ==================== 发送结果确认 ==================== + +test('发送结果确认:命中服务器回复记为「已确认」', () => { + const manager = emittableManager(); + const messenger = new ScheduledMessenger(manager, null); + const t0 = 1_000_000; + messenger.setTasks([{ + id: 'restore', + content: '/u restore confirm', + intervalSeconds: 300, + expectPattern: '民心.*恢复', + confirmTimeoutSeconds: 10 + }], t0); + messenger.start(); // 注册 chat 监听 + + assert.strictEqual(messenger.tick(t0 + 300_000), 1); + manager.emit('chat', '你的疆土民心已经恢复', 'system'); + const result = messenger.snapshot()[0].lastResult; + assert.strictEqual(result.ok, true); + assert.ok(result.detail.includes('民心')); + messenger.stop(); +}); + +test('发送结果确认:超时未回复记为「未确认」并写警告', () => { + const manager = emittableManager(); + const logger = new Logger({ echo: false }); + const messenger = new ScheduledMessenger(manager, logger); + const t0 = 1_000_000; + messenger.setTasks([{ + id: 'restore', + content: '/u restore confirm', + intervalSeconds: 300, + expectPattern: '民心.*恢复', + confirmTimeoutSeconds: 10 + }], t0); + messenger.start(); + + assert.strictEqual(messenger.tick(t0 + 300_000), 1); + messenger.tick(t0 + 300_000 + 11_000); // 超过确认超时 + const result = messenger.snapshot()[0].lastResult; + assert.strictEqual(result.ok, false); + assert.ok(result.detail.includes('没有收到预期回复')); + assert.ok(logger.list().some((e) => e.level === 'warn' && e.message.includes('没有收到预期回复')), + '日志里必须能看到「没生效」这件事'); + messenger.stop(); +}); + +test('没配「期望回复」的任务不做确认,也不会误报未确认', () => { + const manager = emittableManager(); + const messenger = new ScheduledMessenger(manager, null); + const t0 = 1_000_000; + messenger.setTasks([{ id: 'plain', content: 'hi', intervalSeconds: 60 }], t0); + messenger.start(); + assert.strictEqual(messenger.tick(t0 + 60_000), 1); + messenger.tick(t0 + 120_000); + assert.strictEqual(messenger.snapshot()[0].lastResult, null, '未配置确认就不该有结果状态'); + messenger.stop(); +}); diff --git a/test/targetLease.test.js b/test/targetLease.test.js new file mode 100644 index 0000000..5c7cb9c --- /dev/null +++ b/test/targetLease.test.js @@ -0,0 +1,83 @@ +'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 () => { + const table = new TargetLeaseTable({ ttlMs: 40 }); + const chunk = { x: 7, z: 7 }; + table.tryAcquire(chunk, 'A'); + await new Promise((resolve) => setTimeout(resolve, 25)); + assert.strictEqual(table.renew(chunk, 'A'), true); + await new Promise((resolve) => setTimeout(resolve, 25)); + // 已超过原始 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, '默认不应太短,否则长距离寻路会被抢'); +}); \ No newline at end of file diff --git a/test/webServer.test.js b/test/webServer.test.js index 33120e4..328dc7a 100644 --- a/test/webServer.test.js +++ b/test/webServer.test.js @@ -33,7 +33,8 @@ test('timingSafeEqual 只接受等长且完全相同的字符串', () => { }); /** 搭建一个使用桩依赖的控制台实例 */ -function setupConsole() { +function setupConsole(options) { + const opts = options || {}; const map = new Map([[ 'A', { @@ -73,6 +74,8 @@ function setupConsole() { controller, dataService, logger, + bigMap: opts.bigMap, + notifier: opts.notifier, getConfig: () => config, applyConfig: (next) => next }, { host: '127.0.0.1', port: 0 }); @@ -116,6 +119,20 @@ test('statusPayload 汇总 bot / 任务 / 数据源状态', () => { assert.strictEqual(payload.data.stateCount, 1); assert.strictEqual(payload.claimTime.start, 11); assert.strictEqual(payload.claimTime.end, 22); + assert.strictEqual(payload.notify, null, '未注入通知器时为 null(状态接口不应报错)'); +}); + +test('注入通知器时状态接口只暴露域名,不泄露完整 Webhook 地址', () => { + const { Notifier } = require('../src/core/notifier'); + const config = defaultConfig(); + config.notify.enabled = true; + config.notify.webhookUrl = 'https://open.feishu.cn/open-apis/bot/v2/hook/secret-token'; + const notifier = new Notifier(config, null, { fetchImpl: async () => ({ ok: true, status: 200 }) }); + const { webConsole } = setupConsole({ notifier }); + const payload = webConsole.statusPayload(); + assert.strictEqual(payload.notify.enabled, true); + assert.strictEqual(payload.notify.host, 'open.feishu.cn'); + assert.ok(!JSON.stringify(payload).includes('secret-token'), '完整地址不能出现在状态响应里'); }); test('HTTP 接口:/api/status、/api/config、/api/map 可访问', async () => {