feat: 用 Node.js + mineflayer 重构 AutoStore,UI 改为网页控制台并适配 Grim 反作弊

由 Fabric 客户端模组 AutoStore v1.3.1 重构为独立 Node.js 机器人,无需游戏客户端。

核心实现:
- 领土数据:Dynmap markers.json 拉取解析(奇偶填充识别国中飞地)、SimpCore 开放 API 第四数据源、
  带缓存击穿的时间戳请求与内容未变化跳过解析
- 扩地规划:四种策略(方形外扩 / 区域 S 形扫描 / 玩家周围 / 绕国一圈)逐行对照移植,
  接壤判定只算己方领土,飞地优先填补
- 扩地状态机:规划→寻路→等待开垦→成功/失败→下一目标→完成,含超时重试、
  往边上走两步自救、连续超时回城、耐久保护回城
- 生存辅助:自动进食、自动攻击(豁免中立生物)、自动游泳防溺水、自动用船(默认关闭)
- 寻路:Baritone → mineflayer-pathfinder,高度偏好用包装 Goal 等价移植

Grim 反作弊合规层(新增,核心差异点):
- 旋转不超前:接管 bot.look 按 ≤8.6°/tick 推进朝向,消除 mineflayer
  「本地朝向瞬间跳变、数据包滞后补发」导致的 Prediction 分歧
- 攻击三重校验:朝向已同步 + 距离 ≤3.0 格 + 视线可达 + 攻击冷却 + 非骑乘/进食
- 动作互斥:拦下疾跑+潜行、疾跑+使用物品等原版不可能出现的组合
- 物理参数只读自检 + 寻路约束(禁挖/禁放/禁跑酷/禁搭塔/限下落/绕开水域)

网页控制台(新增):
- 状态面板、Canvas 领土地图(缩放/平移/图层)、分组配置表单、实时日志流
- REST + WebSocket 接口,支持 WEB_TOKEN 令牌鉴权

修复:
- containsFuzzy 只取固定长度前缀的末行编辑距离,导致完全相同的子串也匹配不上
- 占领去重依赖 Set.add 返回值(JS 中恒为真),导致同一区块被重复计数
- 非法枚举值不再钳制到最近边界,改为回退默认值

测试:154 个用例,npm test 全绿;npm run check 语法自检通过。
这个提交包含在:
lyizai
2026-09-27 17:17:41 +08:00
当前提交 a12a24c716
共修改 44 个文件,包含 12225 行新增和 0 行删除
+30
查看文件
@@ -0,0 +1,30 @@
# 环境变量示例:复制为 .env 并填入真实值
# .env 已被 .gitignore 忽略,绝不要提交真实密钥
# ==================== Minecraft 服务器 ====================
MC_HOST=bgjq.simpfun.cn
MC_PORT=25565
MC_VERSION=1.21.4
# 离线登录用游戏名;正版登录时忽略此项(需先 `npx mineflayer --auth microsoft` 之外的 device-code 流程)
MC_USERNAME=<your-bot-name>
# offline=离线服(服务器用 /login 密码登录);microsoft=正版
MC_AUTH=offline
# 服务器登录/注册密码(离线服必填;正版服留空)
MC_PASSWORD=<your-server-password>
# ==================== Web 控制台 ====================
WEB_HOST=127.0.0.1
WEB_PORT=3081
# 控制台访问令牌(留空则仅监听 127.0.0.1 且不校验;非本机访问必须设置)
WEB_TOKEN=
# ==================== 领土数据源 ====================
# Dynmap 地图站点根地址(末尾不带斜杠),程序会请求 <DYNMAP_BASE>/tiles/<DYNMAP_WORLD>/markers.json
DYNMAP_BASE=http://bgjq.simpfun.cn
# markers.json 所属世界名(对应 Dynmap 配置里的 world 目录名)
DYNMAP_WORLD=minecraft_overworld
# ==================== SimpCore 开放 API(可选) ====================
# https://open.bgjq.top 申请
SIMP_API_KEY=<your-simp-api-key>
+9
查看文件
@@ -0,0 +1,9 @@
# 统一使用 LF,避免跨平台协作时出现整文件换行符变更
* text=auto eol=lf
# 二进制文件不做换行符转换
*.png binary
*.webp binary
*.jpg binary
*.ico binary
*.jar binary
+23
查看文件
@@ -0,0 +1,23 @@
# 依赖
node_modules/
# 密钥与本地配置(绝不提交)
.env
.env.*
!.env.example
data/
# 日志
logs/
*.log
# 编辑器 / 系统
.idea/
.vscode/
*.iml
.DS_Store
Thumbs.db
# 构建产物
dist/
build/
+55
查看文件
@@ -0,0 +1,55 @@
# 更新日志
本项目遵循 [约定式提交](https://www.conventionalcommits.org/zh-hans/) 与 [语义化版本](https://semver.org/lang/zh-CN/)。
## [1.0.0] - 2026-02-14
由 Fabric 客户端模组 AutoStore v1.3.1 重构为 Node.js + mineflayer 机器人,UI 改为网页控制台,
并新增 Grim 反作弊合规层。
### 新增
- **Grim 反作弊合规层**(`src/bot/grimCompat.js`)
- 旋转不超前:接管 `bot.look`,把朝向按 ≤ 8.6°/tick 逐步推进,保证本地朝向与服务端已收到的朝向一致,
消除 mineflayer「本地朝向瞬间跳变、数据包滞后补发」导致的 Prediction 分歧
- 攻击三重校验:朝向已同步 + 距离 ≤ 3.0 格 + 视线可达 + 攻击冷却 + 非骑乘/进食状态
- 动作互斥:拦下疾跑+潜行、疾跑+使用物品等原版不可能出现的组合(MultiActions)
- 物理参数只读自检:核对 `bot.physics` 关键常量、`physicsEnabled`、游戏模式、鞘翅状态
- 寻路约束:默认禁止挖方块 / 放方块 / 跑酷 / 1×1 搭塔,限制最大下落高度,推高液体代价优先绕开水域
- **网页控制台**(`src/web/`)
- 实时状态面板(连接 / 任务状态机 / 领土数据源 / Grim 合规)
- Canvas 领土地图:滚轮缩放、拖拽平移、图层开关、回到玩家、全图适配
- 7 个分组的配置表单,保存即生效
- 实时日志流、日志导出、手动指令发送
- REST + WebSocket 接口,支持 `WEB_TOKEN` 令牌鉴权(定时安全比较)
- **SimpCore 开放 API 第四数据源**:低频轮询己方邦国领土明细 + 王城兜底,支持手动拉取全服领土
- **开垦时段可配置**:`enforceClaimTime` / `claimStartHour` / `claimEndHour`(原版硬编码 11:00-22:00)
- **战斗筛选可配置**:`combat` 分组(索敌半径、末影人 / 蜘蛛 / 猪灵开关、高危生物跳过)
- **断线自动重连**与登录 / 注册指令模板(密码从 `.env` 读取,支持 `{password}` 占位符)
- **154 个测试用例**,覆盖坐标编解码、领土模型、规划器、数据解析、合规层、状态机、Web 接口
### 变更
- 寻路引擎从 Baritone 换成 mineflayer-pathfinder;高度偏好(`GoalXZAltitude`)用包装 Goal 的
`heuristic = max(0, base - weight × y)` 等价移植
- 区块坐标内部表示从对象改为整数键(`x × 2^26 + z`),避免数万区块产生海量临时对象
- 扩地状态机、规划策略、超时重试与回城逻辑与原版逐行对照移植,行为保持一致
### 修复
- 修正 `containsFuzzy` 的模糊匹配:原实现只取固定长度前缀的末行编辑距离,
导致 `containsFuzzy('开垦中 50%', '开垦中', 0)` 返回 `false`(完全相同的子串都匹配不上)。
现按注释语义改为「存在编辑距离 ≤ maxEdits 的子串」
- 修正占领去重:原实现依赖 `Set.add` 的返回值判断重复,而 JS 的 `Set.add` 恒返回 Set 对象,
会导致同一区块被重复计数
- 修正非法枚举值的处理:策略等枚举字段不再「钳制到最近边界」(把非法的 99 静默变成策略 4),
而是回退到默认值
### 已知限制
- **自动用船默认关闭**:mineflayer 使用的 prismarine-physics 没有载具物理,上船后本地物理预测被关闭,
位置完全由服务端下发的船实体位置决定,Grim 的 Vehicle 检查风险高。
用船逻辑已完整实现,需显式开启 `waterMode: 'boat'` + `autoBoat: true`
- **末影人与蜘蛛默认不打**:mineflayer 拿不到可靠的「是否被激怒」与「环境亮度」信息,
宁可漏打也不误打;可用 `combat.attackEnderman` / `combat.attackSpiders` 放开
- **监守者 / 末影龙 / 凋灵默认跳过**:原版会攻击它们,但那基本等于自杀
+282
查看文件
@@ -0,0 +1,282 @@
# AutoStore Bot
Minecraft 自动扩地机器人 —— 由原 Fabric 客户端模组 [AutoStore](https://git.bgjq.top/Lyizai/AutoStore) 重构而来:
**Node.js + mineflayer 实现,UI 从游戏内 GUI 改为网页控制台,并针对装有 Grim 反作弊的服务器做合规适配。**
不需要开游戏客户端,一条命令启动,浏览器打开控制台即可开始自动扩地。
```
npm install
cp .env.example .env # 填入服务器地址与账号密码
npm start # 打开 http://127.0.0.1:3081
```
---
## 功能对照
| 原模组功能 | 新版实现 | 说明 |
| --- | --- | --- |
| 远程拉取 `markers.json` 领土数据 | ✅ | 带时间戳缓存击穿 + `no-cache` 头,实测 24 个邦国 / 11.5 万区块 200ms 解析完成 |
| 四种扩地策略 | ✅ | 方形外扩 / 区域 S 形扫描 / 玩家周围 / 绕国一圈 |
| 区域扩地严格 S 形扫描 | ✅ | 起始边取离王城最近的边,由内而外蛇形推进 |
| 飞地(疏漏区块)检测与优先填补 | ✅ | 按 dynmap 奇偶填充规则解析多边形,正确识别国中孔洞 |
| 自动寻路前往目标区块 | ✅ | Baritone → mineflayer-pathfinder,高度偏好启发式等价移植 |
| 寻路失败重试 / 往边上走两步 / 连续超时回城 | ✅ | 状态机与阈值完全一致 |
| 运行时接壤校验、目标有效性校验 | ✅ | 每 tick 校验,避免在无效区块上白费时间 |
| 现实时间限制(默认 11:00-22:00) | ✅ | 改为可配置时段,并支持「到点自动开始」 |
| 监听字幕 / 聊天判定开垦结果 | ✅ | 兼容 title / subtitle / actionBar / Boss 条四条通道 |
| 自动进食 | ✅ | |
| 自动攻击敌对生物(豁免中立生物) | ✅ | 铁傀儡 / 僵尸猪灵永不攻击;末影人 / 蜘蛛默认不打(见下方差异说明) |
| 耐久保护回城 | ✅ | |
| 自动游泳防溺水 | ✅ | 水中持续按住跳跃键上浮 |
| 自动用船过水 | ⚠️ 默认关闭 | mineflayer 无载具物理,Grim 风险高,详见「行为差异」 |
| 实时地图 GUI | ✅ 网页版 | Canvas 绘制,支持缩放 / 平移 / 图层开关 |
| 日志导出 | ✅ | 网页按钮导出到 `data/` |
| SimpCore 开放 API 第四数据源 | ✅ | 低频轮询 + 手动全服拉取 |
| Grim 反作弊适配 | ✅ 新增 | 见下方专章 |
---
## Grim 反作弊适配
这是本次重构最核心的工程问题。mineflayer 是第三方协议实现,**它的物理引擎(prismarine-physics)
与行为习惯和原版客户端存在偏差,直接跑在 Grim 服务器上会被 Prediction / Reach / NoSlow / MultiActions
等检查标记**。本项目用一层 `GrimCompat` 合规层把所有对外行为收口:
### 1. 旋转绝不超前(防 Prediction)
mineflayer 的 `bot.look()` 会**立刻**把 `bot.entity.yaw` 改到目标值,而朝向数据包是按
`physics.yawSpeed`(约 8.6°/tick)逐 tick 补发的。这会造成:
```
本地已经朝新方向走了 ← 但服务端还以为你朝旧方向
```
Grim 的 Prediction 检查正是逐 tick 复算玩家位置并与上报位置比对,这种分歧必然被标记。
**做法**:接管 `bot.look` / `bot.lookAt`,只记录目标朝向,在每个 `physicsTick` 里把
`bot.entity.yaw/pitch` 按 ≤ 8.6° 的步长推进。这样「本地朝向」永远等于「服务端已收到的朝向」,
最多差 1 tick。`awaitRotationSettled()` 可等待旋转真正同步完成。
### 2. 攻击三重校验(防 Reach / LineOfSight / Post)
`attackEntity()` 出手前依次确认:
- **朝向已同步** —— 服务端认为你正看着目标(防 Post 检查)
- **眼睛到目标碰撞箱最近点 ≤ 3.0 格** —— 与生存模式实体交互距离一致
- **视线可达** —— 射线检测中间没有实心方块遮挡
- **攻击冷却已好** —— 按手持武器攻速计算(剑 625ms / 斧 1000-1250ms / 空手 250ms)
- **不在骑乘 / 进食 / 使用物品状态**
### 3. 动作互斥(防 MultiActions)
原版客户端不可能出现的组合一律拦下,并计入「已拦下动作」计数供控制台观察:
- 疾跑 + 潜行
- 疾跑 + 使用物品(进食 / 拉弓 / 举盾)
- 疾跑 + 跳跃(**默认放行** —— 这是原版合法动作,Grim 预测支持;实测被标记时可开启 `grim.blockSprintJump` 退化为普通跳跃)
### 4. 物理参数只读校验(防「改物理常数绕过预测」)
`audit()` 会核对 `bot.physics` 的 `gravity` / `playerSpeed` / `sprintSpeed` / `stepHeight` /
`airborneInertia` / `waterInertia` 是否仍为原版值,并检查 `physicsEnabled`、游戏模式、鞘翅飞行状态。
任何一项异常都会在控制台的「Grim 合规」面板里报警。**本项目从不修改物理常量,也从不直接写坐标。**
### 5. 寻路约束
| 约束 | 默认 | 原因 |
| --- | --- | --- |
| `canDig` | 关闭 | Grim 的 FastBreak 检查极严 |
| 放置方块(`scafoldingBlocks`) | 关闭 | Grim 的 place 类检查极严 |
| `allowParkour` | 关闭 | mineflayer 起跳时机与原版有偏差 |
| `allow1by1towers` | 关闭 | 搭塔过程几乎必被标记 |
| `maxDropDown` | 3 格 | 避免长距离坠落 |
| `liquidCost` | 200(avoid 模式) | mineflayer 液体物理与原版差异最大,优先绕开水域 |
| `allowFreeMotion` | 关闭 | 禁止自由运动 |
---
## 与原模组的行为差异(重要)
重构过程中有几处**有意为之**的取舍,请务必了解:
### 1. 自动用船默认关闭
mineflayer 使用的 prismarine-physics **没有实现载具物理**。上船后 mineflayer 会关闭本地物理预测
(`bot.on('mount') → shouldUsePhysics = false`),机器人位置完全由服务端下发的船实体位置决定,
只能靠 `bot.moveVehicle()` 驱动船前进。这意味着无法本地预测船的轨迹,Grim 的 Vehicle 类检查
逐 tick 复算载具运动时极易分歧。
因此 `waterMode` 默认为 `avoid`(绕开水域),`autoBoat` 默认为 `false`。
用船逻辑已完整实现(扫描水域 → 装备船 → 放船 → 登船 → 航行 → 下船 → 打掉回收),
**仅在你实测确认该服务器对船宽容时才建议开启**(`waterMode: 'boat'` + `autoBoat: true`)。
### 2. 末影人与蜘蛛默认不打
原模组按原版 AI 条件判断(末影人仅被激怒时、蜘蛛仅黑暗中才反击)。mineflayer 拿不到可靠的
「是否被激怒」与「环境亮度」信息,因此默认**一律不打**(宁可漏打也不误打)。
需要时可用 `combat.attackEnderman` / `combat.attackSpiders` 放开。
### 3. 新增高危生物跳过
监守者 / 末影龙 / 凋灵默认跳过(`combat.avoidDangerousMobs`)—— 原模组会攻击它们,
但那基本等于自杀。
### 4. 开垦时段可配置
原模组硬编码 11:00-22:00,现改为 `enforceClaimTime` + `claimStartHour` / `claimEndHour`。
### 5. 模糊匹配算法修正
原 Java 版的 `containsFuzzy` 只取固定长度前缀的末行距离,导致 `containsFuzzy('开垦中 50%', '开垦中', 0)`
返回 `false`(连完全相同的子串都匹配不上)。本版按注释所述的语义修正为
「存在编辑距离 ≤ maxEdits 的子串」,并补了回归测试。
---
## 安装与运行
### 依赖
- Node.js 18+(开发与验证使用 Node 24)
- 无需 Java、无需游戏客户端
### 步骤
```bash
npm install
cp .env.example .env
# 编辑 .env 填入服务器地址、账号、密码
npm start
```
启动后控制台会打印访问地址(默认 `http://127.0.0.1:3081`),浏览器打开即可。
### 环境变量
所有敏感信息只放在 `.env`(已被 `.gitignore` 忽略,**绝不要提交**):
| 变量 | 说明 |
| --- | --- |
| `MC_HOST` / `MC_PORT` | 服务器地址与端口 |
| `MC_VERSION` | 协议版本,默认 `1.21.4` |
| `MC_USERNAME` | 机器人游戏名 |
| `MC_AUTH` | `offline`(离线服)或 `microsoft`(正版) |
| `MC_PASSWORD` | 服务器登录 / 注册密码,供 `{password}` 占位符使用 |
| `WEB_HOST` / `WEB_PORT` | 控制台监听地址与端口 |
| `WEB_TOKEN` | 控制台访问令牌;非本机访问**必须**设置 |
| `DYNMAP_BASE` / `DYNMAP_WORLD` | 地图数据源根地址与世界目录名 |
| `SIMP_API_KEY` | SimpCore 开放 API Key(可选) |
---
## 网页控制台
打开控制台后:
- **左栏**:连接状态、任务状态机、领土数据源、Grim 合规面板
- **中栏**:领土地图(Canvas,滚轮缩放 / 拖拽平移 / 图层开关)+ 实时运行日志
- **右栏**:分 7 个标签页的配置表单 + 手动指令输入
顶栏按钮:开始扩地 / 停止 / 重连。
地图图层:己方领土(蓝)、本次占领(绿)、飞地疏漏(红)、当前目标(黄)、机器人(白点)、王城(紫)。
### REST / WebSocket 接口
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/status` | 机器人 / 任务 / 数据源完整状态 |
| GET | `/api/config` | 当前配置 |
| POST | `/api/config` | 保存并立即应用配置 |
| GET | `/api/map?full=1` | 地图数据(`full=1` 附带全部邦国区块) |
| GET | `/api/logs` | 运行日志 |
| POST | `/api/logs/export` | 导出日志到 `data/` |
| POST | `/api/task/start` | 启动扩地(可带配置覆盖) |
| POST | `/api/task/stop` | 停止扩地 |
| POST | `/api/map/refresh` | 手动刷新地图数据 |
| POST | `/api/api/fetch-all` | 手动拉取全服领土(重接口) |
| POST | `/api/command` | 发送游戏指令 |
| POST | `/api/bot/reconnect` | 重连服务器 |
| WS | `/ws` | 实时状态推送(1s)与日志流 |
---
## 配置项
配置保存在 `data/autostore.json`,可通过控制台表单修改。完整字段见
[`src/config/expansionConfig.js`](src/config/expansionConfig.js),分组如下:
| 分组 | 内容 |
| --- | --- |
| 策略 | 扩地策略、目标邦国、目标数量、扩圈上限、区域矩形 |
| 超时 | 寻路 / 等待超时、重试次数、回城阈值、走两步轮数、开垦时段 |
| 寻路 | 疾跑 / 游泳 / 跳跃 / 挖方块 / 放方块、高度偏好、跑酷、搭塔、下落高度、液体代价 |
| 生存 | 自动进食、自动攻击、耐久保护、战斗筛选、水域策略、自动用船 |
| 数据 | 地图刷新间隔、SimpCore API |
| Grim | 合规层全部阈值(见上) |
| 服务器 | 开垦模式指令、登录 / 注册模板、指令节流、重连 |
---
## 开发
```bash
npm test # 运行测试(Node 内置 test runner,无需额外依赖)
npm run check # 语法自检(require 全部源码,捕获语法与加载错误)
npm run dev # 文件变更自动重启
```
### 测试覆盖
154 个测试用例,覆盖:
- 区块坐标编解码(含负数与边界)
- 领土模型:飞地识别、会话占领、API 数据合并
- 规划器:四种策略、S 形顺序、**接壤只算己方领土**(历史 bug 回归)、他国区块切断连通
- markers.json 解析:奇偶填充孔洞、王城换算、颜色解析
- 字幕 / 聊天解析:进度、带坐标成功、通用成功、模糊匹配
- Grim 合规层:旋转限速、动作互斥、攻击冷却表、距离 / 视线校验、物理自检
- 战斗筛选:中立生物豁免、条件敌对、猪灵金甲判定、耐久保护
- 寻路服务:Grim 约束是否真正施加到 Movements、高度偏好目标
- 控制器状态机:完整扩地流程、超时重试、走两步、回城、耐久保护停止、完成
- Web 控制台:地图负载、REST 接口、令牌鉴权
### 目录结构
```
src/
├── index.js 入口:装配模块、启动 Web 控制台、驱动 tick
├── config/
│ ├── expansionConfig.js 配置默认值 / 校验 / 归一化
│ └── configStore.js 配置 JSON 持久化
├── core/
│ ├── chunkPos.js 区块坐标编解码(整数键,零临时对象)
│ ├── territoryModel.js 领土模型:区块集合、飞地、会话占领、API 合并
│ ├── territoryDataService.js Dynmap markers.json 拉取与解析
│ ├── simpApiClient.js SimpCore 开放 API 客户端
│ ├── expansionPlanner.js 四种扩地策略
│ └── subtitleChatMonitor.js 字幕 / 聊天解析与模糊匹配
├── bot/
│ ├── botManager.js 连接、登录、重连、事件归一化
│ ├── grimCompat.js ★ Grim 反作弊合规层
│ ├── pathingService.js 寻路服务(替代 Baritone)
│ ├── autoCombat.js 战斗筛选与耐久检查
│ ├── autoEat.js 自动进食
│ ├── autoBoat.js 自动用船(默认关闭)
│ └── durabilityGuard.js 耐久保护
├── controller/
│ └── expansionController.js 扩地任务状态机
├── web/
│ ├── server.js Express + WebSocket
│ └── public/ 控制台前端(原生 HTML/CSS/JS,无构建步骤)
└── util/
└── logger.js 环形日志缓冲
```
---
## 许可证
MIT
自动生成
+1961
查看文件
文件差异内容过多而无法显示 加载差异
+36
查看文件
@@ -0,0 +1,36 @@
{
"name": "autostore-bot",
"version": "1.0.0",
"description": "AutoStore Bot —— Minecraft 自动扩地机器人(mineflayer 重写版,Web 控制台,Grim 反作弊适配)",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js",
"test": "node --test",
"check": "node scripts/check-syntax.js"
},
"keywords": [
"minecraft",
"mineflayer",
"autostore",
"territory"
],
"author": "",
"license": "MIT",
"private": true,
"engines": {
"node": ">=18.0.0"
},
"dependencies": {
"chalk": "^4.1.2",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"mineflayer": "^4.39.0",
"mineflayer-pathfinder": "^2.4.5",
"vec3": "^0.1.10",
"ws": "^8.18.0"
},
"devDependencies": {
"minecraft-data": "^3.102.3"
}
}
+51
查看文件
@@ -0,0 +1,51 @@
'use strict';
/**
* 语法自检脚本:递归 `require` 所有源码文件,捕获语法错误与模块加载错误。
*
* 之所以不引入 ESLint,是为了不额外增加依赖与配置;
* 本脚本已能覆盖「语法错误 / 顶层抛异常 / 循环依赖缺失导出」这类最常见问题。
*/
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..', 'src');
const failures = [];
let checked = 0;
/**
* 递归遍历目录下的所有 .js 文件。
* @param {string} dir
* @returns {string[]}
*/
function walk(dir) {
const out = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules' || entry.name === 'public') continue;
out.push(...walk(full));
} else if (entry.name.endsWith('.js')) {
out.push(full);
}
}
return out;
}
for (const file of walk(ROOT)) {
checked++;
try {
require(file);
} catch (err) {
failures.push(`${path.relative(ROOT, file)}: ${err.message}`);
}
}
if (failures.length > 0) {
console.error(`语法自检失败(${failures.length}/${checked}):`);
for (const failure of failures) console.error(' - ' + failure);
process.exit(1);
}
console.log(`语法自检通过:${checked} 个文件`);
+471
查看文件
@@ -0,0 +1,471 @@
'use strict';
const { Vec3 } = require('vec3');
/**
* 自动用船过水(mineflayer 版)。
*
* 流程:扫描前方开阔水域 → 装备船 → 放船 → 上船 → 朝目标航行 → 靠岸下船 → 打掉船回收。
*
* ⚠️ 与原 Java 版的重要差异(务必阅读):
* mineflayer 使用的 prismarine-physics **没有实现载具物理**。上船后
* mineflayer 会关闭本地物理预测(`bot.on('mount') → shouldUsePhysics = false`),
* 机器人位置完全由服务端下发的船实体位置决定,只能靠 `bot.moveVehicle()`
* (1.21+ 走 `player_input` 包)驱动船前进。
*
* 这意味着:
* 1. 无法在本地预测船的轨迹,遇到激流 / 冰面 / 碰撞时容易「贴墙空转」;
* 2. Grim 的 Vehicle 类检查会逐 tick 复算载具运动,一旦本地与服务器出现分歧就会被标记;
* 3. 因此本模块**默认关闭**(`config.autoBoat=false`、`config.waterMode='avoid'`),
* 只有在你实测确认该服务器对船宽容时才建议开启。
*
* 用船期间所有移动由本模块接管,寻路器必须处于停止状态。
*/
/** 用船流程状态机 */
const BoatState = {
IDLE: 'IDLE',
EQUIP: 'EQUIP',
PLACE: 'PLACE',
MOUNT: 'MOUNT',
SAIL: 'SAIL',
DISMOUNT: 'DISMOUNT',
COLLECT: 'COLLECT'
};
/** 前方水域扫描的最大距离(格) */
const MAX_SCAN_DISTANCE = 48;
/** 单个状态的最长持续时间(毫秒),超时后放弃流程并复位 */
const STATE_TIMEOUT_MS = 15000;
class AutoBoatController {
/**
* @param {import('mineflayer').Bot} bot
* @param {object} config 完整配置对象
* @param {import('../util/logger').Logger} [logger]
* @param {import('./grimCompat').GrimCompat} [grim]
*/
constructor(bot, config, logger, grim) {
this.bot = bot;
this.config = config;
this.logger = logger || null;
this.grim = grim || null;
this.state = BoatState.IDLE;
this._stateStartedAt = 0;
/** 保存的主手物品名(流程结束后换回) */
this._previousHeldName = null;
/** 本次流程放置的船实体 */
this.boat = null;
/** 当前目标区块 */
this._target = null;
/** 最近一次扫描到的水域信息(供 Web 控制台展示) */
this.lastScan = null;
}
/** @returns {boolean} 是否处于用船流程中 */
isActive() {
return this.state !== BoatState.IDLE;
}
/** @returns {string|null} 状态描述(供状态栏展示) */
describe() {
switch (this.state) {
case BoatState.EQUIP: return '自动用船:装备船只';
case BoatState.PLACE: return '自动用船:放下船只';
case BoatState.MOUNT: return '自动用船:登船';
case BoatState.SAIL: return '自动用船:航行中';
case BoatState.DISMOUNT: return '自动用船:靠岸下船';
case BoatState.COLLECT: return '自动用船:回收船只';
default: return null;
}
}
/**
* 每帧驱动。
*
* @param {{running: boolean, canStart: boolean, target: {x: number, z: number}|null}} context
*/
tick(context) {
const ctx = context || {};
this._target = ctx.target || null;
if (!this.config.autoBoat || this.config.waterMode !== 'boat' || !ctx.running) {
if (this.isActive()) this.reset(true);
return;
}
if (this.state === BoatState.IDLE) {
if (!ctx.canStart || !this._target) return;
if (!this._shouldUseBoat()) return;
this._transition(BoatState.EQUIP);
return;
}
// 状态超时保护:任何一步卡住超过 15 秒都直接放弃并复位,避免卡死扩地流程
if (Date.now() - this._stateStartedAt > STATE_TIMEOUT_MS) {
this._log('自动用船超时,放弃本次流程');
this.reset(true);
return;
}
switch (this.state) {
case BoatState.EQUIP: this._tickEquip(); break;
case BoatState.PLACE: this._tickPlace(); break;
case BoatState.MOUNT: this._tickMount(); break;
case BoatState.SAIL: this._tickSail(); break;
case BoatState.DISMOUNT: this._tickDismount(); break;
case BoatState.COLLECT: this._tickCollect(); break;
default: break;
}
}
/**
* 复位用船流程:松开方向键、下船、换回原主手物品。
* @param {boolean} [releaseItem] 是否换回流程前的主手物品
*/
reset(releaseItem) {
if (this.bot.vehicle) {
try {
this.bot.dismount();
} catch {
// 忽略
}
}
if (this.grim) {
this.grim.setControlState('forward', false);
this.grim.setControlState('sprint', false);
}
if (releaseItem && this._previousHeldName) {
const item = this._findItemByName(this._previousHeldName);
if (item) {
this.bot.equip(item, 'hand').catch(() => {});
}
}
this.state = BoatState.IDLE;
this._stateStartedAt = 0;
this.boat = null;
this._previousHeldName = null;
}
// ==================== 各状态 ====================
_tickEquip() {
const boatItem = this._findBoatItem();
if (!boatItem) {
this._log('背包里没有船,放弃自动用船');
this.reset(false);
return;
}
this._previousHeldName = this.bot.heldItem ? this.bot.heldItem.name : null;
this.bot.equip(boatItem, 'hand')
.then(() => this._transition(BoatState.PLACE))
.catch((err) => {
this._log('装备船只失败:' + (err && err.message ? err.message : String(err)));
this.reset(false);
});
}
_tickPlace() {
const bot = this.bot;
const pos = bot.entity.position;
// 找脚下前方的水面方块作为放置参考(placeEntity 需要一个参考方块 + 面偏移)
const dir = this._directionToTarget();
const refPos = new Vec3(
Math.floor(pos.x + dir.x),
Math.floor(pos.y - 1),
Math.floor(pos.z + dir.z)
);
const refBlock = bot.blockAt(refPos);
if (!refBlock || !isWaterName(refBlock.name)) {
// 参考方块不是水面:退一步用玩家脚下的水
const below = bot.blockAt(new Vec3(Math.floor(pos.x), Math.floor(pos.y - 1), Math.floor(pos.z)));
if (!below || !isWaterName(below.name)) {
this._log('前方没有可放船的水面,放弃自动用船');
this.reset(true);
return;
}
this._placeAgainst(below);
return;
}
this._placeAgainst(refBlock);
}
_placeAgainst(refBlock) {
const bot = this.bot;
bot.placeEntity(refBlock, new Vec3(0, 1, 0))
.then((entity) => {
this.boat = entity;
this._log('已放下船只,准备登船');
this._transition(BoatState.MOUNT);
})
.catch((err) => {
this._log('放船失败:' + (err && err.message ? err.message : String(err)));
this.reset(true);
});
}
_tickMount() {
const bot = this.bot;
if (bot.vehicle) {
this._transition(BoatState.SAIL);
return;
}
const boat = this.boat || this._findNearestBoatEntity();
if (!boat) {
this._log('找不到已放下的船,放弃自动用船');
this.reset(true);
return;
}
this.boat = boat;
// 登船前先对准船(原版右键需要朝向目标),并等旋转同步
const look = this.grim
? this.grim.lookAtEntity(boat)
: bot.lookAt(boat.position.offset(0, 0.5, 0));
Promise.resolve(look).then(() => {
try {
bot.mount(boat);
} catch (err) {
this._log('登船失败:' + (err && err.message ? err.message : String(err)));
}
});
}
_tickSail() {
const bot = this.bot;
const vehicle = bot.vehicle;
if (!vehicle) {
// 船没了(被打掉 / 沉了):结束流程
this._transition(BoatState.DISMOUNT);
return;
}
// 载具位置由服务端下发(mineflayer 无载具物理),因此用船实体的位置判断靠岸
const boatPos = vehicle.position;
const feetBlock = bot.blockAt(new Vec3(Math.floor(boatPos.x), Math.floor(boatPos.y - 0.5), Math.floor(boatPos.z)));
const stillOnWater = feetBlock && isWaterName(feetBlock.name);
const target = this._target;
const arrived = target
&& Math.floor(boatPos.x / 16) === target.x
&& Math.floor(boatPos.z / 16) === target.z;
if (!stillOnWater || arrived) {
this._log(arrived ? '已航行到目标区块,准备下船' : '已靠岸,准备下船');
this._transition(BoatState.DISMOUNT);
return;
}
// 朝目标方向持续前进
const dir = this._directionToTarget();
const yaw = Math.atan2(-dir.x, -dir.z);
if (this.grim) {
this.grim.setLook(yaw, 0);
} else {
bot.look(yaw, 0, true);
}
try {
// forward=1 前进;左右不修正,靠朝向对准目标
bot.moveVehicle(0, 1);
} catch {
// 忽略:老版本协议没有 player_input 包时由 mineflayer 内部处理
}
}
_tickDismount() {
const bot = this.bot;
if (!bot.vehicle) {
this._transition(BoatState.COLLECT);
return;
}
try {
bot.dismount();
} catch {
// 忽略
}
if (this.grim) {
this.grim.setControlState('forward', false);
this.grim.setControlState('sprint', false);
}
this._transition(BoatState.COLLECT);
}
_tickCollect() {
const bot = this.bot;
const boat = this.boat && this.boat.isValid !== false ? this.boat : this._findNearestBoatEntity();
if (!boat) {
this._log('自动用船流程结束');
this.reset(true);
return;
}
const distance = bot.entity.position.distanceTo(boat.position);
if (distance > 4) {
// 船太远,直接放弃回收(避免为了捡船跑回去)
this._log('船离得太远,放弃回收');
this.reset(true);
return;
}
const attack = this.grim
? this.grim.attackEntity(boat, { skipRotation: false })
: Promise.resolve(bot.attack(boat));
Promise.resolve(attack).then((ok) => {
if (ok !== false) {
this._log('自动用船流程结束,已回收船只');
this.reset(true);
}
});
}
// ==================== 判定与工具 ====================
/**
* 判断是否应该启用用船流程:目标方向前方存在足够开阔的水域。
* @returns {boolean}
*/
_shouldUseBoat() {
if (!this._findBoatItem()) return false;
const dir = this._directionToTarget();
const scan = scanWaterAhead(
this.bot,
dir.x,
dir.z,
this.config.autoBoatMinLength,
this.config.autoBoatMinWidth
);
this.lastScan = scan;
return scan.ok;
}
/** @returns {{x: number, z: number}} 从当前位置指向目标区块中心的水平单位方向 */
_directionToTarget() {
const bot = this.bot;
const pos = bot.entity.position;
if (!this._target) return { x: 0, z: 1 };
const dx = this._target.x * 16 + 8 - pos.x;
const dz = this._target.z * 16 + 8 - pos.z;
const length = Math.sqrt(dx * dx + dz * dz);
if (length < 1e-6) return { x: 0, z: 1 };
return { x: dx / length, z: dz / length };
}
/** @returns {object|null} 背包中的第一个船类物品 */
_findBoatItem() {
return this._findItemByName(null, /_boat$/);
}
/** @returns {object|null} 指定名字的物品 */
_findItemByName(name, pattern) {
const slots = this.bot.inventory ? this.bot.inventory.slots : [];
for (const item of slots) {
if (!item || !item.name) continue;
if (name && item.name === name) return item;
if (pattern && pattern.test(item.name)) return item;
}
return null;
}
/** @returns {object|null} 最近的船实体 */
_findNearestBoatEntity() {
const bot = this.bot;
let best = null;
let bestDistance = Infinity;
for (const id of Object.keys(bot.entities || {})) {
const entity = bot.entities[id];
if (!entity || !entity.name || !entity.name.includes('boat')) continue;
const distance = bot.entity.position.distanceTo(entity.position);
if (distance < bestDistance) {
bestDistance = distance;
best = entity;
}
}
return best;
}
_transition(next) {
this.state = next;
this._stateStartedAt = Date.now();
}
_log(message) {
if (this.logger) this.logger.log(message);
}
}
/**
* 沿指定方向扫描前方水域,判断是否值得用船。
*
* 判定条件(与原 Java 版语义一致):
* - 前进方向上连续水方块长度 ≥ minLength(比宽度更稳健,防止横跨窄水道时在岸上提前登船)
* - 水域中间处的垂直方向连续水方块宽度 ≥ minWidth(避免小水洼 / 小溪浪费船只)
*
* @param {import('mineflayer').Bot} bot
* @param {number} dirX 水平方向单位向量 X
* @param {number} dirZ 水平方向单位向量 Z
* @param {number} minLength 最短水域长度(格)
* @param {number} minWidth 最小水面宽度(格)
* @returns {{ok: boolean, length: number, width: number, reason: string}}
*/
function scanWaterAhead(bot, dirX, dirZ, minLength, minWidth) {
const pos = bot.entity.position;
const startX = Math.floor(pos.x);
const startY = Math.floor(pos.y);
const startZ = Math.floor(pos.z);
let length = 0;
let started = false;
let midX = startX;
let midZ = startZ;
for (let step = 1; step <= MAX_SCAN_DISTANCE; step++) {
const x = Math.floor(pos.x + dirX * step);
const z = Math.floor(pos.z + dirZ * step);
if (isWaterAt(bot, x, startY, z)) {
length++;
started = true;
midX = x;
midZ = z;
} else if (started) {
break;
}
// 还没入水:继续往前找(可能隔着一小段岸)
}
if (length < minLength) {
return { ok: false, length, width: 0, reason: `前方水域长度 ${length} < ${minLength}` };
}
// 在水域中段测垂直方向宽度
const perpX = -dirZ;
const perpZ = dirX;
let width = 1;
for (const sign of [1, -1]) {
for (let step = 1; step <= 32; step++) {
const x = Math.floor(midX + perpX * sign * step);
const z = Math.floor(midZ + perpZ * sign * step);
if (!isWaterAt(bot, x, startY, z)) break;
width++;
}
}
if (width < minWidth) {
return { ok: false, length, width, reason: `水面宽度 ${width} < ${minWidth}` };
}
return { ok: true, length, width, reason: '水域足够开阔' };
}
/**
* 判断指定列(脚部高度及其下一格)是否为水。
*
* 显式检查两格而不是用 `a || b` 短路:mineflayer 的 `bot.blockAt` 对未加载区块返回 null,
* 但对已加载的空气返回方块对象,短路写法在假世界 / 测试环境里会拿到空气而漏判。
*
* @param {import('mineflayer').Bot} bot
* @param {number} x
* @param {number} y
* @param {number} z
* @returns {boolean}
*/
function isWaterAt(bot, x, y, z) {
const atFeet = bot.blockAt(new Vec3(x, y, z));
if (atFeet && isWaterName(atFeet.name)) return true;
const below = bot.blockAt(new Vec3(x, y - 1, z));
return !!(below && isWaterName(below.name));
}
/**
* 判断方块名是否为水(含水草等可游泳的「水方块」)。
* @param {string} name
* @returns {boolean}
*/
function isWaterName(name) {
if (!name) return false;
return name === 'water' || name === 'flowing_water' || name === 'bubble_column';
}
module.exports = { AutoBoatController, BoatState, scanWaterAhead, isWaterName, isWaterAt, STATE_TIMEOUT_MS };
+202
查看文件
@@ -0,0 +1,202 @@
'use strict';
/**
* 战斗辅助:自动攻击目标筛选、攻击距离判定与装备耐久保护。
*
* 目标筛选遵循一条核心原则:**只攻击会主动攻击玩家的生物**。
* 铁傀儡、僵尸猪灵等中立生物永远不在目标内;末影人、蜘蛛、猪灵等
* 「条件敌对」生物默认也不打(mineflayer 拿不到可靠的「是否被激怒 / 环境亮度」信息,
* 宁可漏打也不误打),需要时可在配置里逐个放开。
*/
/** 原版实体交互距离(格):视线到目标碰撞箱最近点超过该距离无法命中 */
const ATTACK_RANGE = 3.0;
/** 永不攻击的实体名(含中立生物、宠物、召唤物) */
const NEVER_ATTACK = new Set([
'player',
'iron_golem',
'snow_golem',
'zombified_piglin',
'villager',
'wandering_trader',
'wolf',
'cat',
'ocelot',
'parrot',
'horse',
'donkey',
'mule',
'skeleton_horse',
'zombie_horse',
'llama',
'trader_llama',
'fox',
'bee',
'allay',
'camel',
'sniffer',
'armadillo',
'turtle',
'panda',
'polar_bear',
'goat',
'axolotl',
'frog',
'creaking'
]);
/** 默认跳过的高危生物:打不动或会被反杀 */
const DANGEROUS_MOBS = new Set(['warden', 'ender_dragon', 'wither']);
/** 条件敌对:默认不打,可在配置里放开 */
const CONDITIONAL_HOSTILE = new Set(['enderman', 'spider', 'cave_spider']);
/** 装备耐久保护检查的槽位(背包索引):头、胸、腿、脚、副手 */
const GUARD_SLOTS = [
{ slot: 5, name: '头盔' },
{ slot: 6, name: '胸甲' },
{ slot: 7, name: '护腿' },
{ slot: 8, name: '靴子' },
{ slot: 45, name: '副手' }
];
/**
* 判断实体是否为合法攻击目标。
*
* @param {object} entity mineflayer 实体
* @param {import('mineflayer').Bot} bot
* @param {object} [config] 完整配置(读取 config.combat)
* @returns {boolean}
*/
function isAttackTarget(entity, bot, config) {
const combat = (config && config.combat) || {};
if (!entity || entity === bot.entity) return false;
if (entity.isValid === false) return false;
if (entity.type !== 'mob' && entity.type !== 'hostile') return false;
const name = entity.name || '';
if (!name || NEVER_ATTACK.has(name)) return false;
if (combat.avoidDangerousMobs !== false && DANGEROUS_MOBS.has(name)) return false;
// 猪灵:玩家穿戴金盔甲时保持中立
if (name === 'piglin' || name === 'piglin_brute') {
if (combat.attackPiglins === false) return false;
return !wearsGoldArmor(bot);
}
// 条件敌对生物:默认不打,显式开启才打
if (CONDITIONAL_HOSTILE.has(name)) {
if (name === 'enderman') return combat.attackEnderman === true;
return combat.attackSpiders === true;
}
// 其余仅认 minecraft-data 的敌对分类,避免误伤中立生物
if (entity.kind === 'Hostile mobs') return true;
return false;
}
/**
* 在索敌范围内查找最近的合法攻击目标(不追击,仅就地战斗)。
*
* @param {import('mineflayer').Bot} bot
* @param {number} scanRange 索敌半径(格)
* @param {object} [config]
* @returns {object|null} 最近的攻击目标
*/
function findTarget(bot, scanRange, config) {
if (!bot || !bot.entity || !bot.entities) return null;
const range = scanRange === undefined ? 4.5 : scanRange;
const origin = bot.entity.position;
let best = null;
let bestDistance = Infinity;
for (const id of Object.keys(bot.entities)) {
const entity = bot.entities[id];
if (!entity || !entity.position) continue;
const dx = entity.position.x - origin.x;
const dy = entity.position.y - origin.y;
const dz = entity.position.z - origin.z;
if (dx * dx + dy * dy + dz * dz > range * range) continue;
if (!isAttackTarget(entity, bot, config)) continue;
const distance = eyeToEntityDistance(bot, entity);
if (distance < bestDistance) {
bestDistance = distance;
best = entity;
}
}
return best;
}
/**
* 视线到实体碰撞箱最近点的距离(格),与原版攻击距离判定一致。
*
* @param {import('mineflayer').Bot} bot
* @param {object} entity
* @returns {number}
*/
function eyeToEntityDistance(bot, entity) {
const eye = bot.entity.position.offset(0, bot.entity.eyeHeight || 1.62, 0);
const half = (entity.width || 0.6) / 2;
const height = entity.height || 1.8;
const dx = Math.max(Math.max(entity.position.x - half - eye.x, eye.x - (entity.position.x + half)), 0);
const dy = Math.max(Math.max(entity.position.y - eye.y, eye.y - (entity.position.y + height)), 0);
const dz = Math.max(Math.max(entity.position.z - half - eye.z, eye.z - (entity.position.z + half)), 0);
return Math.sqrt(dx * dx + dy * dy + dz * dz);
}
/**
* 检查已装备物品(主手、副手、四件盔甲)中是否有耐久低于阈值的。
*
* @param {import('mineflayer').Bot} bot
* @param {number} thresholdPercent 耐久阈值(剩余百分比 ≤ 该值视为过低)
* @returns {{slot: number, name: string, percent: number, itemName: string}|null}
*/
function findLowDurabilitySlot(bot, thresholdPercent) {
if (!bot || !bot.inventory) return null;
const threshold = Math.max(1, thresholdPercent);
const candidates = [];
if (bot.heldItem) candidates.push({ slot: bot.quickBarSlot === undefined ? -1 : bot.quickBarSlot, name: '主手', item: bot.heldItem });
for (const guard of GUARD_SLOTS) {
const item = bot.inventory.slots[guard.slot];
if (item) candidates.push({ slot: guard.slot, name: guard.name, item });
}
for (const candidate of candidates) {
const item = candidate.item;
const max = item.maxDurability;
if (!max || max <= 0) continue;
const used = item.durabilityUsed || 0;
const remaining = max - used;
const percent = (remaining * 100) / max;
if (percent <= threshold) {
return {
slot: candidate.slot,
name: candidate.name,
percent: Math.round(percent),
itemName: item.name
};
}
}
return null;
}
/**
* 判断机器人四件盔甲中是否至少一件为金质(金盔甲可使猪灵保持中立)。
* @param {import('mineflayer').Bot} bot
* @returns {boolean}
*/
function wearsGoldArmor(bot) {
if (!bot || !bot.inventory) return false;
for (const slot of [5, 6, 7, 8]) {
const item = bot.inventory.slots[slot];
if (item && item.name && item.name.startsWith('golden_')) return true;
}
return false;
}
module.exports = {
ATTACK_RANGE,
NEVER_ATTACK,
DANGEROUS_MOBS,
CONDITIONAL_HOSTILE,
isAttackTarget,
findTarget,
eyeToEntityDistance,
findLowDurabilitySlot,
wearsGoldArmor
};
+128
查看文件
@@ -0,0 +1,128 @@
'use strict';
/**
* 自动进食:饥饿值低于阈值时自动吃背包里的食物。
*
* Grim 相关约束(见 grimCompat):
* - 进食前松开所有移动键(原版进食会取消疾跑并减速,否则触发 NoSlow / MultiActions)
* - 骑乘状态下不进食(防 Vehicle 类检查)
* - 使用 mineflayer 的 `bot.consume()`,它内部走的是与原版一致的 use_item / block_dig 流程
*/
class AutoEater {
/**
* @param {import('mineflayer').Bot} bot
* @param {object} config 完整配置对象
* @param {import('../util/logger').Logger} [logger]
* @param {import('./grimCompat').GrimCompat} [grim]
*/
constructor(bot, config, logger, grim) {
this.bot = bot;
this.config = config;
this.logger = logger || null;
this.grim = grim || null;
/** 是否正在进食 */
this.eating = false;
/** 进食开始时间戳,用于超时保护 */
this._startedAt = 0;
}
/**
* 每帧调用。
* @returns {boolean} true 表示本帧被进食占用(调用方应跳过其它逻辑)
*/
tick() {
const bot = this.bot;
if (!this.config.autoEat) {
this.eating = false;
return false;
}
const food = typeof bot.food === 'number' ? bot.food : 20;
if (this.eating) {
// 超时保护:进食超过 8 秒仍未结束,强制收尾,避免卡死状态机
if (Date.now() - this._startedAt > 8000) {
this._finish();
return false;
}
if (food >= 20 || food > this.config.eatThreshold) {
this._finish();
return false;
}
return true;
}
if (food > this.config.eatThreshold) return false;
this._begin(food);
return this.eating;
}
/** 开始进食:找到食物并装备到主手,随后异步 consume */
_begin(foodLevel) {
const bot = this.bot;
const slot = findFoodSlot(bot);
if (slot < 0) {
if (!this._warnedNoFood) {
this._warnedNoFood = true;
this._log('饥饿但背包里没有食物');
}
return;
}
this._warnedNoFood = false;
this.eating = true;
this._startedAt = Date.now();
this._log(`自动进食,饥饿值 ${foodLevel}`);
this._consume(slot);
}
/** 异步执行「装备食物 → 进食」,失败时复位状态 */
async _consume(slot) {
const bot = this.bot;
try {
const item = bot.inventory.slots[slot];
if (!item) throw new Error('食物槽位已空');
if (!bot.heldItem || bot.heldItem.name !== item.name) {
await bot.equip(item, 'hand');
}
// 进食期间停止移动(原版行为,防 Grim 的 NoSlow / MultiActions)
if (this.grim && this.config.grim.stopMovementWhileEating) {
for (const control of ['forward', 'back', 'left', 'right', 'sprint', 'jump']) {
this.grim.setControlState(control, false);
}
await bot.waitForTicks(1).catch(() => {});
}
await bot.consume();
} catch (err) {
this._log('自动进食失败:' + (err && err.message ? err.message : String(err)));
} finally {
if (this.eating) this._finish();
}
}
_finish() {
this.eating = false;
this._startedAt = 0;
this._log('自动进食完成');
}
_log(message) {
if (this.logger) this.logger.log(message);
}
}
/**
* 在背包(含快捷栏)中查找第一个可食用的物品槽位。
*
* @param {import('mineflayer').Bot} bot
* @returns {number} 槽位索引,找不到返回 -1
*/
function findFoodSlot(bot) {
if (!bot.inventory) return -1;
const foodsByName = bot.registry && bot.registry.foodsByName ? bot.registry.foodsByName : {};
const slots = bot.inventory.slots;
for (let slot = 0; slot < slots.length; slot++) {
const item = slots[slot];
if (!item || !item.name) continue;
if (foodsByName[item.name]) return slot;
}
return -1;
}
module.exports = { AutoEater, findFoodSlot };
+384
查看文件
@@ -0,0 +1,384 @@
'use strict';
const { EventEmitter } = require('events');
const mineflayer = require('mineflayer');
const { GrimCompat } = require('./grimCompat');
const { PathingService } = require('./pathingService');
const { AutoEater } = require('./autoEat');
const { AutoBoatController } = require('./autoBoat');
const { DurabilityGuard } = require('./durabilityGuard');
/** 服务器要求登录 / 注册时的提示关键词 */
const LOGIN_PROMPT_PATTERN = /请(先)?(输入)?登录|请登录|请输入密码|login|登入/i;
const REGISTER_PROMPT_PATTERN = /请(先)?(输入)?注册|请注册|register/i;
const LOGIN_OK_PATTERN = /登录成功|登陆成功|注册成功|欢迎回来|成功登录/;
const ALREADY_LOGGED_IN_PATTERN = /已登录|无需登录|你已经登录/;
/**
* 机器人管理器:负责连接、登录、断线重连,并把底层 mineflayer 事件
* 归一化成上层(控制器 / Web 控制台)可直接消费的事件。
*
* 同时在这里装配 Grim 合规层、寻路服务、自动进食、自动用船、耐久保护等子模块,
* 使它们与 bot 实例的生命周期保持一致。
*/
class BotManager extends EventEmitter {
/**
* @param {object} connection 连接参数
* @param {string} connection.host
* @param {number} connection.port
* @param {string} connection.username
* @param {string} connection.version
* @param {string} [connection.auth]
* @param {string} [connection.password]
* @param {object} config 完整配置对象
* @param {import('../util/logger').Logger} [logger]
*/
constructor(connection, config, logger) {
super();
this.connection = connection;
this.config = config;
this.logger = logger || null;
/** @type {import('mineflayer').Bot|null} */
this.bot = null;
/** @type {GrimCompat|null} */
this.grim = null;
/** @type {PathingService|null} */
this.pathing = null;
/** @type {AutoEater|null} */
this.eater = null;
/** @type {AutoBoatController|null} */
this.boat = null;
/** @type {DurabilityGuard|null} */
this.guard = null;
/** 连接状态:idle | connecting | online | offline */
this.state = 'idle';
/** 是否已完成登录(离线服密码登录) */
this.loggedIn = false;
/** 断线重连计数 */
this.reconnectAttempts = 0;
/** 是否由用户主动断开(主动断开不重连) */
this._manualDisconnect = false;
this._reconnectTimer = null;
this._lastCommandAt = 0;
/** 待发送的指令队列(保证最小间隔) */
this._commandQueue = [];
this._commandTimer = null;
/** 最近一次收到过的聊天消息(供 Web 控制台展示) */
this.lastMessages = [];
}
// ==================== 连接生命周期 ====================
/** 建立连接(会自动重连) */
connect() {
if (this.bot) return;
this._manualDisconnect = false;
this.state = 'connecting';
const options = {
host: this.connection.host,
port: this.connection.port,
username: this.connection.username,
version: this.connection.version,
auth: this.connection.auth || 'offline',
// 关掉 mineflayer 自带的聊天模式匹配,避免无谓的解析开销
chatLengthLimit: 256
};
this._log(`正在连接 ${options.host}:${options.port}(${options.username} / ${options.version} / ${options.auth})`);
let bot;
try {
bot = mineflayer.createBot(options);
} catch (err) {
this.state = 'offline';
this._log('创建 bot 失败:' + (err && err.message ? err.message : String(err)));
this._scheduleReconnect();
return;
}
this.bot = bot;
this._wireEvents(bot);
}
/** 主动断开(不触发重连) */
disconnect() {
this._manualDisconnect = true;
if (this._reconnectTimer) {
clearTimeout(this._reconnectTimer);
this._reconnectTimer = null;
}
this._commandQueue.length = 0;
if (this.bot) {
try {
this.bot.quit('AutoStore 主动断开');
} catch {
// 忽略
}
}
this.state = 'idle';
}
/** @returns {boolean} 是否已进入世界并完成登录,可以执行任务 */
isReady() {
return this.state === 'online' && !!this.bot && !!this.bot.entity && !!this.bot.pathfinder;
}
/** @returns {boolean} 是否已进入世界(未强制要求登录完成) */
isSpawned() {
return !!this.bot && !!this.bot.entity && this.state === 'online';
}
_scheduleReconnect() {
if (this._manualDisconnect || !this.config.server.autoReconnect) return;
if (this._reconnectTimer) return;
this.reconnectAttempts++;
const delay = this.config.server.reconnectDelayMs;
this._log(`${delay / 1000} 秒后尝试第 ${this.reconnectAttempts} 次重连`);
this._reconnectTimer = setTimeout(() => {
this._reconnectTimer = null;
this.bot = null;
this.connect();
}, delay);
if (this._reconnectTimer.unref) this._reconnectTimer.unref();
}
// ==================== 事件装配 ====================
_wireEvents(bot) {
bot.on('login', () => {
this.state = 'online';
this._log('已连接到服务器,正在进入世界');
this._handleLogin();
});
bot.on('spawn', () => {
this._log('已进入世界');
this.reconnectAttempts = 0;
this._installModules();
this.emit('spawn');
});
bot.on('end', (reason) => {
this.state = 'offline';
this.loggedIn = false;
this._log('连接已断开:' + (reason || '未知原因'));
this.emit('end', reason);
this._scheduleReconnect();
});
bot.on('kicked', (reason) => {
const text = typeof reason === 'string' ? reason : JSON.stringify(reason);
this._log('被服务器踢出:' + text, 'warn');
this.emit('kicked', text);
});
bot.on('error', (err) => {
this._log('连接错误:' + (err && err.message ? err.message : String(err)), 'error');
this.emit('botError', err);
});
bot.on('death', () => {
this._log('机器人死亡', 'warn');
this.emit('death');
});
// ===== 聊天 / 字幕 / 动作栏:占领判定与登录提示的数据源 =====
bot.on('messagestr', (message) => {
if (!message) return;
this._pushMessage(message);
this._handleAuthPrompt(message);
this.emit('chat', message);
});
bot.on('title', (text, type) => {
if (!text) return;
if (type === 'subtitle') this.emit('subtitle', text);
else this.emit('title', text);
});
bot.on('actionBar', (message) => {
const text = message && message.toString ? message.toString() : String(message || '');
if (text) this.emit('subtitle', text);
});
bot.on('bossBarUpdated', (bar) => {
const text = bar && bar.title ? String(bar.title) : '';
if (text) this.emit('subtitle', text);
});
bot.on('bossBarCreated', (bar) => {
const text = bar && bar.title ? String(bar.title) : '';
if (text) this.emit('subtitle', text);
});
bot.on('forcedMove', () => {
this._log('被服务器传送(位置强制同步)');
this.emit('forcedMove');
});
}
/** 在世界里装配各子模块(Grim 层、寻路、进食、用船、耐久) */
_installModules() {
const bot = this.bot;
this.grim = new GrimCompat(bot, this.config, this.logger);
this.grim.install();
const audit = this.grim.audit();
if (!audit.ok) {
for (const issue of audit.issues) this._log('合规自检:' + issue, 'warn');
}
this.pathing = new PathingService(bot, this.config, this.logger);
this.pathing.configure();
this.eater = new AutoEater(bot, this.config, this.logger, this.grim);
this.boat = new AutoBoatController(bot, this.config, this.logger, this.grim);
this.guard = new DurabilityGuard(bot, this.config, this.logger);
this.emit('modulesReady');
}
/**
* 配置变更后重新应用到子模块(寻路器、合规层等)。
* @param {object} config
*/
applyConfig(config) {
this.config = config;
if (this.pathing) this.pathing.applyConfig(config);
if (this.grim) this.grim.config = config;
if (this.eater) this.eater.config = config;
if (this.boat) this.boat.config = config;
if (this.guard) this.guard.config = config;
}
// ==================== 登录 ====================
/** 进入世界后按配置尝试登录 / 注册 */
_handleLogin() {
const server = this.config.server;
const password = this.connection.password;
this.loggedIn = !server.loginCommand;
if (!server.loginCommand || !password) return;
// 稍等片刻再发,避免与服务器的欢迎信息 / 传送抢时序
setTimeout(() => {
if (!this.bot || this.state !== 'online') return;
this.sendCommand(fillTemplate(server.loginCommand, password));
}, 1500).unref?.();
}
/** 从聊天消息里识别服务器的登录 / 注册提示 */
_handleAuthPrompt(message) {
const server = this.config.server;
const password = this.connection.password;
if (!password) return;
if (LOGIN_OK_PATTERN.test(message)) {
if (!this.loggedIn) this._log('登录成功');
this.loggedIn = true;
return;
}
if (ALREADY_LOGGED_IN_PATTERN.test(message)) {
this.loggedIn = true;
return;
}
if (server.registerCommand && REGISTER_PROMPT_PATTERN.test(message)) {
this._log('服务器要求注册,正在注册');
this.sendCommand(fillTemplate(server.registerCommand, password));
return;
}
if (server.loginCommand && LOGIN_PROMPT_PATTERN.test(message)) {
this.sendCommand(fillTemplate(server.loginCommand, password));
}
}
// ==================== 指令发送 ====================
/**
* 发送一条聊天指令(带最小间隔节流,防刷屏被服务器或 Grim 判定异常)。
*
* @param {string} command 不带斜杠的指令,例如 `u claimmode`
* @param {{immediate?: boolean}} [options]
*/
sendCommand(command, options) {
if (!command) return;
const opts = options || {};
if (opts.immediate) {
this._writeCommand(command);
return;
}
this._commandQueue.push(command);
this._drainCommandQueue();
}
_drainCommandQueue() {
if (this._commandTimer) return;
const now = Date.now();
const wait = Math.max(0, this.config.server.commandCooldownMs - (now - this._lastCommandAt));
this._commandTimer = setTimeout(() => {
this._commandTimer = null;
const next = this._commandQueue.shift();
if (next === undefined) return;
this._writeCommand(next);
if (this._commandQueue.length > 0) this._drainCommandQueue();
}, wait);
if (this._commandTimer.unref) this._commandTimer.unref();
}
_writeCommand(command) {
if (!this.bot) return;
this._lastCommandAt = Date.now();
const text = command.startsWith('/') ? command : '/' + command;
this._log('发送指令:' + text);
try {
this.bot.chat(text);
} catch (err) {
this._log('发送指令失败:' + (err && err.message ? err.message : String(err)));
}
}
/** @returns {object} 连接状态快照(供 Web 控制台展示) */
snapshot() {
const bot = this.bot;
const entity = bot ? bot.entity : null;
return {
state: this.state,
loggedIn: this.loggedIn,
username: this.connection.username,
host: `${this.connection.host}:${this.connection.port}`,
version: this.connection.version,
reconnectAttempts: this.reconnectAttempts,
health: bot ? bot.health : null,
food: bot ? bot.food : null,
position: entity ? {
x: Number(entity.position.x.toFixed(2)),
y: Number(entity.position.y.toFixed(2)),
z: Number(entity.position.z.toFixed(2))
} : null,
chunk: entity ? {
x: Math.floor(entity.position.x / 16),
z: Math.floor(entity.position.z / 16)
} : null,
inWater: entity ? entity.isInWater === true : false,
riding: !!(bot && bot.vehicle),
pathing: this.pathing ? this.pathing.isPathing() : false,
eating: this.eater ? this.eater.eating : false,
grim: this.grim ? this.grim.snapshot() : null
};
}
_pushMessage(message) {
this.lastMessages.push({ time: Date.now(), message });
if (this.lastMessages.length > 200) this.lastMessages.shift();
}
_log(message, level) {
if (this.logger) this.logger.log(message, level);
}
}
/**
* 把指令模板里的 `{password}` 占位符替换为真实密码。
* @param {string} template
* @param {string} password
* @returns {string}
*/
function fillTemplate(template, password) {
return String(template).replace(/\{password\}/g, password);
}
module.exports = { BotManager, fillTemplate };
+44
查看文件
@@ -0,0 +1,44 @@
'use strict';
const { findLowDurabilitySlot } = require('./autoCombat');
/** 耐久保护检查间隔(毫秒),与原 Java 版的 40 tick ≈ 2 秒一致 */
const CHECK_INTERVAL_MS = 2000;
/**
* 耐久保护:主手 / 副手 / 四件盔甲任意一件剩余耐久低于阈值时触发回城,
* 防止装备在继续战斗中被怪物磨没。
*/
class DurabilityGuard {
/**
* @param {import('mineflayer').Bot} bot
* @param {object} config 完整配置对象
* @param {import('../util/logger').Logger} [logger]
*/
constructor(bot, config, logger) {
this.bot = bot;
this.config = config;
this.logger = logger || null;
this._lastCheck = 0;
}
/**
* 按间隔节流地检查装备耐久。
* @param {number} [now] 当前时间戳(便于测试注入)
* @returns {{slot: number, name: string, percent: number, itemName: string}|null}
*/
check(now) {
if (!this.config.durabilityGuard) return null;
const current = now === undefined ? Date.now() : now;
if (current - this._lastCheck < CHECK_INTERVAL_MS) return null;
this._lastCheck = current;
return findLowDurabilitySlot(this.bot, this.config.durabilityThreshold);
}
/** 重置节流计时器(任务重新开始时调用) */
reset() {
this._lastCheck = 0;
}
}
module.exports = { DurabilityGuard, CHECK_INTERVAL_MS };
+582
查看文件
@@ -0,0 +1,582 @@
'use strict';
/**
* Grim 反作弊合规层。
*
* 目标:让 mineflayer 机器人在装有 GrimAC 的服务器上「行为上像一个正常原版客户端」。
*
* 核心手段(每一项都对应一类 Grim 检查):
*
* 1. **旋转不超前**(防 Prediction / Aim 类检查)
* mineflayer 的 `bot.look()` 会立刻把 `bot.entity.yaw` 改到目标值,
* 而朝向数据包是按 `physics.yawSpeed` 逐 tick 慢慢补发的。
* 这会造成「本地已经朝新方向走、服务端还以为朝旧方向」的预测分歧 —— Grim 的
* Prediction 检查正是逐 tick 复算玩家位置并与上报位置比对,分歧必然标记。
* 因此本层接管 `bot.look`:只把目标朝向记下来,在每个 physicsTick 里把
* `bot.entity.yaw/pitch` 按 ≤ `grim.maxRotationPerTickDeg` 的步长推进,
* 保证「本地朝向」永远等于「服务端已收到的朝向」,最多差 1 tick。
*
* 2. **攻击前三重校验**(防 Reach / LineOfSight / Post / MultiActions)
* - 旋转已同步(服务端认为你正看着目标)
* - 眼睛到目标碰撞箱的距离 ≤ `grim.attackReach`(生存模式 3.0)
* - 视线可达(中间没有实心方块遮挡)
* - 攻击冷却已好(1.9+ 武器攻速)
* - 不处于骑乘 / 进食 / 使用物品状态
*
* 3. **动作互斥**(防 MultiActions)
* 疾跑 + 潜行、疾跑 + 使用物品、疾跑 + 跳跃(可配)等原版客户端不会出现的组合一律拦下。
*
* 4. **物理参数只读校验**(防「改物理常数绕过预测」这类自杀式操作)
* `audit()` 会核对 `bot.physics` 的关键常量是否仍为原版值。
*/
/** 物理仿真步长(秒),与 mineflayer 的 PHYSICS_INTERVAL_MS=50 对应 */
const PHYSICS_TIMESTEP = 0.05;
/** 原版物理常量基线,用于 audit() 校验未被篡改 */
const VANILLA_PHYSICS_BASELINE = {
gravity: 0.08,
playerSpeed: 0.1,
sprintSpeed: 0.3,
sneakSpeed: 0.3,
stepHeight: 0.6,
airborneInertia: 0.91,
waterInertia: 0.8
};
/** 攻击速度(次/秒)→ 冷却毫秒;按物品名匹配,未命中回退 4.0(空手) */
const ATTACK_SPEED_RULES = [
[/^netherite_sword$|^diamond_sword$|^iron_sword$|^golden_sword$|^stone_sword$|^wooden_sword$/, 1.6],
[/^netherite_axe$|^diamond_axe$/, 1.0],
[/^iron_axe$/, 0.9],
[/^golden_axe$|^stone_axe$|^wooden_axe$/, 0.8],
[/^trident$/, 1.1],
[/^.*_pickaxe$/, 1.2],
[/^.*_shovel$/, 1.0],
[/^.*_hoe$/, 1.0]
];
/** 命中这些方块名视为「不遮挡视线」 */
const NON_OCCLUDING = new Set([
'air', 'cave_air', 'void_air', 'water', 'lava', 'seagrass', 'tall_seagrass',
'kelp', 'kelp_plant', 'bubble_column', 'torch', 'wall_torch', 'soul_torch',
'soul_wall_torch', 'redstone_torch', 'redstone_wall_torch', 'lever', 'tripwire',
'tripwire_hook', 'vine', 'short_grass', 'grass', 'fern', 'large_fern', 'dead_bush',
'sugar_cane', 'lily_pad', 'rail', 'powered_rail', 'detector_rail', 'activator_rail'
]);
class GrimCompat {
/**
* @param {import('mineflayer').Bot} bot
* @param {object} config 完整配置对象(读取 config.grim / config.allowSprint 等)
* @param {import('../util/logger').Logger} [logger]
*/
constructor(bot, config, logger) {
this.bot = bot;
this.config = config;
this.logger = logger || null;
/** 目标朝向(弧度) */
this.targetYaw = bot.entity ? bot.entity.yaw : 0;
this.targetPitch = bot.entity ? bot.entity.pitch : 0;
/** 上一 tick 的朝向,用于限速 */
this.lastAppliedYaw = this.targetYaw;
this.lastAppliedPitch = this.targetPitch;
/** 等待旋转完成的 resolver 列表 */
this._rotationWaiters = [];
/** 最近一次攻击时间戳(毫秒) */
this._lastAttackAt = 0;
/** 拦截到的不合规动作计数,供 Web 控制台展示 */
this.blockedActions = 0;
this._installed = false;
this._originalLook = null;
this._originalLookAt = null;
this._originalSetControlState = null;
this._onPhysicsTick = this._onPhysicsTick.bind(this);
}
/** 单 tick 允许的最大旋转弧度(取配置值与 mineflayer 物理限速的较小者) */
get maxStepRad() {
const configured = degToRad(this.config.grim.maxRotationPerTickDeg);
const physicsLimit = PHYSICS_TIMESTEP * (this.bot.physics ? this.bot.physics.yawSpeed : 3.0);
return Math.min(configured, physicsLimit);
}
/**
* 安装合规层:接管 look / setControlState,并挂上 physicsTick 推进器。
* 必须在 bot 完成 spawn 之后调用(此时 bot.look / bot.physics 才存在)。
*/
install() {
if (this._installed) return;
const bot = this.bot;
if (!this.config.grim.enabled) {
this._installed = true;
return;
}
this.targetYaw = bot.entity.yaw;
this.targetPitch = bot.entity.pitch;
this.lastAppliedYaw = bot.entity.yaw;
this.lastAppliedPitch = bot.entity.pitch;
this._originalLook = bot.look.bind(bot);
this._originalLookAt = bot.lookAt.bind(bot);
this._originalSetControlState = bot.setControlState.bind(bot);
bot.look = (yaw, pitch) => this.setLook(yaw, pitch === undefined ? this.targetPitch : pitch);
bot.lookAt = (point) => this.lookAtPoint(point);
bot.setControlState = (control, state) => this.setControlState(control, state);
bot.on('physicsTick', this._onPhysicsTick);
bot.on('end', () => this._releaseRotationWaiters());
this._installed = true;
this._log('Grim 合规层已启用(旋转限速 ' + this.config.grim.maxRotationPerTickDeg + '°/tick,'
+ '攻击距离 ' + this.config.grim.attackReach + ' 格)');
}
/** 卸载合规层(恢复原始方法,主要供测试使用) */
uninstall() {
if (!this._installed) return;
const bot = this.bot;
bot.removeListener('physicsTick', this._onPhysicsTick);
if (this._originalLook) bot.look = this._originalLook;
if (this._originalLookAt) bot.lookAt = this._originalLookAt;
if (this._originalSetControlState) bot.setControlState = this._originalSetControlState;
this._installed = false;
}
// ==================== 旋转 ====================
/**
* 设置目标朝向(不立即生效,由 physicsTick 逐步推进)。
* @param {number} yaw 目标 yaw(弧度)
* @param {number} [pitch] 目标 pitch(弧度)
* @returns {Promise<void>} 旋转真正同步到服务端后 resolve
*/
setLook(yaw, pitch) {
if (typeof yaw !== 'number' || Number.isNaN(yaw)) return Promise.resolve();
this.targetYaw = yaw;
this.targetPitch = typeof pitch === 'number' && !Number.isNaN(pitch)
? clamp(pitch, -Math.PI / 2, Math.PI / 2)
: this.targetPitch;
if (this.isRotationSettled()) return Promise.resolve();
return new Promise((resolve) => {
this._rotationWaiters.push(resolve);
});
}
/**
* 朝指定世界坐标看过去。
* @param {{x: number, y: number, z: number}} point
* @returns {Promise<void>}
*/
lookAtPoint(point) {
const eye = this.bot.entity.position.offset(0, this.bot.entity.eyeHeight || 1.62, 0);
const dx = point.x - eye.x;
const dy = point.y - eye.y;
const dz = point.z - eye.z;
const yaw = Math.atan2(-dx, -dz);
const groundDistance = Math.sqrt(dx * dx + dz * dz);
const pitch = Math.atan2(dy, groundDistance);
return this.setLook(yaw, pitch);
}
/**
* 看向实体(取碰撞箱中心)。
* @param {object} entity
* @returns {Promise<void>}
*/
lookAtEntity(entity) {
const height = entity.height || 1.8;
return this.lookAtPoint({
x: entity.position.x,
y: entity.position.y + height * 0.85,
z: entity.position.z
});
}
/** @returns {boolean} 当前朝向是否已经与目标一致(即服务端已收到完整旋转) */
isRotationSettled() {
return Math.abs(shortestAngleDelta(this.bot.entity.yaw, this.targetYaw)) < 1e-4
&& Math.abs(this.bot.entity.pitch - this.targetPitch) < 1e-4;
}
/**
* 等待旋转同步完成。
* @param {number} [timeoutMs] 超时(默认 3000ms)
* @returns {Promise<boolean>} 是否在超时前完成
*/
awaitRotationSettled(timeoutMs) {
if (this.isRotationSettled()) return Promise.resolve(true);
const limit = timeoutMs === undefined ? 3000 : timeoutMs;
return new Promise((resolve) => {
let done = false;
const timer = setTimeout(() => {
if (done) return;
done = true;
resolve(false);
}, limit);
this._rotationWaiters.push(() => {
if (done) return;
done = true;
clearTimeout(timer);
resolve(true);
});
});
}
/** physicsTick:把 entity.yaw/pitch 朝目标推进一小步(mineflayer 会在同一 tick 把该朝向发包) */
_onPhysicsTick() {
const bot = this.bot;
if (!bot.entity) return;
const step = this.maxStepRad;
const yawDelta = shortestAngleDelta(bot.entity.yaw, this.targetYaw);
if (Math.abs(yawDelta) > 1e-6) {
bot.entity.yaw += clamp(yawDelta, -step, step);
} else {
bot.entity.yaw = this.targetYaw;
}
const pitchDelta = this.targetPitch - bot.entity.pitch;
if (Math.abs(pitchDelta) > 1e-6) {
bot.entity.pitch += clamp(pitchDelta, -step, step);
} else {
bot.entity.pitch = this.targetPitch;
}
if (this.isRotationSettled()) this._releaseRotationWaiters();
}
_releaseRotationWaiters() {
const waiters = this._rotationWaiters;
this._rotationWaiters = [];
for (const resolve of waiters) {
try {
resolve();
} catch {
// 忽略订阅者异常
}
}
}
// ==================== 控制键 ====================
/**
* 带互斥规则的控制键设置(替换 bot.setControlState)。
*
* 拦下的组合(原版客户端不可能出现,Grim 的 MultiActions 会标记):
* - 疾跑 + 潜行
* - 疾跑 + 使用物品(进食 / 拉弓 / 举盾)
* - 疾跑 + 跳跃(仅在 grim.blockSprintJump 开启时拦,默认放行,因为原版合法)
*
* @param {string} control 'forward'|'back'|'left'|'right'|'jump'|'sprint'|'sneak'
* @param {boolean} state
*/
setControlState(control, state) {
const bot = this.bot;
if (!this.config.grim.enabled) {
this._originalSetControlState(control, state);
return;
}
if (state && this.config.grim.blockConflictingActions) {
const conflicting = this._findConflict(control);
if (conflicting) {
this.blockedActions++;
this._log(`已拦下不合规动作组合:${control} + ${conflicting}`);
return;
}
}
if (state && control === 'sprint' && this.config.grim.blockSprintJump && bot.getControlState('jump')) {
this.blockedActions++;
return;
}
this._originalSetControlState(control, state);
}
/** @returns {string|null} 与指定控制键冲突且当前已按下的控制键 */
_findConflict(control) {
const bot = this.bot;
const sprinting = control === 'sprint' || bot.getControlState('sprint');
if (control === 'sneak' && sprinting) return 'sprint';
if (control === 'sprint' && bot.getControlState('sneak')) return 'sneak';
if (this.config.grim.stopMovementWhileEating && this.isUsingItem()) {
if (control === 'sprint' || control === 'forward' || control === 'jump') return 'use_item';
}
return null;
}
/** 松开所有移动控制键 */
releaseAllControls() {
for (const control of ['forward', 'back', 'left', 'right', 'jump', 'sprint', 'sneak']) {
try {
this._originalSetControlState
? this._originalSetControlState(control, false)
: this.bot.setControlState(control, false);
} catch {
// 忽略
}
}
}
// ==================== 攻击 ====================
/** @returns {boolean} 是否正在使用物品(进食 / 拉弓 / 举盾) */
isUsingItem() {
return this.bot.usingHeldItem === true;
}
/**
* 当前手持物品的两次攻击之间的冷却时间(毫秒)。
* @returns {number}
*/
attackCooldownMs() {
const item = this.bot.heldItem;
const name = item && item.name ? item.name : '';
for (const [pattern, speed] of ATTACK_SPEED_RULES) {
if (pattern.test(name)) return Math.round(1000 / speed);
}
return Math.round(1000 / 4.0); // 空手 / 其它物品:4 次每秒
}
/** @returns {number} 距离下次可攻击还剩多少毫秒(0 表示已冷却好) */
attackCooldownRemainingMs() {
const remaining = this.attackCooldownMs() - (Date.now() - this._lastAttackAt);
return remaining > 0 ? remaining : 0;
}
/**
* 检查是否可以攻击目标(不实际攻击)。
* @param {object} entity
* @returns {{ok: boolean, reason?: string}}
*/
canAttack(entity) {
const bot = this.bot;
if (!entity || !entity.position || !entity.isValid) {
return { ok: false, reason: 'invalid_entity' };
}
if (this.config.grim.blockActionsWhileRiding && bot.vehicle) {
return { ok: false, reason: 'riding' };
}
if (this.isUsingItem()) {
return { ok: false, reason: 'using_item' };
}
const distance = eyeDistanceToEntity(bot, entity);
if (distance > this.config.grim.attackReach) {
return { ok: false, reason: 'out_of_reach' };
}
if (this.config.grim.respectAttackCooldown && this.attackCooldownRemainingMs() > 0) {
return { ok: false, reason: 'cooldown' };
}
if (this.config.grim.requireLineOfSight && !this.hasLineOfSight(entity)) {
return { ok: false, reason: 'no_line_of_sight' };
}
return { ok: true };
}
/**
* 视线可达判定:从眼睛朝目标碰撞箱中心射线,中途不得被实心方块遮挡。
* @param {object} entity
* @returns {boolean}
*/
hasLineOfSight(entity) {
const bot = this.bot;
if (!bot.world || typeof bot.world.raycast !== 'function') return true;
const eye = bot.entity.position.offset(0, bot.entity.eyeHeight || 1.62, 0);
const target = {
x: entity.position.x,
y: entity.position.y + (entity.height || 1.8) * 0.5,
z: entity.position.z
};
const dx = target.x - eye.x;
const dy = target.y - eye.y;
const dz = target.z - eye.z;
const length = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (length < 1e-6) return true;
const direction = { x: dx / length, y: dy / length, z: dz / length };
try {
const hit = bot.world.raycast(eye, direction, length, (block) => {
return block && block.boundingBox !== 'empty' && !NON_OCCLUDING.has(block.name);
});
return !hit;
} catch {
return true;
}
}
/**
* 执行一次合规攻击:先同步朝向,再校验距离 / 视线 / 冷却,最后出手。
*
* @param {object} entity 目标实体
* @param {{skipRotation?: boolean}} [options]
* @returns {Promise<boolean>} 是否成功出手
*/
async attackEntity(entity, options) {
const opts = options || {};
if (!this.config.grim.enabled) {
bot_attack(this.bot, entity);
this._lastAttackAt = Date.now();
return true;
}
if (!opts.skipRotation) {
await this.lookAtEntity(entity);
const settled = await this.awaitRotationSettled(600);
if (!settled) return false;
}
const check = this.canAttack(entity);
if (!check.ok) return false;
bot_attack(this.bot, entity);
this._lastAttackAt = Date.now();
return true;
}
// ==================== 使用物品 ====================
/**
* 合规地使用一次手持物品(进食 / 放船等)。
*
* 原版客户端在使用物品时会自动取消疾跑并减速,这里显式松开移动键,
* 避免 Grim 的 NoSlow / MultiActions 检查。
*
* @param {{holdMs?: number, stopMovement?: boolean}} [options]
* @returns {Promise<boolean>}
*/
async useItem(options) {
const opts = options || {};
const bot = this.bot;
if (this.config.grim.blockActionsWhileRiding && bot.vehicle) return false;
if (this.config.grim.stopMovementWhileEating && opts.stopMovement !== false) {
for (const control of ['forward', 'back', 'left', 'right', 'sprint', 'jump']) {
this.setControlState(control, false);
}
// 等待一个 tick,让服务端看到「已停止移动」后再开始使用物品
await bot.waitForTicks(1).catch(() => {});
}
bot.activateItem();
const holdMs = opts.holdMs === undefined ? 100 : opts.holdMs;
await sleep(holdMs);
return true;
}
/** 结束使用物品(进食完成 / 放船完成后调用) */
stopUsingItem() {
try {
this.bot.deactivateItem();
} catch {
// 忽略
}
}
// ==================== 自检 ====================
/**
* 物理与游戏状态自检:确认没有被改成非原版状态。
* @returns {{ok: boolean, issues: string[]}}
*/
audit() {
const bot = this.bot;
const issues = [];
if (!bot.physics) {
issues.push('bot.physics 不存在,物理引擎未启用');
} else if (this.config.grim.checkPhysicsIntegrity) {
for (const [key, expected] of Object.entries(VANILLA_PHYSICS_BASELINE)) {
const actual = bot.physics[key];
if (typeof actual === 'number' && Math.abs(actual - expected) > 1e-9) {
issues.push(`物理常量 ${key} 被改为 ${actual}(原版应为 ${expected})`);
}
}
}
if (bot.physicsEnabled === false) {
issues.push('bot.physicsEnabled 为 false,移动不会走本地预测,极易触发 Grim 的 Prediction 检查');
}
if (bot.game && bot.game.gameMode === 'creative') {
issues.push('当前为创造模式,飞行/瞬间破坏会直接触发 Grim 检查');
}
if (bot.entity && bot.entity.elytraFlying) {
issues.push('正在鞘翅飞行,mineflayer 的鞘翅物理与原版不一致');
}
if (bot.creative && bot.creative.flying) {
issues.push('bot.creative.flying 为 true,必须关闭');
}
return { ok: issues.length === 0, issues };
}
/** @returns {object} 供 Web 控制台展示的合规状态快照 */
snapshot() {
const audit = this.audit();
return {
enabled: this.config.grim.enabled,
maxRotationPerTickDeg: this.config.grim.maxRotationPerTickDeg,
effectiveStepDeg: Number(radToDeg(this.maxStepRad).toFixed(2)),
attackReach: this.config.grim.attackReach,
rotationSettled: this.isRotationSettled(),
attackCooldownMs: this.attackCooldownRemainingMs(),
blockedActions: this.blockedActions,
physicsOk: audit.ok,
issues: audit.issues
};
}
_log(message) {
if (this.logger) this.logger.log(message);
}
}
/**
* 计算从 from 到 to 的最短角差(结果落在 (-π, π])。
* @param {number} from
* @param {number} to
* @returns {number}
*/
function shortestAngleDelta(from, to) {
const TWO_PI = Math.PI * 2;
let delta = (to - from) % TWO_PI;
if (delta > Math.PI) delta -= TWO_PI;
else if (delta < -Math.PI) delta += TWO_PI;
return delta;
}
/**
* 眼睛到实体碰撞箱的最近距离。
* @param {import('mineflayer').Bot} bot
* @param {object} entity
* @returns {number}
*/
function eyeDistanceToEntity(bot, entity) {
const eye = bot.entity.position.offset(0, bot.entity.eyeHeight || 1.62, 0);
const half = (entity.width || 0.6) / 2;
const height = entity.height || 1.8;
const cx = clamp(eye.x, entity.position.x - half, entity.position.x + half);
const cy = clamp(eye.y, entity.position.y, entity.position.y + height);
const cz = clamp(eye.z, entity.position.z - half, entity.position.z + half);
const dx = cx - eye.x;
const dy = cy - eye.y;
const dz = cz - eye.z;
return Math.sqrt(dx * dx + dy * dy + dz * dz);
}
function bot_attack(bot, entity) {
bot.attack(entity, true);
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function degToRad(deg) {
return (deg * Math.PI) / 180;
}
function radToDeg(rad) {
return (rad * 180) / Math.PI;
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
module.exports = {
GrimCompat,
PHYSICS_TIMESTEP,
VANILLA_PHYSICS_BASELINE,
ATTACK_SPEED_RULES,
shortestAngleDelta,
eyeDistanceToEntity,
degToRad,
radToDeg
};
+247
查看文件
@@ -0,0 +1,247 @@
'use strict';
const pathfinderPlugin = require('mineflayer-pathfinder');
const { GoalXZ, GoalNearXZ, Goal } = pathfinderPlugin.goals;
const { Movements } = pathfinderPlugin;
/**
* 寻路服务:替代原 Java 版的 BaritoneBridge。
*
* 底层用 mineflayer-pathfinder(A* + 逐 tick 控制键执行),对外暴露与 Baritone 语义
* 接近的接口:pathTo / cancel / 路径事件回调。
*
* 与 Grim 相关的关键约束(在 {@link configure} 里统一施加):
* - 默认不允许挖方块、不允许搭方块(Grim 的 FastBreak / place 类检查极严)
* - 默认关闭跑酷与 1×1 搭塔(mineflayer 的起跳时机与原版有偏差)
* - 限制最大下落高度,避免长距离坠落被 NoFall 类检查盯上
* - 大幅提高进入液体格的代价,优先绕开水域(mineflayer 液体物理与原版差异最大)
*/
/** 路径事件枚举,语义对齐 Baritone 的 PathEvent */
const PathEvent = {
CALC_STARTED: 'CALC_STARTED',
CALC_FINISHED_NOW_EXECUTING: 'CALC_FINISHED_NOW_EXECUTING',
CALC_FAILED: 'CALC_FAILED',
AT_GOAL: 'AT_GOAL',
CANCELED: 'CANCELED'
};
class PathingService {
/**
* @param {import('mineflayer').Bot} bot
* @param {object} config 完整配置对象
* @param {import('../util/logger').Logger} [logger]
*/
constructor(bot, config, logger) {
this.bot = bot;
this.config = config;
this.logger = logger || null;
/** @type {((event: string, detail?: object) => void)|null} */
this.onPathEvent = null;
/** 是否已就绪(pathfinder 插件已加载且 movements 已配置) */
this.ready = false;
/** 当前目标(区块坐标) */
this.currentTarget = null;
/** 最近一次路径计算是否失败 */
this.calcFailed = false;
this._pathing = false;
this._lastStatus = null;
this._configured = false;
}
/**
* 初始化:等待 pathfinder 插件就绪并配置 Movements。
* @returns {boolean} 是否成功就绪
*/
configure() {
const bot = this.bot;
if (!bot.pathfinder) {
this._log('mineflayer-pathfinder 尚未加载');
return false;
}
const movements = new Movements(bot);
// ===== Grim 合规约束 =====
movements.canDig = !!this.config.allowBreak;
movements.canOpenDoors = !!this.config.pathing.canOpenDoors;
movements.allowParkour = !!this.config.pathing.allowParkour;
movements.allow1by1towers = !!this.config.pathing.allow1by1towers;
movements.allowSprinting = !!this.config.allowSprint;
movements.allowFreeMotion = false;
movements.maxDropDown = this.config.pathing.maxDropDown;
movements.liquidCost = this.config.pathing.liquidCost;
movements.infiniteLiquidDropdownDistance = false;
movements.dontMineUnderFallingBlock = true;
movements.dontCreateFlow = true;
// 不允许搭方块时清空脚手架方块表(pathfinder 靠这张表决定能否放置)
if (!this.config.allowPlace) {
movements.scafoldingBlocks = [];
}
// 水域处理策略:avoid 时把液体代价推到极高,让 A* 宁可绕远路
if (this.config.waterMode === 'avoid') {
movements.liquidCost = Math.max(movements.liquidCost, 200);
}
bot.pathfinder.setMovements(movements);
// 搜索超时与半径:默认值偏保守,山区大地图容易 noPath
bot.pathfinder.thinkTimeout = 10000;
bot.pathfinder.tickTimeout = 40;
bot.pathfinder.searchRadius = 256;
bot.on('goal_reached', () => {
this._pathing = false;
this._emit(PathEvent.AT_GOAL, { target: this.currentTarget });
});
bot.on('path_update', (r) => {
this._lastStatus = r.status;
if (r.status === 'success') {
this.calcFailed = false;
this._emit(PathEvent.CALC_FINISHED_NOW_EXECUTING, { cost: r.cost, nodes: r.visitedNodes });
} else if (r.status === 'noPath') {
this.calcFailed = true;
this._pathing = false;
this._emit(PathEvent.CALC_FAILED, { target: this.currentTarget });
} else if (r.status === 'timeout') {
this.calcFailed = true;
this._pathing = false;
this._emit(PathEvent.CALC_FAILED, { target: this.currentTarget, timeout: true });
}
});
bot.on('path_reset', (reason) => {
if (reason !== 'chunk_loaded' && reason !== 'movement_update') {
this._emit(PathEvent.CANCELED, { reason });
}
});
this.ready = true;
this._configured = true;
this._log(`寻路器就绪(挖方块 ${this.config.allowBreak ? '开' : '关'},`
+ `放方块 ${this.config.allowPlace ? '开' : '关'},`
+ `跑酷 ${this.config.pathing.allowParkour ? '开' : '关'},`
+ `最大下落 ${this.config.pathing.maxDropDown} 格,水域策略 ${this.config.waterMode})`);
return true;
}
/**
* 配置变更后重新应用(不重建事件监听)。
* @param {object} config 新配置
*/
applyConfig(config) {
this.config = config;
if (this._configured) this.configure();
}
/** @returns {boolean} 是否可用 */
isAvailable() {
return this.ready && !!this.bot.pathfinder;
}
/** @returns {boolean} 当前是否正在寻路 */
isPathing() {
return this._pathing;
}
/**
* 让机器人寻路到指定区块。
*
* @param {{x: number, z: number}} chunk 目标区块
* @param {{y?: number}} [options] 可选的偏好高度
* @returns {boolean} 是否成功下发寻路请求
*/
pathTo(chunk, options) {
const bot = this.bot;
if (!this.isAvailable()) return false;
const opts = options || {};
const blockX = chunk.x * 16 + 8;
const blockZ = chunk.z * 16 + 8;
const startY = Math.floor(bot.entity.position.y);
let goal = new GoalNearXZ(blockX, blockZ, 2);
if (opts.y !== undefined && opts.y !== null) {
goal = new GoalNearXZ(blockX, blockZ, 2);
}
if (this.config.altitudePreference && this.config.altitudeWeight > 0) {
goal = new AltitudeGoal(goal, this.config.altitudeWeight, startY);
}
this.currentTarget = { x: chunk.x, z: chunk.z };
this.calcFailed = false;
this._pathing = true;
this._emit(PathEvent.CALC_STARTED, { target: this.currentTarget });
try {
bot.pathfinder.setGoal(goal);
} catch (err) {
this._pathing = false;
this.calcFailed = true;
this._log('下发寻路请求失败:' + err.message);
this._emit(PathEvent.CALC_FAILED, { target: this.currentTarget, error: err.message });
return false;
}
return true;
}
/** 取消当前寻路 */
cancel() {
if (!this.isAvailable()) return;
this._pathing = false;
this.currentTarget = null;
try {
this.bot.pathfinder.setGoal(null);
} catch {
// 忽略
}
}
/** @returns {string|null} 最近一次 path_update 的状态 */
lastStatus() {
return this._lastStatus;
}
_emit(event, detail) {
if (this.onPathEvent) {
try {
this.onPathEvent(event, detail);
} catch {
// 订阅者异常不影响寻路
}
}
}
_log(message) {
if (this.logger) this.logger.log(message);
}
}
/**
* 高度偏好目标:在原目标启发值上叠加「越高越优」的偏置,
* 对应原 Java 版的 GoalXZAltitude。
*
* heuristic = max(0, 基础启发值 - 权重 × 节点高度)
* 即 y 越大启发成本越低(A* 更倾向走高处),y 越小成本越高(对走低处施惩罚),
* clamp 到非负以免破坏 A* 的排序。到达判定与内层目标完全一致(不约束高度)。
*/
class AltitudeGoal extends Goal {
/**
* @param {Goal} inner 被包装的真实目标
* @param {number} weight 每格高度折算的启发成本
* @param {number} startY 寻路起点高度(仅用于展示,不参与计算)
*/
constructor(inner, weight, startY) {
super();
this.inner = inner;
this.weight = weight;
this.startY = startY;
}
/** @param {{x: number, y: number, z: number}} node */
heuristic(node) {
return Math.max(0, this.inner.heuristic(node) - this.weight * node.y);
}
/** @param {{x: number, y: number, z: number}} node */
isEnd(node) {
return this.inner.isEnd(node);
}
hasChanged() {
return this.inner.hasChanged ? this.inner.hasChanged() : false;
}
}
module.exports = { PathingService, AltitudeGoal, PathEvent, GoalXZ, GoalNearXZ };
+68
查看文件
@@ -0,0 +1,68 @@
'use strict';
const fs = require('fs');
const path = require('path');
const { defaultConfig, normalizeConfig } = require('./expansionConfig');
/**
* 配置文件读写器:把扩地配置持久化为 JSON。
*
* 默认路径为项目根目录下的 `data/autostore.json`(已在 .gitignore 中忽略),
* 也可通过构造参数覆盖,便于测试使用临时目录。
*/
class ConfigStore {
/**
* @param {string} [filePath] 配置文件绝对路径
*/
constructor(filePath) {
this.filePath = filePath || path.join(__dirname, '..', '..', 'data', 'autostore.json');
}
/**
* 从磁盘加载配置;文件不存在或解析失败时返回默认配置。
* @returns {object}
*/
load() {
let raw;
try {
raw = fs.readFileSync(this.filePath, 'utf8');
} catch {
return defaultConfig();
}
try {
const parsed = JSON.parse(raw);
const config = normalizeConfig(parsed);
// 固定值字段:防止旧配置文件里的异常值影响运行
config.autoEat = true;
config.eatThreshold = 14;
// API Key 兜底:配置文件未填写时从环境变量 SIMP_API_KEY 读取
//(密钥不进代码仓库,开发环境由 .env 提供,运行环境可设置系统环境变量)
if (!config.apiKey) {
const envKey = process.env.SIMP_API_KEY;
if (envKey && envKey.trim()) config.apiKey = envKey.trim();
}
return config;
} catch {
return defaultConfig();
}
}
/**
* 把配置写入磁盘(自动创建父目录)。
* @param {object} config
* @returns {boolean} 是否写入成功
*/
save(config) {
try {
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
fs.writeFileSync(this.filePath, JSON.stringify(config, null, 2), 'utf8');
return true;
} catch {
// 保存失败时静默忽略(不阻塞运行)
return false;
}
}
}
module.exports = { ConfigStore };
+328
查看文件
@@ -0,0 +1,328 @@
'use strict';
/**
* 自动扩地配置。
*
* 字段命名与原 Java 版 `ExpansionConfig` 保持一一对应(便于对照移植正确性),
* 新增能力统一收拢在三个分组对象里:
* - `pathing`:mineflayer-pathfinder 专属的寻路开关
* - `grim`:Grim 反作弊合规层的阈值
* - `server`:与服务器交互的指令与节流
*/
/** 扩地策略枚举 */
const STRATEGY = {
/** 方形外扩(优先填洞) */
SQUARE: 1,
/** 区域扩地(S 形蛇形扫描) */
REGION: 2,
/** 玩家周围扩地 */
AROUND_PLAYER: 3,
/** 绕国一圈 */
PERIMETER: 4
};
/** 水域处理策略 */
const WATER_MODE = {
/** 绕开水域(默认,最安全) */
AVOID: 'avoid',
/** 游泳通过 */
SWIM: 'swim',
/** 放船渡水 */
BOAT: 'boat'
};
/**
* 生成一份默认配置。
* @returns {object}
*/
function defaultConfig() {
return {
// ==================== 扩地策略(与 Java 版同名) ====================
/** 扩地策略:1=方形外扩,2=区域扩地,3=玩家周围,4=绕国一圈 */
strategy: STRATEGY.SQUARE,
/** 目标邦国名称;为空时表示自动识别玩家当前所在邦国 */
stateName: '',
/** 目标扩地数量;-1 表示持续扩地直到无可用区块 */
targetCount: -1,
/** 扩圈上限(0 表示不限制) */
maxRings: 0,
// ==================== 区域扩地范围(与 Java 版同名) ====================
regionCenterX: 0,
regionCenterZ: 0,
regionRadius: 5,
/** 矩形边界(区块坐标);maxX < minX 表示未圈选,回退到中心+半径 */
regionMinX: 0,
regionMaxX: -1,
regionMinZ: 0,
regionMaxZ: -1,
// ==================== 超时与重试(与 Java 版同名) ====================
/** 等待 / 寻路超时时间(秒) */
waitTimeoutSeconds: 60,
/** 超时后的最大重试次数 */
retries: 2,
/** 连续寻路超时累计达到该秒数后,发送回城指令并重新开始 */
returnThresholdSeconds: 60,
/** 发送回城指令后等待传送完成的时间(秒) */
returnWaitSeconds: 12,
/** 单个目标最多触发「往边上走两步」自救的轮数 */
sideStepMaxRounds: 3,
// ==================== 开垦时段限制(新增,原版硬编码 11:00-22:00) ====================
/** 是否启用开垦时段限制(服务器只在特定时段接受开拓) */
enforceClaimTime: true,
/** 允许开拓的现实时间起始小时(含,本地时区) */
claimStartHour: 11,
/** 允许开拓的现实时间结束小时(不含,本地时区) */
claimEndHour: 22,
// ==================== 寻路基础开关(与 Java 版同名) ====================
allowSprint: true,
allowSwim: true,
allowJump: true,
allowBreak: false,
allowPlace: false,
/** 寻路高度偏好:优先往高处走 */
altitudePreference: true,
/** 高度偏好权重:每上升 1 格折算的启发成本减少量 */
altitudeWeight: 0.1,
// ==================== 生存辅助(与 Java 版同名) ====================
avoidMobs: true,
autoEat: true,
/** 触发自动进食的饥饿值阈值(0-20) */
eatThreshold: 14,
autoAttack: true,
durabilityGuard: true,
/** 耐久保护阈值:剩余耐久百分比 ≤ 该值时回城(1-99) */
durabilityThreshold: 15,
/** 是否启用自动用船过水 */
autoBoat: false,
/** 触发自动用船的水面最小宽度(格) */
autoBoatMinWidth: 4,
/** 自动用船最短水域长度(格) */
autoBoatMinLength: 6,
/** 水域处理策略:avoid / swim / boat */
waterMode: WATER_MODE.AVOID,
// ==================== 数据源(与 Java 版同名) ====================
/** 地图自动刷新间隔(秒),最小 10 秒 */
refreshIntervalSeconds: 30,
/** 是否启用 SimpCore 开放 API 作为占领判定补充来源 */
apiEnabled: false,
/** SimpCore 开放 API Key(留空时回退读环境变量 SIMP_API_KEY) */
apiKey: '',
/** API 领土数据轮询间隔(秒),最小 60 */
apiRefreshIntervalSeconds: 180,
// ==================== 寻路器专属(新增) ====================
pathing: {
/** 是否允许跑酷跳跃(mineflayer 起跳时机与原版有偏差,默认关闭更安全) */
allowParkour: false,
/** 是否允许 1×1 搭塔(Grim 的 place 类检查极易命中,默认关闭) */
allow1by1towers: false,
/** 允许的最大下落高度(格),超过则绕行 */
maxDropDown: 3,
/** 进入液体格的额外代价(越大越倾向绕开水域) */
liquidCost: 40,
/** 是否允许打开门/栅栏门 */
canOpenDoors: false
},
// ==================== 战斗辅助(新增) ====================
combat: {
/** 自动攻击索敌半径(格):只打进入该范围的敌对生物,不追击 */
scanRange: 4.5,
/** 是否攻击末影人(mineflayer 读不到「是否被激怒」,默认不打) */
attackEnderman: false,
/** 是否攻击蜘蛛(mineflayer 读不到环境亮度,默认不打) */
attackSpiders: false,
/** 是否攻击猪灵(仅在未穿金盔甲时才会攻击) */
attackPiglins: true,
/** 是否跳过高危生物(监守者 / 末影龙 / 凋灵),默认跳过 */
avoidDangerousMobs: true
},
// ==================== Grim 反作弊合规层(新增) ====================
grim: {
/** 总开关:关闭后不做任何限速/校验(仅用于本地测试服) */
enabled: true,
/** 每 tick 允许的最大旋转角度(度);mineflayer 默认 yawSpeed=3.0 rad/s ≈ 8.6°/tick */
maxRotationPerTickDeg: 8.6,
/** 攻击判定距离(格),生存模式实体交互距离为 3.0 */
attackReach: 3.0,
/** 攻击前是否要求视线可达(防 Reach / LineOfSight 检查) */
requireLineOfSight: true,
/** 是否遵守攻击冷却(1.9+ 武器攻速) */
respectAttackCooldown: true,
/** 进食期间是否停止移动(原版进食会取消疾跑并减速,防 NoSlow / MultiActions) */
stopMovementWhileEating: true,
/**
* 是否禁止疾跑跳跃。
* 疾跑跳本身是原版合法动作,Grim 的预测支持;默认放行。
* 若实测被标记,可开启此项退化为普通跳跃(代价是移动变慢)。
*/
blockSprintJump: false,
/** 是否禁止疾跑与潜行/使用物品同时进行(防 MultiActions) */
blockConflictingActions: true,
/** 是否在启动时校验物理参数未被篡改 */
checkPhysicsIntegrity: true,
/** 是否禁止在骑乘状态下攻击 / 进食(防 Vehicle 类检查) */
blockActionsWhileRiding: true
},
// ==================== 服务器交互(新增) ====================
server: {
/** 启用开垦模式的指令(不带斜杠),留空表示服务器自动开启 */
claimCommand: 'u claimmode',
/** 登录指令模板,`{password}` 会被替换为 .env 中的 MC_PASSWORD;留空则不发送 */
loginCommand: 'login {password}',
/** 注册指令模板,`{password}` 会被替换;留空则不发送 */
registerCommand: '',
/** 聊天指令最小间隔(毫秒),防刷屏 */
commandCooldownMs: 1200,
/** 断线后是否自动重连 */
autoReconnect: true,
/** 重连间隔(毫秒) */
reconnectDelayMs: 10000
}
};
}
/**
* 深拷贝配置。
* @param {object} config
* @returns {object}
*/
function copyConfig(config) {
return JSON.parse(JSON.stringify(config));
}
/**
* 把外部传入(配置文件 / Web 控制台)的部分配置合并到默认配置上,并做范围钳制。
* 只接受已知字段,未知字段一律丢弃,避免脏配置污染运行状态。
*
* @param {object} partial 用户提供的部分配置
* @param {object} [base] 基线配置(默认取 defaultConfig())
* @returns {object} 规范化后的完整配置
*/
function normalizeConfig(partial, base) {
const defaults = base || defaultConfig();
const merged = copyConfig(defaults);
if (!partial || typeof partial !== 'object') return validateConfig(merged);
for (const key of Object.keys(merged)) {
const value = partial[key];
if (value === undefined) continue;
if (value && typeof value === 'object' && !Array.isArray(value) && typeof merged[key] === 'object') {
for (const subKey of Object.keys(merged[key])) {
if (value[subKey] !== undefined) merged[key][subKey] = value[subKey];
}
} else {
merged[key] = value;
}
}
return validateConfig(merged);
}
/**
* 钳制配置中的越界值,防止异常配置导致「永不回城」或「刚出门就回城」。
*
* 会先把缺失的字段(含嵌套分组)补齐为默认值,因此可以直接传入部分配置。
*
* @param {object} config
* @returns {object} 同一个对象(原地修正)
*/
function validateConfig(config) {
const base = defaultConfig();
for (const key of Object.keys(base)) {
const defaultValue = base[key];
if (defaultValue !== null && typeof defaultValue === 'object') {
config[key] = Object.assign({}, defaultValue, config[key] || {});
} else if (config[key] === undefined) {
config[key] = defaultValue;
}
}
config.strategy = clampEnum(config.strategy, [1, 2, 3, 4], STRATEGY.SQUARE);
config.targetCount = clampInt(config.targetCount, -1, 1e9, -1);
config.maxRings = clampInt(config.maxRings, 0, 1000, 0);
config.regionRadius = clampInt(config.regionRadius, 1, 512, 5);
config.regionMinX = clampInt(config.regionMinX, -1e6, 1e6, 0);
config.regionMaxX = clampInt(config.regionMaxX, -1e6, 1e6, -1);
config.regionMinZ = clampInt(config.regionMinZ, -1e6, 1e6, 0);
config.regionMaxZ = clampInt(config.regionMaxZ, -1e6, 1e6, -1);
config.waitTimeoutSeconds = clampInt(config.waitTimeoutSeconds, 5, 3600, 60);
config.retries = clampInt(config.retries, 1, 20, 2);
config.returnThresholdSeconds = clampInt(config.returnThresholdSeconds, 0, 3600, 60);
config.returnWaitSeconds = clampInt(config.returnWaitSeconds, 1, 120, 12);
config.sideStepMaxRounds = clampInt(config.sideStepMaxRounds, 0, 10, 3);
config.claimStartHour = clampInt(config.claimStartHour, 0, 23, 11);
config.claimEndHour = clampInt(config.claimEndHour, 1, 24, 22);
config.eatThreshold = clampInt(config.eatThreshold, 0, 20, 14);
config.durabilityThreshold = clampInt(config.durabilityThreshold, 1, 99, 15);
config.autoBoatMinWidth = clampInt(config.autoBoatMinWidth, 1, 64, 4);
config.autoBoatMinLength = clampInt(config.autoBoatMinLength, 1, 128, 6);
config.refreshIntervalSeconds = clampInt(config.refreshIntervalSeconds, 10, 3600, 30);
config.apiRefreshIntervalSeconds = clampInt(config.apiRefreshIntervalSeconds, 60, 3600, 180);
config.altitudeWeight = clampNumber(config.altitudeWeight, 0, 10, 0.1);
config.pathing.maxDropDown = clampInt(config.pathing.maxDropDown, 0, 64, 3);
config.pathing.liquidCost = clampInt(config.pathing.liquidCost, 1, 1000, 40);
config.combat.scanRange = clampNumber(config.combat.scanRange, 1, 32, 4.5);
config.grim.maxRotationPerTickDeg = clampNumber(config.grim.maxRotationPerTickDeg, 1, 180, 8.6);
config.grim.attackReach = clampNumber(config.grim.attackReach, 1, 6, 3.0);
config.server.commandCooldownMs = clampInt(config.server.commandCooldownMs, 0, 60000, 1200);
config.server.reconnectDelayMs = clampInt(config.server.reconnectDelayMs, 1000, 600000, 10000);
if (config.waterMode !== WATER_MODE.SWIM && config.waterMode !== WATER_MODE.BOAT) {
config.waterMode = WATER_MODE.AVOID;
}
if (typeof config.stateName !== 'string') config.stateName = '';
if (typeof config.apiKey !== 'string') config.apiKey = '';
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 = '';
return config;
}
function clampInt(value, min, max, fallback) {
const n = Number(value);
if (!Number.isFinite(n)) return fallback;
return Math.min(max, Math.max(min, Math.trunc(n)));
}
/**
* 枚举字段校验:不在允许集合内时回退到默认值。
*
* 之所以不用「钳制到最近边界」,是因为策略这类枚举没有大小含义,
* 把非法的 99 静默变成「策略 4 绕国一圈」比回退到默认策略更危险。
*
* @param {*} value
* @param {Array<*>} allowed
* @param {*} fallback
* @returns {*}
*/
function clampEnum(value, allowed, fallback) {
return allowed.includes(value) ? value : fallback;
}
function clampNumber(value, min, max, fallback) {
const n = Number(value);
if (!Number.isFinite(n)) return fallback;
return Math.min(max, Math.max(min, n));
}
module.exports = {
STRATEGY,
WATER_MODE,
defaultConfig,
copyConfig,
normalizeConfig,
validateConfig
};
文件差异内容过多而无法显示 加载差异
+135
查看文件
@@ -0,0 +1,135 @@
'use strict';
/**
* 区块坐标工具。
*
* Minecraft 中 1 个区块为 16×16 方块,本模块提供方块坐标与区块坐标的换算、
* 曼哈顿距离、切比雪夫距离,以及用于 Set/Map 的稳定字符串键。
*
* 为性能考虑,区块统一用「整数编码」而非对象表示:
* 编码方式为 `x * 2^26 + z`(x/z 各占 27 位有符号范围),
* 可直接作为 Set/Map 的键使用,避免海量区块产生大量临时对象
* (原 Java 版每帧复制数千 ChunkPos 对象是卡顿来源之一)。
*/
/** 区块坐标编码的位移量 */
const SHIFT = 67108864; // 2^26,保证 ±33554432 区块范围内的编码唯一
/**
* 将方块坐标换算为区块坐标(向下取整,兼容负数)。
* @param {number} blockX 方块 X 坐标
* @param {number} blockZ 方块 Z 坐标
* @returns {{x: number, z: number}} 区块坐标
*/
function fromBlock(blockX, blockZ) {
return {
x: Math.floor(blockX / 16),
z: Math.floor(blockZ / 16)
};
}
/**
* 把区块坐标编码为单个整数键。
* @param {number} x 区块 X
* @param {number} z 区块 Z
* @returns {number} 可用于 Set/Map 的整数键
*/
function key(x, z) {
return x * SHIFT + z;
}
/**
* 把整数键解码回区块坐标。
*
* 由于世界边界限制在 ±1280 区块内,|z| 远小于 SHIFT/2,
* 因此可以直接用四舍五入还原 x,再取余得到 z。
*
* @param {number} k 整数键
* @returns {{x: number, z: number}} 区块坐标
*/
function decode(k) {
const x = Math.round(k / SHIFT) || 0;
return { x, z: k - x * SHIFT };
}
/** 区块中心的方块 X 坐标 */
function centerBlockX(x) {
return x * 16 + 8;
}
/** 区块中心的方块 Z 坐标 */
function centerBlockZ(z) {
return z * 16 + 8;
}
/**
* 曼哈顿距离(|dx| + |dz|)。
* @param {{x: number, z: number}} a
* @param {{x: number, z: number}} b
* @returns {number}
*/
function manhattan(a, b) {
return Math.abs(a.x - b.x) + Math.abs(a.z - b.z);
}
/**
* 切比雪夫距离(max(|dx|, |dz|)),用于「圈数」计算。
* @param {{x: number, z: number}} a
* @param {{x: number, z: number}} b
* @returns {number}
*/
function chebyshev(a, b) {
return Math.max(Math.abs(a.x - b.x), Math.abs(a.z - b.z));
}
/**
* 判断两个区块坐标是否相等。
* @param {{x: number, z: number}|null} a
* @param {{x: number, z: number}|null} b
* @returns {boolean}
*/
function equals(a, b) {
if (a === b) return true;
if (a == null || b == null) return false;
return a.x === b.x && a.z === b.z;
}
/**
* 返回区块的四个正方向邻居(东、西、南、北)。
* @param {number} x 区块 X
* @param {number} z 区块 Z
* @returns {Array<{x: number, z: number}>}
*/
function cardinalNeighbors(x, z) {
return [
{ x: x + 1, z },
{ x: x - 1, z },
{ x, z: z + 1 },
{ x, z: z - 1 }
];
}
/**
* 判断区块是否在世界边界内。
* Minecraft 1.21.4 默认世界边界为 ±2048 区块,这里取 1280 作为安全上限。
* @param {number} x
* @param {number} z
* @returns {boolean}
*/
function withinWorldBorder(x, z) {
return x >= -1280 && x < 1280 && z >= -1280 && z < 1280;
}
module.exports = {
SHIFT,
fromBlock,
key,
decode,
centerBlockX,
centerBlockZ,
manhattan,
chebyshev,
equals,
cardinalNeighbors,
withinWorldBorder
};
+545
查看文件
@@ -0,0 +1,545 @@
'use strict';
const chunkPos = require('./chunkPos');
/**
* 扩地规划器:根据当前邦国领土、配置与玩家位置,计算下一步要占领的区块列表。
*
* 支持四种扩地策略(与 Java 版一一对应):
* - 策略 1:方形外扩(优先填内部空洞,再向外一圈圈扩展)
* - 策略 2:区域扩地(严格 S 形蛇形扫描,由内而外推进)
* - 策略 3:玩家周围扩地(围绕玩家从近到远铺开)
* - 策略 4:绕国一圈(沿领土边界按方位角均匀向外扩张)
*
* 所有内部集合均使用 chunkPos 整数键,避免海量临时对象。
*/
/** 世界边界半径(区块数) */
const WORLD_BORDER_CHUNK_RADIUS = 1280;
class ExpansionPlanner {
/**
* 根据配置的策略分发到对应的扩地算法。
*
* @param {import('./territoryModel').TerritoryModel} model 当前邦国领土模型
* @param {object} config 扩地配置
* @param {{x: number, z: number}} playerChunk 玩家当前所在区块
* @returns {Array<{x: number, z: number}>} 建议占领的区块列表(按优先级排序)
*/
generate(model, config, playerChunk) {
let result;
if (config.strategy === 1) {
result = this.generateHolePriority(model, config, playerChunk);
} else if (config.strategy === 4) {
result = this.generatePerimeter(model, config, playerChunk);
} else if (config.strategy === 3) {
result = this.generateAroundPlayer(model, config, playerChunk);
} else if (config.strategy === 2 && config.regionMaxX >= config.regionMinX) {
result = this.generateRectangle(model, config, playerChunk);
} else {
result = this.generateLine(model, config, playerChunk);
}
return this.prefixFlyingChunks(model, config, playerChunk, result);
}
/**
* 将己方领土内的「飞地(疏漏区块)」提到队列最前,优先填补。
* @returns {Array<{x: number, z: number}>}
*/
prefixFlyingChunks(model, config, playerChunk, planned) {
const flying = model.selfFlyingChunks();
if (!flying || flying.size === 0) return planned;
const limit = config.targetCount > 0
? config.targetCount
: Math.max(planned.length, flying.size);
const result = [];
const resultKeys = new Set();
for (const k of flying) {
if (result.length >= limit) break;
const pos = chunkPos.decode(k);
if (!this.isInRegion(pos, config)) continue;
result.push(pos);
resultKeys.add(k);
}
result.sort((a, b) => chunkPos.manhattan(a, playerChunk) - chunkPos.manhattan(b, playerChunk));
for (const chunk of planned) {
if (result.length >= limit) break;
const k = chunkPos.key(chunk.x, chunk.z);
if (resultKeys.has(k)) continue;
result.push(chunk);
resultKeys.add(k);
}
return result;
}
/**
* 策略 1:方形外扩(优先填洞)。
*
* 以己方领土的外接矩形为基准逐圈向外搜索候选区块;
* 候选必须未被占领、与己方领土相邻、且在配置区域内。
* 先排矩形内部的空洞,再排外部边界,最后按「离玩家近、己方邻居多」排序。
*/
generateHolePriority(model, config, playerChunk) {
const actualOwn = this.ownChunks(model);
const allClaimed = model.allClaimedChunks();
if (actualOwn.size === 0) return [];
const limit = config.targetCount > 0 ? config.targetCount : 256;
const ownBounds = this.bounds(actualOwn);
const maxExpansions = config.maxRings > 0 ? config.maxRings + 1 : 16;
for (let expansion = 0; expansion < maxExpansions; expansion++) {
const candidates = new Set();
const minX = ownBounds[0] - expansion;
const maxX = ownBounds[1] + expansion;
const minZ = ownBounds[2] - expansion;
const maxZ = ownBounds[3] + expansion;
for (let x = minX; x <= maxX; x++) {
for (let z = minZ; z <= maxZ; z++) {
const k = chunkPos.key(x, z);
if (actualOwn.has(k)) continue;
if (!this.isLegal({ x, z }, actualOwn, allClaimed)) continue;
if (!this.isInRegion({ x, z }, config)) continue;
candidates.add(k);
}
}
if (candidates.size > 0) {
const compare = (ka, kb) => {
const a = chunkPos.decode(ka);
const b = chunkPos.decode(kb);
const da = chunkPos.manhattan(a, playerChunk);
const db = chunkPos.manhattan(b, playerChunk);
if (da !== db) return da - db;
return this.ownNeighbors(b, actualOwn) - this.ownNeighbors(a, actualOwn);
};
const inside = [];
const outside = [];
for (const k of candidates) {
const pos = chunkPos.decode(k);
if (pos.x >= ownBounds[0] && pos.x <= ownBounds[1]
&& pos.z >= ownBounds[2] && pos.z <= ownBounds[3]) {
inside.push(k);
} else {
outside.push(k);
}
}
inside.sort(compare);
outside.sort(compare);
return inside.concat(outside).slice(0, limit).map(chunkPos.decode);
}
}
return [];
}
/**
* 策略 4:绕国一圈。
*
* 收集所有与己方领土相邻的合法区块,以领土质心(或王城)为中心按方位角排序,
* 使扩张点均匀分布在领土四周。支持多圈扩张。
*/
generatePerimeter(model, config, playerChunk) {
const actualOwn = this.ownChunks(model);
const allClaimed = model.allClaimedChunks();
const plannedOwn = new Set(actualOwn);
const candidates = new Set();
const center = this.capital(model, this.centroid(actualOwn, playerChunk));
const limit = config.targetCount > 0 ? config.targetCount : 128;
const rings = config.maxRings > 0 ? config.maxRings : 1;
const result = [];
for (let ring = 0; ring < rings && result.length < limit; ring++) {
this.addNeighborsSet(plannedOwn, candidates, allClaimed, config);
if (candidates.size === 0) break;
const sorted = Array.from(candidates).sort((ka, kb) => {
const a = chunkPos.decode(ka);
const b = chunkPos.decode(kb);
const angleDiff = this.angle(a, center) - this.angle(b, center);
if (angleDiff !== 0) return angleDiff;
return chunkPos.manhattan(a, playerChunk) - chunkPos.manhattan(b, playerChunk);
}).slice(0, limit - result.length);
for (const k of sorted) {
result.push(chunkPos.decode(k));
plannedOwn.add(k);
}
candidates.clear();
}
return result;
}
/**
* 策略 3:玩家周围扩地。
*
* 收集所有与己方领土相邻的合法区块,以玩家当前位置为中心,
* 按与玩家的曼哈顿距离由近到远排序(距离相同者优先己方邻居多的),围绕玩家一圈圈向外铺开。
*/
generateAroundPlayer(model, config, playerChunk) {
const actualOwn = this.ownChunks(model);
const allClaimed = model.allClaimedChunks();
if (actualOwn.size === 0) return [];
const limit = config.targetCount > 0 ? config.targetCount : 64;
const plannedOwn = new Set(actualOwn);
const candidates = new Set();
this.addNeighborsSet(plannedOwn, candidates, allClaimed, config);
if (candidates.size === 0) return [];
return Array.from(candidates).sort((ka, kb) => {
const a = chunkPos.decode(ka);
const b = chunkPos.decode(kb);
const da = chunkPos.manhattan(a, playerChunk);
const db = chunkPos.manhattan(b, playerChunk);
if (da !== db) return da - db;
return this.ownNeighbors(b, plannedOwn) - this.ownNeighbors(a, plannedOwn);
}).slice(0, limit).map(chunkPos.decode);
}
/**
* 策略 2(无圈选区域时)/ 未知策略回退:直线推进。
*
* 从玩家最近的己方区块出发,沿一个方向连续向外延伸占领区块。
* 每步挑选评分最优的区块:距离近、方向一致、邻居多、形状规整者优先。
* 当扩张形状长宽比超过 2:1 时切换方向补短边,避免领土过于细长。
*/
generateLine(model, config, playerChunk) {
const actualOwn = this.ownChunks(model);
const allClaimed = model.allClaimedChunks();
const plannedOwn = new Set(actualOwn);
const candidates = new Set();
let cursor = this.nearestOwnToPlayer(actualOwn, playerChunk);
let direction = null;
const limit = config.targetCount > 0 ? config.targetCount : 128;
this.addNeighborsSet(plannedOwn, candidates, allClaimed, config);
const result = [];
let expMinX = Infinity;
let expMaxX = -Infinity;
let expMinZ = Infinity;
let expMaxZ = -Infinity;
while (result.length < limit) {
if (candidates.size === 0) {
this.addNeighborsSet(plannedOwn, candidates, allClaimed, config);
if (candidates.size === 0) break;
}
const nearest = this.nearestFrontier(candidates, cursor);
if (direction === null) {
direction = this.chooseDirection(cursor, nearest, plannedOwn);
}
let best = this.chooseLineCandidate(candidates, plannedOwn, cursor, direction);
if (best === null) best = nearest;
if (best === null) break;
const bestKey = chunkPos.key(best.x, best.z);
candidates.delete(bestKey);
result.push(best);
plannedOwn.add(bestKey);
cursor = best;
expMinX = Math.min(expMinX, best.x);
expMaxX = Math.max(expMaxX, best.x);
expMinZ = Math.min(expMinZ, best.z);
expMaxZ = Math.max(expMaxZ, best.z);
this.addNeighbors(best, plannedOwn, candidates, allClaimed, config);
if (this.expansionImbalanced(expMinX, expMaxX, expMinZ, expMaxZ)) {
direction = this.shortSideDirection(expMinX, expMaxX, expMinZ, expMaxZ);
}
}
return result;
}
/**
* 策略 2 矩形区域扩地:严格 S 形(蛇形)扫描,由内而外推进。
*
* 扫描规则:
* - 起始扫描线 = 矩形四条边中离王城直线距离最近的那条边(点到线段距离),从该边向对边单调推进;
* - 扫描行/列内做标准蛇形往返(相邻行方向交替),即使待开垦区块构成不规则多边形也一路扫过去;
* - 首条扫描线的行内起始端同样选取离王城更近的一端;
* - 每个入选区块必须与「己方领土(含本次已选)」四正方向接壤,保证服务器接受开拓。
*/
generateRectangle(model, config, playerChunk) {
const actualOwn = this.ownChunks(model);
const allClaimed = model.allClaimedChunks();
if (actualOwn.size === 0) return [];
const minX = config.regionMinX;
const maxX = config.regionMaxX;
const minZ = config.regionMinZ;
const maxZ = config.regionMaxZ;
const limit = config.targetCount > 0 ? config.targetCount : Infinity;
const capital = this.capital(model, playerChunk);
// ===== 1. 四条边到王城的距离(点到线段距离的平方),选最近者为起始边 =====
const dxEdge = Math.max(0, Math.max(minX - capital.x, capital.x - maxX));
const dzEdge = Math.max(0, Math.max(minZ - capital.z, capital.z - maxZ));
const distNorth = dxEdge * dxEdge + (minZ - capital.z) * (minZ - capital.z);
const distSouth = dxEdge * dxEdge + (maxZ - capital.z) * (maxZ - capital.z);
const distWest = (minX - capital.x) * (minX - capital.x) + dzEdge * dzEdge;
const distEast = (maxX - capital.x) * (maxX - capital.x) + dzEdge * dzEdge;
const rowScan = Math.min(distNorth, distSouth) <= Math.min(distWest, distEast);
// ===== 2. 生成 S 形扫描序列并按接壤过滤 =====
// 接壤判断集合:己方领土 + 本次已选区块。
// 绝不能把其他邦国领土(allClaimed)混入接壤判断集合,
// 否则会选中「挨着其他邦国、但与己方领土不相邻」的区块,服务器会拒绝开拓。
const claimed = new Set(actualOwn);
const result = [];
if (rowScan) {
const startRow = distNorth <= distSouth ? minZ : maxZ;
const rowStep = startRow === minZ ? 1 : -1;
let ltr = Math.abs(capital.x - minX) <= Math.abs(capital.x - maxX);
for (let row = startRow; row >= minZ && row <= maxZ; row += rowStep) {
if (ltr) {
for (let x = minX; x <= maxX; x++) this.tryClaimRect(result, claimed, allClaimed, x, row, limit);
} else {
for (let x = maxX; x >= minX; x--) this.tryClaimRect(result, claimed, allClaimed, x, row, limit);
}
ltr = !ltr;
if (result.length >= limit) return result;
}
} else {
const startCol = distWest <= distEast ? minX : maxX;
const colStep = startCol === minX ? 1 : -1;
let ttb = Math.abs(capital.z - minZ) <= Math.abs(capital.z - maxZ);
for (let col = startCol; col >= minX && col <= maxX; col += colStep) {
if (ttb) {
for (let z = minZ; z <= maxZ; z++) this.tryClaimRect(result, claimed, allClaimed, col, z, limit);
} else {
for (let z = maxZ; z >= minZ; z--) this.tryClaimRect(result, claimed, allClaimed, col, z, limit);
}
ttb = !ttb;
if (result.length >= limit) return result;
}
}
return result;
}
/**
* S 形扫描中的一个格子:若未被任何邦国占领、且与「己方领土(含本次已选)」接壤,
* 则纳入本次扩张计划;否则跳过(跳过的不打乱后续 S 形顺序)。
*/
tryClaimRect(result, claimed, allClaimed, x, z, limit) {
if (result.length >= limit) return;
const k = chunkPos.key(x, z);
if (allClaimed.has(k) || claimed.has(k)) return;
if (this.ownNeighbors({ x, z }, claimed) === 0) return;
result.push({ x, z });
claimed.add(k);
}
/** 在候选集中找到离当前游标最近的一个区块(曼哈顿距离),作为推进前沿 */
nearestFrontier(candidates, cursor) {
let best = null;
let bestDistance = Infinity;
for (const k of candidates) {
const pos = chunkPos.decode(k);
const d = chunkPos.manhattan(pos, cursor);
if (d < bestDistance) {
bestDistance = d;
best = pos;
}
}
return best;
}
/**
* 计算推进方向:若前沿与游标重合,则优先朝一个未占领的正方向邻居走;
* 否则返回从游标指向前沿的符号方向(-1/0/1)。
*/
chooseDirection(cursor, nearest, own) {
if (!nearest) return { x: 0, z: 0 };
if (chunkPos.equals(cursor, nearest)) {
for (const candidate of chunkPos.cardinalNeighbors(nearest.x, nearest.z)) {
if (own.has(chunkPos.key(candidate.x, candidate.z))) continue;
return { x: candidate.x - nearest.x, z: candidate.z - nearest.z };
}
}
return {
x: Math.sign(nearest.x - cursor.x),
z: Math.sign(nearest.z - cursor.z)
};
}
/**
* 直线推进的候选评分:分数越低越优先。
* 距离近、方向一致(方向点积大)、邻居多、形状影响小 的区块得分更优。
*/
chooseLineCandidate(candidates, plannedOwn, cursor, direction) {
if (!direction || (direction.x === 0 && direction.z === 0)) return null;
let best = null;
let bestScore = Infinity;
for (const k of candidates) {
const candidate = chunkPos.decode(k);
const dx = candidate.x - cursor.x;
const dz = candidate.z - cursor.z;
const directionBonus = dx * direction.x + dz * direction.z;
const distance = chunkPos.manhattan(cursor, candidate);
const shape = this.shapePenalty(candidate, plannedOwn);
const score = distance * 800
- directionBonus * 600
- this.ownNeighbors(candidate, plannedOwn) * 260
+ shape * 200;
if (score < bestScore) {
bestScore = score;
best = candidate;
}
}
return best;
}
/** 判断本次扩张的形状是否失衡:长边超过短边的 2 倍时视为「过瘦」,需要转向补短边 */
expansionImbalanced(minX, maxX, minZ, maxZ) {
if (minX === Infinity) return false;
const width = maxX - minX + 1;
const height = maxZ - minZ + 1;
return Math.max(width, height) > Math.min(width, height) * 2;
}
/** 返回本次扩张短边对应的推进方向:宽 < 高 时向 X 方向补,否则向 Z 方向补 */
shortSideDirection(minX, maxX, minZ, maxZ) {
const width = maxX - minX + 1;
const height = maxZ - minZ + 1;
return width < height ? { x: 1, z: 0 } : { x: 0, z: 1 };
}
/** 返回己方领土中离玩家最近的区块;领土为空时返回玩家所在区块 */
nearestOwnToPlayer(own, playerChunk) {
let best = null;
let bestDistance = Infinity;
for (const k of own) {
const pos = chunkPos.decode(k);
const d = chunkPos.manhattan(pos, playerChunk);
if (d < bestDistance) {
bestDistance = d;
best = pos;
}
}
return best || playerChunk;
}
/**
* 计算一组区块键的外接矩形边界。
* @returns {number[]} [minX, maxX, minZ, maxZ]
*/
bounds(ownKeys) {
let minX = Infinity;
let maxX = -Infinity;
let minZ = Infinity;
let maxZ = -Infinity;
for (const k of ownKeys) {
const { x, z } = chunkPos.decode(k);
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (z < minZ) minZ = z;
if (z > maxZ) maxZ = z;
}
return [minX, maxX, minZ, maxZ];
}
/** 将某区块的合法邻居加入候选集(单个区块版本) */
addNeighbors(chunk, plannedOwn, candidates, allClaimed, config) {
for (const candidate of chunkPos.cardinalNeighbors(chunk.x, chunk.z)) {
if (!this.isLegal(candidate, plannedOwn, allClaimed)) continue;
if (!this.isInRegion(candidate, config)) continue;
candidates.add(chunkPos.key(candidate.x, candidate.z));
}
}
/** 将一组区块的所有合法邻居加入候选集(批量版本) */
addNeighborsSet(ownKeys, candidates, allClaimed, config) {
for (const k of ownKeys) {
this.addNeighbors(chunkPos.decode(k), ownKeys, candidates, allClaimed, config);
}
}
/**
* 判断区块是否「合法」(可被占领):
* 必须在世界边界内、未被任何邦国占领、未被本次规划占用,且至少与一个己方区块相邻。
*/
isLegal(chunk, plannedOwn, allClaimed) {
if (!chunkPos.withinWorldBorder(chunk.x, chunk.z)) return false;
const k = chunkPos.key(chunk.x, chunk.z);
if (allClaimed.has(k) || plannedOwn.has(k)) return false;
return this.ownNeighbors(chunk, plannedOwn) > 0;
}
/** 判断区块是否在配置的扩地区域内(仅策略 2 区域扩地有限制,其他策略恒为 true) */
isInRegion(chunk, config) {
if (config.strategy !== 2) return true;
if (config.regionMaxX >= config.regionMinX) {
return chunk.x >= config.regionMinX && chunk.x <= config.regionMaxX
&& chunk.z >= config.regionMinZ && chunk.z <= config.regionMaxZ;
}
const dx = Math.abs(chunk.x - config.regionCenterX);
const dz = Math.abs(chunk.z - config.regionCenterZ);
return Math.max(dx, dz) <= Math.max(1, config.regionRadius);
}
/**
* 统计某区块周围(四个正方向)属于给定集合的邻居数量。
*
* 调用方必须传入「己方领土」集合(含本次规划已纳入的区块),
* 绝不能把其他邦国的领土混入,否则会误判「与他国接壤」为「可开拓」。
*/
ownNeighbors(chunk, own) {
let count = 0;
if (own.has(chunkPos.key(chunk.x + 1, chunk.z))) count++;
if (own.has(chunkPos.key(chunk.x - 1, chunk.z))) count++;
if (own.has(chunkPos.key(chunk.x, chunk.z + 1))) count++;
if (own.has(chunkPos.key(chunk.x, chunk.z - 1))) count++;
return count;
}
/**
* 形状惩罚分:评估把候选区块纳入后领土的「规整程度」。
* 用外接矩形的面积与已有区块数的比值衡量——比值越大说明扩张越「浪费」(形状越散),惩罚越高。
*/
shapePenalty(candidate, ownKeys) {
if (ownKeys.size === 0) return 1.0;
let minX = Infinity;
let maxX = -Infinity;
let minZ = Infinity;
let maxZ = -Infinity;
for (const k of ownKeys) {
const { x, z } = chunkPos.decode(k);
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (z < minZ) minZ = z;
if (z > maxZ) maxZ = z;
}
minX = Math.min(minX, candidate.x);
maxX = Math.max(maxX, candidate.x);
minZ = Math.min(minZ, candidate.z);
maxZ = Math.max(maxZ, candidate.z);
const width = maxX - minX + 1;
const height = maxZ - minZ + 1;
const longSide = Math.max(width, height);
const shortSide = Math.max(Math.min(width, height), longSide / 2.0);
return (longSide * shortSide) / (ownKeys.size + 1.0);
}
/** 计算区块相对中心点的方位角(弧度),用于绕国一圈策略的均匀排序 */
angle(chunk, center) {
return Math.atan2(chunk.z - center.z, chunk.x - center.x);
}
/** 计算一组区块的质心;集合为空时回退到 fallback */
centroid(chunkKeys, fallback) {
if (chunkKeys.size === 0) return fallback;
let x = 0;
let z = 0;
for (const k of chunkKeys) {
const pos = chunkPos.decode(k);
x += pos.x;
z += pos.z;
}
return { x: Math.trunc(x / chunkKeys.size), z: Math.trunc(z / chunkKeys.size) };
}
/** 汇总「己方」区块:本次会话新占领的区块 + 己方邦国已拥有的区块 */
ownChunks(model) {
const result = new Set(model.sessionClaimsSet());
const self = model.selfState();
if (self) {
for (const k of self.chunks) result.add(k);
}
return result;
}
/** 获取己方邦国的王城位置;未识别邦国时回退到 fallback */
capital(model, fallback) {
const self = model.selfState();
return self && self.capital ? self.capital : fallback;
}
}
module.exports = { ExpansionPlanner, WORLD_BORDER_CHUNK_RADIUS };
+347
查看文件
@@ -0,0 +1,347 @@
'use strict';
const chunkPos = require('./chunkPos');
/**
* SimpCore 开放 API 客户端(文档:https://open.bgjq.top/api-docs.html)。
*
* 邦国崛起服务器的只读查询服务,作为占领判定的第四数据源
* (游戏字幕、游戏消息、Dynmap JSON 之外的补充),提供:
* - 己方邦国领土区块明细(区块坐标、忠诚度、占领时间)——修正本地领土模型
* - 全服领土区块(手动触发)——交叉修正他人区块归属
* - 玩家所属邦国查询——辅助识别玩家邦国
* - 邦国列表(含王城坐标)——王城数据兜底
*
* 鉴权:`Authorization: Bearer <KEY>`;限流:按 Key × 接口每分钟限流。
* 全服数据类接口限流较严且数据量大(实测全服 territory 单次请求可超 2 分钟),
* 因此本客户端**只适合低频调用**:自动轮询仅用单邦国轻接口,全服接口仅手动触发。
* 服务端响应带缓存,数据最多有 1 小时延迟。
*/
/** API 服务基础地址 */
const BASE_URL = 'https://bgjq.simpfun.cn/api/';
/** 默认缓存 TTL:5 分钟。服务端数据本身最多延迟 1 小时,更短的 TTL 意义不大 */
const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000;
class SimpApiClient {
/**
* @param {import('../util/logger').Logger} [logger]
* @param {{baseUrl?: string, cacheTtlMs?: number}} [options]
*/
constructor(logger, options) {
const opts = options || {};
this.logger = logger || null;
this.baseUrl = opts.baseUrl || BASE_URL;
this.cacheTtlMs = opts.cacheTtlMs || DEFAULT_CACHE_TTL_MS;
/** API Key(运行时可动态设置) */
this.apiKey = '';
/** 内存缓存:接口路径 → {body, expiresAt} */
this.cache = new Map();
/** 最近一次请求的 HTTP 状态码(0 表示未请求过) */
this._lastHttpStatus = 0;
/** 最近一次请求失败的错误描述(空串表示无错误) */
this._lastError = '';
}
/**
* 设置 API Key(变更时清空缓存,避免旧 Key 的缓存干扰)。
* @param {string} key
*/
setApiKey(key) {
const fresh = key ? key.trim() : '';
if (fresh !== this.apiKey) this.cache.clear();
this.apiKey = fresh;
}
/** @returns {boolean} 是否已配置 API Key */
hasApiKey() {
return this.apiKey.length > 0;
}
/** @returns {number} 最近一次 HTTP 状态码(0 表示未请求过) */
lastHttpStatus() {
return this._lastHttpStatus;
}
/** @returns {string} 最近一次请求失败的错误描述(空串表示无错误) */
lastError() {
return this._lastError;
}
/** 清空本地响应缓存 */
clearCache() {
this.cache.clear();
}
// ==================== 查询接口 ====================
/**
* 健康检查(免鉴权):`GET /health`。
* @returns {Promise<boolean>} true 表示服务可达
*/
async healthCheck() {
try {
const response = await this._request('health', { auth: false, timeoutMs: 5000 });
this._lastHttpStatus = response.status;
return response.status === 200 && typeof response.body === 'string' && response.body.includes('ok');
} catch (err) {
this._lastError = err && err.message ? err.message : String(err);
return false;
}
}
/**
* 所有邦国列表:`GET /info/unions`。
* @returns {Promise<Map<string, object>>} 邦国名 → 邦国摘要
*/
async getAllUnions() {
const body = await this._getCachedOrFetch('info/unions');
const result = new Map();
if (body === null) return result;
for (const element of parseArray(body)) {
const summary = parseUnionSummary(element);
if (summary) result.set(summary.name, summary);
}
return result;
}
/**
* 单个邦国详情:`GET /info/unions/{key}`(key 可为数字 id 或邦国名)。
* @param {string|number} key
* @returns {Promise<object|null>}
*/
async getUnion(key) {
const body = await this._getCachedOrFetch('info/unions/' + encodeURIComponent(String(key)));
if (body === null) return null;
return parseUnionSummary(parseObject(body));
}
/**
* 邦国领土区块明细:`GET /info/unions/{key}/territory`。
* 响应较快,适合自动轮询。
* @param {string|number} key
* @returns {Promise<Map<string, object>>} 区块键 → 区块详情
*/
async getUnionTerritory(key) {
const body = await this._getCachedOrFetch(`info/unions/${encodeURIComponent(String(key))}/territory`);
const result = new Map();
if (body === null) return result;
for (const element of parseArray(body)) {
const chunk = parseTerritoryChunk(element);
if (chunk) result.set(chunkPos.key(chunk.x, chunk.z), chunk);
}
return result;
}
/**
* 全服领土区块:`GET /info/territory`,可按 union_id 过滤。
*
* **注意**:全服数据量大(实测单次请求可超 2 分钟),限流严格,
* 仅手动触发使用,禁止放入自动轮询。
*
* @param {number|null} [unionId]
* @returns {Promise<Map<string, object>>}
*/
async getAllTerritory(unionId) {
const path = 'info/territory' + (unionId !== undefined && unionId !== null ? `?union_id=${unionId}` : '');
const body = await this._getCachedOrFetch(path, { timeoutMs: 180000 });
const result = new Map();
if (body === null) return result;
for (const element of parseArray(body)) {
const chunk = parseTerritoryChunk(element);
if (chunk) result.set(chunkPos.key(chunk.x, chunk.z), chunk);
}
return result;
}
/**
* 玩家所属邦国:`GET /info/players/{key}/union`(key 可为 UUID 或游戏名)。
* 玩家无邦国时服务端返回 404 → 返回 null。
* @param {string} key
* @returns {Promise<object|null>}
*/
async getPlayerUnion(key) {
const body = await this._getCachedOrFetch(`info/players/${encodeURIComponent(String(key))}/union`);
if (body === null) return null;
return parseUnionSummary(parseObject(body));
}
/**
* 玩家详情:`GET /info/players/{key}`(UUID 或游戏名)。
* @param {string} key
* @returns {Promise<object|null>}
*/
async getPlayer(key) {
const body = await this._getCachedOrFetch(`info/players/${encodeURIComponent(String(key))}`);
const object = body === null ? null : parseObject(body);
if (!object) return null;
return {
name: stringOr(object, 'username', ''),
uuid: stringOr(object, 'uuid', ''),
unionId: intOr(object, 'union_id', -1),
unionName: stringOr(object, 'union_name', ''),
role: stringOr(object, 'role', ''),
job: stringOr(object, 'profession', ''),
title: stringOr(object, 'title', '')
};
}
// ==================== 网络与缓存底层 ====================
/**
* 优先取未过期缓存,否则发起带鉴权的 GET 请求。
* 401(Key 无效)时清空缓存;429(限流)时保留旧缓存兜底。
*
* @param {string} path 接口路径(相对 BASE_URL)
* @param {{timeoutMs?: number}} [options]
* @returns {Promise<string|null>} 响应体;失败返回 null
*/
async _getCachedOrFetch(path, options) {
const opts = options || {};
const entry = this.cache.get(path);
const now = Date.now();
if (entry && entry.expiresAt > now) return entry.body;
if (!this.hasApiKey()) {
this._lastError = '未配置 API Key';
return null;
}
try {
const response = await this._request(path, { auth: true, timeoutMs: opts.timeoutMs || 30000 });
this._lastHttpStatus = response.status;
if (response.status === 200) {
this.cache.set(path, { body: response.body, expiresAt: now + this.cacheTtlMs });
this._lastError = '';
return response.body;
}
if (response.status === 401) {
this._lastError = 'API Key 无效(401)';
this.cache.clear();
} else if (response.status === 429) {
this._lastError = '请求触发限流(429),稍后重试';
} else {
this._lastError = 'HTTP ' + response.status;
}
} catch (err) {
this._lastError = err && err.message ? err.message : String(err);
}
return null;
}
/**
* 发起一次 GET 请求。
* @param {string} path
* @param {{auth?: boolean, timeoutMs?: number}} [options]
* @returns {Promise<{status: number, body: string}>}
*/
async _request(path, options) {
const opts = options || {};
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), opts.timeoutMs || 30000);
const headers = { 'User-Agent': 'AutoStore-Node/1.0' };
if (opts.auth !== false && this.hasApiKey()) {
headers.Authorization = 'Bearer ' + this.apiKey;
}
try {
const response = await fetch(this.baseUrl + path, { headers, signal: controller.signal });
const body = await response.text();
return { status: response.status, body };
} finally {
clearTimeout(timer);
}
}
}
// ==================== JSON 解析(字段名以实测响应为准) ====================
/**
* 解析邦国摘要。实测字段:
* `id, name, leader_name, member_count, territory_chunk_count, territory_block_area,
* special_output_material, capital_x, capital_y, capital_z, union_title, ...`
*/
function parseUnionSummary(element) {
if (!element || typeof element !== 'object') return null;
const name = stringOr(element, 'name', '');
if (!name) return null;
const capitalX = doubleOr(element, 'capital_x', Number.NaN);
const capitalZ = doubleOr(element, 'capital_z', Number.NaN);
return {
id: intOr(element, 'id', -1),
name,
leader: stringOr(element, 'leader_name', ''),
memberCount: intOr(element, 'member_count', 0),
chunkCount: intOr(element, 'territory_chunk_count', 0),
blockArea: longOr(element, 'territory_block_area', 0),
specialOutput: stringOr(element, 'special_output_material', ''),
capital: Number.isNaN(capitalX) || Number.isNaN(capitalZ)
? null
: chunkPos.fromBlock(Math.floor(capitalX), Math.floor(capitalZ))
};
}
/**
* 解析领土区块。实测字段:`id, x, z, union_id, union_name, last_claimed_at, loyalty`
*/
function parseTerritoryChunk(element) {
if (!element || typeof element !== 'object') return null;
if (element.x === undefined || element.z === undefined || element.x === null || element.z === null) return null;
const x = Number(element.x);
const z = Number(element.z);
if (!Number.isFinite(x) || !Number.isFinite(z)) return null;
return {
x: Math.trunc(x),
z: Math.trunc(z),
unionId: intOr(element, 'union_id', -1),
unionName: stringOr(element, 'union_name', ''),
loyalty: intOr(element, 'loyalty', -1),
lastClaimedAt: stringOr(element, 'last_claimed_at', '')
};
}
/** 解析 JSON 数组文本;非法输入返回空列表 */
function parseArray(json) {
try {
const root = JSON.parse(json);
return Array.isArray(root) ? root : [];
} catch {
return [];
}
}
/** 解析 JSON 对象文本;非法输入返回 null */
function parseObject(json) {
try {
const root = JSON.parse(json);
return root && typeof root === 'object' && !Array.isArray(root) ? root : null;
} catch {
return null;
}
}
function stringOr(object, key, fallback) {
const value = object[key];
return value === undefined || value === null ? fallback : String(value);
}
function intOr(object, key, fallback) {
const value = Number(object[key]);
return Number.isFinite(value) ? Math.trunc(value) : fallback;
}
function longOr(object, key, fallback) {
const value = Number(object[key]);
return Number.isFinite(value) ? value : fallback;
}
function doubleOr(object, key, fallback) {
const value = Number(object[key]);
return Number.isFinite(value) ? value : fallback;
}
module.exports = {
SimpApiClient,
BASE_URL,
DEFAULT_CACHE_TTL_MS,
parseUnionSummary,
parseTerritoryChunk
};
+148
查看文件
@@ -0,0 +1,148 @@
'use strict';
/**
* 字幕 / 聊天监听器:解析游戏内消息,识别开垦进度与结果。
*
* 三条输入通道:
* - onSubtitle:来自 HUD 字幕 / 动作栏(如「开垦中 50%」),提取百分比进度
* - onChat:来自聊天栏(如「疆土(x,z) [bx,bz] 已为你的邦国开拓」),提取成功结果
* - onBossBar:部分服务端用 Boss 条显示进度
*
* 消息匹配采用模糊匹配(有界 Levenshtein 距离),以兼容服务器端文案的细微差异。
*/
/** 匹配百分比:如 "开垦中 50%" */
const PERCENT_PATTERN = /(\d+(?:\.\d+)?)\s*%/;
/** 匹配开垦成功的聊天消息:形如「疆土(区块X,区块Z) [方块X,方块Z] 已为你的邦国开拓」 */
const CLAIM_MESSAGE_PATTERN = /疆土[((]\s*(-?\d+)\s*[,,]\s*(-?\d+)\s*[))]\s*\[\s*(-?\d+)\s*[,,]\s*(-?\d+)\s*]\s*已为你的邦国开拓/;
/** 开垦相关关键词(用于判断是否值得解析百分比) */
const CLAIM_KEYWORDS = ['开垦中', '开垦', '开拓中', '开拓'];
/** 通用成功关键词(无坐标) */
const GENERIC_SUCCESS_KEYWORDS = ['疆土已开拓', '已为你的邦国开拓', '疆土已开垦'];
class SubtitleChatMonitor {
/**
* @param {{onProgress: (percent: number) => void, onSuccess: (chunk: {x: number, z: number}, blockX: number, blockZ: number) => void, onGenericSuccess: () => void}} listener
*/
constructor(listener) {
this.listener = listener;
}
/**
* 处理字幕 / 动作栏消息:若包含「开垦」相关文本,则提取其中的百分比并回调进度。
* @param {string} text
*/
onSubtitle(text) {
if (!text) return;
let matched = false;
for (const keyword of CLAIM_KEYWORDS) {
if (containsFuzzy(text, keyword, keyword.length > 2 ? 2 : 1)) {
matched = true;
break;
}
}
if (!matched) return;
const matcher = PERCENT_PATTERN.exec(text);
if (!matcher) return;
const percent = Math.round(Number.parseFloat(matcher[1]));
if (Number.isNaN(percent)) return;
this.listener.onProgress(Math.max(0, Math.min(100, percent)));
}
/**
* 处理聊天消息:优先匹配带坐标的开垦成功消息(回调 onSuccess),
* 否则模糊匹配「疆土已开拓 / 已为你的邦国开拓」等通用成功文案(回调 onGenericSuccess)。
* @param {string} text
*/
onChat(text) {
if (!text) return;
const matcher = CLAIM_MESSAGE_PATTERN.exec(text);
if (matcher) {
const x = Number.parseInt(matcher[1], 10);
const z = Number.parseInt(matcher[2], 10);
const blockX = Number.parseInt(matcher[3], 10);
const blockZ = Number.parseInt(matcher[4], 10);
if (![x, z, blockX, blockZ].some(Number.isNaN)) {
this.listener.onSuccess({ x, z }, blockX, blockZ);
return;
}
}
for (const keyword of GENERIC_SUCCESS_KEYWORDS) {
if (containsFuzzy(text, keyword, 4)) {
this.listener.onGenericSuccess();
return;
}
}
}
}
/**
* 模糊包含匹配:判断 text 中是否存在与 needle 编辑距离 ≤ maxEdits 的子串。
*
* 实现方式:对 text 的每个起始位置,计算「needle 到该位置起任意长度前缀」的最小编辑距离,
* 取全局最小值与 maxEdits 比较。
*
* 注意:原 Java 版只取了固定长度前缀的末行距离,导致 maxEdits=0 时连完全相同的子串都匹配不上
* (例如 `containsFuzzy('开垦中 50%', '开垦中', 0)` 返回 false)。这里按注释所述的语义修正,
* 使「编辑距离 ≤ maxEdits」这一约定真正成立。
*
* @param {string} text 待搜索文本
* @param {string} needle 关键词
* @param {number} maxEdits 允许的最大编辑距离
* @returns {boolean}
*/
function containsFuzzy(text, needle, maxEdits) {
if (!needle) return true;
if (!text) return false;
const n = text.length;
const m = needle.length;
if (m > n + maxEdits) return false;
const lastOffset = n - Math.max(1, m - maxEdits);
for (let offset = 0; offset <= lastOffset; offset++) {
if (boundedLevenshtein(text, offset, needle, maxEdits) <= maxEdits) return true;
}
return false;
}
/**
* 有界 Levenshtein 距离:计算 needle 与 `text[offset..]` 的任意前缀之间的最小编辑距离。
*
* 当最小值超过 limit 时提前返回 `limit + 1`(调用方只需知道「超过了」)。
*
* @param {string} text
* @param {number} textOffset
* @param {string} needle
* @param {number} limit
* @returns {number}
*/
function boundedLevenshtein(text, textOffset, needle, limit) {
const m = needle.length;
const maxLen = Math.min(m + limit, text.length - textOffset);
if (maxLen < 0) return limit + 1;
let previous = new Array(m + 1);
let current = new Array(m + 1);
for (let j = 0; j <= m; j++) previous[j] = j;
let best = previous[m];
for (let i = 1; i <= maxLen; i++) {
current[0] = i;
for (let j = 1; j <= m; j++) {
const cost = text.charCodeAt(textOffset + i - 1) === needle.charCodeAt(j - 1) ? 0 : 1;
current[j] = Math.min(current[j - 1] + 1, previous[j] + 1, previous[j - 1] + cost);
}
if (current[m] < best) best = current[m];
if (best === 0) return 0;
const swap = previous;
previous = current;
current = swap;
}
return best > limit ? limit + 1 : best;
}
module.exports = {
SubtitleChatMonitor,
containsFuzzy,
boundedLevenshtein,
CLAIM_MESSAGE_PATTERN
};
+623
查看文件
@@ -0,0 +1,623 @@
'use strict';
const crypto = require('crypto');
const chunkPos = require('./chunkPos');
const { TerritoryModel } = require('./territoryModel');
const { SimpApiClient } = require('./simpApiClient');
/**
* 领土数据服务:定时从远程地图服务拉取邦国领土数据(Dynmap markers.json),
* 解析为 TerritoryModel 供规划器与 Web 控制台使用。
*
* 占领判定有四个数据源,任意一个显示区块被占领即算占领:
* 1. 游戏内字幕(「开垦中 XX%」)
* 2. 游戏消息(「疆土(x,z) 已为你的邦国开拓」)
* 3. Dynmap 地图数据(本模块的 markers.json 主数据源)
* 4. SimpCore 开放 API(补充 / 交叉验证,含忠诚度数据)
*/
/** 从 marker 弹窗文本中提取「疆土归属: xxx」的邦国名 */
const CLAIM_PATTERN = /疆土归属:\s*([^<\r\n]+)/;
/** 从 marker 弹窗文本中提取「「xxx」的王城」的王城名 */
const CAPITAL_PATTERN = /「([^」]+)」的王城/;
/** 单个多边形填充的区块数上限:面积超过 600 万区块时直接放弃,防止异常数据导致卡死 */
const MAX_FILL_AREA = 6000000;
/** 解析失败 / 缺省时使用的邦国绿(#75bd20) */
const DEFAULT_COLOR = 0x75bd20;
/** API 邦国在地图上的显示颜色(紫 #FF43DE),与 Dynmap 数据区分来源 */
const API_STATE_COLOR = 0xff43de;
/**
* 会话占领「确认宽限」时长(毫秒)。
*
* 占领成功后,服务器动态地图可能尚未同步该区块,因此在宽限期内即使地图未确认
* 也先保留「会话占领」标记;超过宽限期仍未确认的视为假占领并丢弃,
* 使空洞区块重新进入规划。
*/
const CLAIM_CONFIRM_GRACE_MS = 60000;
class TerritoryDataService {
/**
* @param {object} config 完整配置对象
* @param {import('../util/logger').Logger} [logger]
* @param {{markersUrl?: string}} [options] 可覆盖数据源地址(测试用)
*/
constructor(config, logger, options) {
const opts = options || {};
this.config = config;
this.logger = logger || null;
this.markersUrl = opts.markersUrl || buildMarkersUrl(process.env.DYNMAP_BASE, process.env.DYNMAP_WORLD);
/** @type {TerritoryModel} 当前领土模型 */
this.model = TerritoryModel.empty();
/** 当前选中的己方邦国名称 */
this.selectedStateName = '';
/** 最近一次拉取失败的错误信息(空串表示无错误) */
this.lastError = '';
/** 最近一次成功拉取的时间戳(毫秒) */
this.lastSuccessfulFetchMillis = 0;
/** 最近一次尝试拉取的时间戳(毫秒) */
this.lastAttemptMillis = 0;
/** 会话内新占领区块 → 记录时间(毫秒) */
this.sessionClaimTimes = new Map();
/** 上次成功响应的正文哈希,用于识别「网络缓存返回了相同旧数据」并跳过重复解析 */
this._lastBodyHash = '';
this._lastBodyLength = -1;
/** API 数据版本号:API 数据每次成功更新后自增,参与「内容未变化跳过解析」判定 */
this.apiDataVersion = 0;
this._lastMergedApiVersion = -1;
/** @type {SimpApiClient} SimpCore 开放 API 客户端(第四占领判定数据源) */
this.apiClient = new SimpApiClient(logger);
/** API 最近一次成功拉取的 邦国名 → 区块键集合 */
this.apiTerritory = new Map();
/** API 最近一次成功拉取的 邦国名 → 王城区块 */
this.apiCapitals = new Map();
/** API 最近一次成功拉取的时间戳(毫秒),0 表示从未成功 */
this.lastApiSuccessMillis = 0;
/** API 轮询间隔(秒);0 表示未启用 */
this._apiPollIntervalSeconds = 0;
this._refreshTimer = null;
this._apiTimer = null;
this._refreshing = false;
this._refreshQueued = false;
/** @type {((model: TerritoryModel) => void)|null} 模型更新回调 */
this.onModelUpdated = null;
}
// ==================== 生命周期 ====================
/**
* 启动定时刷新:立即刷新一次,之后按固定间隔循环拉取。
* @param {number} [refreshIntervalSeconds]
*/
start(refreshIntervalSeconds) {
const interval = Math.max(10, refreshIntervalSeconds || this.config.refreshIntervalSeconds);
this.refreshAsync();
this.reschedule(interval);
}
/**
* 按新间隔重新调度定时刷新。
* @param {number} refreshIntervalSeconds 新的刷新间隔(秒),最小 10 秒
*/
reschedule(refreshIntervalSeconds) {
const interval = Math.max(10, refreshIntervalSeconds);
if (this._refreshTimer) clearInterval(this._refreshTimer);
this._refreshTimer = setInterval(() => this.refreshAsync(), interval * 1000);
if (this._refreshTimer.unref) this._refreshTimer.unref();
}
/** 停止全部定时器 */
stop() {
if (this._refreshTimer) clearInterval(this._refreshTimer);
if (this._apiTimer) clearInterval(this._apiTimer);
this._refreshTimer = null;
this._apiTimer = null;
}
/**
* 配置 SimpCore 开放 API。
* @param {boolean} enabled 是否启用
* @param {string} apiKey API Key
* @param {number} pollIntervalSeconds 轮询间隔(秒),最小 60
*/
applyApiConfig(enabled, apiKey, pollIntervalSeconds) {
this.apiClient.setApiKey(apiKey || '');
const effective = !!enabled && this.apiClient.hasApiKey();
const interval = effective ? Math.max(60, pollIntervalSeconds || 180) : 0;
if (interval === this._apiPollIntervalSeconds && this._apiTimer) return;
this._apiPollIntervalSeconds = interval;
this._rescheduleApi();
}
/** 按 _apiPollIntervalSeconds 重新调度 API 轮询(≤0 表示停用) */
_rescheduleApi() {
if (this._apiTimer) clearInterval(this._apiTimer);
this._apiTimer = null;
const interval = this._apiPollIntervalSeconds;
if (interval > 0) {
this._apiTimer = setInterval(() => this.refreshApiAsync(), interval * 1000);
if (this._apiTimer.unref) this._apiTimer.unref();
setTimeout(() => this.refreshApiAsync(), 15000).unref?.();
}
}
/** @returns {boolean} API 数据源是否已启用(已配置 Key 且轮询间隔 > 0) */
isApiEnabled() {
return this._apiPollIntervalSeconds > 0 && this.apiClient.hasApiKey();
}
// ==================== 数据拉取 ====================
/**
* 异步刷新领土数据:拉取 markers.json,解析为新模型,
* 并把上一份模型中的「会话新占领区块」与最新地图数据核对,只保留已确认或仍在宽限期的记录。
*
* 若已有刷新在途,则标记一次「待刷新」,在当前刷新结束后立即补一次,
* 避免定时刷新与占领成功后的即时刷新互相覆盖。
*/
refreshAsync() {
if (this._refreshing) {
this._refreshQueued = true;
return;
}
this._refreshing = true;
this._doRefresh().catch((err) => {
this.lastError = err && err.message ? err.message : String(err);
}).finally(() => {
this._refreshing = false;
if (this._refreshQueued) {
this._refreshQueued = false;
this.refreshAsync();
}
});
}
async _doRefresh() {
const apiVersionAtStart = this.apiDataVersion;
this.lastAttemptMillis = Date.now();
// 请求侧缓存击穿:URL 追加时间戳参数 + no-cache 头,确保每次都拿到服务器最新数据。
// 此前约半数刷新命中网络缓存拿到旧数据,导致会话占领迟迟无法被地图确认 / 纠正。
const url = `${this.markersUrl}${this.markersUrl.includes('?') ? '&' : '?'}_=${Date.now()}`;
const response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 autostore-node/1.0',
'Cache-Control': 'no-cache, no-store',
Pragma: 'no-cache',
Accept: 'application/json,text/plain,*/*'
}
});
if (!response.ok) {
this.lastError = `HTTP ${response.status}`;
return;
}
const body = await response.text();
const hash = crypto.createHash('md5').update(body).digest('hex');
// 内容未变化(服务器确实无更新,或缓存仍命中相同旧文)且 API 数据也未更新:跳过重复解析
if (body.length === this._lastBodyLength && hash === this._lastBodyHash
&& apiVersionAtStart === this._lastMergedApiVersion) {
this.lastSuccessfulFetchMillis = Date.now();
this.lastError = '';
return;
}
this._lastBodyHash = hash;
this._lastBodyLength = body.length;
this._lastMergedApiVersion = apiVersionAtStart;
const parsed = parseMarkers(body);
// ===== API 数据源合并(第四判定来源)=====
if (this._apiPollIntervalSeconds > 0 && this.apiTerritory.size > 0) {
parsed.mergeApiData(this.apiTerritory, this.apiCapitals, API_STATE_COLOR);
}
const previous = this.model;
if (previous) {
const now = Date.now();
const freshOwn = new Set();
const freshSelf = parsed.state(this.selectedStateName);
if (freshSelf) {
for (const k of freshSelf.chunks) freshOwn.add(k);
}
const apiOwn = this.apiTerritory.get(this.selectedStateName) || new Set();
for (const sessionClaim of previous.sessionClaimsSet()) {
const claimTime = this.sessionClaimTimes.get(sessionClaim);
const confirmed = freshOwn.has(sessionClaim) || apiOwn.has(sessionClaim);
const withinGrace = claimTime !== undefined && now - claimTime < CLAIM_CONFIRM_GRACE_MS;
if (confirmed || withinGrace) {
parsed.addSessionClaim(chunkPos.decode(sessionClaim));
} else if (claimTime !== undefined) {
// 所有数据源始终未确认且超时:判定为假占领并移除,使空洞区块重新进入规划
this.sessionClaimTimes.delete(sessionClaim);
}
}
}
parsed.setSelfStateName(this.selectedStateName);
parsed.updatedAt = Date.now();
this.model = parsed;
this.lastSuccessfulFetchMillis = Date.now();
this.lastError = '';
if (this.onModelUpdated) {
try {
this.onModelUpdated(parsed);
} catch {
// 订阅者异常不影响刷新
}
}
}
/**
* 异步拉取 API 数据:拉取所有邦国列表(王城兜底)+ 当前选中邦国的领土区块明细。
* 成功后触发一次地图刷新,让新数据尽快参与模型合并与会话占领确认。
*/
async refreshApiAsync() {
if (this._apiPollIntervalSeconds <= 0 || !this.apiClient.hasApiKey()) return;
try {
const unions = await this.apiClient.getAllUnions();
let changed = false;
if (unions.size > 0) {
for (const union of unions.values()) {
if (!union.capital) continue;
const old = this.apiCapitals.get(union.name);
if (!old || old.x !== union.capital.x || old.z !== union.capital.z) {
this.apiCapitals.set(union.name, union.capital);
changed = true;
}
}
}
const stateName = this.selectedStateName || '';
if (stateName) {
const territory = await this.apiClient.getUnionTerritory(stateName);
if (territory.size > 0) {
const chunks = new Set();
for (const chunk of territory.values()) chunks.add(chunkPos.key(chunk.x, chunk.z));
this.apiTerritory.set(stateName, chunks);
changed = true;
} else if (unions.size === 0) {
// 两个接口都失败(网络 / 限流 / Key 问题):保留旧数据
return;
}
}
if (changed) this.apiDataVersion++;
this.lastApiSuccessMillis = Date.now();
this.refreshAsync();
} catch {
// API 拉取失败不影响 Dynmap 主数据源,静默保留旧数据
}
}
/**
* 手动拉取全服领土数据(重接口,限流严格,仅手动触发):
* 按邦国名聚合后并入 API 数据,用于对比与修正本地模型。
*
* @returns {Promise<{ok: boolean, message: string}>}
*/
async fetchAllTerritoryOnce() {
if (!this.apiClient.hasApiKey()) return { ok: false, message: '未配置 API Key' };
try {
const all = await this.apiClient.getAllTerritory(null);
if (all.size === 0) return { ok: false, message: '拉取失败:' + this.apiClient.lastError() };
const byUnion = new Map();
for (const chunk of all.values()) {
if (!chunk.unionName) continue;
let set = byUnion.get(chunk.unionName);
if (!set) {
set = new Set();
byUnion.set(chunk.unionName, set);
}
set.add(chunkPos.key(chunk.x, chunk.z));
}
for (const [name, set] of byUnion) this.apiTerritory.set(name, set);
this.apiDataVersion++;
this.lastApiSuccessMillis = Date.now();
this.refreshAsync();
return { ok: true, message: `已拉取 ${all.size} 个区块,覆盖 ${byUnion.size} 个邦国` };
} catch (err) {
return { ok: false, message: err && err.message ? err.message : String(err) };
}
}
/**
* 通过 API 识别玩家所属邦国(用于自动识别己方邦国的兜底)。
* @param {string} playerName 玩家游戏名
* @returns {Promise<string|null>} 识别到的邦国名(本地模型中必须存在,否则返回 null)
*/
async detectStateByPlayer(playerName) {
if (!playerName || !this.apiClient.hasApiKey()) return null;
try {
const union = await this.apiClient.getPlayerUnion(playerName.trim());
let name = union ? union.name : null;
if (name && !this.model.state(name)) {
const territory = await this.apiClient.getUnionTerritory(name);
if (territory.size > 0) {
const chunks = new Set();
for (const chunk of territory.values()) chunks.add(chunkPos.key(chunk.x, chunk.z));
this.apiTerritory.set(name, chunks);
this.apiDataVersion++;
this.refreshAsync();
await new Promise((resolve) => setTimeout(resolve, 800));
}
if (!this.model.state(name)) name = null;
}
return name;
} catch {
return null;
}
}
// ==================== 模型访问 ====================
/** @returns {TerritoryModel} 当前领土模型 */
getModel() {
return this.model;
}
/**
* 设置当前选中的己方邦国名称。
* @param {string|null} name
*/
setSelectedStateName(name) {
this.selectedStateName = name || '';
this.model.setSelfStateName(this.selectedStateName);
}
/**
* 记录一次会话内新占领的区块。
* @param {{x: number, z: number}} chunk
*/
addSessionClaim(chunk) {
this.sessionClaimTimes.set(chunkPos.key(chunk.x, chunk.z), Date.now());
this.model.addSessionClaim(chunk);
}
/** @returns {object} 数据源状态快照(供 Web 控制台展示) */
snapshot() {
return {
markersUrl: this.markersUrl,
lastError: this.lastError,
lastSuccessfulFetchMillis: this.lastSuccessfulFetchMillis,
lastAttemptMillis: this.lastAttemptMillis,
stateCount: this.model.stateCount,
claimedChunks: this.model.allClaimedChunks().size,
sessionClaims: this.model.sessionClaimsSet().size,
flyingChunks: this.model.flyingChunks().size,
selfState: this.model.selfStateNameValue(),
apiEnabled: this.isApiEnabled(),
apiLastSuccessMillis: this.lastApiSuccessMillis,
apiError: this.apiClient.lastError(),
apiHttpStatus: this.apiClient.lastHttpStatus(),
apiTerritoryCount: this.apiTerritory.size
};
}
}
/**
* 构造 markers.json 地址。
*
* @param {string} [base] 地图站点根地址
* @param {string} [world] markers.json 所属世界名(Dynmap 目录名)
* @returns {string}
*/
function buildMarkersUrl(base, world) {
const root = (base || 'http://bgjq.simpfun.cn').replace(/\/+$/, '');
const worldName = world || 'minecraft_overworld';
return `${root}/tiles/${worldName}/markers.json`;
}
/**
* 解析 markers.json 文本为领土模型。
*
* JSON 结构为数组,每层包含 id 与 markers 列表:
* - `union_claims` 层:每个 marker 是一个领土多边形,弹窗文本含「疆土归属: 邦国名」
* - `union_capitals` 层:每个 marker 是一个王城坐标点,弹窗文本含「「邦国名」的王城」
*
* @param {string} json
* @returns {TerritoryModel}
*/
function parseMarkers(json) {
const root = JSON.parse(json);
if (!Array.isArray(root)) {
throw new Error('markers.json 顶层必须是数组');
}
/** @type {Map<string, Set<number>>} */
const claims = new Map();
/** @type {Map<string, number>} */
const claimColors = new Map();
/** @type {Map<string, {x: number, z: number}>} */
const capitals = new Map();
for (const layer of root) {
if (!layer || typeof layer !== 'object') continue;
const markers = layer.markers;
if (!Array.isArray(markers)) continue;
if (layer.id === 'union_claims') {
for (const marker of markers) parseClaimMarker(marker, claims, claimColors);
} else if (layer.id === 'union_capitals') {
for (const marker of markers) parseCapitalMarker(marker, capitals);
}
}
const states = new Map();
for (const [name, chunkKeys] of claims) {
let capital = capitals.get(name);
if (!capital) {
const first = chunkKeys.values().next();
capital = first.done ? { x: 0, z: 0 } : chunkPos.decode(first.value);
}
const color = claimColors.has(name) ? claimColors.get(name) : DEFAULT_COLOR;
states.set(name, { name, capital, chunks: chunkKeys, color });
}
return new TerritoryModel(states);
}
/**
* 解析一个领土多边形 marker:从弹窗文本提取邦国名,
* 将多边形覆盖的区块按 even-odd 规则填充进该邦国的区块集合,并记录颜色。
*/
function parseClaimMarker(marker, claims, colors) {
if (!marker || typeof marker !== 'object') return;
const popup = typeof marker.popup === 'string' ? marker.popup : '';
const matcher = CLAIM_PATTERN.exec(popup);
if (!matcher) return;
const name = matcher[1].trim();
if (!name) return;
let chunks = claims.get(name);
if (!chunks) {
chunks = new Set();
claims.set(name, chunks);
}
// 一个 marker 的多个多边形遵循 dynmap 的 even-odd 填充规则:
// 外圈为正,内圈多边形(国中飞地的边界)作为孔洞被减去,而非作为独立领土并集填充。
fillPolygonEvenOdd(chunks, extractPolygons(marker.points));
if (!colors.has(name)) {
colors.set(name, parseColor(typeof marker.color === 'string' ? marker.color : '#75bd20'));
}
}
/** 解析一个王城 marker:从弹窗文本提取邦国名,记录王城的区块坐标 */
function parseCapitalMarker(marker, capitals) {
if (!marker || typeof marker !== 'object') return;
const popup = typeof marker.popup === 'string' ? marker.popup : '';
const matcher = CAPITAL_PATTERN.exec(popup);
if (!matcher) return;
const point = marker.point;
if (!point || typeof point !== 'object') return;
capitals.set(matcher[1].trim(), chunkPos.fromBlock(toInt(point.x), toInt(point.z)));
}
/**
* 递归提取多边形列表:points 可能是单个多边形(点数组),
* 也可能是多边形数组(多层嵌套),统一展平为 `Array<Array<[number, number]>>`。
*/
function extractPolygons(points) {
const result = [];
if (!Array.isArray(points) || points.length === 0) return result;
if (isPointObject(points[0])) {
const polygon = parsePointList(points);
if (polygon.length >= 3) result.push(polygon);
} else {
for (const element of points) {
for (const polygon of extractPolygons(element)) result.push(polygon);
}
}
return result;
}
/** 判断 JSON 元素是否是一个「点对象」(同时含 x 与 z 字段) */
function isPointObject(element) {
return !!element && typeof element === 'object' && element.x !== undefined && element.z !== undefined;
}
/** 解析一个点数组为坐标对列表 */
function parsePointList(element) {
const points = [];
if (!Array.isArray(element)) return points;
for (const point of element) {
if (!point || typeof point !== 'object') continue;
points.push([toInt(point.x), toInt(point.z)]);
}
return points;
}
/**
* 将多边形组覆盖的区块按 even-odd 规则填充进集合。
*
* 一个 marker 里的多个多边形遵循 dynmap 的奇偶填充:区块落入**奇数个**多边形内时才视为领土;
* 这样内圈多边形(国中飞地 / 孔洞的边界)会被正确减去,而不是像并集那样把孔洞也填成领土。
*
* @param {Set<number>} output 输出集合(该邦国的区块键集合)
* @param {Array<Array<[number, number]>>} polygons 该 marker 的全部多边形
*/
function fillPolygonEvenOdd(output, polygons) {
if (polygons.length === 0) return;
let minX = Infinity;
let maxX = -Infinity;
let minZ = Infinity;
let maxZ = -Infinity;
for (const polygon of polygons) {
for (const point of polygon) {
const cx = Math.floor(point[0] / 16);
const cz = Math.floor(point[1] / 16);
if (cx < minX) minX = cx;
if (cx > maxX) maxX = cx;
if (cz < minZ) minZ = cz;
if (cz > maxZ) maxZ = cz;
}
}
if ((maxX - minX + 1) * (maxZ - minZ + 1) > MAX_FILL_AREA) return;
for (let cx = minX; cx <= maxX; cx++) {
for (let cz = minZ; cz <= maxZ; cz++) {
// 奇偶填充:落在奇数个多边形内则为领土
let inside = false;
const blockX = cx * 16 + 8;
const blockZ = cz * 16 + 8;
for (const polygon of polygons) {
if (pointInPolygon(blockX, blockZ, polygon)) inside = !inside;
}
if (inside) output.add(chunkPos.key(cx, cz));
}
}
}
/**
* 射线法判断点 (x, z) 是否在多边形内(经典奇偶规则)。
* @param {number} x
* @param {number} z
* @param {Array<[number, number]>} polygon
* @returns {boolean}
*/
function pointInPolygon(x, z, polygon) {
let inside = false;
let j = polygon.length - 1;
for (let i = 0; i < polygon.length; i++) {
const xi = polygon[i][0];
const zi = polygon[i][1];
const xj = polygon[j][0];
const zj = polygon[j][1];
const intersects = (zi > z) !== (zj > z) && x < ((xj - xi) * (z - zi)) / (zj - zi) + xi;
if (intersects) inside = !inside;
j = i;
}
return inside;
}
/**
* 解析十六进制颜色字符串为 0xRRGGBB 整数:
* 支持 #RRGGBB 与 #AARRGGBB 两种格式;解析失败时返回默认的邦国绿。
* @param {string} hex
* @returns {number}
*/
function parseColor(hex) {
const value = String(hex).startsWith('#') ? String(hex).slice(1) : String(hex);
if (value.length === 8) {
return Number.parseInt(value.slice(2), 16) || DEFAULT_COLOR;
}
if (value.length === 6) {
const parsed = Number.parseInt(value, 16);
return Number.isNaN(parsed) ? DEFAULT_COLOR : parsed;
}
return DEFAULT_COLOR;
}
function toInt(value) {
const n = Number(value);
return Number.isFinite(n) ? Math.trunc(n) : 0;
}
module.exports = {
TerritoryDataService,
parseMarkers,
fillPolygonEvenOdd,
pointInPolygon,
extractPolygons,
parseColor,
buildMarkersUrl,
CLAIM_CONFIRM_GRACE_MS,
DEFAULT_COLOR,
API_STATE_COLOR,
CLAIM_PATTERN,
CAPITAL_PATTERN
};
+254
查看文件
@@ -0,0 +1,254 @@
'use strict';
const chunkPos = require('./chunkPos');
/**
* 领土模型:聚合所有邦国的领土状态,并提供全局查询能力。
*
* 维护三份数据:
* - states:邦国名 → 邦国领土状态(含区块集合、王城、颜色)
* - allClaimed:所有邦国已占领区块的并集(判断区块是否已被占用)
* - sessionClaims:本次会话新占领的区块(刷新地图时不会被覆盖)
*
* 区块集合一律用 `chunkPos.key(x, z)` 生成的整数键存进 Set,
* 避免数万区块产生海量临时对象。
*/
/** API 数据独有邦国在地图上的默认颜色(紫 #FF43DE,ARGB 0xFF43DE) */
const API_DEFAULT_COLOR = 0xff43de;
class TerritoryModel {
/**
* @param {Map<string, {name: string, capital: {x: number, z: number}|null, chunks: Set<number>, color: number}>} [states]
*/
constructor(states) {
/** @type {Map<string, {name: string, capital: {x: number, z: number}|null, chunks: Set<number>, color: number}>} */
this.states = new Map(states || []);
/** @type {Set<number>} 所有邦国已占领区块的并集 */
this.allClaimed = new Set();
for (const state of this.states.values()) {
for (const k of state.chunks) this.allClaimed.add(k);
}
/** @type {Set<number>} 本次会话新占领的区块 */
this.sessionClaims = new Set();
/** @type {string} 当前选中的己方邦国名称 */
this.selfStateName = '';
/** @type {Map<string, Set<number>>|null} 飞地缓存 */
this._flyingByState = null;
/** @type {Set<number>|null} 飞地并集缓存 */
this._allFlying = null;
/** 数据来源时间戳,便于 Web 控制台展示新鲜度 */
this.updatedAt = 0;
}
/** @returns {TerritoryModel} 空模型(无任何邦国数据) */
static empty() {
return new TerritoryModel(new Map());
}
/** @returns {IterableIterator<{name: string, capital: {x: number, z: number}|null, chunks: Set<number>, color: number}>} 所有邦国领土状态 */
statesIter() {
return this.states.values();
}
/** @returns {number} 邦国数量 */
get stateCount() {
return this.states.size;
}
/**
* 按名称查询邦国领土状态。
* @param {string} name
* @returns {{name: string, capital: {x: number, z: number}|null, chunks: Set<number>, color: number}|undefined}
*/
state(name) {
return this.states.get(name);
}
/** @returns {Set<number>} 所有邦国已占领区块的并集(勿直接修改) */
allClaimedChunks() {
return this.allClaimed;
}
/** @returns {Set<number>} 本次会话新占领的区块(勿直接修改) */
sessionClaimsSet() {
return this.sessionClaims;
}
/** @returns {string} 当前选中的己方邦国名称 */
selfStateNameValue() {
return this.selfStateName;
}
/**
* 设置当前选中的己方邦国名称。
* @param {string|null} name
*/
setSelfStateName(name) {
this.selfStateName = name || '';
}
/** @returns {object|null} 己方邦国的领土状态;未设置或不存在时为 null */
selfState() {
return this.states.get(this.selfStateName) || null;
}
/**
* 查找包含指定区块的邦国。
* @param {{x: number, z: number}} chunk
* @returns {object|null}
*/
findContainingState(chunk) {
const k = chunkPos.key(chunk.x, chunk.z);
for (const state of this.states.values()) {
if (state.chunks.has(k)) return state;
}
return null;
}
/**
* 所有邦国的飞地(疏漏区块)并集。
*
* 飞地指「四周(四个正方向)都被同一邦国占领、但自身未被占领」的区块,
* 通常是自动扩地时漏掉的疏漏,需要在地图上标出并优先填补。
*
* @returns {Set<number>}
*/
flyingChunks() {
if (this._allFlying) return this._allFlying;
const result = new Set();
for (const chunks of this._flyingByStateMap().values()) {
for (const k of chunks) result.add(k);
}
this._allFlying = result;
return result;
}
/** @returns {Set<number>} 己方邦国的飞地(疏漏区块);未选择邦国时为空集 */
selfFlyingChunks() {
return this._flyingByStateMap().get(this.selfStateName) || EMPTY_SET;
}
/** @returns {Map<string, Set<number>>} 各邦国飞地区块(懒计算并缓存) */
_flyingByStateMap() {
if (this._flyingByState) return this._flyingByState;
const map = new Map();
for (const state of this.states.values()) {
map.set(state.name, this._computeFlyingChunks(state.chunks));
}
this._flyingByState = map;
return map;
}
/**
* 计算某邦国的全部飞地区块:在该邦国的外接矩形内,
* 找出「未被任何邦国占领、但四个正方向均属于该邦国」的区块。
* @param {Set<number>} own
* @returns {Set<number>}
*/
_computeFlyingChunks(own) {
const result = new Set();
if (own.size === 0) return result;
let minX = Infinity;
let maxX = -Infinity;
let minZ = Infinity;
let maxZ = -Infinity;
for (const k of own) {
const { x, z } = chunkPos.decode(k);
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (z < minZ) minZ = z;
if (z > maxZ) maxZ = z;
}
for (let x = minX; x <= maxX; x++) {
for (let z = minZ; z <= maxZ; z++) {
const k = chunkPos.key(x, z);
if (own.has(k) || this.allClaimed.has(k)) continue;
if (own.has(chunkPos.key(x + 1, z)) && own.has(chunkPos.key(x - 1, z))
&& own.has(chunkPos.key(x, z + 1)) && own.has(chunkPos.key(x, z - 1))) {
result.add(k);
}
}
}
return result;
}
/**
* 记录一次会话内新占领的区块:加入会话集合与全局已占领集合,
* 并同步更新己方邦国的区块集合。
* @param {{x: number, z: number}} chunk
*/
addSessionClaim(chunk) {
const k = chunkPos.key(chunk.x, chunk.z);
this.sessionClaims.add(k);
this.allClaimed.add(k);
this._invalidateFlying();
const self = this.states.get(this.selfStateName);
if (self) {
self.chunks.add(k);
}
}
/** 失效飞地缓存 */
_invalidateFlying() {
this._flyingByState = null;
this._allFlying = null;
}
/**
* 合并 SimpCore 开放 API 的领土数据(在模型发布前、单线程内调用)。
*
* 合并原则(「任意一个数据源显示被占领即算占领」):
* - API 中出现、Dynmap 中没有的区块 → 新增一个 API 邦国状态(或并入已有同名邦国);
* - 两边的区块取并集,颜色以 Dynmap 优先(保持地图观感一致);
* - 王城坐标:Dynmap 优先,API 数据兜底。
*
* API 数据块本身有服务端缓存(最多延迟 1 小时),因此只做增量补充,绝不覆盖 Dynmap 已确认的数据。
*
* @param {Map<string, Set<number>>} apiChunks API 返回的 邦国名 → 区块键集合
* @param {Map<string, {x: number, z: number}>} [apiCapitals] API 返回的 邦国名 → 王城区块
* @param {number} [apiColor] API 独有邦国使用的颜色
*/
mergeApiData(apiChunks, apiCapitals, apiColor) {
if (!apiChunks || apiChunks.size === 0) return;
const color = apiColor || API_DEFAULT_COLOR;
const rebuilt = new Map();
for (const [name, chunkKeys] of apiChunks) {
if (!name) continue;
const apiCapital = apiCapitals ? apiCapitals.get(name) : null;
const existing = this.states.get(name);
if (!existing) {
const chunks = new Set(chunkKeys);
let capital = apiCapital;
if (!capital) {
const first = chunks.values().next();
capital = first.done ? { x: 0, z: 0 } : chunkPos.decode(first.value);
}
rebuilt.set(name, { name, capital, chunks, color });
} else {
const chunks = new Set(existing.chunks);
for (const k of chunkKeys) chunks.add(k);
let capital = existing.capital;
if (apiCapital && (!capital || (capital.x === 0 && capital.z === 0))) {
capital = apiCapital;
}
rebuilt.set(name, { name, capital, chunks, color: existing.color });
}
}
// 未被 API 覆盖的邦国原样保留
for (const [name, state] of this.states) {
if (!rebuilt.has(name)) rebuilt.set(name, state);
}
this.states = rebuilt;
this.allClaimed = new Set();
for (const state of rebuilt.values()) {
for (const k of state.chunks) this.allClaimed.add(k);
}
this._invalidateFlying();
}
}
/** 复用的空集合,避免重复分配 */
const EMPTY_SET = new Set();
module.exports = { TerritoryModel, API_DEFAULT_COLOR };
+142
查看文件
@@ -0,0 +1,142 @@
'use strict';
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
const chalk = require('chalk');
const { ConfigStore } = require('./config/configStore');
const { normalizeConfig } = require('./config/expansionConfig');
const { Logger } = require('./util/logger');
const { BotManager } = require('./bot/botManager');
const { TerritoryDataService } = require('./core/territoryDataService');
const { ExpansionController } = require('./controller/expansionController');
const { WebConsole } = require('./web/server');
/** 控制器 tick 间隔(毫秒),50ms ≈ 20 TPS */
const TICK_INTERVAL_MS = 50;
async function main() {
const logger = new Logger({ echo: true });
logger.log(chalk.bold('==== AutoStore Bot 启动 ===='));
// ==================== 连接参数(全部来自环境变量,密钥不落代码) ====================
const connection = {
host: process.env.MC_HOST || 'bgjq.simpfun.cn',
port: Number.parseInt(process.env.MC_PORT || '25565', 10),
username: process.env.MC_USERNAME || 'AutoStoreBot',
version: process.env.MC_VERSION || '1.21.4',
auth: process.env.MC_AUTH || 'offline',
password: process.env.MC_PASSWORD || ''
};
if (!Number.isFinite(connection.port) || connection.port <= 0) connection.port = 25565;
// ==================== 配置 ====================
const configStore = new ConfigStore();
let config = configStore.load();
config = normalizeConfig(config, config);
// 环境变量里的 API Key 作为兜底(配置文件为空时)
if (!config.apiKey && process.env.SIMP_API_KEY) config.apiKey = process.env.SIMP_API_KEY.trim();
logger.log(`配置文件:${configStore.filePath}`);
logger.log(`服务器:${connection.host}:${connection.port} / ${connection.username} / ${connection.version} / ${connection.auth}`);
// ==================== 各模块 ====================
const botManager = new BotManager(connection, config, logger);
const dataService = new TerritoryDataService(config, logger);
const controller = new ExpansionController({ botManager, dataService, logger });
// 寻路事件转发给控制器(与 Baritone 的 PathEvent 语义一致)
botManager.on('modulesReady', () => {
if (botManager.pathing) {
botManager.pathing.onPathEvent = (event, detail) => controller.onPathEvent(event, detail);
}
});
// ==================== Web 控制台 ====================
const webConsole = new WebConsole(
{
botManager,
controller,
dataService,
logger,
getConfig: () => config,
applyConfig: (next) => applyConfig(next)
},
{
host: process.env.WEB_HOST || '127.0.0.1',
port: Number.parseInt(process.env.WEB_PORT || '3081', 10),
token: process.env.WEB_TOKEN || '',
exportDir: path.join(__dirname, '..', 'data')
}
);
/**
* 应用新配置:持久化 + 下发给各运行时模块。
* @param {object} next
* @returns {object} 实际生效的配置
*/
function applyConfig(next) {
config = normalizeConfig(next, config);
configStore.save(config);
botManager.applyConfig(config);
controller.config = controller.config ? Object.assign({}, controller.config, config) : config;
dataService.config = config;
dataService.applyApiConfig(config.apiEnabled, config.apiKey, config.apiRefreshIntervalSeconds);
dataService.reschedule(config.refreshIntervalSeconds);
logger.log('配置已更新并应用');
return config;
}
// ==================== 启动 ====================
dataService.applyApiConfig(config.apiEnabled, config.apiKey, config.apiRefreshIntervalSeconds);
dataService.start(config.refreshIntervalSeconds);
botManager.connect();
const tickTimer = setInterval(() => {
try {
controller.tick();
} catch (err) {
logger.error('tick 异常:' + (err && err.stack ? err.stack : err));
}
}, TICK_INTERVAL_MS);
if (tickTimer.unref) tickTimer.unref();
const url = await webConsole.start();
logger.log(chalk.green('Web 控制台已就绪:' + url));
logger.log('提示:机器人默认不会自动开始扩地,请在上面的控制台点击「开始扩地」。');
// ==================== 优雅退出 ====================
let shuttingDown = false;
const shutdown = async (signal) => {
if (shuttingDown) return;
shuttingDown = true;
logger.log(`收到 ${signal},正在退出…`);
clearInterval(tickTimer);
controller.stop();
botManager.disconnect();
dataService.stop();
try {
await webConsole.stop();
} catch {
// 忽略
}
process.exit(0);
};
process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('unhandledRejection', (reason) => {
logger.error('未处理的 Promise 拒绝:' + (reason && reason.stack ? reason.stack : reason));
});
}
// 仅在作为入口运行时启动(被 require 时只导出工厂,便于测试与语法自检)
if (require.main === module) {
main().catch((err) => {
console.error('启动失败:', err);
process.exit(1);
});
}
module.exports = { main };
+120
查看文件
@@ -0,0 +1,120 @@
'use strict';
const fs = require('fs');
const path = require('path');
/** 环形日志缓冲区最大条数(与原 Java 版一致) */
const MAX_LOGS = 2000;
/**
* 运行日志:内存环形缓冲 + 控制台输出 + 可导出到文件。
*
* 日志格式与原 Java 版保持一致(`[HH:mm:ss] 消息`),
* 便于对照排查;同时支持订阅,供 Web 控制台实时推送。
*/
class Logger {
/**
* @param {{maxLogs?: number, echo?: boolean, onLog?: (entry: {time: string, message: string, level: string}) => void}} [options]
*/
constructor(options) {
const opts = options || {};
this.maxLogs = opts.maxLogs || MAX_LOGS;
this.echo = opts.echo !== false;
this.onLog = opts.onLog || null;
/** @type {Array<{time: string, message: string, level: string}>} */
this.entries = [];
/** @type {((entry: {time: string, message: string, level: string}) => void)|null} */
this.onEntry = null;
}
/**
* 追加一条日志。
* @param {string} message 日志正文
* @param {'info'|'warn'|'error'|'success'} [level] 级别(仅用于 Web 控制台着色)
*/
log(message, level) {
const entry = { time: formatTime(new Date()), message: String(message), level: level || 'info' };
this.entries.push(entry);
while (this.entries.length > this.maxLogs) this.entries.shift();
if (this.echo) {
const prefix = `[${entry.time}]`;
if (entry.level === 'error') console.error(prefix, entry.message);
else if (entry.level === 'warn') console.warn(prefix, entry.message);
else console.log(prefix, entry.message);
}
if (this.onEntry) {
try {
this.onEntry(entry);
} catch {
// 订阅者异常不得影响主流程
}
}
}
/** @param {string} message */
info(message) {
this.log(message, 'info');
}
/** @param {string} message */
warn(message) {
this.log(message, 'warn');
}
/** @param {string} message */
error(message) {
this.log(message, 'error');
}
/** @param {string} message */
success(message) {
this.log(message, 'success');
}
/** @returns {Array<{time: string, message: string, level: string}>} 日志副本 */
list() {
return this.entries.slice();
}
/** 清空日志缓冲 */
clear() {
this.entries.length = 0;
}
/**
* 导出运行日志到文件,返回文件路径;失败时返回 null。
*
* @param {string} filePath 目标路径
* @param {object} [summary] 状态摘要(键值对,逐行输出)
* @returns {string|null} 导出文件路径
*/
exportTo(filePath, summary) {
try {
const lines = ['==== AutoStore Bot 调试日志 ===='];
if (summary) {
for (const [key, value] of Object.entries(summary)) {
lines.push(`${key}: ${value}`);
}
}
lines.push('');
for (const entry of this.entries) lines.push(`[${entry.time}] ${entry.message}`);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, lines.join('\n'), 'utf8');
return filePath;
} catch {
return null;
}
}
}
/**
* 格式化为 HH:mm:ss。
* @param {Date} date
* @returns {string}
*/
function formatTime(date) {
const pad = (n) => String(n).padStart(2, '0');
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
module.exports = { Logger, formatTime, MAX_LOGS };
+853
查看文件
@@ -0,0 +1,853 @@
'use strict';
/**
* AutoStore Bot Web 控制台前端逻辑。
*
* 三块职责:
* 1. 通过 WebSocket 接收实时状态与日志,渲染左侧状态面板;
* 2. 根据配置 schema 动态生成右侧配置表单,读写 `/api/config`;
* 3. 用 Canvas 绘制领土地图(支持缩放 / 平移 / 图层开关)。
*/
// ==================== 工具 ====================
const $ = (id) => document.getElementById(id);
/** 取 URL 上的 token 参数,后续所有请求都带上 */
const TOKEN = new URLSearchParams(location.search).get('token') || '';
function api(path, options) {
const opts = options || {};
const headers = Object.assign({ 'Content-Type': 'application/json' }, opts.headers || {});
if (TOKEN) headers['X-Auth-Token'] = TOKEN;
return fetch(path, Object.assign({}, opts, { headers })).then(async (res) => {
let body = null;
try {
body = await res.json();
} catch {
body = { ok: false, message: 'HTTP ' + res.status };
}
if (!res.ok || body.ok === false) {
throw new Error(body.message || ('HTTP ' + res.status));
}
return body;
});
}
let toastTimer = null;
function toast(message, isError) {
const el = $('toast');
el.textContent = message;
el.className = 'toast show' + (isError ? ' error' : '');
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => { el.className = 'toast'; }, 3500);
}
function fmtTime(ts) {
if (!ts) return '-';
const d = new Date(ts);
const pad = (n) => String(n).padStart(2, '0');
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
function fmtAgo(ts) {
if (!ts) return '从未';
const seconds = Math.floor((Date.now() - ts) / 1000);
if (seconds < 5) return '刚刚';
if (seconds < 60) return seconds + ' 秒前';
if (seconds < 3600) return Math.floor(seconds / 60) + ' 分钟前';
return fmtTime(ts);
}
// ==================== 配置 schema ====================
const STRATEGY_OPTIONS = [
{ value: 1, label: '1 - 方形外扩(优先填洞)' },
{ value: 2, label: '2 - 区域扩地(S 形扫描)' },
{ value: 3, label: '3 - 玩家周围扩地' },
{ value: 4, label: '4 - 绕国一圈' }
];
const WATER_MODE_OPTIONS = [
{ value: 'avoid', label: 'avoid - 绕开水域(推荐)' },
{ value: 'swim', label: 'swim - 游泳通过' },
{ value: 'boat', label: 'boat - 放船渡水(Grim 风险高)' }
];
/**
* 配置表单 schema:每个 tab 下若干分组,每组若干字段。
* path 为配置对象里的点分路径。
*/
const CONFIG_SCHEMA = {
strategy: [
{
title: '扩地策略',
fields: [
{ path: 'strategy', label: '策略', type: 'select', options: STRATEGY_OPTIONS },
{ path: 'stateName', label: '目标邦国', type: 'text', hint: '留空则自动识别机器人所在邦国' },
{ path: 'targetCount', label: '目标扩地数量', type: 'number', min: -1, hint: '-1 表示持续扩地直到无可用区块' },
{ path: 'maxRings', label: '扩圈上限', type: 'number', min: 0, hint: '0 表示不限制' }
]
},
{
title: '区域扩地范围(仅策略 2)',
fields: [
{ path: 'regionMinX', label: '矩形 minX(区块)', type: 'number' },
{ path: 'regionMaxX', label: '矩形 maxX(区块)', type: 'number', hint: 'maxX < minX 表示未圈选,改用中心 + 半径' },
{ path: 'regionMinZ', label: '矩形 minZ(区块)', type: 'number' },
{ path: 'regionMaxZ', label: '矩形 maxZ(区块)', type: 'number' },
{ path: 'regionCenterX', label: '中心 X(区块)', type: 'number' },
{ path: 'regionCenterZ', label: '中心 Z(区块)', type: 'number' },
{ path: 'regionRadius', label: '半径(区块)', type: 'number', min: 1 }
]
}
],
timing: [
{
title: '超时与重试',
fields: [
{ path: 'waitTimeoutSeconds', label: '等待 / 寻路超时(秒)', type: 'number', min: 5 },
{ path: 'retries', label: '超时后最大重试次数', type: 'number', min: 1 },
{ path: 'returnThresholdSeconds', label: '连续超时回城阈值(秒)', type: 'number', min: 0 },
{ path: 'returnWaitSeconds', label: '回城等待传送(秒)', type: 'number', min: 1 },
{ path: 'sideStepMaxRounds', label: '往边上走两步最大轮数', type: 'number', min: 0 }
]
},
{
title: '开垦时段限制',
fields: [
{ path: 'enforceClaimTime', label: '启用时段限制', type: 'checkbox' },
{ path: 'claimStartHour', label: '起始小时(含)', type: 'number', min: 0, max: 23 },
{ path: 'claimEndHour', label: '结束小时(不含)', type: 'number', min: 1, max: 24 }
]
}
],
pathing: [
{
title: '寻路行为',
fields: [
{ path: 'allowSprint', label: '允许疾跑', type: 'checkbox' },
{ path: 'allowSwim', label: '允许游泳', type: 'checkbox' },
{ path: 'allowJump', label: '允许跳跃', type: 'checkbox' },
{ path: 'allowBreak', label: '允许破坏方块', type: 'checkbox', hint: 'Grim 对破坏速度检查严格,默认关闭' },
{ path: 'allowPlace', label: '允许放置方块', type: 'checkbox', hint: 'Grim 对放置检查严格,默认关闭' },
{ path: 'altitudePreference', label: '高度偏好(优先走高处)', type: 'checkbox' },
{ path: 'altitudeWeight', label: '高度偏好权重', type: 'number', step: 0.01, min: 0 }
]
},
{
title: '寻路器专属',
fields: [
{ path: 'pathing.allowParkour', label: '允许跑酷跳跃', type: 'checkbox', hint: 'mineflayer 起跳时机与原版有偏差,默认关闭' },
{ path: 'pathing.allow1by1towers', label: '允许 1×1 搭塔', type: 'checkbox' },
{ path: 'pathing.maxDropDown', label: '最大下落高度(格)', type: 'number', min: 0 },
{ path: 'pathing.liquidCost', label: '液体格额外代价', type: 'number', min: 1 },
{ path: 'pathing.canOpenDoors', label: '允许开门', type: 'checkbox' }
]
}
],
survival: [
{
title: '生存辅助',
fields: [
{ path: 'avoidMobs', label: '避开怪物', type: 'checkbox' },
{ path: 'autoEat', label: '自动进食', type: 'checkbox' },
{ path: 'eatThreshold', label: '进食饥饿阈值', type: 'number', min: 0, max: 20 },
{ path: 'autoAttack', label: '自动攻击敌对生物', type: 'checkbox' },
{ path: 'durabilityGuard', label: '耐久保护', type: 'checkbox' },
{ path: 'durabilityThreshold', label: '耐久保护阈值(%)', type: 'number', min: 1, max: 99 }
]
},
{
title: '战斗筛选',
fields: [
{ path: 'combat.scanRange', label: '索敌半径(格)', type: 'number', step: 0.5, min: 1 },
{ path: 'combat.attackEnderman', label: '攻击末影人', type: 'checkbox', hint: 'mineflayer 读不到「是否被激怒」,默认不打' },
{ path: 'combat.attackSpiders', label: '攻击蜘蛛', type: 'checkbox', hint: 'mineflayer 读不到环境亮度,默认不打' },
{ path: 'combat.attackPiglins', label: '攻击猪灵(未穿金甲时)', type: 'checkbox' },
{ path: 'combat.avoidDangerousMobs', label: '跳过监守者 / 末影龙 / 凋灵', type: 'checkbox' }
]
},
{
title: '水域处理',
fields: [
{ path: 'waterMode', label: '水域策略', type: 'select', options: WATER_MODE_OPTIONS },
{ path: 'autoBoat', label: '启用自动用船', type: 'checkbox', hint: 'mineflayer 无载具物理,Grim 的 Vehicle 检查风险高,默认关闭' },
{ path: 'autoBoatMinWidth', label: '触发用船的最小水面宽度(格)', type: 'number', min: 1 },
{ path: 'autoBoatMinLength', label: '触发用船的最短水域长度(格)', type: 'number', min: 1 }
]
}
],
data: [
{
title: '地图数据源',
fields: [
{ path: 'refreshIntervalSeconds', label: '地图刷新间隔(秒)', type: 'number', min: 10 }
]
},
{
title: 'SimpCore 开放 API',
fields: [
{ path: 'apiEnabled', label: '启用 API 数据源', type: 'checkbox' },
{ path: 'apiKey', label: 'API Key', type: 'password', hint: '留空则回退读环境变量 SIMP_API_KEY' },
{ path: 'apiRefreshIntervalSeconds', label: 'API 轮询间隔(秒)', type: 'number', min: 60 }
]
}
],
grim: [
{
title: 'Grim 反作弊合规层',
fields: [
{ path: 'grim.enabled', label: '启用合规层', type: 'checkbox', hint: '仅在本地测试服才建议关闭' },
{ path: 'grim.maxRotationPerTickDeg', label: '每 tick 最大旋转(度)', type: 'number', step: 0.1, min: 1, max: 180, hint: 'mineflayer 物理限速约 8.6°/tick,实际取两者较小值' },
{ path: 'grim.attackReach', label: '攻击距离(格)', type: 'number', step: 0.1, min: 1, max: 6 },
{ path: 'grim.requireLineOfSight', label: '攻击前校验视线', type: 'checkbox' },
{ path: 'grim.respectAttackCooldown', label: '遵守攻击冷却', type: 'checkbox' },
{ path: 'grim.stopMovementWhileEating', label: '进食时停止移动', type: 'checkbox' },
{ path: 'grim.blockSprintJump', label: '禁止疾跑跳跃', type: 'checkbox', hint: '疾跑跳本身是原版合法动作,仅在实测被标记时开启' },
{ path: 'grim.blockConflictingActions', label: '拦截冲突动作组合', type: 'checkbox' },
{ path: 'grim.checkPhysicsIntegrity', label: '启动物理参数自检', type: 'checkbox' },
{ path: 'grim.blockActionsWhileRiding', label: '骑乘时禁止攻击 / 进食', type: 'checkbox' }
]
}
],
server: [
{
title: '服务器交互',
fields: [
{ path: 'server.claimCommand', label: '开垦模式指令', type: 'text', hint: '不带斜杠;留空表示服务器自动开启' },
{ path: 'server.loginCommand', label: '登录指令模板', type: 'text', hint: '用 {password} 占位,密码取自 .env 的 MC_PASSWORD' },
{ path: 'server.registerCommand', label: '注册指令模板', type: 'text', hint: '留空则不自动注册' },
{ path: 'server.commandCooldownMs', label: '指令最小间隔(毫秒)', type: 'number', min: 0 },
{ path: 'server.autoReconnect', label: '断线自动重连', type: 'checkbox' },
{ path: 'server.reconnectDelayMs', label: '重连间隔(毫秒)', type: 'number', min: 1000 }
]
}
]
};
// ==================== 配置读写 ====================
let currentConfig = null;
function getByPath(obj, path) {
return path.split('.').reduce((acc, key) => (acc == null ? undefined : acc[key]), obj);
}
function setByPath(obj, path, value) {
const keys = path.split('.');
let cursor = obj;
for (let i = 0; i < keys.length - 1; i++) {
if (cursor[keys[i]] == null || typeof cursor[keys[i]] !== 'object') cursor[keys[i]] = {};
cursor = cursor[keys[i]];
}
cursor[keys[keys.length - 1]] = value;
}
let activeTab = 'strategy';
function renderConfig() {
const body = $('configBody');
body.innerHTML = '';
const groups = CONFIG_SCHEMA[activeTab] || [];
for (const group of groups) {
const title = document.createElement('div');
title.className = 'group-title';
title.textContent = group.title;
body.appendChild(title);
for (const field of group.fields) {
body.appendChild(buildField(field));
}
}
}
function buildField(field) {
const wrap = document.createElement('div');
wrap.className = 'field';
const label = document.createElement('label');
label.textContent = field.label;
wrap.appendChild(label);
const value = getByPath(currentConfig, field.path);
if (field.type === 'checkbox') {
const inline = document.createElement('div');
inline.className = 'field-inline';
const input = document.createElement('input');
input.type = 'checkbox';
input.checked = value === true;
input.dataset.path = field.path;
input.dataset.type = 'checkbox';
inline.appendChild(input);
wrap.appendChild(inline);
} else if (field.type === 'select') {
const select = document.createElement('select');
select.className = 'input';
select.dataset.path = field.path;
select.dataset.type = 'select';
for (const option of field.options) {
const opt = document.createElement('option');
opt.value = String(option.value);
opt.textContent = option.label;
if (String(value) === String(option.value)) opt.selected = true;
select.appendChild(opt);
}
wrap.appendChild(select);
} else {
const input = document.createElement('input');
input.className = 'input';
input.type = field.type === 'password' ? 'password' : (field.type === 'number' ? 'number' : 'text');
if (field.min !== undefined) input.min = field.min;
if (field.max !== undefined) input.max = field.max;
if (field.step !== undefined) input.step = field.step;
input.value = value === undefined || value === null ? '' : String(value);
input.dataset.path = field.path;
input.dataset.type = field.type;
wrap.appendChild(input);
}
if (field.hint) {
const hint = document.createElement('div');
hint.className = 'hint';
hint.textContent = field.hint;
wrap.appendChild(hint);
}
return wrap;
}
function collectConfig() {
const result = JSON.parse(JSON.stringify(currentConfig || {}));
for (const input of $('configBody').querySelectorAll('[data-path]')) {
const path = input.dataset.path;
const type = input.dataset.type;
let value;
if (type === 'checkbox') value = input.checked;
else if (type === 'select') {
const raw = input.value;
value = /^-?\d+$/.test(raw) ? Number(raw) : raw;
} else if (type === 'number') value = input.value === '' ? 0 : Number(input.value);
else value = input.value;
setByPath(result, path, value);
}
return result;
}
async function loadConfig() {
try {
const res = await api('/api/config');
currentConfig = res.data;
renderConfig();
} catch (err) {
toast('读取配置失败:' + err.message, true);
}
}
async function saveConfig() {
try {
const config = collectConfig();
const res = await api('/api/config', { method: 'POST', body: JSON.stringify(config) });
currentConfig = res.data;
renderConfig();
toast('配置已保存并应用');
} catch (err) {
toast('保存配置失败:' + err.message, true);
}
}
// ==================== 状态渲染 ====================
let lastStatus = null;
function renderStatus(status) {
lastStatus = status;
const bot = status.bot || {};
const task = status.task || {};
const data = status.data || {};
// ===== 顶栏徽标 =====
const connBadge = $('connBadge');
const stateText = { online: '已连接', connecting: '连接中', offline: '已断开', idle: '未连接' };
connBadge.textContent = stateText[bot.state] || bot.state || '未知';
connBadge.className = 'badge ' + (bot.state === 'online' ? 'badge-online' : 'badge-offline');
const taskBadge = $('taskBadge');
taskBadge.textContent = task.state || 'IDLE';
taskBadge.className = 'badge ' + (task.running ? 'badge-running' : (task.state === 'COMPLETE' ? 'badge-online' : 'badge-idle'));
// ===== 连接卡片 =====
$('kServer').textContent = bot.host || '-';
$('kUsername').textContent = bot.username || '-';
$('kVersion').textContent = bot.version || '-';
$('kPos').textContent = bot.position
? `${bot.position.x} / ${bot.position.y} / ${bot.position.z}`
: '-';
$('kChunk').textContent = bot.chunk ? `${bot.chunk.x}, ${bot.chunk.z}` : '-';
$('kHealth').textContent = bot.health === null || bot.health === undefined
? '-'
: `${bot.health.toFixed(1)} / ${bot.food === null || bot.food === undefined ? '-' : bot.food}`;
$('kBotState').textContent = (bot.loggedIn ? '已登录 · ' : '') + (bot.riding ? '乘船中 · ' : '') + (bot.inWater ? '水中' : '陆地');
$('kReconnect').textContent = String(bot.reconnectAttempts || 0);
// ===== 任务卡片 =====
$('kTaskState').textContent = task.state || 'IDLE';
$('kTaskStatus').textContent = task.status || '-';
$('kTarget').textContent = task.target ? `${task.target.x}, ${task.target.z}` : '-';
$('kQueue').textContent = String(task.queuedTargets || 0);
$('kCompleted').textContent = String(task.completedClaims || 0);
$('kRetry').textContent = String(task.retryCount || 0);
$('kProgress').textContent = (task.lastProgress || 0) + '%';
$('kTimeout').textContent = Math.round((task.consecutiveTimeoutMs || 0) / 1000) + 's';
$('kClaimTime').textContent = task.withinClaimTime ? '时段内' : '时段外';
$('progressBar').style.width = (task.lastProgress || 0) + '%';
// ===== 数据源卡片 =====
$('kMarkersUrl').textContent = data.markersUrl || '-';
$('kLastFetch').textContent = data.lastSuccessfulFetchMillis ? fmtAgo(data.lastSuccessfulFetchMillis) : '从未';
$('kDataError').textContent = data.lastError || '无';
$('kDataError').style.color = data.lastError ? 'var(--red)' : '';
$('kStateCount').textContent = String(data.stateCount || 0);
$('kClaimed').textContent = String(data.claimedChunks || 0);
$('kSession').textContent = String(data.sessionClaims || 0);
$('kFlying').textContent = String(data.flyingChunks || 0);
$('kApi').textContent = data.apiEnabled
? `已启用(${data.apiTerritoryCount || 0} 国,最近成功 ${data.apiLastSuccessMillis ? fmtAgo(data.apiLastSuccessMillis) : '从未'})`
: '未启用';
if (data.apiError) $('kApi').textContent += ' · ' + data.apiError;
// ===== Grim 卡片 =====
const grim = bot.grim;
if (grim) {
$('kGrimEnabled').textContent = grim.enabled ? '已启用' : '已关闭';
$('kGrimRot').textContent = `${grim.maxRotationPerTickDeg}°(实际 ${grim.effectiveStepDeg}°/tick)`;
$('kGrimSettled').textContent = grim.rotationSettled ? '是' : '否';
$('kGrimReach').textContent = grim.attackReach + ' 格';
$('kGrimCooldown').textContent = grim.attackCooldownMs > 0 ? grim.attackCooldownMs + ' ms' : '就绪';
$('kGrimBlocked').textContent = String(grim.blockedActions || 0);
$('kGrimAudit').textContent = grim.physicsOk ? '通过' : '异常';
$('kGrimAudit').style.color = grim.physicsOk ? 'var(--green)' : 'var(--red)';
const issues = $('grimIssues');
issues.innerHTML = '';
for (const issue of grim.issues || []) {
const li = document.createElement('li');
li.textContent = issue;
issues.appendChild(li);
}
}
}
// ==================== 日志 ====================
const MAX_LOG_LINES = 800;
function appendLog(entry) {
const view = $('logView');
const line = document.createElement('span');
line.className = 'log-line log-' + (entry.level || 'info');
const time = document.createElement('span');
time.className = 'log-time';
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;
}
function renderLogs(entries) {
$('logView').innerHTML = '';
for (const entry of entries) appendLog(entry);
}
// ==================== 地图 ====================
const mapView = {
centerX: 0,
centerZ: 0,
scale: 2,
dragging: false,
lastX: 0,
lastY: 0,
data: null,
/** 是否已经按玩家位置做过首次居中 */
centered: false
};
function initMap() {
const canvas = $('mapCanvas');
const ctx = canvas.getContext('2d');
const resize = () => {
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.max(1, Math.floor(rect.width * dpr));
canvas.height = Math.max(1, Math.floor(rect.height * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
drawMap();
};
window.addEventListener('resize', resize);
canvas.addEventListener('mousedown', (e) => {
mapView.dragging = true;
mapView.lastX = e.clientX;
mapView.lastY = e.clientY;
});
window.addEventListener('mouseup', () => { mapView.dragging = false; });
window.addEventListener('mousemove', (e) => {
if (!mapView.dragging) return;
const dx = e.clientX - mapView.lastX;
const dy = e.clientY - mapView.lastY;
mapView.lastX = e.clientX;
mapView.lastY = e.clientY;
mapView.centerX -= dx / mapView.scale;
mapView.centerZ -= dy / mapView.scale;
drawMap();
});
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
mapView.scale = Math.min(24, Math.max(0.15, mapView.scale * factor));
drawMap();
}, { passive: false });
setTimeout(resize, 0);
window.addEventListener('resize', resize);
}
function chunkToScreen(cx, cz, canvas) {
const rect = canvas.getBoundingClientRect();
const w = rect.width;
const h = rect.height;
const scale = mapView.scale;
return {
x: w / 2 + (cx - mapView.centerX) * scale,
y: h / 2 + (cz - mapView.centerZ) * scale,
scale
};
}
function drawMap() {
const canvas = $('mapCanvas');
const ctx = canvas.getContext('2d');
const rect = canvas.getBoundingClientRect();
const w = rect.width;
const h = rect.height;
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = '#05070a';
ctx.fillRect(0, 0, w, h);
const data = mapView.data;
const scale = mapView.scale;
const cell = Math.max(1, scale);
const drawChunks = (flat, color) => {
ctx.fillStyle = color;
for (let i = 0; i < flat.length; i += 2) {
const p = chunkToScreen(flat[i], flat[i + 1], canvas);
if (p.x < -cell || p.y < -cell || p.x > w + cell || p.y > h + cell) continue;
ctx.fillRect(p.x, p.y, cell, cell);
}
};
// 网格
if ($('chkGrid').checked && scale >= 1.2) {
ctx.strokeStyle = 'rgba(255,255,255,0.045)';
ctx.lineWidth = 1;
const halfX = w / 2 / scale;
const halfZ = h / 2 / scale;
const startX = Math.floor(mapView.centerX - halfX);
const endX = Math.ceil(mapView.centerX + halfX);
const startZ = Math.floor(mapView.centerZ - halfZ);
const endZ = Math.ceil(mapView.centerZ + halfZ);
ctx.beginPath();
for (let x = startX; x <= endX; x++) {
const sx = Math.round(chunkToScreen(x, 0, canvas).x) + 0.5;
ctx.moveTo(sx, 0); ctx.lineTo(sx, h);
}
for (let z = startZ; z <= endZ; z++) {
const sy = Math.round(chunkToScreen(0, z, canvas).y) + 0.5;
ctx.moveTo(0, sy); ctx.lineTo(w, sy);
}
ctx.stroke();
}
if (!data) {
ctx.fillStyle = '#48505e';
ctx.font = '13px "Microsoft YaHei", sans-serif';
ctx.fillText('暂无地图数据,等待 markers.json 拉取…', 16, 24);
return;
}
// 全部邦国(按需加载)
if ($('chkFull').checked && data.otherChunks) {
for (const state of data.otherChunks) {
drawChunks(state.chunks, hexToRgba(state.color, 0.55));
}
}
drawChunks(data.self || [], 'rgba(47,129,247,0.85)');
drawChunks(data.session || [], 'rgba(63,185,80,0.95)');
if ($('chkFlying').checked) drawChunks(data.flying || [], 'rgba(248,81,73,0.95)');
// 王城
for (const state of data.states || []) {
if (!state.capital) continue;
const p = chunkToScreen(state.capital.x, state.capital.z, canvas);
if (p.x < -20 || p.y < -20 || p.x > w + 20 || p.y > h + 20) continue;
ctx.fillStyle = '#bc8cff';
ctx.beginPath();
ctx.arc(p.x + scale / 2, p.y + scale / 2, Math.max(3, scale * 0.7), 0, Math.PI * 2);
ctx.fill();
if (scale >= 1) {
ctx.fillStyle = 'rgba(188,140,255,0.85)';
ctx.font = '11px "Microsoft YaHei", sans-serif';
ctx.fillText(state.name, p.x + 6, p.y + 4);
}
}
// 当前目标
if (data.target) {
const p = chunkToScreen(data.target.x, data.target.z, canvas);
ctx.strokeStyle = '#d29922';
ctx.lineWidth = 2;
const size = Math.max(8, scale * 2);
ctx.strokeRect(p.x - size / 2 + scale / 2, p.y - size / 2 + scale / 2, size, size);
ctx.beginPath();
ctx.moveTo(p.x - size, p.y + scale / 2);
ctx.lineTo(p.x + size, p.y + scale / 2);
ctx.moveTo(p.x + scale / 2, p.y - size);
ctx.lineTo(p.x + scale / 2, p.y + size);
ctx.stroke();
}
// 机器人位置
if (data.playerChunk) {
const p = chunkToScreen(data.playerChunk.x, data.playerChunk.z, canvas);
ctx.fillStyle = '#ffffff';
ctx.beginPath();
ctx.arc(p.x + scale / 2, p.y + scale / 2, Math.max(3, scale * 0.6), 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#0d1117';
ctx.lineWidth = 1.5;
ctx.stroke();
}
$('mapInfo').textContent = `缩放 ${scale.toFixed(2)} px/区块 · 中心 ${Math.round(mapView.centerX)}, ${Math.round(mapView.centerZ)}`;
}
function hexToRgba(hex, alpha) {
const value = String(hex).replace('#', '');
const r = parseInt(value.slice(0, 2), 16) || 0;
const g = parseInt(value.slice(2, 4), 16) || 0;
const b = parseInt(value.slice(4, 6), 16) || 0;
return `rgba(${r},${g},${b},${alpha})`;
}
let mapLoading = false;
async function loadMap() {
if (mapLoading) return;
mapLoading = true;
try {
const full = $('chkFull').checked ? '1' : '0';
const res = await api('/api/map?full=' + full);
mapView.data = res.data;
if (!mapView.centered && res.data.playerChunk) {
mapView.centerX = res.data.playerChunk.x;
mapView.centerZ = res.data.playerChunk.z;
mapView.centered = true;
}
drawMap();
} catch (err) {
toast('读取地图失败:' + err.message, true);
} finally {
mapLoading = false;
}
}
// ==================== WebSocket ====================
let ws = null;
let wsRetry = 0;
function connectWs() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const query = TOKEN ? '?token=' + encodeURIComponent(TOKEN) : '';
ws = new WebSocket(`${proto}://${location.host}/ws${query}`);
ws.onopen = () => { wsRetry = 0; };
ws.onmessage = (event) => {
let payload;
try {
payload = JSON.parse(event.data);
} catch {
return;
}
if (payload.type === 'status') renderStatus(payload.data);
else if (payload.type === 'log') appendLog(payload.data);
else if (payload.type === 'logs') renderLogs(payload.data);
};
ws.onclose = () => {
wsRetry++;
const delay = Math.min(15000, 1000 * wsRetry);
setTimeout(connectWs, delay);
};
ws.onerror = () => { try { ws.close(); } catch { /* 忽略 */ } };
}
// ==================== 事件绑定 ====================
function bindEvents() {
$('btnStart').addEventListener('click', async () => {
try {
const res = await api('/api/task/start', { method: 'POST', body: JSON.stringify(collectConfig()) });
toast(res.message || '任务已启动');
renderStatus(res.data);
loadMap();
} catch (err) {
toast('启动失败:' + err.message, true);
}
});
$('btnStop').addEventListener('click', async () => {
try {
await api('/api/task/stop', { method: 'POST' });
toast('已请求停止');
} catch (err) {
toast('停止失败:' + err.message, true);
}
});
$('btnReconnect').addEventListener('click', async () => {
try {
await api('/api/bot/reconnect', { method: 'POST' });
toast('正在重连');
} catch (err) {
toast('重连失败:' + err.message, true);
}
});
$('btnRefreshMap').addEventListener('click', async () => {
try {
await api('/api/map/refresh', { method: 'POST' });
toast('已触发地图刷新');
} catch (err) {
toast(err.message, true);
}
});
$('btnFetchAll').addEventListener('click', async () => {
toast('正在拉取全服领土(可能较慢)…');
try {
const res = await api('/api/api/fetch-all', { method: 'POST' });
toast(res.message);
loadMap();
} catch (err) {
toast('拉取失败:' + err.message, true);
}
});
$('btnSaveConfig').addEventListener('click', saveConfig);
$('btnReloadConfig').addEventListener('click', loadConfig);
$('configTabs').addEventListener('click', (e) => {
const tab = e.target.closest('.tab');
if (!tab) return;
activeTab = tab.dataset.tab;
for (const el of $('configTabs').querySelectorAll('.tab')) {
el.classList.toggle('active', el === tab);
}
renderConfig();
});
$('chkFull').addEventListener('change', loadMap);
$('chkFlying').addEventListener('change', drawMap);
$('chkGrid').addEventListener('change', drawMap);
$('btnMapCenter').addEventListener('click', () => {
if (lastStatus && lastStatus.task && lastStatus.task.playerChunk) {
mapView.centerX = lastStatus.task.playerChunk.x;
mapView.centerZ = lastStatus.task.playerChunk.z;
drawMap();
}
});
$('btnMapFit').addEventListener('click', () => {
const data = mapView.data;
if (!data) return;
let minX = Infinity; let maxX = -Infinity; let minZ = Infinity; let maxZ = -Infinity;
const absorb = (flat) => {
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];
}
};
absorb(data.self || []);
absorb(data.session || []);
if (!Number.isFinite(minX)) {
for (const state of data.states || []) {
if (!state.bounds) continue;
minX = Math.min(minX, state.bounds.minX);
maxX = Math.max(maxX, state.bounds.maxX);
minZ = Math.min(minZ, state.bounds.minZ);
maxZ = Math.max(maxZ, state.bounds.maxZ);
}
}
if (!Number.isFinite(minX)) return;
mapView.centerX = (minX + maxX) / 2;
mapView.centerZ = (minZ + maxZ) / 2;
const canvas = $('mapCanvas');
const rect = canvas.getBoundingClientRect();
const scaleX = rect.width / Math.max(1, maxX - minX + 2);
const scaleZ = rect.height / Math.max(1, maxZ - minZ + 2);
mapView.scale = Math.min(24, Math.max(0.15, Math.min(scaleX, scaleZ)));
drawMap();
});
$('btnClearLog').addEventListener('click', () => { $('logView').innerHTML = ''; });
$('btnExportLog').addEventListener('click', async () => {
try {
const res = await api('/api/logs/export', { method: 'POST' });
toast('已导出到 ' + res.data.file);
} catch (err) {
toast('导出失败:' + err.message, true);
}
});
$('btnSendCmd').addEventListener('click', sendCommand);
$('cmdInput').addEventListener('keydown', (e) => {
if (e.key === 'Enter') sendCommand();
});
for (const btn of document.querySelectorAll('[data-cmd]')) {
btn.addEventListener('click', () => {
$('cmdInput').value = btn.dataset.cmd;
sendCommand();
});
}
async function sendCommand() {
const command = $('cmdInput').value.trim();
if (!command) return;
try {
await api('/api/command', { method: 'POST', body: JSON.stringify({ command }) });
toast('已发送:' + command);
} catch (err) {
toast('发送失败:' + err.message, true);
}
}
}
// ==================== 启动 ====================
async function main() {
initMap();
bindEvents();
await loadConfig();
await loadMap();
connectWs();
// 地图数据每 20 秒自动刷新一次(服务端 markers 刷新间隔通常为 30 秒)
setInterval(loadMap, 20000);
// 相对时间("x 秒前")需要定期重绘
setInterval(() => { if (lastStatus) renderStatus(lastStatus); }, 5000);
}
main();
+172
查看文件
@@ -0,0 +1,172 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AutoStore Bot 控制台</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="logo">AS</span>
<div>
<h1>AutoStore Bot</h1>
<p>Minecraft 自动扩地机器人 · mineflayer · Grim 合规</p>
</div>
</div>
<div class="topbar-right">
<span id="connBadge" class="badge badge-offline">未连接</span>
<span id="taskBadge" class="badge badge-idle">空闲</span>
<button id="btnStart" class="btn btn-primary">开始扩地</button>
<button id="btnStop" class="btn btn-danger">停止</button>
<button id="btnReconnect" class="btn">重连</button>
</div>
</header>
<main class="layout">
<!-- ==================== 左栏:状态面板 ==================== -->
<section class="column col-left">
<div class="card">
<h2>连接</h2>
<dl class="kv">
<dt>服务器</dt><dd id="kServer">-</dd>
<dt>玩家名</dt><dd id="kUsername">-</dd>
<dt>版本</dt><dd id="kVersion">-</dd>
<dt>坐标</dt><dd id="kPos">-</dd>
<dt>所在区块</dt><dd id="kChunk">-</dd>
<dt>生命 / 饥饿</dt><dd id="kHealth">-</dd>
<dt>状态</dt><dd id="kBotState">-</dd>
<dt>重连次数</dt><dd id="kReconnect">0</dd>
</dl>
</div>
<div class="card">
<h2>任务</h2>
<dl class="kv">
<dt>状态机</dt><dd id="kTaskState">IDLE</dd>
<dt>描述</dt><dd id="kTaskStatus">未启动</dd>
<dt>当前目标</dt><dd id="kTarget">-</dd>
<dt>队列</dt><dd id="kQueue">0</dd>
<dt>已完成</dt><dd id="kCompleted">0</dd>
<dt>重试</dt><dd id="kRetry">0</dd>
<dt>开垦进度</dt><dd id="kProgress">0%</dd>
<dt>连续寻路超时</dt><dd id="kTimeout">0s</dd>
<dt>开垦时段</dt><dd id="kClaimTime">-</dd>
</dl>
<div class="progress"><div id="progressBar" class="progress-fill"></div></div>
</div>
<div class="card">
<h2>领土数据源</h2>
<dl class="kv">
<dt>markers.json</dt><dd id="kMarkersUrl" class="mono small">-</dd>
<dt>最近成功</dt><dd id="kLastFetch">-</dd>
<dt>错误</dt><dd id="kDataError">-</dd>
<dt>邦国数</dt><dd id="kStateCount">0</dd>
<dt>已占领区块</dt><dd id="kClaimed">0</dd>
<dt>会话占领</dt><dd id="kSession">0</dd>
<dt>己方飞地</dt><dd id="kFlying">0</dd>
<dt>SimpCore API</dt><dd id="kApi">未启用</dd>
</dl>
<div class="row">
<button id="btnRefreshMap" class="btn btn-sm">刷新地图</button>
<button id="btnFetchAll" class="btn btn-sm">拉取全服领土</button>
</div>
</div>
<div class="card">
<h2>Grim 合规</h2>
<dl class="kv">
<dt>合规层</dt><dd id="kGrimEnabled">-</dd>
<dt>旋转限速</dt><dd id="kGrimRot">-</dd>
<dt>朝向已同步</dt><dd id="kGrimSettled">-</dd>
<dt>攻击距离</dt><dd id="kGrimReach">-</dd>
<dt>攻击冷却</dt><dd id="kGrimCooldown">-</dd>
<dt>已拦下动作</dt><dd id="kGrimBlocked">0</dd>
<dt>物理自检</dt><dd id="kGrimAudit">-</dd>
</dl>
<ul id="grimIssues" class="issues"></ul>
</div>
</section>
<!-- ==================== 中栏:地图 ==================== -->
<section class="column col-center">
<div class="card card-map">
<div class="card-head">
<h2>领土地图</h2>
<div class="row">
<label class="chk"><input type="checkbox" id="chkFull" checked> 显示全部邦国</label>
<label class="chk"><input type="checkbox" id="chkFlying" checked> 标出飞地</label>
<label class="chk"><input type="checkbox" id="chkGrid" checked> 网格</label>
<button id="btnMapCenter" class="btn btn-sm">回到玩家</button>
<button id="btnMapFit" class="btn btn-sm">全图</button>
</div>
</div>
<canvas id="mapCanvas"></canvas>
<div class="map-legend">
<span><i class="sw sw-self"></i>己方领土</span>
<span><i class="sw sw-session"></i>本次占领</span>
<span><i class="sw sw-flying"></i>飞地(疏漏)</span>
<span><i class="sw sw-target"></i>当前目标</span>
<span><i class="sw sw-player"></i>机器人</span>
<span><i class="sw sw-capital"></i>王城</span>
<span id="mapInfo" class="mono small"></span>
</div>
</div>
<div class="card card-log">
<div class="card-head">
<h2>运行日志</h2>
<div class="row">
<label class="chk"><input type="checkbox" id="chkAutoScroll" checked> 自动滚动</label>
<button id="btnClearLog" class="btn btn-sm">清屏</button>
<button id="btnExportLog" class="btn btn-sm">导出日志</button>
</div>
</div>
<div id="logView" class="log-view"></div>
</div>
</section>
<!-- ==================== 右栏:配置 ==================== -->
<section class="column col-right">
<div class="card card-config">
<div class="card-head">
<h2>配置</h2>
<div class="row">
<button id="btnSaveConfig" class="btn btn-sm btn-primary">保存配置</button>
<button id="btnReloadConfig" class="btn btn-sm">重新载入</button>
</div>
</div>
<div class="tabs" id="configTabs">
<button class="tab active" data-tab="strategy">策略</button>
<button class="tab" data-tab="timing">超时</button>
<button class="tab" data-tab="pathing">寻路</button>
<button class="tab" data-tab="survival">生存</button>
<button class="tab" data-tab="data">数据</button>
<button class="tab" data-tab="grim">Grim</button>
<button class="tab" data-tab="server">服务器</button>
</div>
<div id="configBody" class="config-body"></div>
</div>
<div class="card">
<h2>手动指令</h2>
<div class="row">
<input id="cmdInput" class="input" type="text" placeholder="u claimmode(不带斜杠)">
<button id="btnSendCmd" class="btn btn-sm">发送</button>
</div>
<div class="row">
<button class="btn btn-sm" data-cmd="u return">/u return</button>
<button class="btn btn-sm" data-cmd="u claimmode">/u claimmode</button>
<button class="btn btn-sm" data-cmd="spawn">/spawn</button>
<button class="btn btn-sm" data-cmd="list">/list</button>
</div>
</div>
</section>
</main>
<div id="toast" class="toast"></div>
<script src="app.js"></script>
</body>
</html>
+239
查看文件
@@ -0,0 +1,239 @@
:root {
--bg: #0d1117;
--panel: #161b22;
--panel-2: #1c2330;
--border: #30363d;
--text: #c9d1d9;
--muted: #8b949e;
--accent: #e94560;
--accent-2: #2f81f7;
--green: #3fb950;
--yellow: #d29922;
--red: #f85149;
--purple: #bc8cff;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
height: 100%;
background: var(--bg);
color: var(--text);
font-family: "Segoe UI", "Microsoft YaHei", system-ui, -apple-system, sans-serif;
font-size: 14px;
}
.mono { font-family: Consolas, "Cascadia Mono", monospace; }
.small { font-size: 12px; }
/* ==================== 顶栏 ==================== */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 18px;
background: linear-gradient(90deg, #161b22, #1b2230);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 10;
}
.brand { display: flex; align-items: center; gap: 12px; }
.logo {
width: 38px; height: 38px;
display: grid; place-items: center;
border-radius: 9px;
background: var(--accent);
color: #fff; font-weight: 700; letter-spacing: 1px;
}
.brand h1 { font-size: 16px; margin: 0; }
.brand p { font-size: 12px; margin: 2px 0 0; color: var(--muted); }
.topbar-right { display: flex; align-items: center; gap: 8px; }
.badge {
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
border: 1px solid var(--border);
background: var(--panel-2);
}
.badge-online { color: var(--green); border-color: #1f6f34; }
.badge-offline { color: var(--muted); }
.badge-running { color: var(--accent-2); border-color: #1f4f8f; }
.badge-idle { color: var(--muted); }
.badge-error { color: var(--red); border-color: #8f2b26; }
.btn {
padding: 6px 12px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--panel-2);
color: var(--text);
cursor: pointer;
font-size: 13px;
transition: background .15s, border-color .15s;
}
.btn:hover { background: #232b39; border-color: #48505e; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn-primary:hover { background: #d63851; border-color: #d63851; }
.btn-danger { background: #3a1d22; border-color: #7a2b34; color: #ff8b98; }
.btn-danger:hover { background: #4a2329; }
.btn-sm { padding: 4px 9px; font-size: 12px; }
/* ==================== 布局 ==================== */
.layout {
display: grid;
grid-template-columns: 300px minmax(420px, 1fr) 360px;
gap: 12px;
padding: 12px;
align-items: start;
}
.column { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px;
}
.card h2 {
font-size: 13px;
margin: 0 0 8px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: .6px;
}
.card-head {
display: flex; align-items: center; justify-content: space-between;
gap: 8px; margin-bottom: 8px;
}
.card-head h2 { margin: 0; }
.row { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-top: 8px; }
/* ==================== 键值表 ==================== */
.kv {
display: grid;
grid-template-columns: 88px 1fr;
gap: 4px 8px;
margin: 0;
font-size: 13px;
}
.kv dt { color: var(--muted); }
.kv dd { margin: 0; word-break: break-all; }
.progress {
margin-top: 10px; height: 6px; border-radius: 3px;
background: #0b0f14; overflow: hidden;
}
.progress-fill {
height: 100%; width: 0%;
background: linear-gradient(90deg, var(--accent-2), var(--green));
transition: width .25s;
}
.issues { margin: 8px 0 0; padding-left: 18px; color: var(--yellow); font-size: 12px; }
.issues li { margin-bottom: 3px; }
/* ==================== 地图 ==================== */
.card-map { display: flex; flex-direction: column; }
#mapCanvas {
width: 100%;
height: 380px;
background: #05070a;
border: 1px solid var(--border);
border-radius: 8px;
display: block;
cursor: grab;
}
#mapCanvas:active { cursor: grabbing; }
.map-legend {
display: flex; gap: 14px; flex-wrap: wrap;
margin-top: 8px; font-size: 12px; color: var(--muted);
align-items: center;
}
.sw {
display: inline-block; width: 10px; height: 10px;
border-radius: 2px; margin-right: 4px; vertical-align: -1px;
}
.sw-self { background: #2f81f7; }
.sw-session { background: #3fb950; }
.sw-flying { background: #f85149; }
.sw-target { background: #d29922; }
.sw-player { background: #ffffff; }
.sw-capital { background: #bc8cff; }
.chk { display: flex; align-items: center; gap: 4px; font-size: 12px; color: var(--muted); cursor: pointer; }
/* ==================== 日志 ==================== */
.card-log { display: flex; flex-direction: column; }
.log-view {
height: 260px;
overflow-y: auto;
background: #05070a;
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px;
font-family: Consolas, "Cascadia Mono", monospace;
font-size: 12px;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-all;
}
.log-line { display: block; }
.log-time { color: #586069; margin-right: 6px; }
.log-info { color: var(--text); }
.log-warn { color: var(--yellow); }
.log-error { color: var(--red); }
.log-success { color: var(--green); }
/* ==================== 配置 ==================== */
.card-config { display: flex; flex-direction: column; }
.tabs { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 8px; }
.tab {
padding: 4px 10px; font-size: 12px; cursor: pointer;
background: var(--panel-2); border: 1px solid var(--border);
border-radius: 6px; color: var(--muted);
}
.tab.active { color: #fff; background: var(--accent-2); border-color: var(--accent-2); }
.config-body { max-height: 520px; overflow-y: auto; }
.field { margin-bottom: 9px; }
.field label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 3px; }
.field .hint { font-size: 11px; color: #5d6672; margin-top: 2px; }
.input, select.input {
width: 100%; padding: 5px 8px;
background: #0b0f14; border: 1px solid var(--border);
border-radius: 6px; color: var(--text); font-size: 13px;
outline: none;
}
.input:focus { border-color: var(--accent-2); }
.field-inline { display: flex; align-items: center; gap: 8px; }
.field-inline input[type=checkbox] { width: 15px; height: 15px; }
.group-title {
font-size: 12px; color: var(--accent-2);
margin: 12px 0 6px; padding-bottom: 4px;
border-bottom: 1px dashed var(--border);
}
.group-title:first-child { margin-top: 0; }
/* ==================== 提示 ==================== */
.toast {
position: fixed; right: 18px; bottom: 18px;
background: var(--panel-2); border: 1px solid var(--border);
border-left: 3px solid var(--accent-2);
padding: 10px 14px; border-radius: 8px;
font-size: 13px; max-width: 380px;
opacity: 0; transform: translateY(8px);
transition: opacity .2s, transform .2s;
pointer-events: none;
z-index: 100;
}
.toast.show { opacity: 1; transform: translateY(0); }
.toast.error { border-left-color: var(--red); }
@media (max-width: 1280px) {
.layout { grid-template-columns: 1fr; }
.config-body { max-height: none; }
}
+367
查看文件
@@ -0,0 +1,367 @@
'use strict';
const http = require('http');
const path = require('path');
const express = require('express');
const { WebSocketServer } = require('ws');
const chunkPos = require('../core/chunkPos');
const { defaultConfig, normalizeConfig, copyConfig } = require('../config/expansionConfig');
/** 状态推送间隔(毫秒) */
const STATUS_PUSH_INTERVAL_MS = 1000;
/**
* Web 控制台服务:Express 提供静态页面与 REST 接口,WebSocket 推送实时状态与日志。
*
* 安全约定:
* - 默认只监听 127.0.0.1;
* - 配置 `WEB_TOKEN` 后所有 `/api` 请求与 WebSocket 握手都必须带正确令牌;
* - 令牌比较使用定时安全比较,避免时序侧信道。
*/
class WebConsole {
/**
* @param {object} deps
* @param {import('../bot/botManager').BotManager} deps.botManager
* @param {import('../controller/expansionController').ExpansionController} deps.controller
* @param {import('../core/territoryDataService').TerritoryDataService} deps.dataService
* @param {import('../util/logger').Logger} deps.logger
* @param {() => object} deps.getConfig
* @param {(config: object) => object} deps.applyConfig
* @param {{host?: string, port?: number, token?: string, exportDir?: string}} [options]
*/
constructor(deps, options) {
const opts = options || {};
this.botManager = deps.botManager;
this.controller = deps.controller;
this.dataService = deps.dataService;
this.logger = deps.logger;
this.getConfig = deps.getConfig;
this.applyConfig = deps.applyConfig;
this.exportDir = opts.exportDir || path.join(__dirname, '..', '..', 'data');
this.host = opts.host || '127.0.0.1';
this.port = opts.port === undefined ? 3081 : opts.port;
this.token = opts.token || '';
this.app = express();
this.server = null;
this.wss = null;
this._pushTimer = null;
/** @type {Set<import('ws').WebSocket>} */
this.clients = new Set();
this._setupRoutes();
}
/** 启动 HTTP + WebSocket 服务 */
start() {
this.server = http.createServer(this.app);
this.wss = new WebSocketServer({ noServer: true });
this.server.on('upgrade', (request, socket, head) => {
if (!this._authorized(request)) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
this.wss.handleUpgrade(request, socket, head, (ws) => {
this.wss.emit('connection', ws, request);
});
});
this.wss.on('connection', (ws) => {
this.clients.add(ws);
ws.on('close', () => this.clients.delete(ws));
ws.on('error', () => this.clients.delete(ws));
this._send(ws, { type: 'status', data: this.statusPayload() });
this._send(ws, { type: 'logs', data: this.logger.list() });
});
this._pushTimer = setInterval(() => {
if (this.clients.size === 0) return;
const payload = JSON.stringify({ type: 'status', data: this.statusPayload() });
for (const ws of this.clients) {
if (ws.readyState === 1) ws.send(payload);
}
}, STATUS_PUSH_INTERVAL_MS);
if (this._pushTimer.unref) this._pushTimer.unref();
// 新日志实时推送
this.logger.onEntry = (entry) => {
const payload = JSON.stringify({ type: 'log', data: entry });
for (const ws of this.clients) {
if (ws.readyState === 1) ws.send(payload);
}
};
return new Promise((resolve) => {
this.server.listen(this.port, this.host, () => {
const address = this.server.address();
this.actualPort = address && typeof address === 'object' ? address.port : this.port;
resolve(this.url());
});
});
}
/** @returns {string} 控制台访问地址(带令牌,便于直接点击打开) */
url() {
const tokenSuffix = this.token ? `/?token=${encodeURIComponent(this.token)}` : '/';
return this.baseUrl() + tokenSuffix;
}
/** @returns {string} 控制台根地址(不含令牌),供 API 调用方拼接路径 */
baseUrl() {
return `http://${this.host}:${this.actualPort || this.port}`;
}
/** 停止服务 */
async stop() {
if (this._pushTimer) clearInterval(this._pushTimer);
this.logger.onEntry = null;
for (const ws of this.clients) {
try {
ws.close();
} catch {
// 忽略
}
}
this.clients.clear();
if (this.wss) await new Promise((resolve) => this.wss.close(resolve));
if (this.server) await new Promise((resolve) => this.server.close(resolve));
}
// ==================== 路由 ====================
_setupRoutes() {
const app = this.app;
app.disable('x-powered-by');
app.use(express.json({ limit: '1mb' }));
app.use((req, res, next) => {
if (!this._authorized(req)) {
res.status(401).json({ ok: false, message: '未授权:请在 URL 上带 ?token= 或提供 X-Auth-Token 头' });
return;
}
next();
});
app.use(express.static(path.join(__dirname, 'public')));
app.get('/api/status', (req, res) => {
res.json({ ok: true, data: this.statusPayload() });
});
app.get('/api/config', (req, res) => {
res.json({ ok: true, data: this.getConfig() });
});
app.post('/api/config', (req, res) => {
try {
const merged = normalizeConfig(req.body, this.getConfig());
const applied = this.applyConfig(merged);
res.json({ ok: true, data: applied });
} catch (err) {
res.status(400).json({ ok: false, message: err.message });
}
});
app.get('/api/config/defaults', (req, res) => {
res.json({ ok: true, data: defaultConfig() });
});
app.get('/api/logs', (req, res) => {
res.json({ ok: true, data: this.logger.list() });
});
app.post('/api/logs/export', (req, res) => {
const file = path.join(this.exportDir, `autostore-debug-${Date.now()}.log`);
const result = this.controller.exportLogs(file);
if (result) res.json({ ok: true, data: { file: result } });
else res.status(500).json({ ok: false, message: '导出失败' });
});
app.get('/api/map', (req, res) => {
try {
res.json({ ok: true, data: this.mapPayload(req.query.full === '1') });
} catch (err) {
res.status(500).json({ ok: false, message: err.message });
}
});
app.post('/api/task/start', async (req, res) => {
try {
const config = req.body && Object.keys(req.body).length > 0
? normalizeConfig(req.body, this.getConfig())
: this.getConfig();
const ok = await this.controller.start(config);
res.json({ ok, data: this.statusPayload(), message: ok ? '任务已启动' : this.controller.status });
} catch (err) {
res.status(500).json({ ok: false, message: err.message });
}
});
app.post('/api/task/stop', (req, res) => {
this.controller.stop();
res.json({ ok: true, data: this.statusPayload() });
});
app.post('/api/map/refresh', (req, res) => {
this.dataService.refreshAsync();
res.json({ ok: true, message: '已触发地图刷新' });
});
app.post('/api/api/fetch-all', async (req, res) => {
const result = await this.dataService.fetchAllTerritoryOnce();
res.json({ ok: result.ok, message: result.message });
});
app.post('/api/command', (req, res) => {
const command = req.body && req.body.command;
if (!command || typeof command !== 'string') {
res.status(400).json({ ok: false, message: '缺少 command 字段' });
return;
}
this.botManager.sendCommand(command.replace(/^\//, ''));
res.json({ ok: true, message: '已发送:' + command });
});
app.post('/api/bot/reconnect', (req, res) => {
this.botManager.disconnect();
setTimeout(() => this.botManager.connect(), 500);
res.json({ ok: true, message: '正在重连' });
});
}
/**
* 校验请求授权。
* @param {import('http').IncomingMessage} req
* @returns {boolean}
*/
_authorized(req) {
if (!this.token) return true;
let provided = '';
const header = req.headers['x-auth-token'];
if (typeof header === 'string') provided = header;
if (!provided && req.url) {
const query = req.url.includes('?') ? req.url.slice(req.url.indexOf('?') + 1) : '';
const params = new URLSearchParams(query);
provided = params.get('token') || '';
}
return timingSafeEqual(provided, this.token);
}
// ==================== 数据组装 ====================
/** @returns {object} 完整状态负载 */
statusPayload() {
return {
bot: this.botManager.snapshot(),
task: this.controller.snapshot(),
data: this.dataService.snapshot(),
serverTime: Date.now(),
claimTime: {
start: this.getConfig().claimStartHour,
end: this.getConfig().claimEndHour,
enforce: this.getConfig().enforceClaimTime
}
};
}
/**
* 组装地图数据。
*
* 区块一律编码为扁平的 `[x1, z1, x2, z2, ...]` 数组,减少 JSON 体积。
*
* @param {boolean} full 是否包含全部邦国的区块(体积大,按需请求)
* @returns {object}
*/
mapPayload(full) {
const model = this.dataService.getModel();
const selfName = model.selfStateNameValue();
const self = model.selfState();
const result = {
selfState: selfName,
self: self ? flattenChunks(self.chunks) : [],
session: flattenChunks(model.sessionClaimsSet()),
flying: flattenChunks(model.selfFlyingChunks()),
target: this.controller.target,
playerChunk: this.controller.playerChunk,
states: [],
full
};
for (const state of model.statesIter()) {
const bounds = boundsOf(state.chunks);
result.states.push({
name: state.name,
color: '#' + (state.color & 0xffffff).toString(16).padStart(6, '0'),
capital: state.capital,
count: state.chunks.size,
bounds
});
}
result.states.sort((a, b) => b.count - a.count);
if (full) {
result.otherChunks = [];
for (const state of model.statesIter()) {
if (state.name === selfName) continue;
const flat = flattenChunks(state.chunks);
result.otherChunks.push({ name: state.name, color: '#' + (state.color & 0xffffff).toString(16).padStart(6, '0'), chunks: flat });
}
}
return result;
}
_send(ws, payload) {
if (ws.readyState === 1) ws.send(JSON.stringify(payload));
}
}
/**
* 把区块键集合压平成 `[x1, z1, x2, z2, ...]`。
* @param {Set<number>} keys
* @returns {number[]}
*/
function flattenChunks(keys) {
const out = new Array(keys.size * 2);
let i = 0;
for (const k of keys) {
const pos = chunkPos.decode(k);
out[i++] = pos.x;
out[i++] = pos.z;
}
return out;
}
/**
* 计算区块集合的外接矩形。
* @param {Set<number>} keys
* @returns {{minX: number, maxX: number, minZ: number, maxZ: number}|null}
*/
function boundsOf(keys) {
if (keys.size === 0) return null;
let minX = Infinity;
let maxX = -Infinity;
let minZ = Infinity;
let maxZ = -Infinity;
for (const k of keys) {
const pos = chunkPos.decode(k);
if (pos.x < minX) minX = pos.x;
if (pos.x > maxX) maxX = pos.x;
if (pos.z < minZ) minZ = pos.z;
if (pos.z > maxZ) maxZ = pos.z;
}
return { minX, maxX, minZ, maxZ };
}
/**
* 定时安全字符串比较,避免令牌比较的时序侧信道。
* @param {string} a
* @param {string} b
* @returns {boolean}
*/
function timingSafeEqual(a, b) {
if (typeof a !== 'string' || typeof b !== 'string') return false;
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
module.exports = { WebConsole, flattenChunks, boundsOf, timingSafeEqual, STATUS_PUSH_INTERVAL_MS };
+118
查看文件
@@ -0,0 +1,118 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const { Vec3 } = require('vec3');
const { scanWaterAhead, isWaterName, BoatState } = require('../src/bot/autoBoat');
const { FakeBot } = require('./helpers/fakeBot');
/**
* 构造一个「前方全是水」的假世界。
* @param {FakeBot} bot
* @param {(x: number, z: number) => boolean} isWater
*/
function fillWorld(bot, isWater, y) {
const level = y === undefined ? 63 : y;
for (let x = -40; x <= 40; x++) {
for (let z = -40; z <= 40; z++) {
for (const dy of [-1, 0]) {
bot.blockMap.set(`${x},${level + dy},${z}`, {
name: isWater(x, z) ? 'water' : 'stone',
boundingBox: 'block'
});
}
}
}
}
test('isWaterName 识别水与气泡柱', () => {
assert.strictEqual(isWaterName('water'), true);
assert.strictEqual(isWaterName('flowing_water'), true);
assert.strictEqual(isWaterName('bubble_column'), true);
assert.strictEqual(isWaterName('stone'), false);
assert.strictEqual(isWaterName(''), false);
assert.strictEqual(isWaterName(null), false);
});
test('前方开阔水域达到长度与宽度阈值时建议用船', () => {
const bot = new FakeBot({ x: 0, y: 64, z: 0 });
// x 从 1 到 30 全是水,其余为石头
fillWorld(bot, (x) => x >= 1 && x <= 30);
const scan = scanWaterAhead(bot, 1, 0, 6, 4);
assert.strictEqual(scan.ok, true, scan.reason);
assert.ok(scan.length >= 6);
assert.ok(scan.width >= 4);
});
test('水域长度不足时不建议用船', () => {
const bot = new FakeBot({ x: 0, y: 64, z: 0 });
// 只有 3 格宽的水沟
fillWorld(bot, (x) => x >= 1 && x <= 3);
const scan = scanWaterAhead(bot, 1, 0, 6, 1);
assert.strictEqual(scan.ok, false);
assert.ok(scan.reason.includes('长度'));
});
test('水面宽度不足时不建议用船', () => {
const bot = new FakeBot({ x: 0, y: 64, z: 0 });
// 一条沿 x 方向的长水道,但 z 方向只有 1 格宽
fillWorld(bot, (x, z) => x >= 1 && x <= 30 && z === 0);
const scan = scanWaterAhead(bot, 1, 0, 6, 4);
assert.strictEqual(scan.ok, false);
assert.ok(scan.reason.includes('宽度'));
});
test('前方完全没有水时不建议用船', () => {
const bot = new FakeBot({ x: 0, y: 64, z: 0 });
fillWorld(bot, () => false);
const scan = scanWaterAhead(bot, 1, 0, 6, 4);
assert.strictEqual(scan.ok, false);
assert.strictEqual(scan.length, 0);
});
test('用船状态机初始为空闲且描述为空', () => {
const bot = new FakeBot();
const { AutoBoatController } = require('../src/bot/autoBoat');
const { defaultConfig } = require('../src/config/expansionConfig');
const boat = new AutoBoatController(bot, defaultConfig(), null, null);
assert.strictEqual(boat.isActive(), false);
assert.strictEqual(boat.describe(), null);
assert.strictEqual(boat.state, BoatState.IDLE);
});
test('autoBoat 关闭时 tick 不会进入流程', () => {
const bot = new FakeBot();
const { AutoBoatController } = require('../src/bot/autoBoat');
const { defaultConfig } = require('../src/config/expansionConfig');
const config = defaultConfig();
config.autoBoat = false;
const boat = new AutoBoatController(bot, config, null, null);
boat.tick({ running: true, canStart: true, target: { x: 1, z: 1 } });
assert.strictEqual(boat.isActive(), false);
});
test('waterMode 非 boat 时 tick 不会进入流程(即使 autoBoat 打开)', () => {
const bot = new FakeBot();
const { AutoBoatController } = require('../src/bot/autoBoat');
const { defaultConfig } = require('../src/config/expansionConfig');
const config = defaultConfig();
config.autoBoat = true;
config.waterMode = 'avoid';
const boat = new AutoBoatController(bot, config, null, null);
boat.tick({ running: true, canStart: true, target: { x: 1, z: 1 } });
assert.strictEqual(boat.isActive(), false);
});
test('背包里没有船时不会启动用船流程', () => {
const bot = new FakeBot({ x: 0, y: 64, z: 0, slots: [] });
fillWorld(bot, (x) => x >= 1 && x <= 30);
const { AutoBoatController } = require('../src/bot/autoBoat');
const { defaultConfig } = require('../src/config/expansionConfig');
const config = defaultConfig();
config.autoBoat = true;
config.waterMode = 'boat';
const boat = new AutoBoatController(bot, config, null, null);
boat.tick({ running: true, canStart: true, target: { x: 2, z: 0 } });
assert.strictEqual(boat.isActive(), false);
});
+151
查看文件
@@ -0,0 +1,151 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const { Vec3 } = require('vec3');
const { FakeBot } = require('./helpers/fakeBot');
const {
isAttackTarget,
findTarget,
eyeToEntityDistance,
findLowDurabilitySlot,
wearsGoldArmor
} = require('../src/bot/autoCombat');
const { defaultConfig } = require('../src/config/expansionConfig');
/** 构造一个假实体 */
function makeEntity(name, kind, position, extra) {
return Object.assign({
id: Math.floor(Math.random() * 100000),
name,
kind,
type: 'mob',
position: new Vec3(position[0], position[1], position[2]),
width: 0.6,
height: 1.8,
isValid: true
}, extra || {});
}
test('敌对生物可被攻击', () => {
const bot = new FakeBot();
const config = defaultConfig();
assert.strictEqual(isAttackTarget(makeEntity('zombie', 'Hostile mobs', [0, 64, 0]), bot, config), true);
assert.strictEqual(isAttackTarget(makeEntity('skeleton', 'Hostile mobs', [0, 64, 0]), bot, config), true);
assert.strictEqual(isAttackTarget(makeEntity('creeper', 'Hostile mobs', [0, 64, 0]), bot, config), true);
});
test('绝不攻击玩家与中立生物', () => {
const bot = new FakeBot();
const config = defaultConfig();
// 玩家
assert.strictEqual(isAttackTarget(makeEntity('Steve', 'Passive mobs', [0, 64, 0], { type: 'player' }), bot, config), false);
// 铁傀儡、僵尸猪灵(原版中立,绝不主动攻击)
assert.strictEqual(isAttackTarget(makeEntity('iron_golem', 'Passive mobs', [0, 64, 0]), bot, config), false);
assert.strictEqual(isAttackTarget(makeEntity('zombified_piglin', 'Hostile mobs', [0, 64, 0]), bot, config), false);
// 宠物与常见中立生物
for (const name of ['wolf', 'cat', 'llama', 'bee', 'fox', 'polar_bear', 'villager']) {
assert.strictEqual(isAttackTarget(makeEntity(name, 'Passive mobs', [0, 64, 0]), bot, config), false, name + ' 不应被攻击');
}
});
test('条件敌对生物默认不打,可在配置里放开', () => {
const bot = new FakeBot();
const config = defaultConfig();
assert.strictEqual(isAttackTarget(makeEntity('enderman', 'Hostile mobs', [0, 64, 0]), bot, config), false);
assert.strictEqual(isAttackTarget(makeEntity('spider', 'Hostile mobs', [0, 64, 0]), bot, config), false);
config.combat.attackEnderman = true;
config.combat.attackSpiders = true;
assert.strictEqual(isAttackTarget(makeEntity('enderman', 'Hostile mobs', [0, 64, 0]), bot, config), true);
assert.strictEqual(isAttackTarget(makeEntity('spider', 'Hostile mobs', [0, 64, 0]), bot, config), true);
});
test('猪灵:未穿金甲才攻击', () => {
const config = defaultConfig();
const bareBot = new FakeBot();
assert.strictEqual(isAttackTarget(makeEntity('piglin', 'Hostile mobs', [0, 64, 0]), bareBot, config), true);
const armoredBot = new FakeBot({ slots: [] });
armoredBot.inventory.slots[5] = { name: 'golden_helmet' };
assert.strictEqual(wearsGoldArmor(armoredBot), true);
assert.strictEqual(isAttackTarget(makeEntity('piglin', 'Hostile mobs', [0, 64, 0]), armoredBot, config), false);
});
test('高危生物默认跳过', () => {
const bot = new FakeBot();
const config = defaultConfig();
for (const name of ['warden', 'ender_dragon', 'wither']) {
assert.strictEqual(isAttackTarget(makeEntity(name, 'Hostile mobs', [0, 64, 0]), bot, config), false, name + ' 应被跳过');
}
config.combat.avoidDangerousMobs = false;
assert.strictEqual(isAttackTarget(makeEntity('wither', 'Hostile mobs', [0, 64, 0]), bot, config), true);
});
test('无效 / 已消失 / 非生物实体不被攻击', () => {
const bot = new FakeBot();
const config = defaultConfig();
assert.strictEqual(isAttackTarget(null, bot, config), false);
assert.strictEqual(isAttackTarget(makeEntity('zombie', 'Hostile mobs', [0, 64, 0], { isValid: false }), bot, config), false);
assert.strictEqual(isAttackTarget(makeEntity('zombie', 'Hostile mobs', [0, 64, 0], { type: 'object' }), bot, config), false);
assert.strictEqual(isAttackTarget(bot.entity, bot, config), false);
});
test('findTarget 在索敌半径内挑最近的合法目标', () => {
const bot = new FakeBot({ x: 0, y: 64, z: 0 });
const config = defaultConfig();
const near = makeEntity('zombie', 'Hostile mobs', [1, 64, 0]);
const far = makeEntity('skeleton', 'Hostile mobs', [4, 64, 0]);
const neutral = makeEntity('iron_golem', 'Passive mobs', [0.5, 64, 0]);
const outside = makeEntity('creeper', 'Hostile mobs', [20, 64, 0]);
bot.entities = { 1: near, 2: far, 3: neutral, 4: outside };
assert.strictEqual(findTarget(bot, 4.5, config), near);
});
test('findTarget 半径内没有敌对生物时返回 null', () => {
const bot = new FakeBot({ x: 0, y: 64, z: 0 });
const config = defaultConfig();
bot.entities = { 1: makeEntity('cow', 'Passive mobs', [1, 64, 0]) };
assert.strictEqual(findTarget(bot, 4.5, config), null);
});
test('eyeToEntityDistance 计算到碰撞箱最近点的距离', () => {
const bot = new FakeBot({ x: 0, y: 64, z: 0 });
const entity = makeEntity('zombie', 'Hostile mobs', [2, 64, 0]);
const distance = eyeToEntityDistance(bot, entity);
assert.ok(Math.abs(distance - 1.7) < 1e-6, `期望 1.7,实际 ${distance}`);
});
test('耐久保护:识别剩余耐久低于阈值的装备', () => {
const bot = new FakeBot({ slots: [] });
// 主手:钻石剑,最大 1561,已用 1400 → 剩余 10.3%
bot.heldItem = { name: 'diamond_sword', maxDurability: 1561, durabilityUsed: 1400 };
const low = findLowDurabilitySlot(bot, 15);
assert.ok(low);
assert.strictEqual(low.name, '主手');
assert.strictEqual(low.percent, 10);
assert.strictEqual(low.itemName, 'diamond_sword');
});
test('耐久保护:装备充足时返回 null', () => {
const bot = new FakeBot({ slots: [] });
bot.heldItem = { name: 'diamond_sword', maxDurability: 1561, durabilityUsed: 100 };
bot.inventory.slots[5] = { name: 'diamond_helmet', maxDurability: 363, durabilityUsed: 10 };
assert.strictEqual(findLowDurabilitySlot(bot, 15), null);
});
test('耐久保护:检查四件盔甲与副手', () => {
const bot = new FakeBot({ slots: [] });
bot.inventory.slots[7] = { name: 'diamond_leggings', maxDurability: 495, durabilityUsed: 480 };
const low = findLowDurabilitySlot(bot, 15);
assert.ok(low);
assert.strictEqual(low.name, '护腿');
});
test('耐久保护:不可损坏物品被忽略', () => {
const bot = new FakeBot({ slots: [] });
bot.heldItem = { name: 'stone' };
bot.inventory.slots[5] = { name: 'turtle_helmet', maxDurability: 0 };
assert.strictEqual(findLowDurabilitySlot(bot, 90), null);
});
+115
查看文件
@@ -0,0 +1,115 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const { fillTemplate } = require('../src/bot/botManager');
const { AutoEater, findFoodSlot } = require('../src/bot/autoEat');
const { DurabilityGuard, CHECK_INTERVAL_MS } = require('../src/bot/durabilityGuard');
const { defaultConfig } = require('../src/config/expansionConfig');
const { FakeBot } = require('./helpers/fakeBot');
test('fillTemplate 替换密码占位符', () => {
assert.strictEqual(fillTemplate('login {password}', 'abc123'), 'login abc123');
assert.strictEqual(fillTemplate('register {password} {password}', 'p'), 'register p p');
assert.strictEqual(fillTemplate('login', 'abc'), 'login', '无占位符时原样返回');
});
test('findFoodSlot 找到背包中的食物', () => {
const bot = new FakeBot({
slots: [
{ name: 'diamond_sword' },
{ name: 'cooked_beef' },
{ name: 'apple' }
]
});
assert.strictEqual(findFoodSlot(bot), 1);
});
test('findFoodSlot 没有食物时返回 -1', () => {
const bot = new FakeBot({ slots: [{ name: 'diamond_sword' }, { name: 'stone' }] });
assert.strictEqual(findFoodSlot(bot), -1);
});
test('自动进食:饥饿低于阈值时开始进食', async () => {
const bot = new FakeBot({ slots: [{ name: 'cooked_beef' }] });
bot.food = 10;
const config = defaultConfig();
const eater = new AutoEater(bot, config, null, null);
const busy = eater.tick();
assert.strictEqual(busy, true);
await new Promise((resolve) => setImmediate(resolve));
assert.strictEqual(bot.food, 20, '进食后饥饿值应回满');
assert.strictEqual(eater.eating, false, '进食完成后应复位');
});
test('自动进食:饥饿充足时不做任何事', () => {
const bot = new FakeBot({ slots: [{ name: 'cooked_beef' }] });
bot.food = 20;
const eater = new AutoEater(bot, defaultConfig(), null, null);
assert.strictEqual(eater.tick(), false);
assert.strictEqual(eater.eating, false);
});
test('自动进食:背包没有食物时保持非占用状态', () => {
const bot = new FakeBot({ slots: [{ name: 'stone' }] });
bot.food = 5;
const eater = new AutoEater(bot, defaultConfig(), null, null);
assert.strictEqual(eater.tick(), false);
assert.strictEqual(eater.eating, false);
});
test('自动进食:autoEat 关闭时完全跳过', () => {
const bot = new FakeBot({ slots: [{ name: 'cooked_beef' }] });
bot.food = 5;
const config = defaultConfig();
config.autoEat = false;
const eater = new AutoEater(bot, config, null, null);
assert.strictEqual(eater.tick(), false);
});
test('自动进食:进食前松开移动键(防 NoSlow)', async () => {
const bot = new FakeBot({ slots: [{ name: 'cooked_beef' }] });
bot.food = 10;
const controls = {};
const grim = {
setControlState(control, state) { controls[control] = state; },
setLook() {}
};
const config = defaultConfig();
const eater = new AutoEater(bot, config, null, grim);
eater.tick();
await new Promise((resolve) => setImmediate(resolve));
for (const control of ['forward', 'back', 'left', 'right', 'sprint', 'jump']) {
assert.strictEqual(controls[control], false, control + ' 应被松开');
}
});
test('耐久保护:按间隔节流检查', () => {
const bot = new FakeBot({ slots: [] });
bot.heldItem = { name: 'diamond_sword', maxDurability: 1561, durabilityUsed: 1500 };
const guard = new DurabilityGuard(bot, defaultConfig(), null);
const first = guard.check(1000000);
assert.ok(first, '首次检查应立即生效');
assert.strictEqual(guard.check(1000000 + CHECK_INTERVAL_MS - 1), null, '间隔内不再检查');
assert.ok(guard.check(1000000 + CHECK_INTERVAL_MS), '超过间隔后再次检查');
});
test('耐久保护:关闭时永远返回 null', () => {
const bot = new FakeBot({ slots: [] });
bot.heldItem = { name: 'diamond_sword', maxDurability: 1561, durabilityUsed: 1561 };
const config = defaultConfig();
config.durabilityGuard = false;
const guard = new DurabilityGuard(bot, config, null);
assert.strictEqual(guard.check(1000), null);
});
test('耐久保护:reset 后立即重新检查', () => {
const bot = new FakeBot({ slots: [] });
bot.heldItem = { name: 'diamond_sword', maxDurability: 1561, durabilityUsed: 1500 };
const guard = new DurabilityGuard(bot, defaultConfig(), null);
guard.check(1000000);
guard.reset();
assert.ok(guard.check(1000000), 'reset 后应可立即再次检查');
});
+69
查看文件
@@ -0,0 +1,69 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const chunkPos = require('../src/core/chunkPos');
test('fromBlock 向下取整且兼容负数坐标', () => {
assert.deepStrictEqual(chunkPos.fromBlock(0, 0), { x: 0, z: 0 });
assert.deepStrictEqual(chunkPos.fromBlock(15, 15), { x: 0, z: 0 });
assert.deepStrictEqual(chunkPos.fromBlock(16, 31), { x: 1, z: 1 });
// 负数必须向下取整到更小的区块号,而不是向零取整
assert.deepStrictEqual(chunkPos.fromBlock(-1, -16), { x: -1, z: -1 });
assert.deepStrictEqual(chunkPos.fromBlock(-17, -33), { x: -2, z: -3 });
});
test('key/decode 往返一致(含负数与边界)', () => {
const samples = [
[0, 0], [1, 0], [0, 1], [-1, 0], [0, -1], [-1, -1],
[1279, 1279], [-1280, -1280], [12345, -54321], [-2048, 2048]
];
for (const [x, z] of samples) {
const decoded = chunkPos.decode(chunkPos.key(x, z));
assert.deepStrictEqual(decoded, { x, z }, `坐标 ${x},${z} 编解码不一致`);
}
});
test('key 对不同坐标产生不同键', () => {
const seen = new Set();
for (let x = -50; x <= 50; x++) {
for (let z = -50; z <= 50; z++) {
const k = chunkPos.key(x, z);
assert.ok(!seen.has(k), `键冲突:${x},${z}`);
seen.add(k);
}
}
assert.strictEqual(seen.size, 101 * 101);
});
test('centerBlock 取区块中心(+8)', () => {
assert.strictEqual(chunkPos.centerBlockX(0), 8);
assert.strictEqual(chunkPos.centerBlockX(-1), -8);
assert.strictEqual(chunkPos.centerBlockZ(3), 56);
});
test('曼哈顿与切比雪夫距离', () => {
assert.strictEqual(chunkPos.manhattan({ x: 0, z: 0 }, { x: 3, z: -4 }), 7);
assert.strictEqual(chunkPos.chebyshev({ x: 0, z: 0 }, { x: 3, z: -4 }), 4);
assert.strictEqual(chunkPos.chebyshev({ x: 5, z: 5 }, { x: 5, z: 5 }), 0);
});
test('equals 处理 null 与同值', () => {
assert.strictEqual(chunkPos.equals({ x: 1, z: 2 }, { x: 1, z: 2 }), true);
assert.strictEqual(chunkPos.equals({ x: 1, z: 2 }, { x: 2, z: 1 }), false);
assert.strictEqual(chunkPos.equals(null, null), true);
assert.strictEqual(chunkPos.equals({ x: 1, z: 2 }, null), false);
});
test('cardinalNeighbors 返回四个正方向邻居', () => {
const neighbors = chunkPos.cardinalNeighbors(0, 0).map((c) => `${c.x},${c.z}`);
assert.deepStrictEqual(neighbors.sort(), ['-1,0', '0,-1', '0,1', '1,0']);
});
test('withinWorldBorder 以 ±1280 为界', () => {
assert.strictEqual(chunkPos.withinWorldBorder(0, 0), true);
assert.strictEqual(chunkPos.withinWorldBorder(1279, -1280), true);
assert.strictEqual(chunkPos.withinWorldBorder(1280, 0), false);
assert.strictEqual(chunkPos.withinWorldBorder(0, -1281), false);
});
+157
查看文件
@@ -0,0 +1,157 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const {
defaultConfig,
normalizeConfig,
validateConfig,
copyConfig,
STRATEGY,
WATER_MODE
} = require('../src/config/expansionConfig');
const { ConfigStore } = require('../src/config/configStore');
test('默认配置的关键默认值与原 Java 版一致', () => {
const config = defaultConfig();
assert.strictEqual(config.strategy, STRATEGY.SQUARE);
assert.strictEqual(config.targetCount, -1);
assert.strictEqual(config.waitTimeoutSeconds, 60);
assert.strictEqual(config.retries, 2);
assert.strictEqual(config.returnThresholdSeconds, 60);
assert.strictEqual(config.returnWaitSeconds, 12);
assert.strictEqual(config.sideStepMaxRounds, 3);
assert.strictEqual(config.durabilityThreshold, 15);
assert.strictEqual(config.eatThreshold, 14);
assert.strictEqual(config.refreshIntervalSeconds, 30);
assert.strictEqual(config.apiRefreshIntervalSeconds, 180);
assert.strictEqual(config.allowBreak, false);
assert.strictEqual(config.allowPlace, false);
assert.strictEqual(config.altitudeWeight, 0.1);
});
test('Grim 合规默认值偏保守', () => {
const config = defaultConfig();
assert.strictEqual(config.grim.enabled, true);
assert.strictEqual(config.grim.attackReach, 3.0);
assert.strictEqual(config.grim.requireLineOfSight, true);
assert.strictEqual(config.grim.respectAttackCooldown, true);
assert.strictEqual(config.grim.stopMovementWhileEating, true);
assert.strictEqual(config.grim.blockConflictingActions, true);
// 疾跑跳是原版合法动作,默认放行
assert.strictEqual(config.grim.blockSprintJump, false);
// mineflayer 无载具物理,用船默认关闭
assert.strictEqual(config.autoBoat, false);
assert.strictEqual(config.waterMode, WATER_MODE.AVOID);
// 挖 / 放方块默认关闭(Grim 检查严格)
assert.strictEqual(config.allowBreak, false);
assert.strictEqual(config.allowPlace, false);
assert.strictEqual(config.pathing.allowParkour, false);
assert.strictEqual(config.pathing.allow1by1towers, false);
});
test('normalizeConfig 只接受已知字段', () => {
const merged = normalizeConfig({ strategy: 3, 未知字段: 'x', grim: { attackReach: 4 } });
assert.strictEqual(merged.strategy, 3);
assert.strictEqual(merged.grim.attackReach, 4);
assert.strictEqual(merged.未知字段, undefined);
// 未提供的字段保持默认
assert.strictEqual(merged.retries, 2);
assert.strictEqual(merged.grim.requireLineOfSight, true);
});
test('normalizeConfig 对部分配置做深合并而非整体替换', () => {
const merged = normalizeConfig({ pathing: { maxDropDown: 5 } });
assert.strictEqual(merged.pathing.maxDropDown, 5);
assert.strictEqual(merged.pathing.liquidCost, 40, '未提供的子字段应保留默认值');
});
test('validateConfig 钳制越界值', () => {
const config = validateConfig({
strategy: 99,
targetCount: -5,
retries: 0,
waitTimeoutSeconds: 1,
durabilityThreshold: 0,
refreshIntervalSeconds: 1,
apiRefreshIntervalSeconds: 5,
altitudeWeight: -3,
eatThreshold: 50,
pathing: { maxDropDown: 999, liquidCost: 0 },
grim: { attackReach: 99, maxRotationPerTickDeg: 0 },
server: { commandCooldownMs: -1, reconnectDelayMs: 10 },
waterMode: '不存在的模式'
});
assert.strictEqual(config.strategy, 1, '非法策略应回退到默认策略而不是钳制到 4');
assert.strictEqual(config.targetCount, -1);
assert.strictEqual(config.retries, 1);
assert.strictEqual(config.waitTimeoutSeconds, 5);
assert.strictEqual(config.durabilityThreshold, 1);
assert.strictEqual(config.refreshIntervalSeconds, 10);
assert.strictEqual(config.apiRefreshIntervalSeconds, 60);
assert.strictEqual(config.altitudeWeight, 0);
assert.strictEqual(config.eatThreshold, 20);
assert.strictEqual(config.pathing.maxDropDown, 64);
assert.strictEqual(config.pathing.liquidCost, 1);
assert.strictEqual(config.grim.attackReach, 6);
assert.strictEqual(config.grim.maxRotationPerTickDeg, 1);
assert.strictEqual(config.server.commandCooldownMs, 0);
assert.strictEqual(config.server.reconnectDelayMs, 1000);
assert.strictEqual(config.waterMode, WATER_MODE.AVOID);
});
test('copyConfig 是深拷贝', () => {
const original = defaultConfig();
const copy = copyConfig(original);
copy.grim.attackReach = 5;
copy.server.claimCommand = 'changed';
assert.strictEqual(original.grim.attackReach, 3.0);
assert.strictEqual(original.server.claimCommand, 'u claimmode');
});
test('ConfigStore 读写往返一致,且能容忍损坏文件', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-cfg-'));
const file = path.join(dir, 'nested', 'autostore.json');
const store = new ConfigStore(file);
// 文件不存在 → 返回默认配置
const defaults = store.load();
assert.strictEqual(defaults.strategy, STRATEGY.SQUARE);
// 保存后能读回
defaults.strategy = STRATEGY.PERIMETER;
defaults.stateName = '测试国';
assert.strictEqual(store.save(defaults), true);
const loaded = store.load();
assert.strictEqual(loaded.strategy, STRATEGY.PERIMETER);
assert.strictEqual(loaded.stateName, '测试国');
// 损坏文件 → 回退到默认配置
fs.writeFileSync(file, '{ 这不是合法 JSON', 'utf8');
assert.strictEqual(store.load().strategy, STRATEGY.SQUARE);
fs.rmSync(dir, { recursive: true, force: true });
});
test('ConfigStore 在配置文件缺失 API Key 时回退到环境变量', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-cfg-'));
const file = path.join(dir, 'autostore.json');
const store = new ConfigStore(file);
const config = defaultConfig();
config.apiKey = '';
store.save(config);
const previous = process.env.SIMP_API_KEY;
process.env.SIMP_API_KEY = 'env-key-for-test';
try {
assert.strictEqual(store.load().apiKey, 'env-key-for-test');
} finally {
if (previous === undefined) delete process.env.SIMP_API_KEY;
else process.env.SIMP_API_KEY = previous;
fs.rmSync(dir, { recursive: true, force: true });
}
});
+430
查看文件
@@ -0,0 +1,430 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { ExpansionController, State } = require('../src/controller/expansionController');
const { TerritoryModel } = require('../src/core/territoryModel');
const { defaultConfig } = require('../src/config/expansionConfig');
const { Logger } = require('../src/util/logger');
const { FakeBot } = require('./helpers/fakeBot');
const chunkPos = require('../src/core/chunkPos');
/** 假寻路服务 */
class FakePathing {
constructor() {
this.calcFailed = false;
this.currentTarget = null;
this._pathing = false;
this.pathCalls = 0;
}
pathTo(chunk) {
this.currentTarget = chunk;
this._pathing = true;
this.pathCalls++;
return true;
}
cancel() {
this._pathing = false;
}
isPathing() {
return this._pathing;
}
applyConfig() {}
}
/** 假数据服务 */
class FakeDataService {
constructor(model) {
this.model = model;
this.sessionClaims = [];
this.refreshCount = 0;
}
getModel() {
return this.model;
}
setSelectedStateName(name) {
this.model.setSelfStateName(name);
}
addSessionClaim(chunk) {
this.sessionClaims.push(chunk);
this.model.addSessionClaim(chunk);
}
refreshAsync() {
this.refreshCount++;
}
async detectStateByPlayer() {
return null;
}
snapshot() {
return { stateCount: this.model.stateCount };
}
}
/** 假机器人管理器 */
class FakeBotManager {
constructor(bot, model) {
this.bot = bot;
this.pathing = new FakePathing();
this.grim = null;
this.eater = null;
this.boat = null;
this.guard = null;
this.connection = { username: 'TestBot' };
this.commands = [];
this._model = model;
}
isReady() {
return true;
}
isSpawned() {
return true;
}
sendCommand(command) {
if (command) this.commands.push(command);
}
applyConfig() {}
}
/**
* 搭建被测环境。
* @param {{states?: Record<string, Array<[number, number]>>, selfName?: string, x?: number, z?: number, config?: object}} [options]
*/
function setup(options) {
const opts = options || {};
const states = opts.states || { A: [[0, 0]] };
const map = new Map();
for (const [name, coords] of Object.entries(states)) {
map.set(name, {
name,
capital: { x: coords[0][0], z: coords[0][1] },
chunks: new Set(coords.map(([x, z]) => chunkPos.key(x, z))),
color: 0x75bd20
});
}
const model = new TerritoryModel(map);
if (opts.selfName) model.setSelfStateName(opts.selfName);
const bot = new FakeBot({ x: (opts.x === undefined ? 0 : opts.x) * 16 + 8, y: 64, z: (opts.z === undefined ? 0 : opts.z) * 16 + 8 });
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 config = defaultConfig();
config.enforceClaimTime = false;
config.autoEat = false;
config.autoAttack = false;
config.durabilityGuard = false;
config.autoBoat = false;
Object.assign(config, opts.config || {});
return { controller, botManager, dataService, model, bot, logger, config };
}
test('start:自动识别所在邦国并下发开垦模式指令', async () => {
const { controller, botManager, config, model } = setup({ selfName: '' });
const ok = await controller.start(config);
assert.strictEqual(ok, true);
assert.strictEqual(model.selfStateNameValue(), 'A', '应自动识别为所在邦国 A');
assert.ok(botManager.commands.includes('u claimmode'), '应发送开垦模式指令');
assert.strictEqual(controller.state, State.PATHING);
assert.ok(controller.target, '应选出目标区块');
assert.strictEqual(botManager.pathing.pathCalls, 1, '应下发一次寻路');
});
test('start:指定不存在的邦国时拒绝启动', async () => {
const { controller, config } = setup();
config.stateName = '不存在的国';
const ok = await controller.start(config);
assert.strictEqual(ok, false);
assert.strictEqual(controller.state, State.IDLE);
assert.ok(controller.status.includes('不存在'));
});
test('start:不在开垦时段时登记等待自动开始', async () => {
const { controller, config } = setup();
const hour = new Date().getHours();
config.enforceClaimTime = true;
// 构造一个一定不含当前小时的时段
config.claimStartHour = (hour + 2) % 24;
config.claimEndHour = (hour + 3) % 24;
if (config.claimStartHour >= config.claimEndHour) config.claimEndHour = config.claimStartHour + 1;
const ok = await controller.start(config);
assert.strictEqual(ok, true);
assert.strictEqual(controller.isWaitingForClaimTime(), true);
assert.strictEqual(controller.state, State.IDLE);
assert.ok(controller.status.includes('等待开垦时段'));
});
test('到达目标区块后进入等待开垦状态', async () => {
const { controller, botManager, config, bot } = setup();
await controller.start(config);
const target = controller.target;
// 把机器人移到目标区块
bot.entity.position.x = target.x * 16 + 8;
bot.entity.position.z = target.z * 16 + 8;
controller.tick();
assert.strictEqual(controller.state, State.WAITING);
assert.strictEqual(botManager.pathing.isPathing(), false, '到达后应取消寻路');
});
test('开垦成功消息推进计数并进入 SUCCESS', async () => {
const { controller, config, bot, dataService } = setup();
await controller.start(config);
const target = controller.target;
bot.entity.position.x = target.x * 16 + 8;
bot.entity.position.z = target.z * 16 + 8;
controller.tick();
assert.strictEqual(controller.state, State.WAITING);
controller.monitor.onChat(`疆土(${target.x},${target.z}) [${target.x * 16},${target.z * 16}] 已为你的邦国开拓`);
assert.strictEqual(controller.completedClaims, 1);
assert.strictEqual(controller.state, State.SUCCESS);
assert.strictEqual(dataService.sessionClaims.length, 1);
assert.ok(dataService.refreshCount > 0, '占领成功后应触发一次地图刷新');
});
test('同一区块的重复成功消息只计一次', async () => {
const { controller, config, bot } = setup();
await controller.start(config);
const target = controller.target;
bot.entity.position.x = target.x * 16 + 8;
bot.entity.position.z = target.z * 16 + 8;
controller.tick();
const message = `疆土(${target.x},${target.z}) [0,0] 已为你的邦国开拓`;
controller.monitor.onChat(message);
controller.monitor.onChat(message);
assert.strictEqual(controller.completedClaims, 1);
});
test('通用成功消息只在机器人确实位于目标区块时生效', async () => {
const { controller, config, bot } = setup();
await controller.start(config);
const target = controller.target;
// 机器人不在目标区块上
bot.entity.position.x = (target.x + 5) * 16;
bot.entity.position.z = (target.z + 5) * 16;
controller.playerChunk = { x: target.x + 5, z: target.z + 5 };
controller.monitor.onChat('你的疆土已开拓');
assert.strictEqual(controller.completedClaims, 0, '不在目标区块时不得计为成功');
bot.entity.position.x = target.x * 16 + 8;
bot.entity.position.z = target.z * 16 + 8;
controller.playerChunk = { x: target.x, z: target.z };
controller.monitor.onChat('你的疆土已开拓');
assert.strictEqual(controller.completedClaims, 1);
});
test('目标已被他人占领时跳过', async () => {
const { controller, config, model } = setup();
await controller.start(config);
const target = controller.target;
// 地图刷新后该区块变成他国领土
model.allClaimedChunks().add(chunkPos.key(target.x, target.z));
controller.tick();
assert.strictEqual(controller.state, State.NEXT);
assert.strictEqual(controller.status, '跳过已占领区块');
});
test('目标不再与己方领土接壤时跳过', async () => {
const { controller, config, model } = setup();
await controller.start(config);
const target = controller.target;
// 清空己方领土,使目标不再接壤
model.selfState().chunks.clear();
controller.tick();
assert.strictEqual(controller.state, State.NEXT);
assert.strictEqual(controller.status, '跳过不接壤区块');
});
test('寻路超时会重新寻路,重试耗尽后走两步', async () => {
const { controller, botManager, config } = setup({ config: { waitTimeoutSeconds: 5, retries: 2, sideStepMaxRounds: 2 } });
await controller.start(config);
const callsAfterStart = botManager.pathing.pathCalls;
// 把阶段开始时间拨回 10 秒前,触发超时
controller.stageStartedMillis = Date.now() - 10000;
controller.tick();
assert.strictEqual(controller.retryCount, 1);
assert.strictEqual(controller.state, State.PATHING, '未耗尽重试时应继续寻路');
assert.strictEqual(botManager.pathing.pathCalls, callsAfterStart + 1);
controller.stageStartedMillis = Date.now() - 10000;
controller.tick();
assert.strictEqual(controller.sideStepping, true, '重试耗尽后应进入走两步阶段');
assert.strictEqual(controller.sideStepCount, 1);
});
test('连续超时累计达到阈值时回城', async () => {
const { controller, botManager, config } = setup({
config: { waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0, returnThresholdSeconds: 1 }
});
await controller.start(config);
controller.stageStartedMillis = Date.now() - 10000;
controller.tick(); // 第一次超时:重试次数达到上限(retries=1),走两步轮数为 0 → giveUp → 回城
assert.strictEqual(controller.state, State.RETURNING);
assert.ok(botManager.commands.includes('u return'), '应发送回城指令');
});
test('回城等待结束后重新规划(非耐久保护触发)', async () => {
const { controller, config } = setup({
config: { waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0, returnThresholdSeconds: 1, returnWaitSeconds: 1 }
});
await controller.start(config);
controller.stageStartedMillis = Date.now() - 10000;
controller.tick();
assert.strictEqual(controller.state, State.RETURNING);
controller.returnStartMillis = Date.now() - 5000;
controller.tick();
assert.strictEqual(controller.state, State.PATHING, '回城完成后应重新规划并开始寻路');
assert.strictEqual(controller.consecutiveTimeoutMillis, 0, '连续超时计数应清零');
});
test('耐久保护触发回城并在传送后停止任务', async () => {
const { controller, botManager, config } = setup({ config: { durabilityGuard: true, returnWaitSeconds: 1 } });
botManager.guard = {
check: () => ({ slot: 0, name: '主手', percent: 5, itemName: 'diamond_sword' }),
reset: () => {}
};
await controller.start(config);
controller.tick();
assert.strictEqual(controller.state, State.RETURNING);
assert.ok(botManager.commands.includes('u return'));
controller.returnStartMillis = Date.now() - 5000;
controller.tick();
assert.strictEqual(controller.state, State.STOPPED, '耐久保护回城后应停止任务');
});
test('达成目标数量后进入 COMPLETE', async () => {
const { controller, config, bot } = setup({ config: { targetCount: 1 } });
await controller.start(config);
const target = controller.target;
bot.entity.position.x = target.x * 16 + 8;
bot.entity.position.z = target.z * 16 + 8;
controller.tick(); // → WAITING
controller.monitor.onChat(`疆土(${target.x},${target.z}) [0,0] 已为你的邦国开拓`); // → SUCCESS
controller.tick(); // SUCCESS → NEXT
controller.tick(); // NEXT → 达成目标 → COMPLETE
assert.strictEqual(controller.state, State.COMPLETE);
assert.strictEqual(controller.completedClaims, 1);
assert.strictEqual(controller.isRunning(), false);
});
test('stop 后进入 STOPPED 且不再运行', async () => {
const { controller, botManager, config } = setup();
await controller.start(config);
controller.stop();
assert.strictEqual(controller.state, State.STOPPED);
assert.strictEqual(controller.isRunning(), false);
assert.strictEqual(botManager.pathing.isPathing(), false, '停止时应取消寻路');
});
test('stop 可取消「等待自动开始」', async () => {
const { controller, config } = setup();
const hour = new Date().getHours();
config.enforceClaimTime = true;
config.claimStartHour = (hour + 2) % 24;
config.claimEndHour = (hour + 3) % 24;
if (config.claimStartHour >= config.claimEndHour) config.claimEndHour = config.claimStartHour + 1;
await controller.start(config);
assert.strictEqual(controller.isWaitingForClaimTime(), true);
controller.stop();
assert.strictEqual(controller.isWaitingForClaimTime(), false);
assert.strictEqual(controller.state, State.IDLE);
});
test('自动游泳:水中按住跳跃键,离开水后松开', async () => {
const { controller, botManager, config, bot } = setup();
botManager.grim = {
controls: {},
setControlState(control, state) { this.controls[control] = state; },
setLook() {}
};
await controller.start(config);
bot.entity.isInWater = true;
controller.tick();
assert.strictEqual(botManager.grim.controls.jump, true, '水中应按住跳跃键上浮');
bot.entity.isInWater = false;
controller.tick();
assert.strictEqual(botManager.grim.controls.jump, false, '离水后应松开跳跃键');
});
test('船上不自动游泳', async () => {
const { controller, botManager, config, bot } = setup();
botManager.grim = {
controls: {},
setControlState(control, state) { this.controls[control] = state; },
setLook() {}
};
await controller.start(config);
bot.entity.isInWater = true;
bot.vehicle = { id: 1 };
controller.tick();
assert.notStrictEqual(botManager.grim.controls.jump, true, '坐船时不应按跳跃键');
});
test('snapshot 暴露任务状态供控制台展示', async () => {
const { controller, config } = setup();
await controller.start(config);
const snapshot = controller.snapshot();
assert.strictEqual(snapshot.running, true);
assert.strictEqual(snapshot.state, State.PATHING);
assert.ok(snapshot.target);
assert.strictEqual(snapshot.completedClaims, 0);
assert.strictEqual(snapshot.withinClaimTime, true);
assert.strictEqual(snapshot.claimModeSent, true);
});
test('exportLogs 写出状态摘要与全部日志', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-log-'));
const file = path.join(dir, 'debug.log');
const { controller, config } = setup();
await controller.start(config);
const result = controller.exportLogs(file);
assert.strictEqual(result, file);
const content = fs.readFileSync(file, 'utf8');
assert.ok(content.includes('AutoStore Bot 调试日志'));
assert.ok(content.includes('状态:'));
assert.ok(content.includes('开始自动扩地'));
fs.rmSync(dir, { recursive: true, force: true });
});
test('未启动任务时 tick 不做任何事', () => {
const { controller } = setup();
assert.doesNotThrow(() => controller.tick());
assert.strictEqual(controller.state, State.IDLE);
});
test('规划队列为空时直接进入 COMPLETE', async () => {
// 己方领土被完全包围且无空位:构造一个只有 1 个区块、四周全被他国占领的局面
const { controller, config } = setup({
states: {
A: [[0, 0]],
B: [[1, 0], [-1, 0], [0, 1], [0, -1]]
},
selfName: 'A'
});
await controller.start(config);
assert.strictEqual(controller.state, State.COMPLETE);
assert.strictEqual(controller.status, '没有可用的相邻无主区块');
});
+269
查看文件
@@ -0,0 +1,269 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const { ExpansionPlanner } = require('../src/core/expansionPlanner');
const { TerritoryModel } = require('../src/core/territoryModel');
const { defaultConfig } = require('../src/config/expansionConfig');
const chunkPos = require('../src/core/chunkPos');
/**
* 构造领土模型。
* @param {Record<string, Array<[number, number]>>} states 邦国名 → 区块坐标列表
* @param {string} [selfName] 己方邦国名
*/
function makeModel(states, selfName) {
const map = new Map();
for (const [name, coords] of Object.entries(states)) {
const chunks = new Set(coords.map(([x, z]) => chunkPos.key(x, z)));
map.set(name, {
name,
capital: { x: coords[0][0], z: coords[0][1] },
chunks,
color: 0x75bd20
});
}
const model = new TerritoryModel(map);
if (selfName) model.setSelfStateName(selfName);
return model;
}
const planner = new ExpansionPlanner();
test('策略 1:只返回与己方领土相邻的未占领区块', () => {
const model = makeModel({ A: [[0, 0]] }, 'A');
const config = Object.assign(defaultConfig(), { strategy: 1 });
const result = planner.generate(model, config, { x: 0, z: 0 });
// 接壤只算四个正方向,因此单个己方区块只有 4 个合法邻居(对角线不算)
assert.strictEqual(result.length, 4, '单个己方区块应产生 4 个正方向邻居候选');
for (const chunk of result) {
assert.strictEqual(
Math.abs(chunk.x) + Math.abs(chunk.z),
1,
`候选 ${chunk.x},${chunk.z} 不是 (0,0) 的正方向邻居`
);
}
});
test('策略 1:绝不选中「只与其他邦国相邻」的区块(历史 bug 回归)', () => {
// 己方只有 (0,0);其他邦国占据 (1,0) 与 (1,1)
const model = makeModel({ A: [[0, 0]], B: [[1, 0], [1, 1]] }, 'A');
const config = Object.assign(defaultConfig(), { strategy: 1 });
const result = planner.generate(model, config, { x: 0, z: 0 });
const keys = result.map((c) => chunkPos.key(c.x, c.z));
assert.ok(!keys.includes(chunkPos.key(1, 0)), '不能选中已被 B 占领的区块');
assert.ok(!keys.includes(chunkPos.key(1, 1)), '不能选中已被 B 占领的区块');
// (2,0) 只与 B 的 (1,0) 相邻,与 A 不相邻,必须被排除
assert.ok(!keys.includes(chunkPos.key(2, 0)), '不能选中只与他国相邻的区块');
for (const chunk of result) {
const adjacentToOwn = chunkPos.cardinalNeighbors(chunk.x, chunk.z)
.some((n) => n.x === 0 && n.z === 0);
assert.ok(adjacentToOwn, `候选 ${chunk.x},${chunk.z} 必须与己方领土接壤`);
}
});
test('策略 1:矩形内部的空洞优先于外部边界', () => {
// 己方占 3×3 外圈,中心 (1,1) 是空洞
const ring = [];
for (let x = 0; x <= 2; x++) {
for (let z = 0; z <= 2; z++) {
if (x === 1 && z === 1) continue;
ring.push([x, z]);
}
}
const model = makeModel({ A: ring }, 'A');
const config = Object.assign(defaultConfig(), { strategy: 1 });
const result = planner.generate(model, config, { x: 1, z: 1 });
assert.deepStrictEqual(result[0], { x: 1, z: 1 }, '内部空洞应排在最前');
});
test('策略 1:maxRings 影响搜索圈数(扩张轮数为 0 时只看第一圈)', () => {
const model = makeModel({ A: [[0, 0]] }, 'A');
const config = Object.assign(defaultConfig(), { strategy: 1, maxRings: 1 });
const result = planner.generate(model, config, { x: 0, z: 0 });
// maxRings=1 → maxExpansions=2,第一圈就能找到候选,结果仍是 4 个正方向邻居
assert.strictEqual(result.length, 4);
});
test('策略 2:矩形扩地严格按 S 形蛇形扫描', () => {
const model = makeModel({ A: [[0, 0]] }, 'A');
const config = Object.assign(defaultConfig(), {
strategy: 2,
regionMinX: 0,
regionMaxX: 3,
regionMinZ: 0,
regionMaxZ: 3
});
const result = planner.generate(model, config, { x: 0, z: 0 });
assert.strictEqual(result.length, 15, '4×4 区域去掉 1 个己方区块应为 15 个');
// 王城在 (0,0):起始边为 z=0(北),行内从 x 小到大
assert.deepStrictEqual(result[0], { x: 1, z: 0 });
assert.deepStrictEqual(result[1], { x: 2, z: 0 });
assert.deepStrictEqual(result[2], { x: 3, z: 0 });
// 第二行反向(蛇形)
assert.deepStrictEqual(result[3], { x: 3, z: 1 });
assert.deepStrictEqual(result[4], { x: 2, z: 1 });
assert.deepStrictEqual(result[5], { x: 1, z: 1 });
assert.deepStrictEqual(result[6], { x: 0, z: 1 });
// 第三行再次反向
assert.deepStrictEqual(result[7], { x: 0, z: 2 });
});
test('策略 2:每个入选区块都与己方领土(含本次已选)接壤', () => {
const model = makeModel({ A: [[0, 0]] }, 'A');
const config = Object.assign(defaultConfig(), {
strategy: 2,
regionMinX: -2,
regionMaxX: 4,
regionMinZ: -2,
regionMaxZ: 4
});
const result = planner.generate(model, config, { x: 0, z: 0 });
const claimed = new Set([chunkPos.key(0, 0)]);
for (const chunk of result) {
const adjacent = chunkPos.cardinalNeighbors(chunk.x, chunk.z)
.some((n) => claimed.has(chunkPos.key(n.x, n.z)));
assert.ok(adjacent, `区块 ${chunk.x},${chunk.z} 必须与己方领土接壤`);
claimed.add(chunkPos.key(chunk.x, chunk.z));
}
});
test('策略 2:跳过他国区块但不打乱 S 形顺序', () => {
// 己方 (0,0)(1,0),他国占 (1,1),区域 0..3 × 0..1
const model = makeModel({ A: [[0, 0], [1, 0]], B: [[1, 1]] }, 'A');
const config = Object.assign(defaultConfig(), {
strategy: 2,
regionMinX: 0,
regionMaxX: 3,
regionMinZ: 0,
regionMaxZ: 1
});
const result = planner.generate(model, config, { x: 0, z: 0 });
const keys = result.map((c) => `${c.x},${c.z}`);
assert.ok(!keys.includes('1,1'), '他国区块必须跳过');
// S 形顺序保持不变:第一行向右扫,第二行向左扫,(1,1) 被跳过但顺序不被打乱;
// 末尾的 (0,1) 因为与己方 (0,0) 正方向接壤而被选中
assert.deepStrictEqual(keys, ['2,0', '3,0', '3,1', '2,1', '0,1']);
});
test('策略 2:他国区块会切断连通,其后不可达区块不入选', () => {
// 己方只有 (0,0),他国占 (2,0):x=3 一列与他国相邻但离己方太远,必须排除
const model = makeModel({ A: [[0, 0]], B: [[2, 0]] }, 'A');
const config = Object.assign(defaultConfig(), {
strategy: 2,
regionMinX: 0,
regionMaxX: 3,
regionMinZ: 0,
regionMaxZ: 0
});
const result = planner.generate(model, config, { x: 0, z: 0 });
assert.deepStrictEqual(result.map((c) => `${c.x},${c.z}`), ['1,0']);
});
test('策略 3:按与玩家的距离由近到远排序', () => {
const model = makeModel({ A: [[0, 0]] }, 'A');
const config = Object.assign(defaultConfig(), { strategy: 3 });
const playerChunk = { x: 5, z: 5 };
const result = planner.generate(model, config, playerChunk);
assert.strictEqual(result.length, 4);
const distances = result.map((c) => chunkPos.manhattan(c, playerChunk));
for (let i = 1; i < distances.length; i++) {
assert.ok(distances[i] >= distances[i - 1], '距离必须非递减');
}
assert.strictEqual(distances[0], 9, '最近的邻居距离应为 9');
});
test('策略 4:绕国一圈按方位角均匀排序', () => {
const model = makeModel({ A: [[0, 0], [1, 0], [0, 1], [1, 1]] }, 'A');
const config = Object.assign(defaultConfig(), { strategy: 4, maxRings: 1 });
const result = planner.generate(model, config, { x: 0, z: 0 });
assert.strictEqual(result.length, 8, '2×2 领土外圈应有 8 个候选');
// 质心 (0,0):方位角从 -π/2 起
assert.deepStrictEqual(result[0], { x: 0, z: -1 });
assert.deepStrictEqual(result[1], { x: 1, z: -1 });
});
test('策略 4:多圈扩张时逐圈向外推进', () => {
const model = makeModel({ A: [[0, 0]] }, 'A');
const config = Object.assign(defaultConfig(), { strategy: 4, maxRings: 2 });
const result = planner.generate(model, config, { x: 0, z: 0 });
assert.ok(result.length > 4, '两圈应超过第一圈的 4 个候选');
const firstRing = result.slice(0, 4).every((c) => Math.abs(c.x) + Math.abs(c.z) === 1);
assert.ok(firstRing, '前 4 个应是第一圈的四个邻居');
});
test('prefixFlyingChunks:飞地优先且去重、受目标数量限制', () => {
const ring = [];
for (let x = 0; x <= 2; x++) {
for (let z = 0; z <= 2; z++) {
if (x === 1 && z === 1) continue;
ring.push([x, z]);
}
}
const model = makeModel({ A: ring }, 'A');
const config = Object.assign(defaultConfig(), { strategy: 3, targetCount: 3 });
const planned = [{ x: 3, z: 0 }, { x: 3, z: 1 }, { x: 3, z: 2 }];
const result = planner.prefixFlyingChunks(model, config, { x: 0, z: 0 }, planned);
assert.deepStrictEqual(result[0], { x: 1, z: 1 }, '飞地必须排在最前');
assert.strictEqual(result.length, 3, '不得超过 targetCount');
const keys = result.map((c) => `${c.x},${c.z}`);
assert.strictEqual(new Set(keys).size, keys.length, '结果不得重复');
});
test('prefixFlyingChunks:区域外的飞地不被优先填入', () => {
const ring = [];
for (let x = 0; x <= 2; x++) {
for (let z = 0; z <= 2; z++) {
if (x === 1 && z === 1) continue;
ring.push([x, z]);
}
}
const model = makeModel({ A: ring }, 'A');
const config = Object.assign(defaultConfig(), {
strategy: 2,
regionMinX: 10,
regionMaxX: 12,
regionMinZ: 10,
regionMaxZ: 12
});
const planned = [{ x: 10, z: 10 }];
const result = planner.prefixFlyingChunks(model, config, { x: 0, z: 0 }, planned);
assert.deepStrictEqual(result, [{ x: 10, z: 10 }], '区域外的飞地应被过滤掉');
});
test('isInRegion:非策略 2 恒为 true,策略 2 支持矩形与中心+半径', () => {
const base = defaultConfig();
assert.strictEqual(planner.isInRegion({ x: 999, z: -999 }, Object.assign(base, { strategy: 1 })), true);
const rect = Object.assign(defaultConfig(), {
strategy: 2, regionMinX: 0, regionMaxX: 2, regionMinZ: 0, regionMaxZ: 2
});
assert.strictEqual(planner.isInRegion({ x: 2, z: 2 }, rect), true);
assert.strictEqual(planner.isInRegion({ x: 3, z: 2 }, rect), false);
const circle = Object.assign(defaultConfig(), {
strategy: 2, regionMinX: 0, regionMaxX: -1, regionCenterX: 0, regionCenterZ: 0, regionRadius: 2
});
assert.strictEqual(planner.isInRegion({ x: 2, z: -2 }, circle), true);
assert.strictEqual(planner.isInRegion({ x: 3, z: 0 }, circle), false);
});
test('己方领土为空时不做任何规划', () => {
const model = makeModel({ B: [[5, 5]] }, 'A');
const config = defaultConfig();
for (const strategy of [1, 2, 3, 4]) {
config.strategy = strategy;
const result = planner.generate(model, config, { x: 0, z: 0 });
assert.deepStrictEqual(result, [], `策略 ${strategy} 在己方无领土时应返回空`);
}
});
test('规划结果始终在世界边界内', () => {
const model = makeModel({ A: [[1279, 1279]] }, 'A');
const config = Object.assign(defaultConfig(), { strategy: 1 });
const result = planner.generate(model, config, { x: 1279, z: 1279 });
for (const chunk of result) {
assert.ok(chunkPos.withinWorldBorder(chunk.x, chunk.z), `${chunk.x},${chunk.z} 越界`);
}
});
+226
查看文件
@@ -0,0 +1,226 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const { Vec3 } = require('vec3');
const { FakeBot } = require('./helpers/fakeBot');
const {
GrimCompat,
shortestAngleDelta,
eyeDistanceToEntity,
VANILLA_PHYSICS_BASELINE
} = require('../src/bot/grimCompat');
const { defaultConfig } = require('../src/config/expansionConfig');
/**
* 构造一个已安装合规层的假 bot。
* @param {object} [options]
*/
function setup(options) {
const opts = options || {};
const config = Object.assign(defaultConfig(), opts.config || {});
const bot = new FakeBot(opts.bot);
const grim = new GrimCompat(bot, config, null);
grim.install();
return { bot, grim, config };
}
test('shortestAngleDelta 走最短路径并处理环绕', () => {
assert.ok(Math.abs(shortestAngleDelta(0, 0.5) - 0.5) < 1e-9);
// 从 170° 到 -170° 应走 +20° 而不是 -340°
const from = (170 * Math.PI) / 180;
const to = (-170 * Math.PI) / 180;
assert.ok(Math.abs(shortestAngleDelta(from, to) - (20 * Math.PI) / 180) < 1e-9);
assert.ok(Math.abs(shortestAngleDelta(0, Math.PI * 2)) < 1e-9);
});
test('eyeDistanceToEntity 计算眼睛到碰撞箱的最近距离', () => {
const bot = new FakeBot({ x: 0, y: 64, z: 0 });
// 目标在正前方 2 格,碰撞箱中心高度与眼睛同高
const entity = { position: new Vec3(2, 64, 0), width: 0.6, height: 1.8 };
const distance = eyeDistanceToEntity(bot, entity);
assert.ok(Math.abs(distance - (2 - 0.3)) < 1e-6, `距离应为 1.7,实际 ${distance}`);
});
test('旋转被限制在每 tick 的最大步长内,且不会超前于服务端', async () => {
const { bot, grim } = setup();
const targetYaw = Math.PI / 2; // 90°
const promise = grim.setLook(targetYaw, 0);
assert.strictEqual(grim.isRotationSettled(), false, '刚设置目标时不应认为已同步');
bot.tick();
const step = grim.maxStepRad;
assert.ok(Math.abs(bot.entity.yaw - step) < 1e-9, `单 tick 只应前进 ${step} 弧度`);
assert.ok(bot.entity.yaw < targetYaw, '本地朝向不得一次跳到目标值(否则与服务端预测分歧)');
// 推进足够多 tick 后应到位
for (let i = 0; i < 60; i++) bot.tick();
await promise;
assert.ok(Math.abs(bot.entity.yaw - targetYaw) < 1e-4);
assert.strictEqual(grim.isRotationSettled(), true);
});
test('每 tick 旋转步长不超过 mineflayer 物理限速', () => {
const { grim } = setup();
// 物理限速 = 0.05s × 3.0 rad/s = 0.15 rad ≈ 8.59°
assert.ok(grim.maxStepRad <= 0.15 + 1e-9);
assert.ok(grim.maxStepRad > 0.14);
});
test('pitch 被钳制在 ±90° 内', async () => {
const { bot, grim } = setup();
grim.setLook(0, Math.PI); // 超出范围
for (let i = 0; i < 60; i++) bot.tick();
assert.ok(bot.entity.pitch <= Math.PI / 2 + 1e-9);
assert.ok(bot.entity.pitch >= -Math.PI / 2 - 1e-9);
});
test('拦截疾跑 + 潜行的非法组合(MultiActions)', () => {
const { bot, grim } = setup();
grim.setControlState('sprint', true);
assert.strictEqual(bot.getControlState('sprint'), true);
grim.setControlState('sneak', true);
assert.strictEqual(bot.getControlState('sneak'), false, '疾跑时不允许潜行');
assert.strictEqual(grim.blockedActions, 1);
});
test('拦截疾跑 + 使用物品的非法组合', () => {
const { bot, grim } = setup();
grim.setControlState('sprint', true);
bot.usingHeldItem = true;
grim.setControlState('forward', true);
assert.strictEqual(bot.getControlState('forward'), false, '进食时不允许前进(NoSlow)');
});
test('默认放行疾跑跳跃(原版合法动作)', () => {
const { bot, grim } = setup();
grim.setControlState('jump', true);
grim.setControlState('sprint', true);
assert.strictEqual(bot.getControlState('sprint'), true);
});
test('blockSprintJump 开启时拦下疾跑跳跃', () => {
const config = defaultConfig();
config.grim.blockSprintJump = true;
const { bot, grim } = setup({ config });
grim.setControlState('jump', true);
grim.setControlState('sprint', true);
assert.strictEqual(bot.getControlState('sprint'), false);
});
test('攻击冷却按手持武器攻速计算', () => {
const config = defaultConfig();
const swordBot = new FakeBot({ heldItem: { name: 'diamond_sword' } });
const swordGrim = new GrimCompat(swordBot, config, null);
assert.strictEqual(swordGrim.attackCooldownMs(), 625, '钻石剑 1.6 次/秒 → 625ms');
const axeBot = new FakeBot({ heldItem: { name: 'netherite_axe' } });
const axeGrim = new GrimCompat(axeBot, config, null);
assert.strictEqual(axeGrim.attackCooldownMs(), 1000, '下界合金斧 1.0 次/秒 → 1000ms');
const handBot = new FakeBot({ heldItem: null });
const handGrim = new GrimCompat(handBot, config, null);
assert.strictEqual(handGrim.attackCooldownMs(), 250, '空手 4 次/秒 → 250ms');
});
test('canAttack 校验距离、视线与冷却', async () => {
const { bot, grim } = setup();
bot.entity.position = new Vec3(0, 64, 0);
const far = { position: new Vec3(10, 64, 0), width: 0.6, height: 1.8, isValid: true };
assert.deepStrictEqual(grim.canAttack(far), { ok: false, reason: 'out_of_reach' });
const near = { position: new Vec3(2, 64, 0), width: 0.6, height: 1.8, isValid: true };
assert.deepStrictEqual(grim.canAttack(near), { ok: true });
// 攻击后进入冷却
const attacked = await grim.attackEntity(near, { skipRotation: true });
assert.strictEqual(attacked, true);
assert.strictEqual(bot.lastAttack.entity, near);
assert.deepStrictEqual(grim.canAttack(near), { ok: false, reason: 'cooldown' });
});
test('canAttack 拒绝视线被遮挡的目标', () => {
const config = defaultConfig();
const bot = new FakeBot({
world: { raycast: () => ({ position: new Vec3(1, 64, 0) }) } // 总是命中方块
});
const grim = new GrimCompat(bot, config, null);
const target = { position: new Vec3(2, 64, 0), width: 0.6, height: 1.8, isValid: true };
assert.deepStrictEqual(grim.canAttack(target), { ok: false, reason: 'no_line_of_sight' });
config.grim.requireLineOfSight = false;
assert.deepStrictEqual(grim.canAttack(target), { ok: true });
});
test('canAttack 拒绝骑乘状态下的攻击', () => {
const { bot, grim } = setup();
bot.vehicle = { id: 1 };
const target = { position: new Vec3(2, 64, 0), width: 0.6, height: 1.8, isValid: true };
assert.deepStrictEqual(grim.canAttack(target), { ok: false, reason: 'riding' });
});
test('attackEntity 会先同步朝向再出手', async () => {
const { bot, grim } = setup();
const target = { position: new Vec3(2, 64, 0), width: 0.6, height: 1.8, isValid: true, id: 7 };
const promise = grim.attackEntity(target);
// 旋转尚未完成时不应出手
bot.tick();
assert.strictEqual(bot.lastAttack, undefined, '朝向未同步前不得攻击');
for (let i = 0; i < 60; i++) bot.tick();
await promise;
assert.ok(bot.lastAttack, '朝向同步后应完成攻击');
});
test('关闭合规层后不做任何限速与拦截', () => {
const config = defaultConfig();
config.grim.enabled = false;
const bot = new FakeBot();
const grim = new GrimCompat(bot, config, null);
grim.install();
bot.setControlState('sprint', true);
bot.setControlState('sneak', true);
assert.strictEqual(bot.getControlState('sneak'), true, '关闭后不拦截');
assert.strictEqual(grim.blockedActions, 0);
});
test('audit 检出被篡改的物理常量与创造模式', () => {
const { bot, grim } = setup();
assert.deepStrictEqual(grim.audit(), { ok: true, issues: [] });
bot.physics.gravity = 0.02;
const tampered = grim.audit();
assert.strictEqual(tampered.ok, false);
assert.ok(tampered.issues.some((issue) => issue.includes('gravity')));
bot.physics.gravity = VANILLA_PHYSICS_BASELINE.gravity;
bot.game.gameMode = 'creative';
assert.ok(grim.audit().issues.some((issue) => issue.includes('创造模式')));
});
test('audit 检出被关闭的本地物理预测', () => {
const { bot, grim } = setup();
bot.physicsEnabled = false;
assert.ok(grim.audit().issues.some((issue) => issue.includes('physicsEnabled')));
});
test('snapshot 暴露合规状态供控制台展示', () => {
const { grim } = setup();
const snapshot = grim.snapshot();
assert.strictEqual(snapshot.enabled, true);
assert.strictEqual(snapshot.attackReach, 3.0);
assert.strictEqual(snapshot.physicsOk, true);
assert.ok(snapshot.effectiveStepDeg > 0 && snapshot.effectiveStepDeg <= 9);
assert.strictEqual(snapshot.blockedActions, 0);
});
test('releaseAllControls 松开全部控制键', () => {
const { bot, grim } = setup();
grim.setControlState('forward', true);
grim.setControlState('sprint', true);
grim.releaseAllControls();
for (const control of ['forward', 'back', 'left', 'right', 'jump', 'sprint', 'sneak']) {
assert.strictEqual(bot.getControlState(control), false, control + ' 应被松开');
}
});
+141
查看文件
@@ -0,0 +1,141 @@
'use strict';
const { EventEmitter } = require('events');
const { Vec3 } = require('vec3');
/** 真实的 1.21.4 注册表(mineflayer-pathfinder 的 Movements 依赖它) */
let cachedRegistry = null;
function defaultRegistry() {
if (!cachedRegistry) {
cachedRegistry = require('minecraft-data')('1.21.4');
}
return cachedRegistry;
}
/**
* 测试用的假 bot:只实现被测模块真正用到的接口,
* 避免测试依赖真实 Minecraft 服务器。
*/
class FakeBot extends EventEmitter {
constructor(options) {
super();
const opts = options || {};
this.username = opts.username || 'TestBot';
this.physicsEnabled = true;
this.usingHeldItem = false;
this.vehicle = null;
this.food = 20;
this.health = 20;
this.game = { gameMode: opts.gameMode || 'survival' };
this.physics = Object.assign({
gravity: 0.08,
playerSpeed: 0.1,
sprintSpeed: 0.3,
sneakSpeed: 0.3,
stepHeight: 0.6,
airborneInertia: 0.91,
waterInertia: 0.8,
yawSpeed: 3.0,
pitchSpeed: 3.0
}, opts.physics || {});
this.entity = {
position: new Vec3(opts.x || 0, opts.y || 64, opts.z || 0),
velocity: new Vec3(0, 0, 0),
yaw: 0,
pitch: 0,
eyeHeight: 1.62,
isInWater: false,
isInLava: false,
onGround: true
};
/** @type {Record<string, boolean>} */
this.controls = {};
this.heldItem = opts.heldItem || null;
this.inventory = { slots: opts.slots || [] };
this.entities = {};
this.world = opts.world || { raycast: () => null };
this.registry = opts.registry || defaultRegistry();
this.pathfinder = null;
this.blockMap = opts.blockMap || new Map();
this.creative = { flying: false };
}
getControlState(control) {
return this.controls[control] === true;
}
setControlState(control, state) {
this.controls[control] = state === true;
this.emit('controlState', control, state);
}
blockAt(pos) {
const key = `${Math.floor(pos.x)},${Math.floor(pos.y)},${Math.floor(pos.z)}`;
if (this.blockMap.has(key)) return this.blockMap.get(key);
return { name: 'air', boundingBox: 'empty' };
}
look(yaw, pitch) {
this.entity.yaw = yaw;
this.entity.pitch = pitch === undefined ? this.entity.pitch : pitch;
return Promise.resolve();
}
lookAt(point) {
return this.look(this.entity.yaw, this.entity.pitch);
}
attack(entity, swing) {
this.lastAttack = { entity, swing };
}
activateItem() {
this.usingHeldItem = true;
}
deactivateItem() {
this.usingHeldItem = false;
}
async waitForTicks(ticks) {
for (let i = 0; i < (ticks || 1); i++) this.emit('physicsTick');
}
chat(message) {
this.lastChat = message;
}
equip() {
return Promise.resolve();
}
consume() {
this.usingHeldItem = false;
this.food = 20;
return Promise.resolve();
}
mount() {
this.vehicle = { id: 99, position: this.entity.position.clone(), name: 'oak_boat' };
}
dismount() {
this.vehicle = null;
}
moveVehicle() {
this.lastVehicleMove = Array.from(arguments);
}
/** 推进一个物理 tick(触发合规层的旋转推进) */
tick() {
this.emit('physicsTick');
}
/** 推进 n 个物理 tick */
tickTimes(n) {
for (let i = 0; i < n; i++) this.emit('physicsTick');
}
}
module.exports = { FakeBot };
+138
查看文件
@@ -0,0 +1,138 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const { AltitudeGoal, PathingService, PathEvent } = require('../src/bot/pathingService');
const { GoalXZ, GoalNearXZ } = require('mineflayer-pathfinder').goals;
const { defaultConfig } = require('../src/config/expansionConfig');
const { FakeBot } = require('./helpers/fakeBot');
test('AltitudeGoal 的到达判定与内层目标一致', () => {
const inner = new GoalXZ(100, 200);
const goal = new AltitudeGoal(inner, 0.1, 64);
assert.strictEqual(goal.isEnd({ x: 100, y: 70, z: 200 }), true, '不约束高度');
assert.strictEqual(goal.isEnd({ x: 101, y: 70, z: 200 }), false);
});
test('AltitudeGoal 对高处给更低启发值、对低处给更高启发值', () => {
const goal = new AltitudeGoal(new GoalXZ(0, 0), 0.1, 64);
const high = goal.heuristic({ x: 10, y: 100, z: 10 });
const low = goal.heuristic({ x: 10, y: 20, z: 10 });
assert.ok(high < low, `高处启发值 ${high} 应小于低处 ${low}`);
// 与 GoalXZ 的差值应等于 weight × y
const base = new GoalXZ(0, 0).heuristic({ x: 10, y: 100, z: 10 });
assert.ok(Math.abs((base - high) - 0.1 * 100) < 1e-9);
});
test('AltitudeGoal 启发值被钳制为非负', () => {
const goal = new AltitudeGoal(new GoalXZ(0, 0), 10, 0);
assert.ok(goal.heuristic({ x: 0, y: 300, z: 0 }) >= 0);
});
test('AltitudeGoal 使用 GoalNearXZ 时保留其到达范围', () => {
const goal = new AltitudeGoal(new GoalNearXZ(100, 200, 2), 0.1, 64);
assert.strictEqual(goal.isEnd({ x: 100, y: 70, z: 201 }), true, '范围内算到达');
});
test('configure 施加 Grim 合规约束(默认禁止挖 / 放 / 跑酷 / 搭塔)', () => {
const bot = new FakeBot();
const movements = {};
bot.pathfinder = {
setMovements: (m) => { movements.value = m; },
thinkTimeout: 0,
tickTimeout: 0,
searchRadius: 0
};
const config = defaultConfig();
const service = new PathingService(bot, config, null);
assert.strictEqual(service.configure(), true);
const m = movements.value;
assert.strictEqual(m.canDig, false, '默认不允许挖方块');
assert.deepStrictEqual(m.scafoldingBlocks, [], '默认不允许放方块');
assert.strictEqual(m.allowParkour, false);
assert.strictEqual(m.allow1by1towers, false);
assert.strictEqual(m.allowFreeMotion, false);
assert.strictEqual(m.maxDropDown, 3);
assert.strictEqual(m.infiniteLiquidDropdownDistance, false);
assert.strictEqual(m.dontMineUnderFallingBlock, true);
// waterMode=avoid 时液体代价被推高
assert.ok(m.liquidCost >= 200, 'avoid 模式下液体代价应被推高');
});
test('configure 在 allowBreak / allowPlace 打开时放行对应能力', () => {
const bot = new FakeBot();
const movements = {};
bot.pathfinder = { setMovements: (m) => { movements.value = m; }, thinkTimeout: 0, tickTimeout: 0, searchRadius: 0 };
const config = defaultConfig();
config.allowBreak = true;
config.allowPlace = true;
config.waterMode = 'swim';
const service = new PathingService(bot, config, null);
service.configure();
assert.strictEqual(movements.value.canDig, true);
assert.ok(movements.value.scafoldingBlocks.length > 0);
assert.strictEqual(movements.value.liquidCost, config.pathing.liquidCost);
});
test('pathTo 下发目标区块中心并触发 CALC_STARTED', () => {
const bot = new FakeBot();
let goal = null;
bot.pathfinder = {
setMovements: () => {},
setGoal: (g) => { goal = g; },
thinkTimeout: 0,
tickTimeout: 0,
searchRadius: 0
};
const events = [];
const service = new PathingService(bot, defaultConfig(), null);
service.configure();
service.onPathEvent = (event, detail) => events.push({ event, detail });
assert.strictEqual(service.pathTo({ x: 3, z: -2 }), true);
assert.ok(goal, '应下发目标');
assert.strictEqual(goal.isEnd({ x: 3 * 16 + 8, y: 64, z: -2 * 16 + 8 }), true, '目标应为区块中心附近');
assert.strictEqual(events[0].event, PathEvent.CALC_STARTED);
assert.strictEqual(service.isPathing(), true);
});
test('关闭高度偏好时不包装目标', () => {
const bot = new FakeBot();
let goal = null;
bot.pathfinder = { setMovements: () => {}, setGoal: (g) => { goal = g; }, thinkTimeout: 0, tickTimeout: 0, searchRadius: 0 };
const config = defaultConfig();
config.altitudePreference = false;
const service = new PathingService(bot, config, null);
service.configure();
service.pathTo({ x: 0, z: 0 });
assert.ok(!(goal instanceof AltitudeGoal), '未启用高度偏好时不应包装');
});
test('cancel 清空目标并停止寻路状态', () => {
const bot = new FakeBot();
let cleared = false;
bot.pathfinder = {
setMovements: () => {},
setGoal: (g) => { if (g === null) cleared = true; },
thinkTimeout: 0,
tickTimeout: 0,
searchRadius: 0
};
const service = new PathingService(bot, defaultConfig(), null);
service.configure();
service.pathTo({ x: 1, z: 1 });
service.cancel();
assert.strictEqual(cleared, true);
assert.strictEqual(service.isPathing(), false);
assert.strictEqual(service.currentTarget, null);
});
test('pathfinder 未加载时 configure 返回 false 且 pathTo 不抛异常', () => {
const bot = new FakeBot();
const service = new PathingService(bot, defaultConfig(), null);
assert.strictEqual(service.configure(), false);
assert.strictEqual(service.isAvailable(), false);
assert.strictEqual(service.pathTo({ x: 0, z: 0 }), false);
service.cancel(); // 不应抛异常
});
+105
查看文件
@@ -0,0 +1,105 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const {
SubtitleChatMonitor,
containsFuzzy,
boundedLevenshtein
} = require('../src/core/subtitleChatMonitor');
/** 收集回调结果的假监听器 */
function makeListener() {
const events = [];
return {
events,
onProgress: (percent) => events.push({ type: 'progress', percent }),
onSuccess: (chunk, blockX, blockZ) => events.push({ type: 'success', chunk, blockX, blockZ }),
onGenericSuccess: () => events.push({ type: 'generic' })
};
}
test('字幕解析开垦进度百分比', () => {
const listener = makeListener();
const monitor = new SubtitleChatMonitor(listener);
monitor.onSubtitle('开垦中 50%');
assert.deepStrictEqual(listener.events, [{ type: 'progress', percent: 50 }]);
});
test('字幕进度支持小数并钳制到 0-100', () => {
const listener = makeListener();
const monitor = new SubtitleChatMonitor(listener);
monitor.onSubtitle('开垦中 99.6%');
monitor.onSubtitle('开垦中 150%');
// 负号不属于百分比模式(与原 Java 版一致:模式为数字 + %,负号被当作普通文本忽略)
monitor.onSubtitle('开垦中 -20%');
assert.deepStrictEqual(listener.events.map((e) => e.percent), [100, 100, 20]);
});
test('不含开垦关键词的字幕被忽略', () => {
const listener = makeListener();
const monitor = new SubtitleChatMonitor(listener);
monitor.onSubtitle('服务器公告:今晚 20:00 攻城 50%');
assert.strictEqual(listener.events.length, 0);
});
test('聊天解析带坐标的开垦成功消息', () => {
const listener = makeListener();
const monitor = new SubtitleChatMonitor(listener);
monitor.onChat('疆土(123,-45) [1968,-720] 已为你的邦国开拓');
assert.deepStrictEqual(listener.events, [{
type: 'success',
chunk: { x: 123, z: -45 },
blockX: 1968,
blockZ: -720
}]);
});
test('聊天成功消息兼容全角括号与全角逗号', () => {
const listener = makeListener();
const monitor = new SubtitleChatMonitor(listener);
monitor.onChat('疆土(7,8) [ 112 , 128 ] 已为你的邦国开拓');
assert.strictEqual(listener.events.length, 1);
assert.deepStrictEqual(listener.events[0].chunk, { x: 7, z: 8 });
assert.strictEqual(listener.events[0].blockX, 112);
assert.strictEqual(listener.events[0].blockZ, 128);
});
test('无坐标的通用成功消息走 onGenericSuccess', () => {
const listener = makeListener();
const monitor = new SubtitleChatMonitor(listener);
monitor.onChat('你的疆土已开拓,继续努力!');
assert.deepStrictEqual(listener.events, [{ type: 'generic' }]);
});
test('完全无关的聊天不触发任何回调', () => {
const listener = makeListener();
const monitor = new SubtitleChatMonitor(listener);
monitor.onChat('<Steve> 大家好');
monitor.onChat('');
monitor.onChat(null);
assert.strictEqual(listener.events.length, 0);
});
test('containsFuzzy 容忍细微文案差异', () => {
assert.strictEqual(containsFuzzy('开垦中 50%', '开垦中', 0), true);
// 差一个字("开垦" vs "开恳")在 maxEdits=1 下应命中
assert.strictEqual(containsFuzzy('开恳中 50%', '开垦中', 1), true);
assert.strictEqual(containsFuzzy('开恳中 50%', '开垦中', 0), false);
assert.strictEqual(containsFuzzy('完全无关的文本', '开垦中', 1), false);
});
test('containsFuzzy 空关键词视为命中,空文本视为不命中', () => {
assert.strictEqual(containsFuzzy('任意文本', '', 0), true);
assert.strictEqual(containsFuzzy('', '开垦中', 1), false);
assert.strictEqual(containsFuzzy(null, '开垦中', 1), false);
});
test('boundedLevenshtein 计算有界编辑距离', () => {
assert.strictEqual(boundedLevenshtein('abc', 0, 'abc', 3), 0);
assert.strictEqual(boundedLevenshtein('abd', 0, 'abc', 3), 1);
assert.strictEqual(boundedLevenshtein('xyz', 0, 'abc', 3), 3);
// 超过 limit 时提前返回 limit+1
assert.strictEqual(boundedLevenshtein('xyzxyz', 0, 'abc', 2), 3);
});
+125
查看文件
@@ -0,0 +1,125 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const {
parseMarkers,
fillPolygonEvenOdd,
pointInPolygon,
extractPolygons,
parseColor,
buildMarkersUrl
} = require('../src/core/territoryDataService');
const chunkPos = require('../src/core/chunkPos');
/** 把 [x, z] 坐标对转换为 dynmap 的点对象形式 */
function points(coords) {
return coords.map(([x, z]) => ({ x, z }));
}
/** 构造一个 markers.json 的单个多边形 marker */
function claimMarker(name, polygons, color) {
const converted = polygons.map(points);
return {
popup: `<div>疆土归属: ${name}</div>`,
color: color || '#75bd20',
points: converted.length === 1 ? converted[0] : converted
};
}
test('pointInPolygon 经典射线法', () => {
const square = [[0, 0], [32, 0], [32, 32], [0, 32]];
assert.strictEqual(pointInPolygon(16, 16, square), true);
assert.strictEqual(pointInPolygon(-1, 16, square), false);
assert.strictEqual(pointInPolygon(33, 16, square), false);
});
test('extractPolygons 展平单个多边形与嵌套多边形数组', () => {
const single = points([[0, 0], [16, 0], [16, 16]]);
assert.strictEqual(extractPolygons(single).length, 1);
const nested = [points([[0, 0], [16, 0], [16, 16]]), points([[4, 4], [8, 4], [8, 8]])];
assert.strictEqual(extractPolygons(nested).length, 2);
// 少于 3 个点的多边形被丢弃
assert.strictEqual(extractPolygons(points([[0, 0], [1, 1]])).length, 0);
});
test('fillPolygonEvenOdd 用内圈孔洞挖空外圈', () => {
const outer = [[0, 0], [48, 0], [48, 48], [0, 48]];
const hole = [[16, 16], [32, 16], [32, 32], [16, 32]];
const out = new Set();
fillPolygonEvenOdd(out, [outer, hole]);
// 3×3 区块的外圈 = 9 格,挖掉中心 1 格 = 8 格
assert.strictEqual(out.size, 8);
assert.ok(!out.has(chunkPos.key(1, 1)), '孔洞中心区块不应被填充');
assert.ok(out.has(chunkPos.key(0, 0)));
assert.ok(out.has(chunkPos.key(2, 2)));
});
test('parseMarkers 解析邦国领土与王城', () => {
const json = JSON.stringify([
{
id: 'union_claims',
markers: [
claimMarker('测试国', [[[0, 0], [32, 0], [32, 32], [0, 32]]], '#ff0000'),
claimMarker('另一国', [[[64, 64], [96, 64], [96, 96], [64, 96]]], '#00ff00')
]
},
{
id: 'union_capitals',
markers: [
{ popup: '<div>「测试国」的王城</div>', point: { x: 8, y: 70, z: 8 } }
]
}
]);
const model = parseMarkers(json);
assert.strictEqual(model.stateCount, 2);
const state = model.state('测试国');
assert.ok(state);
assert.strictEqual(state.color, 0xff0000);
assert.deepStrictEqual(state.capital, { x: 0, z: 0 }, '王城应由方块坐标 8,8 换算为区块 0,0');
assert.strictEqual(state.chunks.size, 4, '2×2 区块应被填充');
// 没有王城 marker 的邦国回退到第一个区块
const other = model.state('另一国');
assert.ok(other.capital);
});
test('parseMarkers 忽略弹窗中没有邦国名的 marker', () => {
const json = JSON.stringify([
{ id: 'union_claims', markers: [{ popup: '<div>无归属</div>', points: points([[0, 0], [16, 0], [16, 16]]) }] },
{ id: 'other_layer', markers: [claimMarker('忽略我', [[[0, 0], [16, 0], [16, 16]]])] }
]);
const model = parseMarkers(json);
assert.strictEqual(model.stateCount, 0);
});
test('parseMarkers 顶层必须是数组', () => {
assert.throws(() => parseMarkers('{}'), /数组/);
});
test('parseColor 支持 #RRGGBB 与 #AARRGGBB', () => {
assert.strictEqual(parseColor('#ff0000'), 0xff0000);
assert.strictEqual(parseColor('#80ff0000'), 0xff0000);
assert.strictEqual(parseColor('75bd20'), 0x75bd20);
// 非法输入回退到默认邦国绿
assert.strictEqual(parseColor('#zzzzzz'), 0x75bd20);
assert.strictEqual(parseColor(''), 0x75bd20);
});
test('buildMarkersUrl 拼接地图站点与世界目录', () => {
assert.strictEqual(
buildMarkersUrl('http://bgjq.simpfun.cn', 'minecraft_overworld'),
'http://bgjq.simpfun.cn/tiles/minecraft_overworld/markers.json'
);
// 末尾斜杠应被去掉
assert.strictEqual(
buildMarkersUrl('https://map.example.com/', 'world'),
'https://map.example.com/tiles/world/markers.json'
);
// 缺省值
assert.strictEqual(buildMarkersUrl(), 'http://bgjq.simpfun.cn/tiles/minecraft_overworld/markers.json');
});
+135
查看文件
@@ -0,0 +1,135 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const { TerritoryModel } = require('../src/core/territoryModel');
const chunkPos = require('../src/core/chunkPos');
/**
* 构造一个邦国状态。
* @param {string} name
* @param {Array<[number, number]>} coords
*/
function makeState(name, coords, capital) {
const chunks = new Set(coords.map(([x, z]) => chunkPos.key(x, z)));
return {
name,
capital: capital || { x: coords[0][0], z: coords[0][1] },
chunks,
color: 0x75bd20
};
}
test('构造时建立 allClaimed 并集', () => {
const model = new TerritoryModel(new Map([
['A', makeState('A', [[0, 0], [1, 0]])],
['B', makeState('B', [[5, 5]])]
]));
assert.strictEqual(model.stateCount, 2);
assert.strictEqual(model.allClaimedChunks().size, 3);
assert.ok(model.allClaimedChunks().has(chunkPos.key(1, 0)));
});
test('findContainingState 找到覆盖区块的邦国', () => {
const model = new TerritoryModel(new Map([
['A', makeState('A', [[0, 0], [1, 0]])],
['B', makeState('B', [[5, 5]])]
]));
assert.strictEqual(model.findContainingState({ x: 1, z: 0 }).name, 'A');
assert.strictEqual(model.findContainingState({ x: 5, z: 5 }).name, 'B');
assert.strictEqual(model.findContainingState({ x: 9, z: 9 }), null);
});
test('飞地识别:四周被同一邦国包围的空洞', () => {
// 3×3 中空方块:中心 (1,1) 是飞地
const coords = [];
for (let x = 0; x <= 2; x++) {
for (let z = 0; z <= 2; z++) {
if (x === 1 && z === 1) continue;
coords.push([x, z]);
}
}
const model = new TerritoryModel(new Map([['A', makeState('A', coords)]]));
const flying = model.selfFlyingChunks();
// 未设置己方邦国名时为空集
assert.strictEqual(flying.size, 0);
model.setSelfStateName('A');
const selfFlying = model.selfFlyingChunks();
assert.strictEqual(selfFlying.size, 1);
assert.ok(selfFlying.has(chunkPos.key(1, 1)));
assert.strictEqual(model.flyingChunks().size, 1);
});
test('飞地不包含被其他邦国占领的区块', () => {
const aCoords = [];
for (let x = 0; x <= 2; x++) {
for (let z = 0; z <= 2; z++) {
if (x === 1 && z === 1) continue;
aCoords.push([x, z]);
}
}
const model = new TerritoryModel(new Map([
['A', makeState('A', aCoords)],
['B', makeState('B', [[1, 1]])]
]));
model.setSelfStateName('A');
assert.strictEqual(model.selfFlyingChunks().size, 0);
});
test('addSessionClaim 同步更新会话集合、全局集合与己方邦国', () => {
const model = new TerritoryModel(new Map([['A', makeState('A', [[0, 0]])]]));
model.setSelfStateName('A');
model.addSessionClaim({ x: 1, z: 0 });
assert.ok(model.sessionClaimsSet().has(chunkPos.key(1, 0)));
assert.ok(model.allClaimedChunks().has(chunkPos.key(1, 0)));
assert.ok(model.selfState().chunks.has(chunkPos.key(1, 0)));
});
test('addSessionClaim 后飞地缓存失效并重算', () => {
const coords = [];
for (let x = 0; x <= 2; x++) {
for (let z = 0; z <= 2; z++) {
if (x === 1 && z === 1) continue;
coords.push([x, z]);
}
}
const model = new TerritoryModel(new Map([['A', makeState('A', coords)]]));
model.setSelfStateName('A');
assert.strictEqual(model.selfFlyingChunks().size, 1);
// 填上飞地后飞地应为空
model.addSessionClaim({ x: 1, z: 1 });
assert.strictEqual(model.selfFlyingChunks().size, 0);
});
test('mergeApiData:API 独有邦国新增、共有邦国取并集且 Dynmap 颜色优先', () => {
const model = new TerritoryModel(new Map([['A', makeState('A', [[0, 0]])]]));
const apiChunks = new Map([
['A', new Set([chunkPos.key(1, 0)])],
['C', new Set([chunkPos.key(9, 9)])]
]);
const apiCapitals = new Map([['C', { x: 9, z: 9 }]]);
model.mergeApiData(apiChunks, apiCapitals, 0xff43de);
const a = model.state('A');
assert.strictEqual(a.chunks.size, 2, 'A 的区块应为并集');
assert.strictEqual(a.color, 0x75bd20, 'Dynmap 已有邦国应保留原颜色');
const c = model.state('C');
assert.ok(c, 'API 独有邦国应被新增');
assert.strictEqual(c.color, 0xff43de);
assert.deepStrictEqual(c.capital, { x: 9, z: 9 });
assert.ok(model.allClaimedChunks().has(chunkPos.key(9, 9)));
});
test('mergeApiData:Dynmap 王城优先于 API 王城', () => {
const model = new TerritoryModel(new Map([['A', makeState('A', [[0, 0]], { x: 7, z: 7 })]]));
model.mergeApiData(new Map([['A', new Set([chunkPos.key(1, 0)])]]), new Map([['A', { x: 99, z: 99 }]]), 0);
assert.deepStrictEqual(model.state('A').capital, { x: 7, z: 7 });
});
test('mergeApiData:空输入直接返回不改动模型', () => {
const model = new TerritoryModel(new Map([['A', makeState('A', [[0, 0]])]]));
model.mergeApiData(new Map(), new Map(), 0);
assert.strictEqual(model.stateCount, 1);
});
+198
查看文件
@@ -0,0 +1,198 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const { flattenChunks, boundsOf, timingSafeEqual, WebConsole } = require('../src/web/server');
const { TerritoryModel } = require('../src/core/territoryModel');
const { defaultConfig } = require('../src/config/expansionConfig');
const { Logger } = require('../src/util/logger');
const chunkPos = require('../src/core/chunkPos');
test('flattenChunks 压平为 [x1,z1,x2,z2,...]', () => {
const keys = new Set([chunkPos.key(1, 2), chunkPos.key(-3, 4)]);
const flat = flattenChunks(keys);
assert.strictEqual(flat.length, 4);
const pairs = [];
for (let i = 0; i < flat.length; i += 2) pairs.push([flat[i], flat[i + 1]]);
pairs.sort((a, b) => a[0] - b[0]);
assert.deepStrictEqual(pairs, [[-3, 4], [1, 2]]);
});
test('boundsOf 计算外接矩形并对空集合返回 null', () => {
const keys = new Set([chunkPos.key(1, 2), chunkPos.key(-3, 4), chunkPos.key(5, -6)]);
assert.deepStrictEqual(boundsOf(keys), { minX: -3, maxX: 5, minZ: -6, maxZ: 4 });
assert.strictEqual(boundsOf(new Set()), null);
});
test('timingSafeEqual 只接受等长且完全相同的字符串', () => {
assert.strictEqual(timingSafeEqual('abc', 'abc'), true);
assert.strictEqual(timingSafeEqual('abc', 'abd'), false);
assert.strictEqual(timingSafeEqual('abc', 'abcd'), false);
assert.strictEqual(timingSafeEqual(undefined, 'abc'), false);
});
/** 搭建一个使用桩依赖的控制台实例 */
function setupConsole() {
const map = new Map([[
'A',
{
name: 'A',
capital: { x: 0, z: 0 },
chunks: new Set([chunkPos.key(0, 0), chunkPos.key(1, 0)]),
color: 0x2f81f7
}
]]);
const model = new TerritoryModel(map);
model.setSelfStateName('A');
model.addSessionClaim({ x: 1, z: 0 });
const logger = new Logger({ echo: false });
const botManager = {
snapshot: () => ({ state: 'online', username: 'TestBot' }),
sendCommand: () => {}
};
const controller = {
target: { x: 2, z: 0 },
playerChunk: { x: 0, z: 0 },
snapshot: () => ({ state: 'PATHING', running: true }),
exportLogs: () => 'data/debug.log',
stop: () => {},
start: async () => true,
status: '测试中'
};
const dataService = {
getModel: () => model,
snapshot: () => ({ stateCount: 1, claimedChunks: 2 }),
refreshAsync: () => {},
fetchAllTerritoryOnce: async () => ({ ok: true, message: '已拉取 0 个区块' })
};
const config = defaultConfig();
const webConsole = new WebConsole({
botManager,
controller,
dataService,
logger,
getConfig: () => config,
applyConfig: (next) => next
}, { host: '127.0.0.1', port: 0 });
return { webConsole, model, logger, controller };
}
test('mapPayload 返回己方 / 会话 / 飞地 / 目标与各邦国摘要', () => {
const { webConsole } = setupConsole();
const payload = webConsole.mapPayload(false);
assert.strictEqual(payload.selfState, 'A');
assert.strictEqual(payload.self.length, 4, '2 个区块 → 4 个数字');
assert.strictEqual(payload.session.length, 2);
assert.deepStrictEqual(payload.target, { x: 2, z: 0 });
assert.strictEqual(payload.states.length, 1);
assert.strictEqual(payload.states[0].name, 'A');
assert.strictEqual(payload.states[0].color, '#2f81f7');
assert.strictEqual(payload.states[0].count, 2);
assert.strictEqual(payload.otherChunks, undefined, '默认不返回全量他国区块');
});
test('mapPayload(full) 额外返回他国区块', () => {
const { webConsole, model } = setupConsole();
model.states.set('B', {
name: 'B',
capital: { x: 9, z: 9 },
chunks: new Set([chunkPos.key(9, 9)]),
color: 0xff43de
});
const payload = webConsole.mapPayload(true);
assert.strictEqual(payload.full, true);
assert.strictEqual(payload.otherChunks.length, 1);
assert.strictEqual(payload.otherChunks[0].name, 'B');
assert.strictEqual(payload.otherChunks[0].color, '#ff43de');
});
test('statusPayload 汇总 bot / 任务 / 数据源状态', () => {
const { webConsole } = setupConsole();
const payload = webConsole.statusPayload();
assert.strictEqual(payload.bot.state, 'online');
assert.strictEqual(payload.task.state, 'PATHING');
assert.strictEqual(payload.data.stateCount, 1);
assert.strictEqual(payload.claimTime.start, 11);
assert.strictEqual(payload.claimTime.end, 22);
});
test('HTTP 接口:/api/status、/api/config、/api/map 可访问', async () => {
const { webConsole } = setupConsole();
await webConsole.start();
const base = webConsole.baseUrl();
try {
const status = await fetch(base + '/api/status').then((r) => r.json());
assert.strictEqual(status.ok, true);
assert.strictEqual(status.data.bot.state, 'online');
const config = await fetch(base + '/api/config').then((r) => r.json());
assert.strictEqual(config.ok, true);
assert.strictEqual(config.data.strategy, 1);
const map = await fetch(base + '/api/map?full=1').then((r) => r.json());
assert.strictEqual(map.ok, true);
assert.strictEqual(map.data.full, true);
const html = await fetch(base + '/').then((r) => r.text());
assert.ok(html.includes('AutoStore Bot'), '首页应返回控制台 HTML');
} finally {
await webConsole.stop();
}
});
test('HTTP 接口:配置令牌后拒绝无令牌请求', async () => {
const { webConsole } = setupConsole();
webConsole.token = 'secret-token';
await webConsole.start();
const base = webConsole.baseUrl();
try {
const unauthorized = await fetch(base + '/api/status');
assert.strictEqual(unauthorized.status, 401);
const authorized = await fetch(base + '/api/status?token=secret-token');
assert.strictEqual(authorized.status, 200);
const viaHeader = await fetch(base + '/api/status', { headers: { 'X-Auth-Token': 'secret-token' } });
assert.strictEqual(viaHeader.status, 200);
} finally {
await webConsole.stop();
}
});
test('HTTP 接口:POST /api/config 会走 applyConfig 并返回生效配置', async () => {
const { webConsole } = setupConsole();
await webConsole.start();
const base = webConsole.baseUrl();
try {
const response = await fetch(base + '/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ strategy: 4, targetCount: 12 })
}).then((r) => r.json());
assert.strictEqual(response.ok, true);
assert.strictEqual(response.data.strategy, 4);
assert.strictEqual(response.data.targetCount, 12);
// 未提供的字段保持默认
assert.strictEqual(response.data.retries, 2);
} finally {
await webConsole.stop();
}
});
test('HTTP 接口:POST /api/command 缺参数时返回 400', async () => {
const { webConsole } = setupConsole();
await webConsole.start();
const base = webConsole.baseUrl();
try {
const response = await fetch(base + '/api/command', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
assert.strictEqual(response.status, 400);
} finally {
await webConsole.stop();
}
});