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 语法自检通过。
这个提交包含在:
+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>
|
||||
@@ -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` 放开
|
||||
- **监守者 / 末影龙 / 凋灵默认跳过**:原版会攻击它们,但那基本等于自杀
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 后应可立即再次检查');
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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, '没有可用的相邻无主区块');
|
||||
});
|
||||
@@ -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} 越界`);
|
||||
}
|
||||
});
|
||||
@@ -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 + ' 应被松开');
|
||||
}
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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(); // 不应抛异常
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户