From a12a24c7163d44ca4bbe57ee2b98c5c7db24587a Mon Sep 17 00:00:00 2001 From: lyizai <3945107889@qq.com> Date: Sun, 27 Sep 2026 17:17:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=94=A8=20Node.js=20+=20mineflayer=20?= =?UTF-8?q?=E9=87=8D=E6=9E=84=20AutoStore=EF=BC=8CUI=20=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E7=BD=91=E9=A1=B5=E6=8E=A7=E5=88=B6=E5=8F=B0=E5=B9=B6=E9=80=82?= =?UTF-8?q?=E9=85=8D=20Grim=20=E5=8F=8D=E4=BD=9C=E5=BC=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 由 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 语法自检通过。 --- .env.example | 30 + .gitattributes | 9 + .gitignore | 23 + CHANGELOG.md | 55 + README.md | 282 ++++ package-lock.json | 1961 +++++++++++++++++++++++++ package.json | 36 + scripts/check-syntax.js | 51 + src/bot/autoBoat.js | 471 ++++++ src/bot/autoCombat.js | 202 +++ src/bot/autoEat.js | 128 ++ src/bot/botManager.js | 384 +++++ src/bot/durabilityGuard.js | 44 + src/bot/grimCompat.js | 582 ++++++++ src/bot/pathingService.js | 247 ++++ src/config/configStore.js | 68 + src/config/expansionConfig.js | 328 +++++ src/controller/expansionController.js | 1002 +++++++++++++ src/core/chunkPos.js | 135 ++ src/core/expansionPlanner.js | 545 +++++++ src/core/simpApiClient.js | 347 +++++ src/core/subtitleChatMonitor.js | 148 ++ src/core/territoryDataService.js | 623 ++++++++ src/core/territoryModel.js | 254 ++++ src/index.js | 142 ++ src/util/logger.js | 120 ++ src/web/public/app.js | 853 +++++++++++ src/web/public/index.html | 172 +++ src/web/public/style.css | 239 +++ src/web/server.js | 367 +++++ test/autoBoat.test.js | 118 ++ test/autoCombat.test.js | 151 ++ test/botModules.test.js | 115 ++ test/chunkPos.test.js | 69 + test/config.test.js | 157 ++ test/expansionController.test.js | 430 ++++++ test/expansionPlanner.test.js | 269 ++++ test/grimCompat.test.js | 226 +++ test/helpers/fakeBot.js | 141 ++ test/pathingService.test.js | 138 ++ test/subtitleChatMonitor.test.js | 105 ++ test/territoryDataService.test.js | 125 ++ test/territoryModel.test.js | 135 ++ test/webServer.test.js | 198 +++ 44 files changed, 12225 insertions(+) create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 README.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/check-syntax.js create mode 100644 src/bot/autoBoat.js create mode 100644 src/bot/autoCombat.js create mode 100644 src/bot/autoEat.js create mode 100644 src/bot/botManager.js create mode 100644 src/bot/durabilityGuard.js create mode 100644 src/bot/grimCompat.js create mode 100644 src/bot/pathingService.js create mode 100644 src/config/configStore.js create mode 100644 src/config/expansionConfig.js create mode 100644 src/controller/expansionController.js create mode 100644 src/core/chunkPos.js create mode 100644 src/core/expansionPlanner.js create mode 100644 src/core/simpApiClient.js create mode 100644 src/core/subtitleChatMonitor.js create mode 100644 src/core/territoryDataService.js create mode 100644 src/core/territoryModel.js create mode 100644 src/index.js create mode 100644 src/util/logger.js create mode 100644 src/web/public/app.js create mode 100644 src/web/public/index.html create mode 100644 src/web/public/style.css create mode 100644 src/web/server.js create mode 100644 test/autoBoat.test.js create mode 100644 test/autoCombat.test.js create mode 100644 test/botModules.test.js create mode 100644 test/chunkPos.test.js create mode 100644 test/config.test.js create mode 100644 test/expansionController.test.js create mode 100644 test/expansionPlanner.test.js create mode 100644 test/grimCompat.test.js create mode 100644 test/helpers/fakeBot.js create mode 100644 test/pathingService.test.js create mode 100644 test/subtitleChatMonitor.test.js create mode 100644 test/territoryDataService.test.js create mode 100644 test/territoryModel.test.js create mode 100644 test/webServer.test.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1522f04 --- /dev/null +++ b/.env.example @@ -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= +# offline=离线服(服务器用 /login 密码登录);microsoft=正版 +MC_AUTH=offline + +# 服务器登录/注册密码(离线服必填;正版服留空) +MC_PASSWORD= + +# ==================== Web 控制台 ==================== +WEB_HOST=127.0.0.1 +WEB_PORT=3081 +# 控制台访问令牌(留空则仅监听 127.0.0.1 且不校验;非本机访问必须设置) +WEB_TOKEN= + +# ==================== 领土数据源 ==================== +# Dynmap 地图站点根地址(末尾不带斜杠),程序会请求 /tiles//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= diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..209f819 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# 统一使用 LF,避免跨平台协作时出现整文件换行符变更 +* text=auto eol=lf + +# 二进制文件不做换行符转换 +*.png binary +*.webp binary +*.jpg binary +*.ico binary +*.jar binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..87a89f0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# 依赖 +node_modules/ + +# 密钥与本地配置(绝不提交) +.env +.env.* +!.env.example +data/ + +# 日志 +logs/ +*.log + +# 编辑器 / 系统 +.idea/ +.vscode/ +*.iml +.DS_Store +Thumbs.db + +# 构建产物 +dist/ +build/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..80605a4 --- /dev/null +++ b/CHANGELOG.md @@ -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` 放开 +- **监守者 / 末影龙 / 凋灵默认跳过**:原版会攻击它们,但那基本等于自杀 diff --git a/README.md b/README.md new file mode 100644 index 0000000..5fcfe37 --- /dev/null +++ b/README.md @@ -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 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c197008 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1961 @@ +{ + "name": "autostore-bot", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "autostore-bot", + "version": "1.0.0", + "license": "MIT", + "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" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "14.16.1", + "resolved": "https://registry.npmmirror.com/@azure/msal-common/-/msal-common-14.16.1.tgz", + "integrity": "sha512-nyxsA6NA4SVKh5YyRpbSXiMr7oQbwark7JU9LMeg6tJYTSPyAGkdx61wPT4gyxZfxlSxMMEyAsWaubBlNyIa1w==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "2.16.3", + "resolved": "https://registry.npmmirror.com/@azure/msal-node/-/msal-node-2.16.3.tgz", + "integrity": "sha512-CO+SE4weOsfJf+C5LM8argzvotrXw252/ZU6SM2Tz63fEblhH1uuVaaO4ISYFuN4Q6BhTo7I3qIdi8ydUQCqhw==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "14.16.1", + "jsonwebtoken": "^9.0.0", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@types/node": { + "version": "26.6.3", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-26.6.3.tgz", + "integrity": "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/node-rsa": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/@types/node-rsa/-/node-rsa-1.1.4.tgz", + "integrity": "sha512-dB0ECel6JpMnq5ULvpUTunx3yNm8e/dIkv8Zu9p2c8me70xIRUUG3q+qXRwcSf9rN3oqamv4116iHy90dJGRpA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/readable-stream": { + "version": "4.0.24", + "resolved": "https://registry.npmmirror.com/@types/readable-stream/-/readable-stream-4.0.24.tgz", + "integrity": "sha512-NRvUNC/JFGPJvqdAfEve8oginbM6V08u5NzLWpG8MwA2kTPOLnqk+wpwuPT+mp3aUsxyuT6m2gnrPuHYCruzEg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@xboxreplay/xboxlive-auth": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/@xboxreplay/xboxlive-auth/-/xboxlive-auth-5.1.0.tgz", + "integrity": "sha512-UngHHsehZbiTjyyNmo8HvdoUDKMID1U9uVfrpFWUK/2UxPuVTKy5n+CzZQ3S488sW5vOhgh0lHqqynT8ouwgvw==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/aes-js": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/aes-js/-/aes-js-3.1.2.tgz", + "integrity": "sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/asn1": { + "version": "0.2.3", + "resolved": "https://registry.npmmirror.com/asn1/-/asn1-0.2.3.tgz", + "integrity": "sha512-6i37w/+EhlWlGUJff3T/Q8u1RGmP5wgbiwYnOnbOqvtrPxT63/sYFyP9RcpxtxGymtfA075IvmOnL7ycNOWl3w==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.8", + "resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-1.20.8.tgz", + "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.16.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/buffer-equal/-/buffer-equal-1.0.1.tgz", + "integrity": "sha512-QoV3ptgEaQpvVwbXdSO39iqPQTCxSF7A5U99AxbHYqUdCizL/lH2Z0A2y6nbZucxMEOtNyZfG2s6gsVugGpKkg==", + "license": "MIT", + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmmirror.com/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmmirror.com/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/discontinuous-range": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/discontinuous-range/-/discontinuous-range-1.0.0.tgz", + "integrity": "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==", + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/endian-toggle": { + "version": "0.0.0", + "resolved": "https://registry.npmmirror.com/endian-toggle/-/endian-toggle-0.0.0.tgz", + "integrity": "sha512-ShfqhXeHRE4TmggSlHXG8CMGIcsOsqDw/GcoPcosToE59Rm9e4aXaMhEQf2kPBsBRrKem1bbOAv5gOKnkliMFQ==", + "license": "MIT" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmmirror.com/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/express": { + "version": "4.22.3", + "resolved": "https://registry.npmmirror.com/express/-/express-4.22.3.tgz", + "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.13", + "proxy-addr": "~2.0.7", + "qs": "~6.16.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmmirror.com/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmmirror.com/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmmirror.com/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmmirror.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/lodash.reduce": { + "version": "4.6.0", + "resolved": "https://registry.npmmirror.com/lodash.reduce/-/lodash.reduce-4.6.0.tgz", + "integrity": "sha512-6raRe2vxCYBhpBu+B+TtNGUzah+hQjVdu3E17wfusjyrXBka2nBS8OH/gjVZ5PvHOhWmIZTYri09Z6n/QfnNMw==", + "license": "MIT" + }, + "node_modules/macaddress": { + "version": "0.5.4", + "resolved": "https://registry.npmmirror.com/macaddress/-/macaddress-0.5.4.tgz", + "integrity": "sha512-i8xVWoUjj2woYU8kbpQby86Kq7uF7xl2brtKREXUBWpfgqx1fKXEeYzDiVMVxA/IufC1d3xxwJRHtFCX+9IspA==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minecraft-data": { + "version": "3.117.0", + "resolved": "https://registry.npmmirror.com/minecraft-data/-/minecraft-data-3.117.0.tgz", + "integrity": "sha512-glKv0EEmVwKvJwdNAKAal2P7gi/nijU9rEMp8gJmqYYRVswS6hA0q9566lcGHH07Ocmpqw5bMRbARC25pR13gA==", + "license": "MIT" + }, + "node_modules/minecraft-folder-path": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/minecraft-folder-path/-/minecraft-folder-path-1.2.0.tgz", + "integrity": "sha512-qaUSbKWoOsH9brn0JQuBhxNAzTDMwrOXorwuRxdJKKKDYvZhtml+6GVCUrY5HRiEsieBEjCUnhVpDuQiKsiFaw==", + "license": "MIT" + }, + "node_modules/minecraft-protocol": { + "version": "1.68.0", + "resolved": "https://registry.npmmirror.com/minecraft-protocol/-/minecraft-protocol-1.68.0.tgz", + "integrity": "sha512-atAGg/fhIVqyK6voWvED5QA+4ah65PNwpoDnkHawWhVzQbwFii77KcF6OpsU/Xlbi0o9GpihHiPdORSxXEA+Ug==", + "license": "BSD-3-Clause", + "dependencies": { + "@types/node-rsa": "^1.1.4", + "@types/readable-stream": "^4.0.0", + "aes-js": "^3.1.2", + "buffer-equal": "^1.0.0", + "debug": "^4.3.2", + "endian-toggle": "^0.0.0", + "lodash.merge": "^4.3.0", + "minecraft-data": "^3.109.0", + "minecraft-folder-path": "^1.2.0", + "node-fetch": "^2.6.1", + "node-rsa": "^0.4.2", + "prismarine-auth": "^3.1.1", + "prismarine-chat": "^1.10.0", + "prismarine-nbt": "^2.5.0", + "prismarine-realms": "^1.2.0", + "protodef": "^1.17.0", + "readable-stream": "^4.1.0", + "uuid-1345": "^1.0.1", + "yggdrasil": "^1.4.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/minecraft-protocol/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/minecraft-protocol/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mineflayer": { + "version": "4.39.0", + "resolved": "https://registry.npmmirror.com/mineflayer/-/mineflayer-4.39.0.tgz", + "integrity": "sha512-HptE++dIG55aRmT7GIj3QComS6MhEXstE3RJSuYMFhLtobsQhJJsqWmY9ObfiLp84MrS6Q4Em+wUH0rVQMXLJw==", + "license": "MIT", + "dependencies": { + "minecraft-data": "^3.114.0", + "minecraft-protocol": "^1.67.0", + "mojangson": "^2.0.4", + "prismarine-biome": "^1.1.1", + "prismarine-block": "^1.22.0", + "prismarine-chat": "^1.7.1", + "prismarine-chunk": "^1.41.0", + "prismarine-entity": "^2.5.0", + "prismarine-item": "^1.17.0", + "prismarine-nbt": "^2.0.0", + "prismarine-physics": "^1.11.1", + "prismarine-recipe": "^1.5.0", + "prismarine-registry": "^1.10.0", + "prismarine-windows": "^2.9.0", + "prismarine-world": "^3.6.0", + "protodef": "^1.18.0", + "typed-emitter": "^1.0.0", + "uuid-1345": "^1.0.2", + "vec3": "^0.1.7" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/mineflayer-pathfinder": { + "version": "2.4.5", + "resolved": "https://registry.npmmirror.com/mineflayer-pathfinder/-/mineflayer-pathfinder-2.4.5.tgz", + "integrity": "sha512-Jh3JnUgRLwhMh2Dugo4SPza68C41y+NPP5sdsgxRu35ydndo70i1JJGxauVWbXrpNwIxYNztUw78aFyb7icw8g==", + "license": "MIT", + "dependencies": { + "minecraft-data": "^3.5.1", + "prismarine-block": "^1.16.3", + "prismarine-entity": "^2.1.1", + "prismarine-item": "^1.11.5", + "prismarine-nbt": "^2.2.1", + "prismarine-physics": "^1.5.2", + "vec3": "^0.1.7" + } + }, + "node_modules/mojangson": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/mojangson/-/mojangson-2.1.0.tgz", + "integrity": "sha512-uz0PPHAc/kRO5tBOmEZIWxLlne3NRqO2PRyvIXbU0523tItgpvt0XG83IFZgH21EaKY1/B9c9ca074/eZlI59Q==", + "license": "MIT", + "dependencies": { + "nearley": "^2.19.5" + } + }, + "node_modules/moo": { + "version": "0.5.3", + "resolved": "https://registry.npmmirror.com/moo/-/moo-0.5.3.tgz", + "integrity": "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==", + "license": "BSD-3-Clause" + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/nearley": { + "version": "2.20.1", + "resolved": "https://registry.npmmirror.com/nearley/-/nearley-2.20.1.tgz", + "integrity": "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==", + "license": "MIT", + "dependencies": { + "commander": "^2.19.0", + "moo": "^0.5.0", + "railroad-diagrams": "^1.0.0", + "randexp": "0.4.6" + }, + "bin": { + "nearley-railroad": "bin/nearley-railroad.js", + "nearley-test": "bin/nearley-test.js", + "nearley-unparse": "bin/nearley-unparse.js", + "nearleyc": "bin/nearleyc.js" + }, + "funding": { + "type": "individual", + "url": "https://nearley.js.org/#give-to-nearley" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-rsa": { + "version": "0.4.2", + "resolved": "https://registry.npmmirror.com/node-rsa/-/node-rsa-0.4.2.tgz", + "integrity": "sha512-Bvso6Zi9LY4otIZefYrscsUpo2mUpiAVIEmSZV2q41sP8tHZoert3Yu6zv4f/RXJqMNZQKCtnhDugIuCma23YA==", + "license": "MIT", + "dependencies": { + "asn1": "0.2.3" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/prismarine-auth": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/prismarine-auth/-/prismarine-auth-3.1.1.tgz", + "integrity": "sha512-NuNrMGZdoigFKsvi1ZZgAEvNYNuE5qe6lo/tw+bqeNbkhpjHC0u1JNxLEujnfqduXI18e19PvUtWNMDl/gH7yw==", + "license": "MIT", + "dependencies": { + "@azure/msal-node": "^2.0.2", + "@xboxreplay/xboxlive-auth": "^5.1.0", + "debug": "^4.3.3", + "smart-buffer": "^4.1.0", + "uuid-1345": "^1.0.2" + } + }, + "node_modules/prismarine-auth/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/prismarine-auth/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/prismarine-biome": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/prismarine-biome/-/prismarine-biome-1.4.0.tgz", + "integrity": "sha512-fD2WmjN8Zr/xA/jeMInReLgaDlznwA5xlaK529PzWuGzgjpc5ijVu1Lp1oqHyZn3WxOG/bRVtW1bU+tmgCurWA==", + "license": "MIT", + "peerDependencies": { + "prismarine-registry": "^1.1.0" + } + }, + "node_modules/prismarine-block": { + "version": "1.23.0", + "resolved": "https://registry.npmmirror.com/prismarine-block/-/prismarine-block-1.23.0.tgz", + "integrity": "sha512-j2UoU4KbXMvNlBw+aLkMOnEuMayYefznUfbrfv1VIbckG3RA9LpNWltOMHXuOR5YkHp8uIZPOclj95XC88jgGw==", + "license": "MIT", + "dependencies": { + "minecraft-data": "^3.38.0", + "prismarine-biome": "^1.1.0", + "prismarine-chat": "^1.5.0", + "prismarine-item": "^1.10.1", + "prismarine-nbt": "^2.0.0", + "prismarine-registry": "^1.1.0" + } + }, + "node_modules/prismarine-chat": { + "version": "1.13.0", + "resolved": "https://registry.npmmirror.com/prismarine-chat/-/prismarine-chat-1.13.0.tgz", + "integrity": "sha512-tvDbrQmJEoy09yLE5nnedGhQYxnRDaPRePMv7W39dFaHr2LGcA2JfCmH0vG5193+BsEFz3a5+0EpQSK8OW7YmA==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.2", + "mojangson": "^2.0.1", + "prismarine-nbt": "^2.0.0", + "prismarine-registry": "^1.4.0" + } + }, + "node_modules/prismarine-chat/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/prismarine-chat/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/prismarine-chunk": { + "version": "1.41.0", + "resolved": "https://registry.npmmirror.com/prismarine-chunk/-/prismarine-chunk-1.41.0.tgz", + "integrity": "sha512-KldSl3pDzPU7jcttxSYWNJ40rJMdiUxYvTpqXzqiQ3lIdNL1HAuJHPdscHcqlKJRB9Hw/cbFcROR1h5ncavY+Q==", + "license": "MIT", + "dependencies": { + "prismarine-biome": "^1.2.0", + "prismarine-block": "^1.14.1", + "prismarine-nbt": "^2.2.1", + "prismarine-registry": "^1.1.0", + "smart-buffer": "^4.1.0", + "uint4": "^0.1.2", + "vec3": "^0.1.3", + "xxhash-wasm": "^0.4.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/prismarine-entity": { + "version": "2.6.0", + "resolved": "https://registry.npmmirror.com/prismarine-entity/-/prismarine-entity-2.6.0.tgz", + "integrity": "sha512-/LlZRLOpACiXk+GqoaKi0XPBFnNMjb1d4OIzuSCSEgNMK6FUo3Wnin5yeSZ7ff3Ztt7yagN9lX2jSOafn6IIzg==", + "license": "MIT", + "dependencies": { + "prismarine-chat": "^1.4.1", + "prismarine-item": "^1.11.2", + "prismarine-registry": "^1.4.0", + "vec3": "^0.1.4" + } + }, + "node_modules/prismarine-item": { + "version": "1.18.0", + "resolved": "https://registry.npmmirror.com/prismarine-item/-/prismarine-item-1.18.0.tgz", + "integrity": "sha512-8pEq6YfcneVvarvUFnex09a3+MR8/4NCQVyawIKAa3kh/g9dHLexoEcpQEgM3cmpg4gbLmspSiARGwed5uGhlg==", + "license": "MIT", + "dependencies": { + "prismarine-nbt": "^2.0.0", + "prismarine-registry": "^1.4.0" + } + }, + "node_modules/prismarine-nbt": { + "version": "2.8.0", + "resolved": "https://registry.npmmirror.com/prismarine-nbt/-/prismarine-nbt-2.8.0.tgz", + "integrity": "sha512-5D6FUZq0PNtf3v/41ImDlwThVesOv5adyqCRMZLzmkUGEmRJNNh5C6AsnvrClBftXs+IF0yqPnZoj8kcNPiMGg==", + "license": "MIT", + "dependencies": { + "protodef": "^1.18.0" + } + }, + "node_modules/prismarine-physics": { + "version": "1.11.1", + "resolved": "https://registry.npmmirror.com/prismarine-physics/-/prismarine-physics-1.11.1.tgz", + "integrity": "sha512-GO9Pqqg5Khs0YxvPqRXYi3Ep3/OoxnL3RtfMknrgddY0ogpT91bTna6HDTQXVeBinfCB3WU9vXaFoTND/3xqyA==", + "license": "MIT", + "dependencies": { + "minecraft-data": "^3.0.0", + "prismarine-nbt": "^2.0.0", + "vec3": "^0.2.0" + } + }, + "node_modules/prismarine-physics/node_modules/vec3": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/vec3/-/vec3-0.2.0.tgz", + "integrity": "sha512-jOjU4zbCNWOKOGLHop1I0J2Ar4h5Zz76455LJhJKhiOJwI21QP6d5TnKklNZDKG+c5h71zWtPAbd9wh+Va57OQ==", + "license": "BSD" + }, + "node_modules/prismarine-realms": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/prismarine-realms/-/prismarine-realms-1.6.0.tgz", + "integrity": "sha512-AwemW0vwxG9hQaFtg1twSV7eymB6QtYxGK0jjpxfdA2sdK15kU8jh8uD1o5XF0oxSMU+BbpzZMCmXtXq4QE6bw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.3", + "node-fetch": "^2.6.1" + } + }, + "node_modules/prismarine-realms/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/prismarine-realms/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/prismarine-recipe": { + "version": "1.5.0", + "resolved": "https://registry.npmmirror.com/prismarine-recipe/-/prismarine-recipe-1.5.0.tgz", + "integrity": "sha512-GRZHbsyBIUgVNF10vFRv2YWZj86vokCT5EWX6iK6gfx6h4FapgZT29V2DNkjv5+hmdzBCLZvfx1/RYr8VPeoGQ==", + "license": "MIT", + "peerDependencies": { + "prismarine-registry": "^1.4.0" + } + }, + "node_modules/prismarine-registry": { + "version": "1.12.0", + "resolved": "https://registry.npmmirror.com/prismarine-registry/-/prismarine-registry-1.12.0.tgz", + "integrity": "sha512-OC5U6YrflY6OcAWRZEqe2HGZuNp0bIuP7H+oKEHD6rLfKNDxo8Ymx5eh2VvrZWnMVugpwID1Qj/UjA4MoCzNDw==", + "license": "MIT", + "dependencies": { + "minecraft-data": "^3.70.0", + "prismarine-block": "^1.17.1", + "prismarine-nbt": "^2.0.0" + } + }, + "node_modules/prismarine-windows": { + "version": "2.10.0", + "resolved": "https://registry.npmmirror.com/prismarine-windows/-/prismarine-windows-2.10.0.tgz", + "integrity": "sha512-ssXLGAr7W9JLvvLjYMoo1j4j6AdJaoIb0/HlqkWMWlQqvZJeiS4zyBjJY6+GtR4OzpjkEf6IvF5cNXhHFpbcZQ==", + "license": "MIT", + "dependencies": { + "prismarine-item": "^1.12.2", + "prismarine-registry": "^1.7.0", + "typed-emitter": "^2.1.0" + } + }, + "node_modules/prismarine-windows/node_modules/typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/typed-emitter/-/typed-emitter-2.1.0.tgz", + "integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==", + "license": "MIT", + "optionalDependencies": { + "rxjs": "*" + } + }, + "node_modules/prismarine-world": { + "version": "3.7.0", + "resolved": "https://registry.npmmirror.com/prismarine-world/-/prismarine-world-3.7.0.tgz", + "integrity": "sha512-M5euvNjQ3vIk689BSa0YC6PBwpVY35Oc6q6KyZ0IqyFtI+cQ9em+8l5OTAK/uu9/gzDDhR7cmm9L2WXgTXBQCw==", + "license": "MIT", + "dependencies": { + "vec3": "^0.1.7" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmmirror.com/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/protodef": { + "version": "1.19.0", + "resolved": "https://registry.npmmirror.com/protodef/-/protodef-1.19.0.tgz", + "integrity": "sha512-94f3GR7pk4Qi5YVLaLvWBfTGUIzzO8hyo7vFVICQuu5f5nwKtgGDaeC1uXIu49s5to/49QQhEYeL0aigu1jEGA==", + "license": "MIT", + "dependencies": { + "lodash.reduce": "^4.6.0", + "protodef-validator": "^1.3.0", + "readable-stream": "^4.4.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/protodef-validator": { + "version": "1.5.0", + "resolved": "https://registry.npmmirror.com/protodef-validator/-/protodef-validator-1.5.0.tgz", + "integrity": "sha512-GrPKsNpcd7/4bIAZN3Ojgk5ILsRX9s1IItzU+o29D1o0AKbIaLuBCmaSGbZE90Gn8zxF2/Vj8aPUHjo0AbgFdw==", + "license": "MIT", + "dependencies": { + "ajv": "^6.5.4" + }, + "bin": { + "protodef-validator": "cli.js" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmmirror.com/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/railroad-diagrams": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", + "integrity": "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==", + "license": "CC0-1.0" + }, + "node_modules/randexp": { + "version": "0.4.6", + "resolved": "https://registry.npmmirror.com/randexp/-/randexp-0.4.6.tgz", + "integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==", + "license": "MIT", + "dependencies": { + "discontinuous-range": "1.0.0", + "ret": "~0.1.10" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmmirror.com/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/ret": { + "version": "0.1.15", + "resolved": "https://registry.npmmirror.com/ret/-/ret-0.1.15.tgz", + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", + "license": "MIT", + "engines": { + "node": ">=0.12" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmmirror.com/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmmirror.com/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmmirror.com/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmmirror.com/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmmirror.com/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-emitter": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/typed-emitter/-/typed-emitter-1.4.0.tgz", + "integrity": "sha512-weBmoo3HhpKGgLBOYwe8EB31CzDFuaK7CCL+axXhUYhn4jo6DSkHnbefboCF5i4DQ2aMFe0C/FdTWcPdObgHyg==", + "license": "MIT" + }, + "node_modules/uint4": { + "version": "0.1.2", + "resolved": "https://registry.npmmirror.com/uint4/-/uint4-0.1.2.tgz", + "integrity": "sha512-lhEx78gdTwFWG+mt6cWAZD/R6qrIj0TTBeH5xwyuDJyswLNlGe+KVlUPQ6+mx5Ld332pS0AMUTo9hIly7YsWxQ==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmmirror.com/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/uuid-1345": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/uuid-1345/-/uuid-1345-1.0.2.tgz", + "integrity": "sha512-bA5zYZui+3nwAc0s3VdGQGBfbVsJLVX7Np7ch2aqcEWFi5lsAEcmO3+lx3djM1npgpZI8KY2FITZ2uYTnYUYyw==", + "license": "MIT", + "dependencies": { + "macaddress": "^0.5.1" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vec3": { + "version": "0.1.10", + "resolved": "https://registry.npmmirror.com/vec3/-/vec3-0.1.10.tgz", + "integrity": "sha512-Sr1U3mYtMqCOonGd3LAN9iqy0qF6C+Gjil92awyK/i2OwiUo9bm7PnLgFpafymun50mOjnDcg4ToTgRssrlTcw==", + "license": "BSD" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmmirror.com/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xxhash-wasm": { + "version": "0.4.2", + "resolved": "https://registry.npmmirror.com/xxhash-wasm/-/xxhash-wasm-0.4.2.tgz", + "integrity": "sha512-/eyHVRJQCirEkSZ1agRSCwriMhwlyUcFkXD5TPVSLP+IPzjsqMVzZwdoczLp1SoQU0R3dxz1RpIK+4YNQbCVOA==", + "license": "MIT" + }, + "node_modules/yggdrasil": { + "version": "1.8.0", + "resolved": "https://registry.npmmirror.com/yggdrasil/-/yggdrasil-1.8.0.tgz", + "integrity": "sha512-r5bKOhkZ52DJ6q034uSkdsdZLoFVhOmfDOagRs6h/JX5W7+XIPOMb+peCbElhLEoIckwt43NCUoNQbydOzuPcQ==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.6.1", + "uuid": "^10.0.0" + } + }, + "node_modules/yggdrasil/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmmirror.com/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..0c6c452 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/scripts/check-syntax.js b/scripts/check-syntax.js new file mode 100644 index 0000000..322c99f --- /dev/null +++ b/scripts/check-syntax.js @@ -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} 个文件`); diff --git a/src/bot/autoBoat.js b/src/bot/autoBoat.js new file mode 100644 index 0000000..b196513 --- /dev/null +++ b/src/bot/autoBoat.js @@ -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 }; diff --git a/src/bot/autoCombat.js b/src/bot/autoCombat.js new file mode 100644 index 0000000..71c4615 --- /dev/null +++ b/src/bot/autoCombat.js @@ -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 +}; diff --git a/src/bot/autoEat.js b/src/bot/autoEat.js new file mode 100644 index 0000000..0640d54 --- /dev/null +++ b/src/bot/autoEat.js @@ -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 }; diff --git a/src/bot/botManager.js b/src/bot/botManager.js new file mode 100644 index 0000000..2e47d2a --- /dev/null +++ b/src/bot/botManager.js @@ -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 }; diff --git a/src/bot/durabilityGuard.js b/src/bot/durabilityGuard.js new file mode 100644 index 0000000..af4ca91 --- /dev/null +++ b/src/bot/durabilityGuard.js @@ -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 }; diff --git a/src/bot/grimCompat.js b/src/bot/grimCompat.js new file mode 100644 index 0000000..d0f6916 --- /dev/null +++ b/src/bot/grimCompat.js @@ -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} 旋转真正同步到服务端后 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} + */ + 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} + */ + 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} 是否在超时前完成 + */ + 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} 是否成功出手 + */ + 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} + */ + 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 +}; diff --git a/src/bot/pathingService.js b/src/bot/pathingService.js new file mode 100644 index 0000000..34fa790 --- /dev/null +++ b/src/bot/pathingService.js @@ -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 }; diff --git a/src/config/configStore.js b/src/config/configStore.js new file mode 100644 index 0000000..cad28bd --- /dev/null +++ b/src/config/configStore.js @@ -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 }; diff --git a/src/config/expansionConfig.js b/src/config/expansionConfig.js new file mode 100644 index 0000000..f53eb5d --- /dev/null +++ b/src/config/expansionConfig.js @@ -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 +}; diff --git a/src/controller/expansionController.js b/src/controller/expansionController.js new file mode 100644 index 0000000..5095de9 --- /dev/null +++ b/src/controller/expansionController.js @@ -0,0 +1,1002 @@ +'use strict'; + +const chunkPos = require('../core/chunkPos'); +const { ExpansionPlanner } = require('../core/expansionPlanner'); +const { SubtitleChatMonitor } = require('../core/subtitleChatMonitor'); +const { findTarget } = require('../bot/autoCombat'); +const { PathEvent } = require('../bot/pathingService'); + +/** + * 扩地任务状态机枚举(与原 Java 版一一对应)。 + */ +const State = { + /** 空闲(未启动) */ + IDLE: 'IDLE', + /** 正在生成扩地计划 */ + PLANNING: 'PLANNING', + /** 正在寻路前往目标区块 */ + PATHING: 'PATHING', + /** 已到达,等待开垦完成 */ + WAITING: 'WAITING', + /** 当前区块开垦成功 */ + SUCCESS: 'SUCCESS', + /** 当前区块开垦失败 / 超时 */ + FAIL: 'FAIL', + /** 准备处理下一个目标 */ + NEXT: 'NEXT', + /** 已发送回城指令,等待传送完成 */ + RETURNING: 'RETURNING', + /** 全部目标完成 */ + COMPLETE: 'COMPLETE', + /** 被手动停止 */ + STOPPED: 'STOPPED' +}; + +/** 环形日志中「导出日志」用的状态摘要键名 */ +const EXPORT_SUMMARY_KEYS = ['状态', '目标区块', '玩家区块', '队列', '已占领', '重试', '最近进度']; + +/** + * 自动扩地控制器:扩地任务的核心状态机。 + * + * 工作流程:规划(PLANNING)→ 寻路(PATHING)→ 等待开垦(WAITING)→ 成功 / 失败 + * (SUCCESS / FAIL)→ 下一个目标(NEXT)→ 完成(COMPLETE)。 + * 由外部按 tick 驱动 {@link tick},通过寻路服务自动移动到目标区块, + * 并监听聊天 / 字幕消息判断开垦是否成功。支持自动进食、自动攻击敌对生物、 + * 低耐久自动回城、超时重试、手动停止等功能。 + */ +class ExpansionController { + /** + * @param {object} deps 依赖注入 + * @param {import('../bot/botManager').BotManager} deps.botManager + * @param {import('../core/territoryDataService').TerritoryDataService} deps.dataService + * @param {import('../util/logger').Logger} deps.logger + */ + constructor(deps) { + this.botManager = deps.botManager; + this.dataService = deps.dataService; + this.logger = deps.logger; + this.planner = new ExpansionPlanner(); + /** @type {import('../core/subtitleChatMonitor').SubtitleChatMonitor} */ + this.monitor = new SubtitleChatMonitor({ + onProgress: (percent) => this.onProgress(percent), + onSuccess: (chunk, blockX, blockZ) => this.onSuccess(chunk, blockX, blockZ), + onGenericSuccess: () => this.onGenericSuccess() + }); + + /** 当前扩地配置 */ + this.config = null; + /** 当前状态机状态 */ + this.state = State.IDLE; + /** 当前状态描述文本 */ + this.status = '未启动'; + /** @type {{x: number, z: number}|null} 当前目标区块 */ + this.target = null; + /** @type {{x: number, z: number}} 玩家当前所在区块(每 tick 更新) */ + this.playerChunk = { x: 0, z: 0 }; + /** 已成功开垦的区块数量 */ + this.completedClaims = 0; + /** 当前目标的连续重试次数 */ + this.retryCount = 0; + /** 当前阶段(寻路 / 等待)的开始时间戳 */ + this.stageStartedMillis = 0; + /** 最近一次开垦进度(0-100) */ + this.lastProgress = 0; + + /** @type {Array<{x: number, z: number}>} 待占领区块队列 */ + this.queue = []; + /** 已计入完成数的区块键集合(去重) */ + this.claimedSet = new Set(); + + /** 等待开拓时段自动开始:不在时段时点击开始会暂存配置(null 表示无等待任务) */ + this.pendingStart = null; + /** 连续寻路超时累计时长(毫秒) */ + this.consecutiveTimeoutMillis = 0; + /** 发送回城指令的时间戳 */ + this.returnStartMillis = 0; + /** 当前目标已触发「往边上走两步」的次数 */ + this.sideStepCount = 0; + /** 是否正在「往边上走两步」阶段 */ + this.sideStepping = false; + /** 走两步的方向符号(+1 左侧 / -1 右侧) */ + this.sideStepSign = 1; + /** 走两步的整体超时截止时间戳 */ + this.sideStepDeadlineMillis = 0; + /** 最近一次换向的时间戳 */ + this.sideStepSinceMillis = 0; + /** 走两步开始时的玩家方块坐标 */ + this.sideStepStartPos = null; + /** 本次回城是否由耐久保护触发:RETURNING 结束后停止任务而非重新规划 */ + this.returnThenStop = false; + /** 自动攻击当前目标的实体 ID(仅用于避免重复记录战斗日志) */ + this.combatTargetEntityId = -1; + /** 当前是否正持续按住跳跃键(自动游泳用) */ + this.holdingJump = false; + /** 是否已经发送过开垦模式指令 */ + this._claimModeSent = false; + /** @type {((status: object) => void)|null} 状态变化回调(Web 控制台用) */ + this.onStateChange = null; + /** 最近一次错误 */ + this.lastError = ''; + } + + // ==================== 对外状态 ==================== + + /** @returns {boolean} 任务是否运行中 */ + isRunning() { + return this.state !== State.IDLE && this.state !== State.COMPLETE && this.state !== State.STOPPED; + } + + /** @returns {boolean} 是否处于「等待开拓时段自动开始」状态 */ + isWaitingForClaimTime() { + return this.pendingStart !== null; + } + + /** @returns {number} 队列中待占领区块数 */ + get queuedTargets() { + return this.queue.length; + } + + /** + * 判断当前现实时间(本地时区)是否在允许开拓的时间段内。 + * @returns {boolean} + */ + isWithinClaimTime() { + const cfg = this.config || { enforceClaimTime: false }; + if (!cfg.enforceClaimTime) return true; + const hour = new Date().getHours(); + return hour >= cfg.claimStartHour && hour < cfg.claimEndHour; + } + + /** + * 启动自动扩地任务。 + * + * 前置校验:机器人必须已进入世界并完成登录、寻路器已就绪; + * 邦国名称为空时自动识别玩家所在邦国,否则校验指定邦国是否存在。 + * + * @param {object} requestedConfig 本次任务的扩地配置 + * @returns {Promise} 是否成功启动 + */ + async start(requestedConfig) { + const bm = this.botManager; + if (!bm.isReady()) { + this.status = '机器人尚未就绪(未连接 / 未进入世界)'; + this.state = State.IDLE; + return false; + } + this.config = requestedConfig; + // 现实时间限制:不在允许时段时登记「等待自动开始」,进入时段后由 tick() 自动启动 + if (!this.isWithinClaimTime()) { + this.pendingStart = requestedConfig; + this.setState(State.IDLE, `等待开垦时段,${this.config.claimStartHour}:00 自动开始`); + this._log(`不在开垦时段内(${this.config.claimStartHour}:00-${this.config.claimEndHour}:00),` + + `已登记自动开始,当前现实时间 ${formatClock(new Date())}`); + bm.sendCommand('', { immediate: true }); + return true; + } + this.pendingStart = null; + this.playerChunk = this._playerChunk(); + + // ===== 识别己方邦国 ===== + const model = this.dataService.getModel(); + if (!this.config.stateName) { + const auto = model.findContainingState(this.playerChunk); + if (auto) { + model.setSelfStateName(auto.name); + this.dataService.setSelectedStateName(auto.name); + } else { + // 兜底:通过 SimpCore API 按玩家名识别 + const detected = await this.dataService.detectStateByPlayer(bm.connection.username); + if (detected) { + this.dataService.getModel().setSelfStateName(detected); + this.dataService.setSelectedStateName(detected); + } else { + this.status = '无法自动识别邦国,请在控制台手动选择'; + this.state = State.IDLE; + return false; + } + } + } else { + const model2 = this.dataService.getModel(); + if (!model2.state(this.config.stateName)) { + this.status = '所选邦国不存在:' + this.config.stateName; + this.state = State.IDLE; + return false; + } + model2.setSelfStateName(this.config.stateName); + this.dataService.setSelectedStateName(this.config.stateName); + } + + // ===== 复位任务状态 ===== + this.completedClaims = 0; + this.retryCount = 0; + this.consecutiveTimeoutMillis = 0; + this.sideStepCount = 0; + this.sideStepping = false; + this.returnThenStop = false; + this.combatTargetEntityId = -1; + this.queue = []; + this.claimedSet.clear(); + this.target = null; + this.lastProgress = 0; + this.lastError = ''; + this._claimModeSent = false; + if (bm.guard) bm.guard.reset(); + if (bm.pathing) bm.pathing.applyConfig(this.config); + + // ===== 开启服务器开垦模式 ===== + if (this.config.server.claimCommand) { + bm.sendCommand(this.config.server.claimCommand); + this._claimModeSent = true; + this._log('已发送开垦模式指令:' + this.config.server.claimCommand); + } + + this.setState(State.PLANNING, '正在生成扩张计划'); + this._log('开始自动扩地,邦国:' + this.dataService.getModel().selfStateNameValue()); + await this.plan(); + return true; + } + + /** 手动停止任务 */ + stop() { + const bm = this.botManager; + if (this.pendingStart) { + this.pendingStart = null; + this.setState(State.IDLE, '已取消自动开始'); + this._log('已取消等待自动开始'); + return; + } + if (!this.isRunning()) return; + if (bm.pathing) bm.pathing.cancel(); + if (this.sideStepping) this.stopSideStep(); + this.releaseJump(); + if (bm.boat) bm.boat.reset(true); + this.setState(State.STOPPED, '已手动停止'); + this._log('已手动停止'); + } + + /** + * 每 tick 驱动逻辑:更新玩家区块,依次处理等待时段、自动进食、耐久保护、 + * 自动游泳、自动用船、自动攻击,再按当前状态分发到对应的 tick 方法。 + */ + tick() { + const bm = this.botManager; + if (!this.config) return; // 尚未启动过任务,没有可驱动的状态 + if (!bm.isSpawned()) { + if (this.isRunning()) { + this.stop(); + this.status = '世界已退出,任务停止'; + } + return; + } + this.playerChunk = this._playerChunk(); + + // 等待开垦时段自动开始:进入时段后自动启动已登记的任务 + if (this.pendingStart && this.isWithinClaimTime()) { + const pending = this.pendingStart; + this.pendingStart = null; + this._log('已进入开垦时段,自动开始'); + this.start(pending).catch((err) => this._log('自动开始失败:' + err.message, 'error')); + } + // 运行中超出允许时段则自动停止,避免服务器拒绝开拓导致超时 + if (this.isRunning() && !this.isWithinClaimTime()) { + this.stop(); + this.status = `已到开垦时段外(${this.config.claimStartHour}:00-${this.config.claimEndHour}:00),任务停止`; + this._log(`已到开垦时段外,停止任务(当前现实时间 ${formatClock(new Date())})`); + return; + } + + const boatActive = bm.boat && bm.boat.isActive(); + // 自动用船流程进行中:暂停自动进食,避免进食中断航行 / 游泳按键 + if (!boatActive && bm.eater && bm.eater.tick()) return; + + // 耐久保护优先于一切寻路 / 战斗:装备耐久过低时先回城,防止装备被怪物磨没 + if (this._tickDurabilityGuard()) return; + + this._tickAutoSwim(); + this._tickAutoBoat(); + // 处于「往边上走两步」阶段:每 tick 驱动手动横向行走,走开后自动重试原目标 + if (this.sideStepping) { + this._tickSideStep(); + return; + } + // 自动攻击:面向并攻击范围内的敌对生物(用船期间不攻击,避免抢转向) + if (!(bm.boat && bm.boat.isActive())) { + this._tickAutoAttack(); + } + + switch (this.state) { + case State.PLANNING: this.plan(); break; + case State.PATHING: this._tickPathing(); break; + case State.WAITING: this._tickWaiting(); break; + case State.SUCCESS: this._tickSuccess(); break; + case State.FAIL: this._tickFail(); break; + case State.NEXT: this._tickNext(); break; + case State.RETURNING: this._tickReturning(); break; + default: break; // IDLE / COMPLETE / STOPPED 无需每 tick 驱动 + } + } + + // ==================== 规划与寻路 ==================== + + /** + * 生成扩地计划:调用规划器计算候选区块,过滤已被他人占领的区块后入队。 + * 若已达成目标数量或没有可用区块则直接完成。 + */ + async plan() { + const bm = this.botManager; + if (this.config.targetCount > 0 && this.completedClaims >= this.config.targetCount) { + this.finish(); + return; + } + this.queue = []; + const model = this.dataService.getModel(); + const planned = this.planner.generate(model, this.config, this.playerChunk); + for (const chunk of planned) { + if (!this._isClaimedByOthers(chunk)) this.queue.push(chunk); + } + // 矩形区域扩地时不受扩圈上限限制(玩家圈选的矩形即为明确边界,需完整填充) + const rectangleMode = this.config.strategy === 2 && this.config.regionMaxX >= this.config.regionMinX; + if (this.config.maxRings > 0 && !rectangleMode) { + this.queue = this.queue.filter((chunk) => this._ringDistance(chunk, model) <= this.config.maxRings); + } + this._log(`规划完成,共 ${this.queue.length} 个候选区块`); + if (this.queue.length === 0) { + this.setState(State.COMPLETE, '没有可用的相邻无主区块'); + this._log('没有可用的相邻无主区块'); + return; + } + this.target = this.queue.shift(); + this._startPathing(); + } + + /** 让机器人寻路前往当前目标区块,并进入 PATHING 状态 */ + _startPathing() { + // 寻路前先校验目标区块是否仍与己方领土接壤(地图异步刷新后可能已不接壤) + if (this._skipIfTargetNotAdjacent()) return; + this.retryCount = 0; + this.sideStepCount = 0; + this.stageStartedMillis = Date.now(); + this.lastProgress = 0; + const ok = this.botManager.pathing.pathTo(this.target); + if (!ok) { + this._log('寻路器不可用,跳过目标 ' + this._describeChunk(this.target), 'warn'); + this.setState(State.NEXT, '寻路器不可用'); + return; + } + this.setState(State.PATHING, `正在前往目标区块 ${this.target.x},${this.target.z}`); + this._log(`寻路前往 ${this.target.x},${this.target.z}(玩家区块 ${this.playerChunk.x},${this.playerChunk.z})`); + } + + /** + * 寻路阶段每 tick 逻辑:到达目标区块后进入等待开垦状态; + * 若寻路超时则重新寻路;重试耗尽先「往边上走两步」再重试,仍失败则回城或跳过该目标。 + */ + _tickPathing() { + const bm = this.botManager; + if (!this.target) { + this.setState(State.NEXT, '目标为空'); + return; + } + if (this._skipIfTargetClaimed('目标已被占领')) return; + // 已到达目标区块:寻路完成 + if (chunkPos.equals(this.playerChunk, this.target)) { + bm.pathing.cancel(); + this.consecutiveTimeoutMillis = 0; + this.stageStartedMillis = Date.now(); + this.setState(State.WAITING, '已到达,等待领地开垦'); + this._log(`已到达目标 ${this.target.x},${this.target.z},开始等待开垦`); + return; + } + // 自动用船流程进行中:移动由船导航接管,跳过超时 / 重试 / 路径失败处理 + if (bm.boat && bm.boat.isActive()) return; + // 寻路器报告路径计算失败(目标不可达):先重新寻路一次,重试耗尽才走两步 / 跳过 + if (bm.pathing.calcFailed) { + bm.pathing.calcFailed = false; + this.retryCount++; + if (this.retryCount >= this.config.retries) { + if (this.sideStepCount < this.config.sideStepMaxRounds) { + this._startSideStep(); + } else { + this._giveUpTarget('寻路失败(目标不可达)'); + } + } else { + this._log(`寻路失败(目标不可达),重新寻路 ${this.retryCount}/${this.config.retries}`); + this.dataService.refreshAsync(); + this._retrySameTarget('寻路失败,重新寻路'); + } + return; + } + if (this._skipIfTargetNotAdjacent()) return; + if (chunkPos.equals(this.playerChunk, this.target)) { + bm.pathing.cancel(); + this.consecutiveTimeoutMillis = 0; + this.stageStartedMillis = Date.now(); + this.setState(State.WAITING, '已到达,等待领地开垦'); + this._log(`已到达目标 ${this.target.x},${this.target.z},开始等待开垦`); + return; + } + const elapsedSeconds = Math.floor((Date.now() - this.stageStartedMillis) / 1000); + if (elapsedSeconds >= this.config.waitTimeoutSeconds) { + this.retryCount++; + this.dataService.refreshAsync(); + // 累计「连续寻路超时」时长:多次寻路都超时说明被地形卡死,达阈值后回城传送 + this.consecutiveTimeoutMillis += this.config.waitTimeoutSeconds * 1000; + if (this.retryCount < this.config.retries) { + this._log(`寻路超时,重新寻路 ${this.retryCount}/${this.config.retries}`); + this._retrySameTarget('寻路超时,重新寻路'); + } else if (this.sideStepCount < this.config.sideStepMaxRounds) { + this._startSideStep(); + } else { + this._giveUpTarget('寻路超时'); + } + } + } + + /** + * 发送回城指令:取消当前寻路,向服务器发送 `/u return`(约 10 秒后自动传送回安全位置), + * 随后进入 RETURNING 状态等待传送完成。 + * + * @param {string} reason 触发回城的原因 + */ + _sendReturnCommand(reason) { + const bm = this.botManager; + bm.pathing.cancel(); + this._log(reason + ',发送 /u return 回城,10 秒后自动传送'); + bm.sendCommand('u return'); + this.returnStartMillis = Date.now(); + this.setState(State.RETURNING, '等待回城传送(10 秒后自动传送)'); + } + + /** + * 回城传送等待阶段每 tick 逻辑:等待 returnWaitSeconds 秒后, + * 耐久保护触发的回城 → 停止任务;寻路超时触发的回城 → 重新规划扩地计划。 + */ + _tickReturning() { + if (Date.now() - this.returnStartMillis < this.config.returnWaitSeconds * 1000) return; + if (this.returnThenStop) { + this.returnThenStop = false; + this._log('耐久保护回城完成,任务已停止;请修复 / 更换装备后重新开始扩地'); + this.setState(State.STOPPED, '装备耐久过低已回城,请修复装备后重新开始'); + return; + } + this.consecutiveTimeoutMillis = 0; + this._log('回城传送完成,重新规划并从最高优先级区块开始'); + this.plan(); + } + + /** + * 目标寻路重试耗尽时的脱困策略入口:取消当前寻路,改为朝「目标连线垂直方向」 + * 走开约 2.5 格(左右各试一侧),走开后自动重新寻路原目标。 + */ + _startSideStep() { + this.botManager.pathing.cancel(); + this.sideStepping = true; + this.sideStepCount++; + this.sideStepSign = 1; + this.sideStepStartPos = this._blockPos(); + this.sideStepDeadlineMillis = Date.now() + 10000; + this.sideStepSinceMillis = Date.now(); + this._log(`寻路重试耗尽,开始往边上走两步再重试原目标(第 ${this.sideStepCount}/${this.config.sideStepMaxRounds} 轮)`); + this.setState(State.PATHING, '往边上走两步...'); + } + + /** 走两步阶段每 tick 逻辑:朝目标连线垂直方向按住前进键行走,走开约 2.5 格后重试原目标 */ + _tickSideStep() { + const bm = this.botManager; + const bot = bm.bot; + if (!bot || !this.sideStepStartPos || !this.target) { + this.stopSideStep(); + return; + } + if (Date.now() >= this.sideStepDeadlineMillis) { + this.stopSideStep(); + this._giveUpTarget('往边上走两步超时'); + return; + } + const playerPos = bot.entity.position; + const dx = this.target.x * 16 + 8 + 0.5 - playerPos.x; + const dz = this.target.z * 16 + 8 + 0.5 - playerPos.z; + const length = Math.hypot(dx, dz) || 1; + const dirX = dx / length; + const dirZ = dz / length; + // 垂直向量(左为 +,右为 -) + const sideX = -dirZ * this.sideStepSign; + const sideZ = dirX * this.sideStepSign; + const yaw = Math.atan2(-sideX, -sideZ); + if (bm.grim) { + bm.grim.setLook(yaw, 0); + bm.grim.setControlState('forward', true); + } else { + bot.look(yaw, 0, true); + bot.setControlState('forward', true); + } + // 已走开足够距离(约 2.5 格):停止并重试原目标 + const moved = Math.hypot( + playerPos.x - this.sideStepStartPos.x, + playerPos.z - this.sideStepStartPos.z + ); + if (moved >= 2.5) { + this.stopSideStep(); + this._retrySameTarget('往边上走两步完成,重新寻路'); + return; + } + // 卡住检查:同一方向 5 秒没移动就换另一侧;两侧都试过仍不动则放弃 + if (Date.now() - this.sideStepSinceMillis > 5000) { + if (this.sideStepSign !== -1) { + this.sideStepSign = -1; + this.sideStepSinceMillis = Date.now(); + this._log('往边上走被挡,取反方向继续'); + } else { + this.stopSideStep(); + this._giveUpTarget('往边上走两侧均被挡'); + } + } + } + + /** 结束走两步阶段:释放前进键 */ + stopSideStep() { + this.sideStepping = false; + const bm = this.botManager; + if (bm.grim) bm.grim.setControlState('forward', false); + else if (bm.bot) bm.bot.setControlState('forward', false); + } + + /** + * 放弃当前目标(重试与走两步均已尝试)的统一出口: + * 连续寻路超时累计达到阈值则回城传送并重新规划;否则跳过当前目标。 + */ + _giveUpTarget(reason) { + if (this.consecutiveTimeoutMillis >= this.config.returnThresholdSeconds * 1000) { + this._sendReturnCommand(`连续寻路超时超过 ${this.config.returnThresholdSeconds} 秒`); + return; + } + this._log(this.target ? `${reason},跳过 ${this.target.x},${this.target.z}` : reason); + this.setState(State.FAIL, reason); + } + + /** 等待开垦阶段每 tick 逻辑:玩家离开目标区块则重新寻路;等待超时则重试或跳过 */ + _tickWaiting() { + if (!this.target) { + this.setState(State.NEXT, '目标为空'); + return; + } + if (this._skipIfTargetClaimed('目标已被占领')) return; + if (this._skipIfTargetNotAdjacent()) return; + if (!chunkPos.equals(this.playerChunk, this.target)) { + this._retrySameTarget(`离开目标区块,重新前往 ${this.target.x},${this.target.z}`); + return; + } + const elapsedSeconds = Math.floor((Date.now() - this.stageStartedMillis) / 1000); + if (elapsedSeconds >= this.config.waitTimeoutSeconds) { + this.retryCount++; + this.dataService.refreshAsync(); + if (this.retryCount >= this.config.retries) { + this._log(`等待超时,跳过 ${this.target.x},${this.target.z}`); + this.setState(State.FAIL, '等待领地开垦超时'); + } else { + this._log(`等待超时,重试 ${this.retryCount}/${this.config.retries}`); + this._retrySameTarget(`等待超时,重新前往 ${this.target.x},${this.target.z}`); + } + } + } + + /** 重试当前目标:取消旧寻路,重新寻路到同一目标区块 */ + _retrySameTarget(statusText) { + const bm = this.botManager; + if (this._skipIfTargetClaimed('重试时发现目标已被占领')) return; + if (this._skipIfTargetNotAdjacent()) return; + bm.pathing.cancel(); + this.stageStartedMillis = Date.now(); + bm.pathing.pathTo(this.target); + this.setState(State.PATHING, statusText); + } + + /** 成功后的过渡:进入 NEXT 状态 */ + _tickSuccess() { + this.setState(State.NEXT, `已完成 ${this.completedClaims}${this.config.targetCount > 0 ? '/' + this.config.targetCount : '/持续'}`); + } + + /** 失败后的过渡:进入 NEXT 状态,跳过当前目标 */ + _tickFail() { + this.setState(State.NEXT, '准备下一个目标'); + } + + /** 处理下一个目标:达成目标数量则完成;队列为空时重新规划,否则取队首开始寻路 */ + _tickNext() { + this.target = null; + if (this.config.targetCount > 0 && this.completedClaims >= this.config.targetCount) { + this.finish(); + return; + } + if (this.queue.length === 0) { + this.plan(); + return; + } + this.target = this.queue.shift(); + if (this._isClaimedByOthers(this.target)) { + this._log(`目标已被占领,跳过 ${this.target.x},${this.target.z}`); + this.setState(State.NEXT, '跳过已占领区块'); + return; + } + this._startPathing(); + } + + /** 完成任务 */ + finish() { + this.botManager.pathing.cancel(); + this.setState(State.COMPLETE, this.config.targetCount > 0 + ? `已完成目标数量 ${this.completedClaims}/${this.config.targetCount}` + : `已无可扩区块,累计扩地 ${this.completedClaims}`); + this._log('自动扩地完成'); + } + + // ==================== 目标校验 ==================== + + /** + * 若当前目标已被任何邦国占领(含己方与会话),则记录日志、跳过并进入 NEXT 状态。 + * @returns {boolean} + */ + _skipIfTargetClaimed(reason) { + if (this.target && this._isClaimedByOthers(this.target)) { + this._log(`${reason} ${this.target.x},${this.target.z},跳过`); + this.setState(State.NEXT, '跳过已占领区块'); + return true; + } + return false; + } + + /** + * 若当前目标不再与己方领土(含会话占领)四正方向接壤,则跳过并进入 NEXT 状态。 + * + * 这是解决「走到不接壤区块」的关键:规划时目标必然接壤,但寻路 / 等待过程中 + * 领土数据会异步刷新,导致目标可能变得不接壤。每 tick 校验可避免在无效区块上白费时间。 + * + * @returns {boolean} + */ + _skipIfTargetNotAdjacent() { + if (this.target && !this._isAdjacentToOwn(this.target)) { + this._log(`目标与己方领土不相邻,跳过 ${this.target.x},${this.target.z}`); + this.setState(State.NEXT, '跳过不接壤区块'); + return true; + } + return false; + } + + /** + * 判断区块是否与己方领土接壤(四个正方向,只算己方,绝不把其他邦国领土计入)。 + * @param {{x: number, z: number}} chunk + * @returns {boolean} + */ + _isAdjacentToOwn(chunk) { + const model = this.dataService.getModel(); + const self = model.selfState(); + const ownChunks = self ? self.chunks : null; + const sessionClaims = model.sessionClaimsSet(); + const x = chunk.x; + const z = chunk.z; + return this._isOwnAt(ownChunks, sessionClaims, x + 1, z) + || this._isOwnAt(ownChunks, sessionClaims, x - 1, z) + || this._isOwnAt(ownChunks, sessionClaims, x, z + 1) + || this._isOwnAt(ownChunks, sessionClaims, x, z - 1); + } + + _isOwnAt(ownChunks, sessionClaims, x, z) { + const k = chunkPos.key(x, z); + return (ownChunks !== null && ownChunks.has(k)) || sessionClaims.has(k); + } + + /** @returns {boolean} 区块是否已被任何邦国占领(含己方与会话) */ + _isClaimedByOthers(chunk) { + return this.dataService.getModel().allClaimedChunks().has(chunkPos.key(chunk.x, chunk.z)); + } + + /** + * 计算区块到己方领土的「圈数」(切比雪夫距离的最小值),用于过滤扩圈上限。 + * @param {{x: number, z: number}} chunk + * @param {import('../core/territoryModel').TerritoryModel} model + * @returns {number} + */ + _ringDistance(chunk, model) { + let min = Infinity; + const self = model.selfState(); + if (self) { + for (const k of self.chunks) { + const pos = chunkPos.decode(k); + min = Math.min(min, Math.max(Math.abs(chunk.x - pos.x), Math.abs(chunk.z - pos.z))); + } + } + for (const k of model.sessionClaimsSet()) { + const pos = chunkPos.decode(k); + min = Math.min(min, Math.max(Math.abs(chunk.x - pos.x), Math.abs(chunk.z - pos.z))); + } + return min === Infinity ? 0 : min; + } + + // ==================== 消息回调 ==================== + + /** + * 寻路事件回调:记录关键事件,并在「计算失败」时置标志供寻路阶段消费。 + * @param {string} event + * @param {object} [detail] + */ + onPathEvent(event, detail) { + if (!this.isRunning()) return; + if (event === PathEvent.CALC_FAILED) { + this._log('寻路事件:CALC_FAILED(目标不可达)' + (detail && detail.timeout ? '(搜索超时)' : '')); + } else if (event === PathEvent.AT_GOAL) { + this._log('寻路事件:AT_GOAL'); + } else if (event === PathEvent.CALC_FINISHED_NOW_EXECUTING) { + this._log(`寻路事件:路径已生成(代价 ${detail ? detail.cost : '?'})`); + } + } + + /** 开垦进度更新(0-100%) */ + onProgress(percent) { + if (!this.isRunning()) return; + this.lastProgress = percent; + this.status = `开垦中 ${percent}%`; + } + + /** + * 开垦成功(带具体坐标):把新占领区块加入模型与数据服务,累计完成数并进入 SUCCESS 状态。 + * @param {{x: number, z: number}} chunk + */ + onSuccess(chunk) { + if (!this.isRunning()) return; + const target = chunk || this.target; + if (!target) return; + const key = chunkPos.key(target.x, target.z); + // 去重:同一区块只计一次 + if (this.claimedSet.has(key)) return; + this.claimedSet.add(key); + this.dataService.getModel().addSessionClaim(target); + this.dataService.addSessionClaim(target); + // 占领成功后立即触发一次地图刷新(请求侧已带缓存击穿),缩短「会话占领等待确认」窗口 + this.dataService.refreshAsync(); + this.completedClaims++; + this.consecutiveTimeoutMillis = 0; + this.target = target; + this.lastProgress = 100; + this._log(`占领成功 ${target.x},${target.z}`); + this.setState(State.SUCCESS, `疆土已开拓 ${target.x},${target.z}`); + } + + /** + * 开垦成功(无具体坐标的通用消息):按当前目标区块记录。 + * + * 开垦只能发生在机器人所在区块,因此必须校验机器人确实位于目标区块上: + * 通用成功文案采用模糊匹配,可能被服务器广播的其他玩家开垦消息误触发。 + */ + onGenericSuccess() { + if (!this.isRunning() || !this.target) return; + if (!chunkPos.equals(this.playerChunk, this.target)) return; + const key = chunkPos.key(this.target.x, this.target.z); + // 去重:同一区块只计一次 + if (this.claimedSet.has(key)) return; + this.claimedSet.add(key); + this.dataService.getModel().addSessionClaim(this.target); + this.dataService.addSessionClaim(this.target); + this.dataService.refreshAsync(); + this.completedClaims++; + this.consecutiveTimeoutMillis = 0; + this.lastProgress = 100; + this._log(`占领成功 ${this.target.x},${this.target.z}`); + this.setState(State.SUCCESS, `疆土已开拓 ${this.target.x},${this.target.z}`); + } + + // ==================== 生存辅助 ==================== + + /** + * 自动游泳:只要机器人在水中(含没入 / 水面 / 浅水)、不在船中、无自动用船流程、 + * 未在手动走两步,就持续按住跳跃键,使其不断浮到水面,避免溺水。 + */ + _tickAutoSwim() { + const bm = this.botManager; + const bot = bm.bot; + if (!this.isRunning() || !bot || !bot.entity) { + this.releaseJump(); + return; + } + const inWater = bot.entity.isInWater === true; + const inBoat = !!bot.vehicle; + const boatActive = bm.boat && bm.boat.isActive(); + if (inWater && !inBoat && !boatActive && !this.sideStepping) { + this.pressJump(); + } else { + this.releaseJump(); + } + } + + /** 按住跳跃键(仅在当前未按时生效,避免重复赋值) */ + pressJump() { + if (this.holdingJump) return; + const bm = this.botManager; + if (bm.grim) bm.grim.setControlState('jump', true); + else if (bm.bot) bm.bot.setControlState('jump', true); + this.holdingJump = true; + } + + /** 释放跳跃键 */ + releaseJump() { + if (!this.holdingJump) return; + const bm = this.botManager; + if (bm.grim) bm.grim.setControlState('jump', false); + else if (bm.bot) bm.bot.setControlState('jump', false); + this.holdingJump = false; + } + + /** + * 自动攻击:索敌半径内出现敌对生物时,面向该生物并在攻击距离内自动出手(满蓄力), + * 不追击、不打断寻路;目标筛选由 autoCombat.isAttackTarget 保证 + * 绝不攻击铁傀儡、僵尸猪灵等不会主动攻击玩家的中立生物。 + */ + _tickAutoAttack() { + const bm = this.botManager; + if (!this.config.autoAttack || !this.isRunning() || !bm.bot) { + this.combatTargetEntityId = -1; + return; + } + const target = findTarget(bm.bot, this.config.combat.scanRange, this.config); + if (!target) { + this.combatTargetEntityId = -1; + return; + } + if (target.id !== this.combatTargetEntityId) { + this.combatTargetEntityId = target.id; + this._log(`发现敌对生物 ${target.name},自动攻击中`); + } + // 攻击是异步的(要先同步朝向),这里不 await,避免阻塞状态机 tick + if (bm.grim) { + bm.grim.attackEntity(target).catch(() => {}); + } else { + try { + bm.bot.attack(target); + } catch { + // 忽略 + } + } + } + + /** 自动用船过水 */ + _tickAutoBoat() { + const bm = this.botManager; + if (!bm.boat) return; + const wasActive = bm.boat.isActive(); + const canStartFlow = this.state === State.PATHING && !this.sideStepping && this.target !== null; + bm.boat.tick({ + running: this.isRunning(), + canStart: canStartFlow, + target: this.target + }); + if (bm.boat.isActive()) { + // 自动用船流程接管移动:取消寻路,避免与船的按键 / 方向冲突 + bm.pathing.cancel(); + const desc = bm.boat.describe(); + if (desc) this.status = desc; + } else if (wasActive) { + // 用船流程刚结束:若仍处于寻路阶段且未到达目标,恢复寻路 + if (this.state === State.PATHING && this.target + && !chunkPos.equals(this.playerChunk, this.target) && !this.sideStepping) { + this.stageStartedMillis = Date.now(); + bm.pathing.pathTo(this.target); + this._log('自动用船结束,恢复寻路'); + } + } + } + + /** + * 耐久保护:主手 / 副手 / 四件盔甲任意一件剩余耐久低于阈值时, + * 发送 `/u return` 回城并停止扩地,防止装备在继续战斗中被怪物磨没。 + * + * @returns {boolean} true 表示本 tick 已触发回城 + */ + _tickDurabilityGuard() { + const bm = this.botManager; + if (!bm.guard || !this.config.durabilityGuard || !this.isRunning() || this.state === State.RETURNING) { + return false; + } + const low = bm.guard.check(); + if (!low) return false; + const reason = `装备耐久过低(${low.name} 剩余耐久 ${low.percent}% ≤ ${this.config.durabilityThreshold}%)`; + this.returnThenStop = true; + this._log(reason + ',发送 /u return 回城并停止扩地,防止装备被怪物磨没'); + this._sendReturnCommand(reason); + return true; + } + + // ==================== 内部工具 ==================== + + /** @returns {{x: number, z: number}} 机器人当前所在区块 */ + _playerChunk() { + const bot = this.botManager.bot; + if (!bot || !bot.entity) return { x: 0, z: 0 }; + return chunkPos.fromBlock(Math.floor(bot.entity.position.x), Math.floor(bot.entity.position.z)); + } + + /** @returns {{x: number, y: number, z: number}|null} 机器人当前方块坐标 */ + _blockPos() { + const bot = this.botManager.bot; + if (!bot || !bot.entity) return null; + return { + x: Math.floor(bot.entity.position.x), + y: Math.floor(bot.entity.position.y), + z: Math.floor(bot.entity.position.z) + }; + } + + _describeChunk(chunk) { + return chunk ? `${chunk.x},${chunk.z}` : '无'; + } + + /** 更新状态与状态描述文本,并通知订阅者 */ + setState(newState, newStatus) { + const changed = this.state !== newState || this.status !== newStatus; + this.state = newState; + this.status = newStatus; + if (changed && this.onStateChange) { + try { + this.onStateChange(this.snapshot()); + } catch { + // 订阅者异常不影响状态机 + } + } + } + + _log(message, level) { + if (this.logger) this.logger.log(message, level); + } + + /** + * @returns {object} 任务状态快照(供 Web 控制台展示) + */ + snapshot() { + return { + state: this.state, + status: this.status, + running: this.isRunning(), + waitingForClaimTime: this.isWaitingForClaimTime(), + target: this.target, + playerChunk: this.playerChunk, + queuedTargets: this.queue.length, + completedClaims: this.completedClaims, + retryCount: this.retryCount, + lastProgress: this.lastProgress, + consecutiveTimeoutMs: this.consecutiveTimeoutMillis, + sideStepping: this.sideStepping, + sideStepCount: this.sideStepCount, + autoBoatActive: !!(this.botManager.boat && this.botManager.boat.isActive()), + withinClaimTime: this.isWithinClaimTime(), + claimModeSent: this._claimModeSent, + lastError: this.lastError + }; + } + + /** + * 导出运行日志到文件。 + * @param {string} filePath 目标路径 + * @returns {string|null} 导出文件路径,失败返回 null + */ + exportLogs(filePath) { + const snapshot = this.snapshot(); + const summary = { + 状态: `${snapshot.state} / ${snapshot.status}`, + 目标区块: this._describeChunk(snapshot.target), + 玩家区块: `${snapshot.playerChunk.x},${snapshot.playerChunk.z}`, + 队列: `${snapshot.queuedTargets} | 已占领: ${snapshot.completedClaims} | 重试: ${snapshot.retryCount} | 最近进度: ${snapshot.lastProgress}%`, + 数据源: JSON.stringify(this.dataService.snapshot()) + }; + return this.logger ? this.logger.exportTo(filePath, summary) : null; + } +} + +/** + * 格式化为 HH:mm:ss。 + * @param {Date} date + * @returns {string} + */ +function formatClock(date) { + const pad = (n) => String(n).padStart(2, '0'); + return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; +} + +module.exports = { ExpansionController, State, EXPORT_SUMMARY_KEYS }; diff --git a/src/core/chunkPos.js b/src/core/chunkPos.js new file mode 100644 index 0000000..2a8e7de --- /dev/null +++ b/src/core/chunkPos.js @@ -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 +}; diff --git a/src/core/expansionPlanner.js b/src/core/expansionPlanner.js new file mode 100644 index 0000000..92b5847 --- /dev/null +++ b/src/core/expansionPlanner.js @@ -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 }; diff --git a/src/core/simpApiClient.js b/src/core/simpApiClient.js new file mode 100644 index 0000000..7858ca8 --- /dev/null +++ b/src/core/simpApiClient.js @@ -0,0 +1,347 @@ +'use strict'; + +const chunkPos = require('./chunkPos'); + +/** + * SimpCore 开放 API 客户端(文档:https://open.bgjq.top/api-docs.html)。 + * + * 邦国崛起服务器的只读查询服务,作为占领判定的第四数据源 + * (游戏字幕、游戏消息、Dynmap JSON 之外的补充),提供: + * - 己方邦国领土区块明细(区块坐标、忠诚度、占领时间)——修正本地领土模型 + * - 全服领土区块(手动触发)——交叉修正他人区块归属 + * - 玩家所属邦国查询——辅助识别玩家邦国 + * - 邦国列表(含王城坐标)——王城数据兜底 + * + * 鉴权:`Authorization: Bearer `;限流:按 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} 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>} 邦国名 → 邦国摘要 + */ + 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} + */ + 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>} 区块键 → 区块详情 + */ + 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>} + */ + 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} + */ + 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} + */ + 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} 响应体;失败返回 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 +}; diff --git a/src/core/subtitleChatMonitor.js b/src/core/subtitleChatMonitor.js new file mode 100644 index 0000000..bb94366 --- /dev/null +++ b/src/core/subtitleChatMonitor.js @@ -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 +}; diff --git a/src/core/territoryDataService.js b/src/core/territoryDataService.js new file mode 100644 index 0000000..4c53f90 --- /dev/null +++ b/src/core/territoryDataService.js @@ -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} 识别到的邦国名(本地模型中必须存在,否则返回 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>} */ + const claims = new Map(); + /** @type {Map} */ + const claimColors = new Map(); + /** @type {Map} */ + 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>`。 + */ +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} output 输出集合(该邦国的区块键集合) + * @param {Array>} 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 +}; diff --git a/src/core/territoryModel.js b/src/core/territoryModel.js new file mode 100644 index 0000000..a9bd430 --- /dev/null +++ b/src/core/territoryModel.js @@ -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, color: number}>} [states] + */ + constructor(states) { + /** @type {Map, color: number}>} */ + this.states = new Map(states || []); + /** @type {Set} 所有邦国已占领区块的并集 */ + this.allClaimed = new Set(); + for (const state of this.states.values()) { + for (const k of state.chunks) this.allClaimed.add(k); + } + /** @type {Set} 本次会话新占领的区块 */ + this.sessionClaims = new Set(); + /** @type {string} 当前选中的己方邦国名称 */ + this.selfStateName = ''; + /** @type {Map>|null} 飞地缓存 */ + this._flyingByState = null; + /** @type {Set|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, 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, color: number}|undefined} + */ + state(name) { + return this.states.get(name); + } + + /** @returns {Set} 所有邦国已占领区块的并集(勿直接修改) */ + allClaimedChunks() { + return this.allClaimed; + } + + /** @returns {Set} 本次会话新占领的区块(勿直接修改) */ + 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} + */ + 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} 己方邦国的飞地(疏漏区块);未选择邦国时为空集 */ + selfFlyingChunks() { + return this._flyingByStateMap().get(this.selfStateName) || EMPTY_SET; + } + + /** @returns {Map>} 各邦国飞地区块(懒计算并缓存) */ + _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} own + * @returns {Set} + */ + _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>} apiChunks API 返回的 邦国名 → 区块键集合 + * @param {Map} [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 }; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..558f52a --- /dev/null +++ b/src/index.js @@ -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 }; diff --git a/src/util/logger.js b/src/util/logger.js new file mode 100644 index 0000000..1175c1f --- /dev/null +++ b/src/util/logger.js @@ -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 }; diff --git a/src/web/public/app.js b/src/web/public/app.js new file mode 100644 index 0000000..8123f96 --- /dev/null +++ b/src/web/public/app.js @@ -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(); diff --git a/src/web/public/index.html b/src/web/public/index.html new file mode 100644 index 0000000..3762838 --- /dev/null +++ b/src/web/public/index.html @@ -0,0 +1,172 @@ + + + + + +AutoStore Bot 控制台 + + + +
+
+ +
+

AutoStore Bot

+

Minecraft 自动扩地机器人 · mineflayer · Grim 合规

+
+
+
+ 未连接 + 空闲 + + + +
+
+ +
+ +
+
+

连接

+
+
服务器
-
+
玩家名
-
+
版本
-
+
坐标
-
+
所在区块
-
+
生命 / 饥饿
-
+
状态
-
+
重连次数
0
+
+
+ +
+

任务

+
+
状态机
IDLE
+
描述
未启动
+
当前目标
-
+
队列
0
+
已完成
0
+
重试
0
+
开垦进度
0%
+
连续寻路超时
0s
+
开垦时段
-
+
+
+
+ +
+

领土数据源

+
+
markers.json
-
+
最近成功
-
+
错误
-
+
邦国数
0
+
已占领区块
0
+
会话占领
0
+
己方飞地
0
+
SimpCore API
未启用
+
+
+ + +
+
+ +
+

Grim 合规

+
+
合规层
-
+
旋转限速
-
+
朝向已同步
-
+
攻击距离
-
+
攻击冷却
-
+
已拦下动作
0
+
物理自检
-
+
+
    +
    +
    + + +
    +
    +
    +

    领土地图

    +
    + + + + + +
    +
    + +
    + 己方领土 + 本次占领 + 飞地(疏漏) + 当前目标 + 机器人 + 王城 + +
    +
    + +
    +
    +

    运行日志

    +
    + + + +
    +
    +
    +
    +
    + + +
    +
    +
    +

    配置

    +
    + + +
    +
    +
    + + + + + + + +
    +
    +
    + +
    +

    手动指令

    +
    + + +
    +
    + + + + +
    +
    +
    +
    + +
    + + + diff --git a/src/web/public/style.css b/src/web/public/style.css new file mode 100644 index 0000000..3dcdfe1 --- /dev/null +++ b/src/web/public/style.css @@ -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; } +} diff --git a/src/web/server.js b/src/web/server.js new file mode 100644 index 0000000..d754634 --- /dev/null +++ b/src/web/server.js @@ -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} */ + 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} 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} 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 }; diff --git a/test/autoBoat.test.js b/test/autoBoat.test.js new file mode 100644 index 0000000..164a1a4 --- /dev/null +++ b/test/autoBoat.test.js @@ -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); +}); diff --git a/test/autoCombat.test.js b/test/autoCombat.test.js new file mode 100644 index 0000000..579b802 --- /dev/null +++ b/test/autoCombat.test.js @@ -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); +}); diff --git a/test/botModules.test.js b/test/botModules.test.js new file mode 100644 index 0000000..7ae5c76 --- /dev/null +++ b/test/botModules.test.js @@ -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 后应可立即再次检查'); +}); diff --git a/test/chunkPos.test.js b/test/chunkPos.test.js new file mode 100644 index 0000000..932245b --- /dev/null +++ b/test/chunkPos.test.js @@ -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); +}); diff --git a/test/config.test.js b/test/config.test.js new file mode 100644 index 0000000..06a0b34 --- /dev/null +++ b/test/config.test.js @@ -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 }); + } +}); diff --git a/test/expansionController.test.js b/test/expansionController.test.js new file mode 100644 index 0000000..4b07626 --- /dev/null +++ b/test/expansionController.test.js @@ -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>, 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, '没有可用的相邻无主区块'); +}); diff --git a/test/expansionPlanner.test.js b/test/expansionPlanner.test.js new file mode 100644 index 0000000..67cef3e --- /dev/null +++ b/test/expansionPlanner.test.js @@ -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>} 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} 越界`); + } +}); diff --git a/test/grimCompat.test.js b/test/grimCompat.test.js new file mode 100644 index 0000000..351732b --- /dev/null +++ b/test/grimCompat.test.js @@ -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 + ' 应被松开'); + } +}); diff --git a/test/helpers/fakeBot.js b/test/helpers/fakeBot.js new file mode 100644 index 0000000..cf9fe07 --- /dev/null +++ b/test/helpers/fakeBot.js @@ -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} */ + 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 }; diff --git a/test/pathingService.test.js b/test/pathingService.test.js new file mode 100644 index 0000000..988a168 --- /dev/null +++ b/test/pathingService.test.js @@ -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(); // 不应抛异常 +}); diff --git a/test/subtitleChatMonitor.test.js b/test/subtitleChatMonitor.test.js new file mode 100644 index 0000000..b4effd0 --- /dev/null +++ b/test/subtitleChatMonitor.test.js @@ -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(' 大家好'); + 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); +}); diff --git a/test/territoryDataService.test.js b/test/territoryDataService.test.js new file mode 100644 index 0000000..9adc4d1 --- /dev/null +++ b/test/territoryDataService.test.js @@ -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: `
    疆土归属: ${name}
    `, + 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: '
    「测试国」的王城
    ', 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: '
    无归属
    ', 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'); +}); diff --git a/test/territoryModel.test.js b/test/territoryModel.test.js new file mode 100644 index 0000000..bc289c4 --- /dev/null +++ b/test/territoryModel.test.js @@ -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); +}); diff --git a/test/webServer.test.js b/test/webServer.test.js new file mode 100644 index 0000000..252b4f4 --- /dev/null +++ b/test/webServer.test.js @@ -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(); + } +});