比较提交
9
次代码提交
| 作者 | SHA1 | 提交日期 | |
|---|---|---|---|
|
|
71e78f06dc | ||
|
|
b9cae1aac2 | ||
|
|
f1316bf58c | ||
|
|
a83dea9677 | ||
|
|
fcd5fb2bd9 | ||
|
|
abecdd2648 | ||
|
|
2671da95d5 | ||
|
|
d6b1339867 | ||
|
|
39624da2d5 |
+27
-9
@@ -5,26 +5,44 @@
|
|||||||
MC_HOST=bgjq.simpfun.cn
|
MC_HOST=bgjq.simpfun.cn
|
||||||
MC_PORT=25565
|
MC_PORT=25565
|
||||||
MC_VERSION=1.21.4
|
MC_VERSION=1.21.4
|
||||||
# 离线登录用游戏名;正版登录时忽略此项(需先 `npx mineflayer --auth microsoft` 之外的 device-code 流程)
|
|
||||||
|
# ---- 多机器人(推荐):同时挂多个玩家,用逗号分隔「用户名:密码」 ----
|
||||||
|
# 密码只从这里读取,绝不写入代码、配置文件或控制台存储
|
||||||
|
MC_BOTS=<bot-name-1>:<password-1>,<bot-name-2>:<password-2>
|
||||||
|
# 用户名/密码里含逗号或冒号时,改用 JSON 数组写法:
|
||||||
|
# MC_BOTS=[{"username":"bot1","password":"p1"},{"username":"bot2","password":"p2","label":"二号机"}]
|
||||||
|
|
||||||
|
# ---- 单机器人兜底:未设置 MC_BOTS 时生效 ----
|
||||||
MC_USERNAME=<your-bot-name>
|
MC_USERNAME=<your-bot-name>
|
||||||
|
MC_PASSWORD=<your-server-password>
|
||||||
|
|
||||||
# offline=离线服(服务器用 /login 密码登录);microsoft=正版
|
# offline=离线服(服务器用 /login 密码登录);microsoft=正版
|
||||||
MC_AUTH=offline
|
MC_AUTH=offline
|
||||||
|
|
||||||
# 服务器登录/注册密码(离线服必填;正版服留空)
|
|
||||||
MC_PASSWORD=<your-server-password>
|
|
||||||
|
|
||||||
# ==================== Web 控制台 ====================
|
# ==================== Web 控制台 ====================
|
||||||
WEB_HOST=127.0.0.1
|
WEB_HOST=127.0.0.1
|
||||||
WEB_PORT=3081
|
WEB_PORT=3081
|
||||||
# 控制台访问令牌(留空则仅监听 127.0.0.1 且不校验;非本机访问必须设置)
|
# 控制台令牌(可写进 URL 的 ?token=,便于直接点击打开;留空则本机免登录)
|
||||||
WEB_TOKEN=
|
WEB_TOKEN=
|
||||||
|
# 控制台登录密码:配置后页面会弹出登录框,验证通过才允许操作机器人的控制台 / 聊天框 / 扩地任务
|
||||||
|
WEB_PASSWORD=
|
||||||
|
|
||||||
# ==================== 领土数据源 ====================
|
# ==================== 领土数据源与大地图 ====================
|
||||||
# Dynmap 地图站点根地址(末尾不带斜杠),程序会请求 <DYNMAP_BASE>/tiles/<DYNMAP_WORLD>/markers.json
|
# 地图站点根地址(末尾不带斜杠)
|
||||||
|
# 程序会请求:
|
||||||
|
# <DYNMAP_BASE>/tiles/<DYNMAP_WORLD>/markers.json 领土标记
|
||||||
|
# <DYNMAP_BASE>/tiles/<DYNMAP_WORLD>/<z>/<x>_<y>.png 大地图瓦片图片
|
||||||
DYNMAP_BASE=http://bgjq.simpfun.cn
|
DYNMAP_BASE=http://bgjq.simpfun.cn
|
||||||
# markers.json 所属世界名(对应 Dynmap 配置里的 world 目录名)
|
# 世界目录名(对应地图站点上的世界名)
|
||||||
DYNMAP_WORLD=minecraft_overworld
|
DYNMAP_WORLD=minecraft_overworld
|
||||||
|
|
||||||
# ==================== SimpCore 开放 API(可选) ====================
|
# ==================== SimpCore 开放 API(可选) ====================
|
||||||
# https://open.bgjq.top 申请
|
# 文档:https://open.bgjq.top/api-docs.html
|
||||||
|
# 注意:API 与 API-V2 是两套**独立、互不通用**的密钥体系,需要在密钥中心分别申请
|
||||||
|
#
|
||||||
|
# 第一套 API:邦国 / 领土 / 玩家 / 全服数据(https://bgjq.simpfun.cn/api/)
|
||||||
SIMP_API_KEY=<your-simp-api-key>
|
SIMP_API_KEY=<your-simp-api-key>
|
||||||
|
# 第二套 API-V2(ServerOpenAPI):在线玩家 / 装备 / 游戏时间 / 末影龙
|
||||||
|
# https://bgjq.simpfun.cn/api-v2/
|
||||||
|
# 配额很小(末影龙仅 1 次/分钟),程序只按需查询、不做轮询
|
||||||
|
SIMP_API_V2_KEY=<your-simp-api-v2-key>
|
||||||
|
|||||||
+302
@@ -2,6 +2,308 @@
|
|||||||
|
|
||||||
本项目遵循 [约定式提交](https://www.conventionalcommits.org/zh-hans/) 与 [语义化版本](https://semver.org/lang/zh-CN/)。
|
本项目遵循 [约定式提交](https://www.conventionalcommits.org/zh-hans/) 与 [语义化版本](https://semver.org/lang/zh-CN/)。
|
||||||
|
|
||||||
|
## [1.5.1] - 2026-09-28
|
||||||
|
|
||||||
|
接入 API-V2 密钥后按实测响应修正字段名(之前是按文档猜的,跑起来全不对)。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- **API-V2 `/players` 返回的是对象不是数组**:`{"online":8,"max":5000,"players":[...]}`,
|
||||||
|
原解析按裸数组处理会拿到空列表;且里面**没有坐标与延迟**,只有所在世界
|
||||||
|
- **API-V2 `/dragon` 的字段是 `dragonAlive`**,不是 `alive`;也没有 health / phase
|
||||||
|
- **API-V2 `/time` 直接给算好的时刻**:`{world, tick, phase, time12, time24}`,
|
||||||
|
原实现还在用 `world_time` / `day_time` / `is_day` 自己换算,全部取不到
|
||||||
|
- **API-V2 `/players/{name}/equipment` 的物品结构**:
|
||||||
|
`{type, key, amount, damage, maxDurability, unbreakable}`,装备在 `equipment` 子对象里。
|
||||||
|
`damage` 是**已损耗值**,原来会把「损耗 157」当成「剩余 157」显示;
|
||||||
|
现在换算成 `remainingDurability` 并在界面显示「剩余 / 上限」+ 百分比
|
||||||
|
- **限流提示**:V2 触发 429 时服务端带 `retryAfterSeconds`,现在直接告诉用户还要等多少秒
|
||||||
|
(末影龙接口只有 1 次/分钟,实测超限时提示「还需等待 16 秒」)
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- `SimpApiV2Client` 支持注入 `fetchImpl`,便于测试替换网络层
|
||||||
|
|
||||||
|
## [1.5.0] - 2026-09-28
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- **SimpCore 开放 API 数据浏览**:以前只用了「邦国领土」这一类接口,控制台也只能看到一行
|
||||||
|
「已启用 / 未启用」。现在把官方文档里的公开接口都接进来,并做成「查看 API 数据」弹层:
|
||||||
|
- **概览**:两套密钥的配置状态、请求数、缓存命中、最近 HTTP 状态与错误、健康检查按钮
|
||||||
|
- **邦国**:27 个邦国的君主 / 成员数 / 区块数 / 领土面积 / 特产 / 王城区块;
|
||||||
|
点开看成员(职位、职业)、已激活科技、捐献收支、领土统计(忠诚度、最早/最近占领)
|
||||||
|
- **玩家**:1600+ 名玩家可搜索(邦国 / 职位 / 职业 / 称号),点开看详情与所属邦国
|
||||||
|
- **全服**:生效加成 / 外交关系 / 封禁记录 / 上下线日志 / 公告历史
|
||||||
|
- **实时 V2**:在线玩家、游戏时间、末影龙、指定玩家的装备与手持物品
|
||||||
|
- **`SimpApiClient` 补齐到官方文档的全量接口**(原来只有 7 个,现在 16 个):
|
||||||
|
`/info/players`、`/info/unions/{key}/members`、`/tech`、`/contributions`、
|
||||||
|
`/info/buffs`、`/info/diplomacy`、`/info/bans`、`/info/logs`、`/info/announcements`
|
||||||
|
- **`SimpApiV2Client`**(新):`/players`、`/players/{name}/equipment`、`/time`、`/dragon`。
|
||||||
|
文档明确 **API 与 API-V2 是两套独立、互不通用的密钥体系**,因此单独一个实例与配置;
|
||||||
|
V2 配额很小(末影龙仅 1 次/分钟),一律按需查询、不做轮询
|
||||||
|
|
||||||
|
### 变更
|
||||||
|
|
||||||
|
- 快捷发送去掉 `/spawn` 与 `/list`,只留 `/u return` `/u claimmode` `/u restore confirm`
|
||||||
|
|
||||||
|
### 修复(解析字段名以实测响应为准)
|
||||||
|
|
||||||
|
- 捐献记录:响应里**只有 `player_uuid` 没有玩家名**,现在先用邦国成员表、查不到再退到全服
|
||||||
|
玩家表补全;时间字段是 `contributed_at` 不是 `created_at`;补充 `reason`(用途)列
|
||||||
|
- 公告:`message` 是 **Minecraft 聊天组件的 JSON 字符串**,新增 `componentToText()` 压平成纯文本
|
||||||
|
- 封禁:实际字段是 `banned_player_name` / `banned_by_player_name` / `banned_at` / `unbanned`
|
||||||
|
- 日志:实际字段是 `username` / `action` / `timestamp`
|
||||||
|
- 玩家表:上下线时间是 `last_join_time` / `last_quit_time`
|
||||||
|
- 王城坐标:服务端可能返回 `null`,原来会被 `Number(null)` 算成 0,
|
||||||
|
伪装成「王城在世界原点 (0,0)」;现在显式判空返回 null
|
||||||
|
|
||||||
|
## [1.4.1] - 2026-09-27
|
||||||
|
|
||||||
|
### 变更
|
||||||
|
|
||||||
|
- **控制台输入框不再区分聊天与指令**:两者本来就是同一个入口 ——
|
||||||
|
以 `/` 开头按游戏指令发送,否则按公屏消息发送。因此删掉了「公屏聊天 / 游戏指令」下拉框
|
||||||
|
和重复的「快捷公屏消息」输入框,只保留**一个输入框 + 快捷发送 + 移动测试**。
|
||||||
|
接口返回新增 `kind` 字段说明实际走的是哪条路,Toast 也会区分「消息已发送 / 指令已发送」
|
||||||
|
- 快捷发送按钮补上 `/u restore confirm`(配合定时消息常用)
|
||||||
|
|
||||||
|
## [1.4.0] - 2026-09-27
|
||||||
|
|
||||||
|
大地图改为跟随机器人、支持可调图幅;新增定时重复发送消息;控制台界面重做并适配移动端。
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- **定时重复发送消息 / 指令**(`src/bot/scheduledMessenger.js`)
|
||||||
|
- 每个机器人可配置多条定时任务,按固定间隔重复发送同一条内容,
|
||||||
|
例如每 300 秒发一次 `/u restore confirm` 维持疆土民心
|
||||||
|
- 以 `/` 开头的内容自动按游戏指令发送,否则按公屏消息发送
|
||||||
|
- 只在「已连接 + 已通过登录验证」时才发;未就绪的周期直接顺延、**不补发**,
|
||||||
|
避免重连后一次性连发多条
|
||||||
|
- 与指令节流队列共用,不会绕过 `server.commandCooldownMs` 刷屏
|
||||||
|
- 控制台新增「定时消息」卡片:新增 / 删除 / 启用停用,并显示距下次发送的剩余秒数
|
||||||
|
- 接口:`GET|POST /api/bots/:id/schedules`
|
||||||
|
- **大地图跟随机器人 + 可调图幅**
|
||||||
|
- 爬取与显示区域改为**以机器人当前位置为中心**(`bigMap.followBot`,默认开启),
|
||||||
|
不再傻傻地爬世界原点周边
|
||||||
|
- 界面新增「图幅」下拉框(±512 / ±1024 / ±2048 / ±4096 / ±8192 / 全世界),
|
||||||
|
切换后即时重新对焦并拉取对应范围
|
||||||
|
- 新增「爬取周边」按钮:只爬当前图幅内的瓦片。
|
||||||
|
层级 0 每张瓦片覆盖 4096 格,所以 ±4096 只需 **9 张**,而全世界要 100 张
|
||||||
|
- `POST /api/bigmap/crawl` 支持 `centerX` / `centerZ` / `rangeBlocks`;
|
||||||
|
`GET /api/bigmap` 支持 `rangeBlocks` 与 `bot` 查询参数
|
||||||
|
- 启动预爬取会先等机器人进入世界,以它的位置为中心;等不到才退回世界原点
|
||||||
|
- **领土地图以机器人所在邦国为中心**
|
||||||
|
- 之前初始视图用的是控制器的 `playerChunk`,而任务启动前它是 (0,0),
|
||||||
|
导致地图永远停在世界原点附近 —— 完全不是用户关心的位置
|
||||||
|
- 现在按「机器人所在区块 → 包含它的邦国 → 该邦国王城 / 外接矩形」定位
|
||||||
|
- **控制台界面重做 + 移动端适配**
|
||||||
|
- 重新设计的深色主题:统一圆角、阴影、焦点态与悬停态,卡片式分层
|
||||||
|
- 响应式三档:≥1200px 三栏;≤1200px 两栏(配置栏落到下方并自动分列);≤820px 单栏
|
||||||
|
- 移动端:底部导航(状态 / 地图 / 日志 / 配置)一键定位、触控目标放大到 ≥36px、
|
||||||
|
地图与日志高度按视口自适应、避让底部安全区、Toast 全宽
|
||||||
|
- 横屏手机压矮地图高度;尊重系统「减少动效」偏好
|
||||||
|
- 「添加玩家」折叠进 details,减少左栏长度
|
||||||
|
- 顶栏在窄屏下自动换行,按钮等分排布
|
||||||
|
|
||||||
|
### 变更
|
||||||
|
|
||||||
|
- 大地图默认图幅从「全世界 ±20480」改为「±4096 格」,默认预爬取瓦片数从 100 张降到 9 张
|
||||||
|
- 「回到机器人」保留并修正:没有坐标时给出明确提示,而不是静默无反应
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- `POST /api/bigmap/crawl` 的响应把中心与图幅字段传错(实际爬取是对的,但返回值显示成世界原点)
|
||||||
|
- 定时任务保存后接口返回的是原始列表而非归一化结果,导致前端拿不到「剩余秒数」
|
||||||
|
|
||||||
|
## [1.3.0] - 2026-09-27
|
||||||
|
|
||||||
|
按线上实测结果修复「机器人走不动」,并移植旧版机器人已验证的关键补包。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- **控制台登录按钮点不动(致命)**:`bindEvents()` 只在 `startApp()` 里执行,而 `startApp()`
|
||||||
|
只在登录成功后才跑 —— 未登录时登录按钮根本没绑上事件,形成死锁。
|
||||||
|
现在登录相关按钮在 `main()` 里无条件绑定
|
||||||
|
- **机器人走不动(致命,根因已定位并修复)**:mineflayer **不发 `player_input` 包**。
|
||||||
|
1.21.2+ 服务端的移动预测依赖它,原版客户端每 tick 都发,而 mineflayer 只在
|
||||||
|
骑乘 / 跳下 / 潜行时发,地面移动的 forward / jump / sprint 完全不发。
|
||||||
|
带反作弊的服务器因此判定「没有任何输入却发生了位移」,**每 tick 把玩家拉回原位置**。
|
||||||
|
实测:修复前入站强制位置同步约 **17 次/秒**、按方向键 2 秒位移 0.00 格;
|
||||||
|
修复后入站降到 **0 次**、位移 0.53~1.40 格。
|
||||||
|
同时补发原版每 tick 末尾的 **`tick_end`** 包(反作弊用它判定 tick 边界,mineflayer 完全不发)
|
||||||
|
- **寻路插件从未加载**:项目从没调用 `bot.loadPlugin(pathfinder)`,`bot.pathfinder` 恒为
|
||||||
|
`undefined`、`isReady()` 恒为 false,扩地任务连启动都启动不了
|
||||||
|
- **寻路事件监听重复注册**:`configure()` 每次配置变更都会重跑并再挂一遍监听,
|
||||||
|
导致同一事件被处理多次(日志里同一秒出现多条相同事件)
|
||||||
|
- **资源包未自动接受**:服务器要求资源包时不接会卡在加载界面进不了世界
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- **长距离分段寻路**:mineflayer-pathfinder 只能规划**已加载区块**范围(实测约 8 区块 ≈ 130 格),
|
||||||
|
而扩地目标常在几十个区块之外,A* 会瞬间返回 noPath。现在会把旅程拆段:
|
||||||
|
走到已加载范围边缘附近 → 新区块加载 → 再规划下一段。
|
||||||
|
段长用 `bot.blockAt` 对未加载区块返回 null 的特性**二分探测**得到,不依赖世界内部结构
|
||||||
|
- **寻路预算可配置**:`pathing.thinkTimeoutMs` / `tickTimeoutMs` / `searchRadius`
|
||||||
|
(`searchRadius` 为负表示不限制剪枝)
|
||||||
|
- **位置同步诊断**:位置包收发计数、`player_input` / `tick_end` 计数、`onGround`、
|
||||||
|
游戏模式、维度、脚下方块、六向「脚部 / 头部 / 地面」与可通行判定
|
||||||
|
- **手动移动测试**:控制台按钮绕开寻路器直接按方向键并回报真实位移
|
||||||
|
- 配置界面新增协议补包与寻路预算相关字段
|
||||||
|
|
||||||
|
### 已知限制(重要)
|
||||||
|
|
||||||
|
- **当前所在位置无法寻路**:实测机器人站在 (6759, 72, 11926) 时,A* **恒定只展开 47 个节点**
|
||||||
|
就返回 noPath。已逐项排除:挖方块 / 放方块 / 跑酷 / 搭塔 / 开门 / 最大下落(试到 64 格)/
|
||||||
|
高度偏好 / 水域代价 / 搜索半径(试到不限制)/ 时间预算 / 依赖版本
|
||||||
|
(与能正常移动的旧机器人完全一致:mineflayer 4.39.0 + pathfinder 2.4.5 + prismarine-world 3.7.0)。
|
||||||
|
旧机器人日志证明寻路本身可用(2 次 `goal_reached`、2 秒走过 8.7 格),
|
||||||
|
但它当时在 **y=-59**,而本机器人被 `/spawn` 放到了 **y=72**,怀疑是新位置的地形让移动生成器
|
||||||
|
找不到任何可行走分支。**下一步需要把机器人挪到开阔地再试**
|
||||||
|
|
||||||
|
## [1.2.0] - 2026-09-27
|
||||||
|
|
||||||
|
首次真正连上线上服务器(Debian 12 + pm2 + 端口 78)后,暴露并修掉了一批
|
||||||
|
**只有实机运行才会发现**的缺陷。这些问题的共同点是:单元测试全绿,但机器人根本干不了活。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- **寻路插件从未加载(致命)**:项目从没调用过 `bot.loadPlugin(pathfinder)`,
|
||||||
|
导致 `bot.pathfinder` 恒为 `undefined`、`isReady()` 恒为 false,
|
||||||
|
扩地任务连启动都启动不了,而界面上只显示一句含糊的「机器人尚未就绪」。
|
||||||
|
现在在 `createBot` 之后立刻加载,失败时明确报错
|
||||||
|
- **登录门禁死锁**:`/login`、`/register`、简幻通的 `/auth` 本身就是登录流程的一部分,
|
||||||
|
原先的门禁把它们一并拦下,形成「要先登录才能发登录指令」,账号永远卡在未验证状态。
|
||||||
|
新增 `server.preLoginCommands` 白名单(默认 `login,l,register,reg,auth`)
|
||||||
|
- **验证码发不出去**:部分服务器要求把验证码**直接当聊天内容**发出去(不是指令),
|
||||||
|
而 `sendChat` 同样被登录门禁挡住。现在服务器明确要求验证时会自动放行聊天,
|
||||||
|
另有 `server.allowChatBeforeLogin` 给无提示的服务器兜底
|
||||||
|
- **配置了登录指令却没密码时永久卡死**:`.env` 里没有密码时程序无法发送任何登录指令,
|
||||||
|
却把 `loggedIn` 永久置为 false,控制台连手动指令都发不出去。
|
||||||
|
现在走与「服务器不回提示」一致的兜底逻辑(超时后按无需登录放行),并在日志里说明
|
||||||
|
- **离线时指令被静默丢弃却回报「已发送」**:`bot.chat()` 在未连接时无处可发。
|
||||||
|
现在 `canSendCommand()` 要求已连接并进入世界,失败信息区分
|
||||||
|
「未连接」「登录被服务器拒绝」「未通过登录验证」三种情况
|
||||||
|
- **「被服务器传送」日志洪水**:账号未完成服务器验证时服务端每 tick 强推一次位置,
|
||||||
|
实测 3 分钟刷出 748 行日志,几十分钟就会冲光日志环形缓冲并把 WebSocket 打满。
|
||||||
|
现在首次立即记录、其余按 30 秒窗口聚合,断开连接时刷出未汇总计数
|
||||||
|
- **外站账号绑定流程无法识别**:原先只认 `/login` 与 `/register`,
|
||||||
|
遇到要求 `/auth <UID> <验证码>` 或扫码绑定的服务器时,表现只是静默的死循环。
|
||||||
|
现在命中相关提示会输出可操作的中文告警(每个连接只提示一次),
|
||||||
|
并新增 `server.authCommand` 模板供有固定验证码的服务器自动过验证
|
||||||
|
- **重连无退避**:连续被踢时每 10 秒硬撞服务器。现在按指数退避
|
||||||
|
(`server.reconnectMaxDelayMs`,默认上限 120 秒),成功进入世界后计数清零
|
||||||
|
- **控制台瓦片 URL 与后端路由不一致**:前端请求 `/api/bigmap/tile/:z/:x_y.png`,
|
||||||
|
后端路由为 `/api/bigmap/tile/:z/:x/:y.png`
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- **控制台添加 / 移除玩家**:不必改 `.env` 重启,直接在界面上填玩家名 + 密码即可挂新账号。
|
||||||
|
密码只写入服务器本地凭据文件 `data/bots.credentials.json`(权限强制 0600),
|
||||||
|
不进仓库、不出现在任何接口响应里;`.env` 与凭据文件同名时以 `.env` 为准。
|
||||||
|
`.env` 里配置的账号不允许从控制台移除(否则重启后会「复活」)
|
||||||
|
- **手动移动测试**:控制台可按前 / 后 / 左 / 右 / 跳 2 秒并显示真实位移,
|
||||||
|
用于区分「寻路器算不出路径」与「客户端根本走不动」这两种完全不同的故障
|
||||||
|
(绕开寻路器直接走控制键)
|
||||||
|
- **位置同步诊断**:`snapshot` 暴露位置包收发计数、onGround、游戏模式、维度、
|
||||||
|
脚下方块与六向实心情况,`/api/bots/:id/status` 可直接看到
|
||||||
|
- **按世界数据修正出站 onGround**(`grim.reportOnGroundFromWorld`,默认开启):
|
||||||
|
mineflayer 收到服务端 `position` 包时会把 `entity.onGround` 置为 false,
|
||||||
|
在频繁下发位置包的服务器上会导致客户端持续上报「站在地上却在空中」。
|
||||||
|
现在发包前用脚下方块复核,只有确实不一致时才改写
|
||||||
|
- `scripts/check-protocol.js`:核对 mineflayer 写出的包名 / 字段名与目标版本协议是否一致
|
||||||
|
|
||||||
|
### 已知限制(重要)
|
||||||
|
|
||||||
|
- **本服上 mineflayer 客户端无法移动**:实测(`DiLiDaLaDaLa`,已绑定简幻通、登录正常)
|
||||||
|
按方向键 2 秒位移 0.00~0.02 格;服务端每 tick 回弹一次(入站 ~17/s、出站 ~19/s)。
|
||||||
|
已系统性排除:区块数据缺失、onGround 不一致、Grim 合规层、游戏模式(survival)、
|
||||||
|
物理引擎未启用、寻路插件缺失、`teleport_confirm` 协议字段不匹配。
|
||||||
|
连接、登录、收发指令、收发聊天、公屏日志、大地图爬取均正常,**唯独移动被服务端拒绝**。
|
||||||
|
彻底定位需要把客户端的移动包与原生客户端逐字节对比,属于独立的排查工作
|
||||||
|
- 同机上原有的 `minecraft-bot`(端口 91)与该机器人不能同时使用同一账号,会互相踢下线
|
||||||
|
|
||||||
|
## [1.1.0] - 2026-09-27
|
||||||
|
|
||||||
|
新增多机器人挂机、开拓日志、游戏公屏完整日志与大地图。
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
|
||||||
|
- **多机器人同时挂机**(`src/bot/botPool.js`、`src/config/botsConfig.js`)
|
||||||
|
- `.env` 里用 `MC_BOTS` 一次配置多个账号(支持 JSON 数组与 `用户名:密码` 逗号分隔两种写法),
|
||||||
|
未配置时回退到 `MC_USERNAME` / `MC_PASSWORD` 单机器人模式
|
||||||
|
- 每个机器人独立连接、独立登录、独立跑扩地状态机;共享同一份领土数据服务与运行日志
|
||||||
|
- **密码只从 `.env` 读取**:`data/bots.json` 只存启用状态、备注名、是否自动开始与各自的配置覆盖
|
||||||
|
- 每个机器人可有自己的扩地配置覆盖(策略、目标邦国、目标数量等),未覆盖字段回退全局默认
|
||||||
|
- 控制台新增机器人列表(连接状态 / 坐标 / 任务 / 已开拓数)、机器人切换、全部开始 / 全部停止
|
||||||
|
- **开拓日志**(`src/core/expansionLog.js`)
|
||||||
|
- 逐条记录**开拓玩家、邦国、区块、方块坐标、时间、判定来源**,追加写入 `data/expansion-log.jsonl`
|
||||||
|
- 支持按机器人 / 邦国 / 时间筛选、按机器人统计与今日计数、导出带 BOM 的 CSV
|
||||||
|
- 文件超过 16MB 自动轮转为 `.1`;进程被杀留下的半行在重启时被安全跳过
|
||||||
|
- 控制台新增「开拓日志」表格页签,支持筛选、清屏、导出
|
||||||
|
- **游戏公屏完整日志 + 聊天框**(`src/core/chatLog.js`)
|
||||||
|
- 每个机器人收到的**全部**公屏 / 系统 / 动作栏消息按机器人分文件落盘(`data/chat/<机器人>.jsonl`)
|
||||||
|
- 本机器人发出的聊天与指令同样留档,可对照「指令发出后服务器回了什么」
|
||||||
|
- 控制台新增「公屏聊天」页签(可按机器人或全部机器人查看、关键字筛选、导出)与公屏消息发送框
|
||||||
|
- **登录验证门禁**
|
||||||
|
- 游戏侧:未完成服务器登录验证时拒绝发送指令 / 聊天,且 `isReady()` 不放行扩地任务;
|
||||||
|
新增登录结果确认兜底(`server.loginConfirmTimeoutMs`),避免服务器不回提示导致机器人永远卡在未登录
|
||||||
|
- 控制台侧:新增 `WEB_PASSWORD` 与 `/api/login`、`/api/session`,页面弹出登录框,
|
||||||
|
验证通过后才允许操作各机器人的控制台、聊天框与扩地任务
|
||||||
|
- **大地图**(`src/core/bigMapService.js`)
|
||||||
|
- 爬取服务器地图站点(squaremap)的瓦片图片与领土标记,拼在一张图上查看**所有机器人的坐标**
|
||||||
|
- 坐标系已对源站实测标定:瓦片 512×512,层级 0 每张覆盖 4096 方块,层级 3 为 1 方块 1 像素
|
||||||
|
(用 27 个王城坐标交叉验证:正确公式下王城所在瓦片平均海洋占比 28.7%,
|
||||||
|
错误假设为 41.6% / 47.3% / 76.0%)
|
||||||
|
- 瓦片磁盘缓存 `data/mapcache/`,默认启动时预爬取层级 0(全世界 10×10=100 张),
|
||||||
|
更高层级按需穿透拉取并永久缓存;`/api/bigmap/tile/:z/:x/:y` 代理避免跨域
|
||||||
|
- 领土以 markers.json 的原始多边形绘制(预编译为 `Path2D`),叠加本次会话新占领区块、
|
||||||
|
王城与全部机器人坐标;支持滚轮缩放、拖拽平移、全世界 / 回到机器人一键定位
|
||||||
|
- **测试从 154 个增加到 242 个**:新增开拓日志、公屏日志、大地图坐标系与缓存、
|
||||||
|
机器人池与配置隔离、凭据解析、机器人登录门禁、多机器人 Web 接口与登录鉴权等用例
|
||||||
|
|
||||||
|
### 变更
|
||||||
|
|
||||||
|
- `TerritoryModel` 新增 `flyingChunksOf()` / `ownChunksOf()`,邦国归属改为**按名称显式传入**,
|
||||||
|
不再依赖全局 `selfStateName`(多机器人会互相覆盖);同时保留 markers.json 的原始多边形
|
||||||
|
- `ExpansionPlanner` 的己方邦国解析顺序改为「配置里的 `stateName` → 模型全局 `selfStateName`」
|
||||||
|
- 运行日志统一加机器人名前缀(`[游戏名] …`),多机器人共用一份日志也能分清来源
|
||||||
|
- Web 接口改为以 `/api/bots/:id/...` 为主,原有单机器人接口(`/api/task/*`、`/api/command`、
|
||||||
|
`/api/bot/reconnect`)保留为默认机器人的等价别名
|
||||||
|
- `/api/map` 支持 `?bot=<id>` 按机器人视角返回其邦国、目标与玩家区块
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- 修正控制台地图瓦片 URL 与后端路由不一致的问题(前端请求 `/api/bigmap/tile/:z/:x_y.png`,
|
||||||
|
后端路由为 `/api/bigmap/tile/:z/:x/:y.png`)
|
||||||
|
- **「被服务器传送」日志洪水**:账号未完成服务器验证时,服务端会每 tick 强推一次位置,
|
||||||
|
实测 3 分钟刷出 748 行日志,几十分钟就会把 2000 条的日志环形缓冲冲光、并把 WebSocket 打满。
|
||||||
|
现改为首次立即记录、其余按 30 秒窗口聚合成一条(`被服务器传送另有 N 次`),
|
||||||
|
并在连接断开时把未汇总的计数刷出去
|
||||||
|
- **外站账号绑定流程无法识别**:本项目原先只认 `/login` 与 `/register`,
|
||||||
|
遇到「简幻通」这类要求 `/auth <UID> <验证码>` 或扫码绑定的服务器时,
|
||||||
|
表现只是「连上 → 被传送 → 120 秒后被踢 → 重连」的静默死循环,失败原因对用户完全不可见。
|
||||||
|
现新增 `NEED_AUTH_PATTERN` 识别,命中后输出可操作的中文告警(每个连接只提示一次),
|
||||||
|
并新增 `server.authCommand` 模板供配置固定验证码的服务器自动过验证
|
||||||
|
- **重连无退避**:连续被踢时每 10 秒硬撞一次服务器。现按指数退避拉长间隔
|
||||||
|
(`server.reconnectMaxDelayMs`,默认上限 120 秒),成功进入世界后计数清零
|
||||||
|
- **登录门禁死锁**:有些服务器的登录流程本身就是指令(`/login`、`/register`,
|
||||||
|
简幻通体系还要先 `/auth`),原先的门禁把这些指令也一并拦下,导致
|
||||||
|
「要先登录才能发登录指令」,账号永远卡在未验证状态。
|
||||||
|
现新增 `server.preLoginCommands`(默认 `login,l,register,reg,auth`)白名单,
|
||||||
|
这些前缀在未验证时也放行;其余指令仍要求已完成验证
|
||||||
|
- **离线时指令被静默丢弃却回报「已发送」**:`bot.chat()` 在未连接时无处可发,
|
||||||
|
原先调用方只会收到「已发送」。现在 `canSendCommand()` 要求已连接并进入世界,
|
||||||
|
且失败信息会区分「未连接」「登录被拒」「未通过验证」三种情况并给出对应处理建议
|
||||||
|
|
||||||
|
### 已知限制
|
||||||
|
|
||||||
|
- **多个机器人共享一份领土模型**:`markers.json` 是全服数据,没必要每个机器人各拉一遍;
|
||||||
|
因此「会话新占领」区块是全局共享的,控制台地图的己方邦国显示的是最近启动的那个机器人的邦国
|
||||||
|
- **大地图层级 3 不预爬取**:全图 80×80=6400 张瓦片约 200MB,改为按需拉取 + 永久缓存
|
||||||
|
- **扫码类账号绑定无法自动化**:`/auth <UID> <验证码>` 的验证码是动态的、扫码更是需要人工,
|
||||||
|
程序只能明确报错提示;绑定是一次性的,绑定成功后同一账号即可自动登录
|
||||||
|
|
||||||
## [1.0.0] - 2026-02-14
|
## [1.0.0] - 2026-02-14
|
||||||
|
|
||||||
由 Fabric 客户端模组 AutoStore v1.3.1 重构为 Node.js + mineflayer 机器人,UI 改为网页控制台,
|
由 Fabric 客户端模组 AutoStore v1.3.1 重构为 Node.js + mineflayer 机器人,UI 改为网页控制台,
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ Minecraft 自动扩地机器人 —— 由原 Fabric 客户端模组 [AutoStore]
|
|||||||
**Node.js + mineflayer 实现,UI 从游戏内 GUI 改为网页控制台,并针对装有 Grim 反作弊的服务器做合规适配。**
|
**Node.js + mineflayer 实现,UI 从游戏内 GUI 改为网页控制台,并针对装有 Grim 反作弊的服务器做合规适配。**
|
||||||
|
|
||||||
不需要开游戏客户端,一条命令启动,浏览器打开控制台即可开始自动扩地。
|
不需要开游戏客户端,一条命令启动,浏览器打开控制台即可开始自动扩地。
|
||||||
|
支持**同时挂多个玩家(机器人)**、**开拓日志**、**游戏公屏完整日志**与**爬取服务器地图的大地图**。
|
||||||
|
|
||||||
```
|
```
|
||||||
npm install
|
npm install
|
||||||
cp .env.example .env # 填入服务器地址与账号密码
|
cp .env.example .env # 填入服务器地址与账号密码(支持多个账号)
|
||||||
npm start # 打开 http://127.0.0.1:3081
|
npm start # 打开 http://127.0.0.1:3081
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -35,6 +36,326 @@ npm start # 打开 http://127.0.0.1:3081
|
|||||||
| 日志导出 | ✅ | 网页按钮导出到 `data/` |
|
| 日志导出 | ✅ | 网页按钮导出到 `data/` |
|
||||||
| SimpCore 开放 API 第四数据源 | ✅ | 低频轮询 + 手动全服拉取 |
|
| SimpCore 开放 API 第四数据源 | ✅ | 低频轮询 + 手动全服拉取 |
|
||||||
| Grim 反作弊适配 | ✅ 新增 | 见下方专章 |
|
| Grim 反作弊适配 | ✅ 新增 | 见下方专章 |
|
||||||
|
| **多机器人同时挂机** | ✅ 新增 | 一个进程挂多个账号,各自独立连接 / 登录 / 扩地,配置可独立覆盖 |
|
||||||
|
| **开拓日志** | ✅ 新增 | 逐条记录开拓玩家、邦国、区块、方块坐标、时间、判定来源,落盘可导出 CSV |
|
||||||
|
| **游戏公屏完整日志 + 聊天框** | ✅ 新增 | 全部公屏 / 系统消息按机器人分文件留档,控制台可直接发公屏消息 |
|
||||||
|
| **大地图** | ✅ 新增 | 爬取服务器地图站点瓦片与领土标记,一张图上看全部机器人坐标 |
|
||||||
|
| **登录验证门禁** | ✅ 新增 | 游戏内登录 + 控制台登录双重校验,通过后才允许操作机器人与发送消息 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 多机器人同时挂机
|
||||||
|
|
||||||
|
### 配置
|
||||||
|
|
||||||
|
在 `.env` 里一次配好所有账号(**密码只从这里读,绝不落代码与配置文件**):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 写法一:用户名:密码,逗号分隔
|
||||||
|
MC_BOTS=开拓一号:password1,开拓二号:password2
|
||||||
|
|
||||||
|
# 写法二:JSON 数组(用户名/密码含逗号冒号时用这个)
|
||||||
|
MC_BOTS=[{"username":"bot1","password":"p1"},{"username":"bot2","password":"p2","label":"二号机"}]
|
||||||
|
|
||||||
|
# 不设置 MC_BOTS 时回退到单机器人
|
||||||
|
MC_USERNAME=AutoStoreBot
|
||||||
|
MC_PASSWORD=xxxxxx
|
||||||
|
```
|
||||||
|
|
||||||
|
启动后控制台左栏会列出全部机器人(连接状态 / 坐标 / 任务状态 / 已开拓数),
|
||||||
|
顶栏下拉框切换当前操作的机器人。
|
||||||
|
|
||||||
|
### 每个机器人独立的配置
|
||||||
|
|
||||||
|
控制台右侧配置表单勾选「**仅对当前机器人生效**」后保存,即为该机器人写入专属配置覆盖
|
||||||
|
(存 `data/bots.json`,**不含密码**);不勾选则写入全局默认配置。未覆盖的字段一律回退到全局默认。
|
||||||
|
|
||||||
|
典型用法:给每个账号指定不同的 `目标邦国` 与 `扩地策略`,让它们分工扩张不同方向。
|
||||||
|
|
||||||
|
### 设计取舍
|
||||||
|
|
||||||
|
- **共享一份领土模型**:`markers.json` 是全服数据(约 160KB / 11.5 万区块),
|
||||||
|
没必要每个机器人各拉一遍,因此数据服务是单例、共享给全部控制器。
|
||||||
|
- **邦国归属按机器人解析**:`ExpansionController` 在 `start()` 时把识别到的邦国固化到
|
||||||
|
`controller.stateName` 并写回本次任务配置,规划器不再读全局 `selfStateName`
|
||||||
|
(否则会被最后启动的机器人覆盖,导致 A 机器人按 B 的邦国规划)。
|
||||||
|
- **「会话新占领」区块是全局共享的**:控制台地图的「己方领土」显示的是最近启动的那个机器人的邦国。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 开拓日志
|
||||||
|
|
||||||
|
`src/core/expansionLog.js` 逐条记录每一次成功开拓:
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `t` | 时间戳(毫秒),界面按 `YYYY-MM-DD HH:mm:ss` 展示 |
|
||||||
|
| `bot` / `botId` | 开拓玩家(机器人游戏名 / id) |
|
||||||
|
| `state` | 开拓归属的邦国 |
|
||||||
|
| `cx` / `cz` | 区块坐标 |
|
||||||
|
| `bx` / `bz` | 方块坐标(服务器回报的精确位置,通用成功文案时为空) |
|
||||||
|
| `source` | 判定来源:`chat`(带坐标的聊天消息)/ `chat-generic`(无坐标的通用文案) |
|
||||||
|
| `sessionCount` | 该机器人本轮任务的第几个 |
|
||||||
|
|
||||||
|
- 落盘:`data/expansion-log.jsonl`(追加写入,超过 16MB 轮转为 `.1`)
|
||||||
|
- 内存:保留最近 5000 条供实时查看,磁盘上的历史不受此限制
|
||||||
|
- 控制台「开拓日志」页签支持按机器人筛选、清屏、导出带 BOM 的 CSV(Excel 打开中文不乱码)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 游戏公屏完整日志与聊天框
|
||||||
|
|
||||||
|
`src/core/chatLog.js` 把每个机器人收到的**全部**消息留档,不只留开垦相关的:
|
||||||
|
|
||||||
|
- 公屏聊天(其他玩家发言,含发言人)、系统公告、动作栏消息
|
||||||
|
- 本机器人发出的聊天与指令(便于对照「指令发出后服务器回了什么」)
|
||||||
|
- 落盘:`data/chat/<机器人名>.jsonl`(每机器人一份,超过 16MB 轮转)
|
||||||
|
- 控制台「公屏聊天」页签可按当前机器人或全部机器人查看、按关键字过滤、导出为纯文本
|
||||||
|
|
||||||
|
控制台右下角的「控制台 / 聊天框」可以选择**公屏聊天**或**游戏指令**模式发送内容。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 大地图
|
||||||
|
|
||||||
|
`src/core/bigMapService.js` 爬取服务器地图站点(本项目部署的是
|
||||||
|
[squaremap](https://github.com/jpenilla/squaremap))的瓦片图片与领土标记,
|
||||||
|
把所有机器人画在同一张图上。
|
||||||
|
|
||||||
|
### 坐标系(已对源站实测标定)
|
||||||
|
|
||||||
|
squaremap 前端用 `L.CRS.Simple` + `tileSize: 512`,瓦片路径为
|
||||||
|
`tiles/<world>/<z>/<x>_<y>.png`,并且:
|
||||||
|
|
||||||
|
- 瓦片图片固定 **512×512** 像素;
|
||||||
|
- 最大原生层级 `zoom.max`(本站为 3)下 **1 方块 = 1 像素**;
|
||||||
|
- 因此层级 `z` 每张瓦片覆盖 `512 × 2^(maxZoom − z)` 个方块,
|
||||||
|
瓦片 `(x, y)` 覆盖方块区间 `[x·B, (x+1)·B) × [y·B, (y+1)·B)`。
|
||||||
|
|
||||||
|
实测标定:本站层级 0 每张瓦片覆盖 4096 方块,全世界(±20480)为 **10×10 = 100 张**。
|
||||||
|
用 27 个王城坐标交叉验证——按上式换算,王城所在瓦片的平均海洋像素占比为 **28.7%**,
|
||||||
|
而错误假设(256 / 1024 方块每瓦片、整体偏移半张瓦片)分别为 76.0% / 47.3% / 41.6%,
|
||||||
|
即只有正确公式能让王城稳定落在陆地上。
|
||||||
|
|
||||||
|
### 以机器人为中心 + 可调图幅
|
||||||
|
|
||||||
|
大地图的用途是看**自己机器人周边**,所以默认以机器人当前位置为中心(`bigMap.followBot`)。
|
||||||
|
界面上的「图幅」下拉框决定半径:
|
||||||
|
|
||||||
|
| 图幅 | 层级 0 需要的瓦片数 |
|
||||||
|
| --- | --- |
|
||||||
|
| ±512 格 | 1 |
|
||||||
|
| ±1024 格 | 1~4 |
|
||||||
|
| ±4096 格(默认) | 9 |
|
||||||
|
| ±8192 格 | 25 |
|
||||||
|
| 全世界(±20480) | 100 |
|
||||||
|
|
||||||
|
层级 0 每张瓦片覆盖 4096 格,所以「只看周边」比「拉全世界」省一到两个数量级。
|
||||||
|
点「爬取周边」只会爬当前图幅内的瓦片;「回到机器人」把视图拉回机器人所在位置。
|
||||||
|
|
||||||
|
### 缓存与爬取
|
||||||
|
|
||||||
|
| 层级 | 每瓦片方块数 | 全世界瓦片数 | 策略 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 0 | 4096 | 10×10 = 100 | 启动时自动预爬取(以机器人位置为中心,只爬当前图幅内) |
|
||||||
|
| 1 | 2048 | 20×20 = 400 | 按需拉取 + 永久缓存 |
|
||||||
|
| 2 | 1024 | 40×40 = 1600 | 按需拉取 + 永久缓存 |
|
||||||
|
| 3 | 512 | 80×80 = 6400 | 按需拉取 + 永久缓存 |
|
||||||
|
|
||||||
|
- 磁盘缓存:`data/mapcache/<world>/<z>/<x>_<y>.png`,命中即不再回源
|
||||||
|
- 瓦片由后端代理(`/api/bigmap/tile/:z/:x/:y`),避免浏览器直连地图站点的跨域问题
|
||||||
|
- 领土以 `markers.json` 的**原始多边形**绘制(预编译成 `Path2D`,按 even-odd 填充保留国中孔洞),
|
||||||
|
叠加本次会话新占领区块、王城与**全部机器人坐标(含名字标签)**
|
||||||
|
- 操作:滚轮缩放、拖拽平移、图幅切换、「爬取周边」/「回到机器人」一键定位
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SimpCore 开放 API
|
||||||
|
|
||||||
|
`src/core/simpApiClient.js` 对接 [SimpCore 开放 API](https://open.bgjq.top/api-docs.html)
|
||||||
|
(邦国崛起服务器的只读查询服务)。它既是占领判定的第四数据源,也把服务端的公开信息
|
||||||
|
完整暴露到控制台。
|
||||||
|
|
||||||
|
### 文档里的接口基本都接了
|
||||||
|
|
||||||
|
| 分类 | 接口 |
|
||||||
|
| --- | --- |
|
||||||
|
| 健康 | `GET /health`(免鉴权) |
|
||||||
|
| 玩家 | `/info/players`、`/info/players/{key}`、`/info/players/{key}/union` |
|
||||||
|
| 邦国 | `/info/unions`、`/info/unions/{key}`、`/members`、`/territory`、`/tech`、`/contributions` |
|
||||||
|
| 全服 | `/info/territory`、`/info/buffs`、`/info/diplomacy`、`/info/bans`、`/info/logs`、`/info/announcements` |
|
||||||
|
|
||||||
|
在左栏「领土数据源」点 **查看 API 数据** 打开浏览弹层,分五个页签:概览 / 邦国 / 玩家 / 全服 / 实时 V2。
|
||||||
|
限流按「Key × 接口」每分钟计算,所以**全部按需点击加载 + 会话内缓存 + 服务端 5 分钟缓存**,绝不做轮询。
|
||||||
|
|
||||||
|
### API 与 API-V2 是两套独立的密钥
|
||||||
|
|
||||||
|
文档明确说明两者**互不通用的**:
|
||||||
|
|
||||||
|
- **API**:`https://bgjq.simpfun.cn/api/` —— 邦国、领土、玩家、全服数据,配置 `apiKey`(或环境变量 `SIMP_API_KEY`)
|
||||||
|
- **API-V2**:`https://bgjq.simpfun.cn/api-v2/` —— 服务器实时状态(在线玩家 / 装备 / 游戏时间 / 末影龙),
|
||||||
|
需要在密钥中心**另外申请**,配置 `apiV2Key`(或环境变量 `SIMP_API_V2_KEY`)
|
||||||
|
|
||||||
|
V2 的接口级配额很小(末影龙只有 **1 次/分钟**),因此 V2 一律按需查询、不做自动轮询。
|
||||||
|
|
||||||
|
### 几个响应字段的坑(已按实测处理)
|
||||||
|
|
||||||
|
| 接口 | 坑 | 处理 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 捐献 | 只有 `player_uuid`,**没有玩家名** | 先用邦国成员表补全,查不到再退到全服玩家表 |
|
||||||
|
| 捐献 | `amount` 可为负 | 负数是邦国支出(如「恢复民心」消耗金锭/钻石),UI 注明 |
|
||||||
|
| 公告 | `message` 是**聊天组件 JSON 字符串** | `componentToText()` 压平成纯文本 |
|
||||||
|
| 王城 | 服务端可能返回 `null` | 显式判空,避免 `Number(null)` 算成 0 伪装成 (0,0) |
|
||||||
|
| 封禁 / 日志 | 字段名是 `banned_player_name` / `timestamp` 等 | 按实测字段解析 |
|
||||||
|
| V2 `/players` | 返回**对象** `{online, max, players[]}` 而不是数组,且没有坐标 | 按对象解析 |
|
||||||
|
| V2 `/dragon` | 字段是 `dragonAlive`,没有 health / phase | 按实测字段解析 |
|
||||||
|
| V2 `/time` | 服务端已算好 `time24` / `time12` / `phase` | 直接用,不再自己用 tick 换算 |
|
||||||
|
| V2 `/equipment` | 物品的 `damage` 是**已损耗值** | 换算成 `remainingDurability` 再显示 |
|
||||||
|
| V2 限流 | 429 带 `retryAfterSeconds` | 提示里直接写出还要等多少秒 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 登录验证门禁
|
||||||
|
|
||||||
|
「登录验证后才允许操作」在两侧都做了强制:
|
||||||
|
|
||||||
|
**游戏侧**(`src/bot/botManager.js`)
|
||||||
|
|
||||||
|
- `canOperate()` = 已连接 + 已进入世界 + 已完成登录验证
|
||||||
|
- 未通过时 `sendCommand` / `sendChat` 直接拒绝并记警告,`isReady()` 也不放行扩地任务
|
||||||
|
- 登录 / 注册流程自身用 `{ internal: true }` 绕过门禁
|
||||||
|
- **登录确认兜底**:部分离线服登录成功后不回明确提示,`server.loginConfirmTimeoutMs`
|
||||||
|
(默认 10 秒)内既没收到成功也没收到失败提示时按「已登录」放行,避免机器人永远卡死;
|
||||||
|
收到「密码错误 / 登录失败」则标记 `loginFailed` 且不放行。设为 `0` 可禁用兜底
|
||||||
|
- **免登录指令白名单**:有些服务器的登录流程本身就是指令(`/login`、`/register`,
|
||||||
|
简幻通体系还要先 `/auth`),若把门禁一刀切会形成「要先登录才能发登录指令」的死锁。
|
||||||
|
`server.preLoginCommands`(默认 `login,l,register,reg,auth`)里列出的前缀在未验证时也放行
|
||||||
|
- **外站账号绑定(如简幻通)**:服务器要求 `/auth <UID> <验证码>` 或扫码绑定时,
|
||||||
|
密码无法自动完成,程序会输出明确的中文告警(每个连接只提示一次)而不是静默死循环。
|
||||||
|
若该服务器提供固定验证码,可在配置的「账号绑定 / 二次验证模板」(`server.authCommand`)里填写
|
||||||
|
- **失败信息可操作**:发送失败会区分「未连接」「登录被服务器拒绝」「未通过登录验证」三种情况,
|
||||||
|
而不是笼统报一句「发送失败」
|
||||||
|
|
||||||
|
**控制台侧**(`src/web/server.js`)
|
||||||
|
|
||||||
|
- 配置 `WEB_PASSWORD`(或 `WEB_TOKEN`)后,页面弹出登录框,`POST /api/login` 校验通过才解锁
|
||||||
|
- 所有 `/api` 与 WebSocket 握手都要求凭证(`X-Auth-Token` 头 / `?token=` / Cookie)
|
||||||
|
- `/api/session` 与 `/api/login` 本身免鉴权,否则用户无法登录
|
||||||
|
- 控制台顶栏显示登录状态,可随时退出登录
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 部署到服务器
|
||||||
|
|
||||||
|
本项目已在 Debian 12 上以 pm2 常驻运行验证过,关键点:
|
||||||
|
|
||||||
|
### 1. Node.js 版本要求 ≥ 22
|
||||||
|
|
||||||
|
`mineflayer 4.39` 的 `engines` 声明为 `>=22`,用 Node 18 会在运行时出问题。
|
||||||
|
若服务器上已有别的服务依赖旧版 Node,**不要升级系统 Node**,装一份项目私有运行时即可:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p /opt/autostore-bot/runtime && cd /opt/autostore-bot/runtime
|
||||||
|
curl -fsSL -o node.tar.xz https://nodejs.org/dist/v24.16.0/node-v24.16.0-linux-x64.tar.xz
|
||||||
|
tar -xJf node.tar.xz && rm node.tar.xz
|
||||||
|
ln -sfn /opt/autostore-bot/runtime/node-v24.16.0-linux-x64 /opt/autostore-bot/runtime/current
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 环境变量
|
||||||
|
|
||||||
|
对外提供控制台时,`WEB_HOST` 必须是 `0.0.0.0`,并且**必须**设置 `WEB_PASSWORD`
|
||||||
|
(否则任何人访问该端口都能操作你的游戏账号):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
WEB_HOST=0.0.0.0
|
||||||
|
WEB_PORT=78
|
||||||
|
WEB_PASSWORD=<强密码>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. pm2 常驻
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PATH=/opt/autostore-bot/runtime/current/bin:$PATH
|
||||||
|
npm install --omit=dev
|
||||||
|
pm2 start ecosystem.config.js # interpreter 指向项目私有 Node
|
||||||
|
pm2 save
|
||||||
|
pm2 startup systemd -u root --hp /root # 开机自启
|
||||||
|
```
|
||||||
|
|
||||||
|
`ecosystem.config.js` 里把 `interpreter` 显式指向私有运行时,避免 pm2 用系统 Node 启动。
|
||||||
|
|
||||||
|
### 4. 日志分两个文件
|
||||||
|
|
||||||
|
`data/pm2-out.log` 是 stdout(info 级),`data/pm2-error.log` 是 stderr
|
||||||
|
(**warn 与 error 级都在这里**)。排查问题时两个都要看;
|
||||||
|
控制台页面上的运行日志是内存环形缓冲,包含全部级别,不受这个拆分影响。
|
||||||
|
|
||||||
|
### 5. 排障经验
|
||||||
|
|
||||||
|
| 现象 | 原因 | 处理 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 任务点了「开始扩地」却说「机器人尚未就绪」 | 寻路插件没加载,`bot.pathfinder` 恒为 undefined | 已在 `createBot` 后自动 `loadPlugin`;若仍出现看日志里的插件加载错误 |
|
||||||
|
| 日志被「被服务器传送」刷屏 | 账号未完成服务器验证,服务端每 tick 强推位置 | 已按 30 秒窗口聚合;看是否有「账号绑定」告警 |
|
||||||
|
| 连上 120 秒后被踢,反复循环 | 服务器要求外站账号绑定(如简幻通 `/auth`) | 人工完成一次绑定;绑定后同账号即可自动登录 |
|
||||||
|
| `mineflayer` 加载报错 / 语法不支持 | 系统 Node < 22 | 用项目私有运行时启动 |
|
||||||
|
| 能连上、能收指令,但**走不动** | 客户端移动包被服务端拒绝(回弹) | 用控制台的「移动测试」按钮确认位移;详见下方专章 |
|
||||||
|
|
||||||
|
### 6. 「机器人走不动」的排查方法
|
||||||
|
|
||||||
|
控制台右下角有**移动测试**按钮(前 / 后 / 左 / 右 / 跳),它绕开寻路器直接按控制键 2 秒并回报真实位移。
|
||||||
|
这能一眼区分两种完全不同的故障:
|
||||||
|
|
||||||
|
- **位移正常、但任务报「目标不可达」** → 是规划问题(距离太远、被地形挡住、挖/放方块被 Grim 约束禁用)
|
||||||
|
- **位移 ≈ 0** → 客户端移动被服务端拒绝,属于协议 / 反作弊交互问题,改规划参数没有意义
|
||||||
|
|
||||||
|
#### 最关键的一条:mineflayer 不发 `player_input`
|
||||||
|
|
||||||
|
这是本项目在真实服务器上「怎么按方向键都不动」的根因,**已在 `GrimCompat` 里自动补发**:
|
||||||
|
|
||||||
|
1.21.2+ 服务端的移动预测依赖 `player_input` 包,原版客户端每 tick 都发;
|
||||||
|
而 mineflayer 只在骑乘 / 跳下 / 潜行时发,地面移动的 forward / jump / sprint **完全不发**。
|
||||||
|
带反作弊的服务器因此判定「没有任何输入却发生了位移」,**每 tick 把玩家拉回原位置**。
|
||||||
|
|
||||||
|
实测对比(同一账号、同一位置、同一时段):
|
||||||
|
|
||||||
|
| | 入站强制位置同步 | 按方向键 2 秒的位移 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 不补 `player_input` | ~17 次/秒 | 0.00 格 |
|
||||||
|
| 补 `player_input` + `tick_end` | **0 次** | 0.53 ~ 1.40 格 |
|
||||||
|
|
||||||
|
同时补发的还有 `tick_end`(原版 1.21.2+ 客户端每 tick 末尾发送,反作弊用它判定 tick 边界)。
|
||||||
|
两者可在配置里关掉(`grim.sendPlayerInput` / `grim.sendTickEnd`),但**线上服务器不要关**。
|
||||||
|
|
||||||
|
#### 长距离寻路会被自动拆段
|
||||||
|
|
||||||
|
mineflayer-pathfinder 只能规划**已加载区块**范围(实测本服约 8 区块 ≈ 130 格),
|
||||||
|
而扩地目标常在几十个区块之外,A* 会瞬间返回 noPath。
|
||||||
|
寻路服务会把旅程拆成若干段:走到已加载范围边缘附近 → 新区块加载 → 再规划下一段。
|
||||||
|
段长由 `bot.blockAt` 对未加载区块返回 null 的特性二分探测得出,不依赖世界内部结构。
|
||||||
|
相关配置:`pathing.maxSegmentBlocks`、`pathing.segmentMarginBlocks`。
|
||||||
|
|
||||||
|
#### 配套的诊断数据
|
||||||
|
|
||||||
|
在 `GET /api/bots/:id/status`:
|
||||||
|
|
||||||
|
| 字段 | 含义 |
|
||||||
|
| --- | --- |
|
||||||
|
| `packetStats.inboundPosition` | 服务端下发的强制位置同步次数(持续很高 = 被回弹) |
|
||||||
|
| `packetStats.outboundPosition` | 客户端上报的位置包次数 |
|
||||||
|
| `packetStats.playerInput` / `tickEnd` | 每 tick 补发的协议包次数(应约等于 20/秒) |
|
||||||
|
| `packetStats.onGroundFixed` | 出站包中 onGround 被世界数据纠正的次数 |
|
||||||
|
| `onGround` | 客户端当前认为自己在不在空中 |
|
||||||
|
| `gameMode` / `dimension` | 游戏模式与维度(adventure / spectator 会限制移动) |
|
||||||
|
| `world.blockBelow` / `world.surroundings.exits` | 脚下方块与各方向「脚部 / 头部 / 地面」方块名 + 可通行判定 |
|
||||||
|
| `grim.protocolPackets` | 协议补包是否生效及发送计数 |
|
||||||
|
|
||||||
|
判读方式:
|
||||||
|
|
||||||
|
- 入站高、出站为 0 → 客户端根本没上报位置
|
||||||
|
- 出入都高但位移为 0 → 客户端在报,服务端不认(回弹)
|
||||||
|
- `onGround` 恒为 false 而 `world.blockBelowSolid` 为 true → 开启 `grim.reportOnGroundFromWorld`
|
||||||
|
- **寻路日志里「已探索节点数」极小(如几十个)就 noPath** → 不是距离问题,是移动生成器
|
||||||
|
从起点就找不到分支:检查 `world.surroundings.exits` 的 `walkable`,或把机器人挪到开阔地
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -161,12 +482,13 @@ npm start
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `MC_HOST` / `MC_PORT` | 服务器地址与端口 |
|
| `MC_HOST` / `MC_PORT` | 服务器地址与端口 |
|
||||||
| `MC_VERSION` | 协议版本,默认 `1.21.4` |
|
| `MC_VERSION` | 协议版本,默认 `1.21.4` |
|
||||||
| `MC_USERNAME` | 机器人游戏名 |
|
| `MC_BOTS` | **多机器人**账号列表:`用户名:密码` 逗号分隔,或 JSON 数组写法 |
|
||||||
|
| `MC_USERNAME` / `MC_PASSWORD` | 单机器人兜底(未设置 `MC_BOTS` 时生效) |
|
||||||
| `MC_AUTH` | `offline`(离线服)或 `microsoft`(正版) |
|
| `MC_AUTH` | `offline`(离线服)或 `microsoft`(正版) |
|
||||||
| `MC_PASSWORD` | 服务器登录 / 注册密码,供 `{password}` 占位符使用 |
|
|
||||||
| `WEB_HOST` / `WEB_PORT` | 控制台监听地址与端口 |
|
| `WEB_HOST` / `WEB_PORT` | 控制台监听地址与端口 |
|
||||||
| `WEB_TOKEN` | 控制台访问令牌;非本机访问**必须**设置(页面静态资源不校验,`/api` 与 WebSocket 握手校验) |
|
| `WEB_TOKEN` | 控制台令牌;非本机访问**必须**设置(页面静态资源不校验,`/api` 与 WebSocket 握手校验) |
|
||||||
| `DYNMAP_BASE` / `DYNMAP_WORLD` | 地图数据源根地址与世界目录名 |
|
| `WEB_PASSWORD` | 控制台登录密码:配置后页面弹出登录框,验证通过才允许操作机器人的控制台 / 聊天框 |
|
||||||
|
| `DYNMAP_BASE` / `DYNMAP_WORLD` | 地图站点根地址与世界目录名(领土数据 + 大地图瓦片) |
|
||||||
| `SIMP_API_KEY` | SimpCore 开放 API Key(可选) |
|
| `SIMP_API_KEY` | SimpCore 开放 API Key(可选) |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -175,37 +497,98 @@ npm start
|
|||||||
|
|
||||||
打开控制台后:
|
打开控制台后:
|
||||||
|
|
||||||
- **左栏**:连接状态、任务状态机、领土数据源、Grim 合规面板
|
- **顶栏**:机器人下拉选择、连接 / 任务徽标、控制台登录状态、开始扩地 / 停止 / 重连
|
||||||
- **中栏**:领土地图(Canvas,滚轮缩放 / 拖拽平移 / 图层开关)+ 实时运行日志
|
- **左栏**:机器人列表、连接、任务、**定时消息**、领土数据源、Grim 合规
|
||||||
- **右栏**:分 7 个标签页的配置表单 + 手动指令输入
|
- **中栏**:
|
||||||
|
- 地图页签 —— **大地图**(瓦片 + 领土多边形 + 全部机器人坐标)与 **领土地图**(区块级)
|
||||||
|
- 日志页签 —— **运行日志** / **公屏聊天** / **开拓日志**
|
||||||
|
- **右栏**:控制台(统一输入框 + 快捷发送 + 移动测试)+ 8 个标签页的配置表单
|
||||||
|
|
||||||
顶栏按钮:开始扩地 / 停止 / 重连。
|
### 控制台输入框:聊天与指令是同一个入口
|
||||||
|
|
||||||
地图图层:己方领土(蓝)、本次占领(绿)、飞地疏漏(红)、当前目标(黄)、机器人(白点)、王城(紫)。
|
不需要切换「聊天 / 指令」模式 —— **以 `/` 开头就按游戏指令发送,否则按公屏消息发送**。
|
||||||
|
服务端 `/api/bots/:id/chat` 就是这么分流的,返回里带 `kind` 字段说明实际走的是哪条路。
|
||||||
|
|
||||||
### REST / WebSocket 接口
|
另外提供:
|
||||||
|
|
||||||
|
- **快捷发送**:`/u return`、`/u claimmode`、`/u restore confirm`、`/spawn`、`/list` 一键发出
|
||||||
|
- **移动测试**:前 / 后 / 左 / 右 / 跳,绕开寻路器直接按控制键 2 秒并回报真实位移
|
||||||
|
|
||||||
|
### 响应式与移动端
|
||||||
|
|
||||||
|
| 宽度 | 布局 |
|
||||||
|
| --- | --- |
|
||||||
|
| ≥1200px | 三栏(状态 / 地图日志 / 配置) |
|
||||||
|
| ≤1200px | 两栏,配置栏落到下方并自动分列 |
|
||||||
|
| ≤820px | 单栏 + 底部导航(状态 / 地图 / 日志 / 配置一键定位) |
|
||||||
|
|
||||||
|
移动端额外处理:触控目标放大到 ≥36px、地图与日志高度按视口自适应、
|
||||||
|
避让底部安全区(`env(safe-area-inset-bottom)`)、横屏时压矮地图高度、
|
||||||
|
尊重系统「减少动效」偏好。
|
||||||
|
|
||||||
|
### 定时重复发送消息
|
||||||
|
|
||||||
|
挂机时常需要周期性发同一条指令(例如每 300 秒 `/u restore confirm` 维持疆土民心)。
|
||||||
|
左栏「定时消息」卡片里点「+ 新增」,填内容与间隔即可;以 `/` 开头按游戏指令发送,否则按公屏消息发送。
|
||||||
|
|
||||||
|
实现上刻意**不用**朴素的 `setInterval`:
|
||||||
|
|
||||||
|
- 只在「已连接 + 已通过登录验证」时才发送;
|
||||||
|
- 未就绪的周期直接顺延,**不补发** —— 否则重连后会把漏掉的几条一次性连发出去;
|
||||||
|
- 与指令节流队列共用,不会绕过 `server.commandCooldownMs` 变成刷屏;
|
||||||
|
- 间隔下限 5 秒、单个机器人最多 20 条,防止误配置把账号玩进黑名单。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## REST / WebSocket 接口
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| GET | `/api/status` | 机器人 / 任务 / 数据源完整状态 |
|
| GET | `/api/session` | 控制台登录状态(免鉴权,用于决定是否弹登录框) |
|
||||||
| GET | `/api/config` | 当前配置 |
|
| POST | `/api/login` | 控制台登录(免鉴权) |
|
||||||
| POST | `/api/config` | 保存并立即应用配置 |
|
| GET | `/api/status` | 机器人池 / 任务 / 数据源 / 日志 / 大地图完整状态 |
|
||||||
| GET | `/api/map?full=1` | 地图数据(`full=1` 附带全部邦国区块) |
|
| GET | `/api/bots` | 全部机器人状态列表 |
|
||||||
|
| GET | `/api/bots/:id/status` | 单个机器人状态 |
|
||||||
|
| GET | `/api/bots/:id/settings` | 单个机器人的设置与生效配置 |
|
||||||
|
| POST | `/api/bots/:id/settings` | 保存备注名 / 启用 / 自动开始 / 配置覆盖 |
|
||||||
|
| GET | `/api/bots/:id/schedules` | 该机器人的定时消息列表(含距下次发送的秒数) |
|
||||||
|
| POST | `/api/bots/:id/schedules` | 整体替换该机器人的定时消息列表 |
|
||||||
|
| POST | `/api/bots/:id/task/start` | 启动该机器人的扩地(body 为配置覆盖) |
|
||||||
|
| POST | `/api/bots/:id/task/stop` | 停止该机器人的扩地 |
|
||||||
|
| POST | `/api/bots/:id/reconnect` | 重连该机器人 |
|
||||||
|
| POST | `/api/bots/:id/move` | 手动移动测试(`direction` / `seconds`),返回真实位移 |
|
||||||
|
| POST | `/api/bots` | 控制台新增玩家(`username` / `password` / `label`) |
|
||||||
|
| DELETE | `/api/bots/:id` | 移除控制台新增的玩家(`.env` 里的会返回 400) |
|
||||||
|
| POST | `/api/bots/:id/chat` | 发送内容:**以 `/` 开头按指令发送,否则按公屏消息发送**(要求已登录验证) |
|
||||||
|
| POST | `/api/bots/:id/command` | 同上,但强制按指令发送(等价于给内容加 `/` 前缀) |
|
||||||
|
| GET | `/api/bots/:id/chat` | 该机器人的公屏日志(`limit` / `since` / `kind` / `keyword`) |
|
||||||
|
| POST | `/api/bots/:id/chat/export` | 导出该机器人的公屏日志到 `data/` |
|
||||||
|
| GET | `/api/expansion-log` | 开拓日志(`limit` / `botId` / `state` / `since`)+ 统计 |
|
||||||
|
| POST | `/api/expansion-log/export` | 导出开拓日志 CSV 到 `data/` |
|
||||||
|
| POST | `/api/expansion-log/clear` | 清空内存中的开拓日志(磁盘保留) |
|
||||||
|
| GET | `/api/bigmap` | 大地图元信息(坐标系、图幅、瓦片范围、缓存进度、机器人坐标、领土标记);支持 `rangeBlocks` / `bot` 参数 |
|
||||||
|
| GET | `/api/bigmap/markers` | 大地图领土标记(多边形 + 王城) |
|
||||||
|
| GET | `/api/bigmap/tile/:z/:x/:y` | 瓦片代理(缓存优先,避免跨域) |
|
||||||
|
| POST | `/api/bigmap/crawl` | 爬取瓦片;默认爬机器人周边,支持 `centerX` / `centerZ` / `rangeBlocks` |
|
||||||
|
| POST | `/api/bigmap/cache/clear` | 清空瓦片缓存 |
|
||||||
|
| GET | `/api/config` | 全局配置 |
|
||||||
|
| POST | `/api/config` | 保存并立即应用全局配置 |
|
||||||
|
| GET | `/api/map?full=1&bot=<id>` | 领土地图数据(`full=1` 附带全部邦国区块,`bot` 指定视角机器人) |
|
||||||
| GET | `/api/logs` | 运行日志 |
|
| GET | `/api/logs` | 运行日志 |
|
||||||
| POST | `/api/logs/export` | 导出日志到 `data/` |
|
| POST | `/api/logs/export` | 导出运行日志到 `data/` |
|
||||||
| POST | `/api/task/start` | 启动扩地(可带配置覆盖) |
|
|
||||||
| POST | `/api/task/stop` | 停止扩地 |
|
|
||||||
| POST | `/api/map/refresh` | 手动刷新地图数据 |
|
| POST | `/api/map/refresh` | 手动刷新地图数据 |
|
||||||
| POST | `/api/api/fetch-all` | 手动拉取全服领土(重接口) |
|
| POST | `/api/api/fetch-all` | 手动拉取全服领土(重接口) |
|
||||||
| POST | `/api/command` | 发送游戏指令 |
|
| POST | `/api/task/start`、`/api/task/stop`、`/api/command`、`/api/bot/reconnect` | 默认机器人的等价别名(兼容单机器人用法) |
|
||||||
| POST | `/api/bot/reconnect` | 重连服务器 |
|
| WS | `/ws` | 实时推送:`status`(1s)、`log`、`chat`、`expansion` |
|
||||||
| WS | `/ws` | 实时状态推送(1s)与日志流 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 配置项
|
## 配置项
|
||||||
|
|
||||||
配置保存在 `data/autostore.json`,可通过控制台表单修改。完整字段见
|
配置保存在 `data/autostore.json`(全局默认),可通过控制台表单修改;勾选「仅对当前机器人生效」时
|
||||||
|
写入 `data/bots.json` 作为该机器人的配置覆盖。完整字段见
|
||||||
[`src/config/expansionConfig.js`](src/config/expansionConfig.js),分组如下:
|
[`src/config/expansionConfig.js`](src/config/expansionConfig.js),分组如下:
|
||||||
|
|
||||||
| 分组 | 内容 |
|
| 分组 | 内容 |
|
||||||
@@ -215,8 +598,9 @@ npm start
|
|||||||
| 寻路 | 疾跑 / 游泳 / 跳跃 / 挖方块 / 放方块、高度偏好、跑酷、搭塔、下落高度、液体代价 |
|
| 寻路 | 疾跑 / 游泳 / 跳跃 / 挖方块 / 放方块、高度偏好、跑酷、搭塔、下落高度、液体代价 |
|
||||||
| 生存 | 自动进食、自动攻击、耐久保护、战斗筛选、水域策略、自动用船 |
|
| 生存 | 自动进食、自动攻击、耐久保护、战斗筛选、水域策略、自动用船 |
|
||||||
| 数据 | 地图刷新间隔、SimpCore API |
|
| 数据 | 地图刷新间隔、SimpCore API |
|
||||||
| Grim | 合规层全部阈值(见上) |
|
| 大地图 | 开关、世界名、预爬取层级、最大层级、**图幅半径、是否跟随机器人**、启动预爬取、下载并发数 |
|
||||||
| 服务器 | 开垦模式指令、登录 / 注册模板、指令节流、重连 |
|
| Grim | 合规层全部阈值 + 协议补包开关(见上) |
|
||||||
|
| 服务器 | 开垦模式指令、登录 / 注册模板、登录确认超时、指令节流、重连 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -230,49 +614,74 @@ npm run dev # 文件变更自动重启
|
|||||||
|
|
||||||
### 测试覆盖
|
### 测试覆盖
|
||||||
|
|
||||||
154 个测试用例,覆盖:
|
242 个测试用例,覆盖:
|
||||||
|
|
||||||
- 区块坐标编解码(含负数与边界)
|
- 区块坐标编解码(含负数与边界)
|
||||||
- 领土模型:飞地识别、会话占领、API 数据合并
|
- 领土模型:飞地识别、会话占领、按邦国名取飞地与己方区块、API 数据合并、多边形保留
|
||||||
- 规划器:四种策略、S 形顺序、**接壤只算己方领土**(历史 bug 回归)、他国区块切断连通
|
- 规划器:四种策略、S 形顺序、**接壤只算己方领土**(历史 bug 回归)、他国区块切断连通、
|
||||||
- markers.json 解析:奇偶填充孔洞、王城换算、颜色解析
|
按 `config.stateName` 解析己方邦国
|
||||||
|
- markers.json 解析:奇偶填充孔洞、王城换算、颜色解析、多边形扁平化
|
||||||
- 字幕 / 聊天解析:进度、带坐标成功、通用成功、模糊匹配
|
- 字幕 / 聊天解析:进度、带坐标成功、通用成功、模糊匹配
|
||||||
- Grim 合规层:旋转限速、动作互斥、攻击冷却表、距离 / 视线校验、物理自检
|
- Grim 合规层:旋转限速、动作互斥、攻击冷却表、距离 / 视线校验、物理自检
|
||||||
- 战斗筛选:中立生物豁免、条件敌对、猪灵金甲判定、耐久保护
|
- 战斗筛选:中立生物豁免、条件敌对、猪灵金甲判定、耐久保护
|
||||||
- 寻路服务:Grim 约束是否真正施加到 Movements、高度偏好目标
|
- 寻路服务:Grim 约束是否真正施加到 Movements、高度偏好目标
|
||||||
- 控制器状态机:完整扩地流程、超时重试、走两步、回城、耐久保护停止、完成
|
- 控制器状态机:完整扩地流程、超时重试、走两步、回城、耐久保护停止、完成、
|
||||||
- Web 控制台:地图负载、REST 接口、令牌鉴权
|
多机器人邦国归属隔离、开拓日志写入与去重
|
||||||
|
- 多机器人:凭据解析(JSON / 逗号分隔 / 兜底)、机器人池组装、配置覆盖与持久化、
|
||||||
|
密码不落配置文件、未登录时拒绝发送、全局配置下发保留各自覆盖
|
||||||
|
- 机器人登录门禁:就绪判定、登录成功 / 失败 / 已登录提示、注册与登录自动补发、
|
||||||
|
确认兜底超时、公屏消息类型映射
|
||||||
|
- 开拓日志:字段记录、筛选、统计、JSONL 落盘与重启恢复、损坏行容错、环形缓冲、CSV 导出
|
||||||
|
- 公屏日志:按机器人隔离、类型 / 关键字过滤、分文件落盘、文件名安全化、文本导出
|
||||||
|
- 大地图:坐标系换算(含对源站实测标定的王城用例)、**以机器人为中心的图幅换算**、瓦片范围、缓存命中、批量爬取、缓存清理
|
||||||
|
- 定时消息:内容归一化与间隔钳制、到点发送与按周期重复、未就绪时顺延不补发、多任务独立计时、整体替换重置计时
|
||||||
|
- Web 控制台:地图负载、单 / 多机器人接口、聊天与开拓日志接口、定时任务接口、
|
||||||
|
图幅与中心计算、瓦片代理、令牌与登录鉴权、WebSocket 握手鉴权
|
||||||
|
|
||||||
### 目录结构
|
### 目录结构
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── index.js 入口:装配模块、启动 Web 控制台、驱动 tick
|
├── index.js 入口:装配模块、启动 Web 控制台、驱动多机器人 tick
|
||||||
├── config/
|
├── config/
|
||||||
│ ├── expansionConfig.js 配置默认值 / 校验 / 归一化
|
│ ├── expansionConfig.js 配置默认值 / 校验 / 归一化
|
||||||
│ └── configStore.js 配置 JSON 持久化
|
│ ├── configStore.js 全局配置 JSON 持久化
|
||||||
|
│ ├── botsConfig.js 从 .env 解析多机器人凭据(密码只在这里读)
|
||||||
|
│ └── botStore.js 机器人设置持久化(不含密码)
|
||||||
├── core/
|
├── core/
|
||||||
│ ├── chunkPos.js 区块坐标编解码(整数键,零临时对象)
|
│ ├── chunkPos.js 区块坐标编解码(整数键,零临时对象)
|
||||||
│ ├── territoryModel.js 领土模型:区块集合、飞地、会话占领、API 合并
|
│ ├── territoryModel.js 领土模型:区块集合、飞地、会话占领、API 合并、多边形
|
||||||
│ ├── territoryDataService.js Dynmap markers.json 拉取与解析
|
│ ├── territoryDataService.js markers.json 拉取与解析
|
||||||
│ ├── simpApiClient.js SimpCore 开放 API 客户端
|
│ ├── simpApiClient.js SimpCore 开放 API 客户端
|
||||||
│ ├── expansionPlanner.js 四种扩地策略
|
│ ├── expansionPlanner.js 四种扩地策略
|
||||||
│ └── subtitleChatMonitor.js 字幕 / 聊天解析与模糊匹配
|
│ ├── subtitleChatMonitor.js 字幕 / 聊天解析与模糊匹配
|
||||||
|
│ ├── expansionLog.js ★ 开拓日志(玩家 / 邦国 / 区块 / 时间 / 来源)
|
||||||
|
│ ├── chatLog.js ★ 游戏公屏完整日志(按机器人分文件)
|
||||||
|
│ └── bigMapService.js ★ 大地图瓦片爬取与缓存
|
||||||
├── bot/
|
├── bot/
|
||||||
│ ├── botManager.js 连接、登录、重连、事件归一化
|
│ ├── botPool.js ★ 机器人池:同时挂多个玩家
|
||||||
│ ├── grimCompat.js ★ Grim 反作弊合规层
|
│ ├── botManager.js 连接、登录验证、公屏留档、指令 / 聊天发送
|
||||||
|
│ ├── scheduledMessenger.js ★ 定时重复发送消息 / 指令
|
||||||
|
│ ├── grimCompat.js ★ Grim 反作弊合规层 + 协议补包(player_input / tick_end)
|
||||||
│ ├── pathingService.js 寻路服务(替代 Baritone)
|
│ ├── pathingService.js 寻路服务(替代 Baritone)
|
||||||
│ ├── autoCombat.js 战斗筛选与耐久检查
|
│ ├── autoCombat.js 战斗筛选与耐久检查
|
||||||
│ ├── autoEat.js 自动进食
|
│ ├── autoEat.js 自动进食
|
||||||
│ ├── autoBoat.js 自动用船(默认关闭)
|
│ ├── autoBoat.js 自动用船(默认关闭)
|
||||||
│ └── durabilityGuard.js 耐久保护
|
│ └── durabilityGuard.js 耐久保护
|
||||||
├── controller/
|
├── controller/
|
||||||
│ └── expansionController.js 扩地任务状态机
|
│ └── expansionController.js 扩地任务状态机(按机器人实例化)
|
||||||
├── web/
|
├── web/
|
||||||
│ ├── server.js Express + WebSocket
|
│ ├── server.js Express + WebSocket + 登录鉴权
|
||||||
│ └── public/ 控制台前端(原生 HTML/CSS/JS,无构建步骤)
|
│ └── public/ 控制台前端(原生 HTML/CSS/JS,无构建步骤)
|
||||||
└── util/
|
└── util/
|
||||||
└── logger.js 环形日志缓冲
|
└── logger.js 环形日志缓冲
|
||||||
|
|
||||||
|
data/ (已 gitignore,运行时生成)
|
||||||
|
├── autostore.json 全局配置
|
||||||
|
├── bots.json 机器人设置(启用 / 备注 / 自动开始 / 配置覆盖,不含密码)
|
||||||
|
├── expansion-log.jsonl 开拓日志
|
||||||
|
├── chat/<机器人>.jsonl 游戏公屏日志
|
||||||
|
└── mapcache/<world>/<z>/ 大地图瓦片缓存
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+3
-3
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"name": "autostore-bot",
|
"name": "autostore-bot",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"description": "AutoStore Bot —— Minecraft 自动扩地机器人(mineflayer 重写版,Web 控制台,Grim 反作弊适配)",
|
"description": "AutoStore Bot —— Minecraft 自动扩地机器人(mineflayer 重写版,多机器人挂机,Web 控制台,开拓日志,大地图,Grim 反作弊适配)",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/index.js",
|
"start": "node src/index.js",
|
||||||
"dev": "node --watch src/index.js",
|
"dev": "node --watch src/index.js",
|
||||||
"test": "node --test",
|
"test": "node --test",
|
||||||
"check": "node scripts/check-syntax.js"
|
"check": "node scripts/check-syntax.js && node scripts/check-web-ids.js"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"minecraft",
|
"minecraft",
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 协议自检:核对 mineflayer 写出的 `teleport_confirm` 包在 1.21.4 上是否存在、
|
||||||
|
* 字段名是否匹配。
|
||||||
|
*
|
||||||
|
* 背景:部分服务端在收到 `player_position` 后会每 tick 重发位置包,
|
||||||
|
* 直到客户端回一个 teleport_confirm 确认。若 mineflayer 因为版本字段名不一致
|
||||||
|
* 而写不出这个包,就会形成「服务端一直传送、客户端一直走不动」的死循环。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const mcData = require('minecraft-data')('1.21.4');
|
||||||
|
|
||||||
|
const toServer = mcData.protocol.play.toServer;
|
||||||
|
const mappings = toServer.types.packet[1][0].type[1].mappings;
|
||||||
|
|
||||||
|
console.log('=== 1.21.4 客户端 → 服务端 中与传送/位置相关的包名 ===');
|
||||||
|
for (const [id, name] of Object.entries(mappings)) {
|
||||||
|
if (/teleport|position|confirm/i.test(name)) {
|
||||||
|
console.log(` ${id.padStart(3)} ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n=== 客户端 ← 服务端 中与传送/位置相关的包名 ===');
|
||||||
|
const toClientMappings = mcData.protocol.play.toClient.types.packet[1][0].type[1].mappings;
|
||||||
|
for (const [id, name] of Object.entries(toClientMappings)) {
|
||||||
|
if (/teleport|position/i.test(name)) {
|
||||||
|
console.log(` ${id.padStart(3)} ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n=== teleport_confirm / position_look 的字段定义(客户端 → 服务端)===');
|
||||||
|
function dumpPacketFields(packetName) {
|
||||||
|
// protocol 结构:types.packet = ['container', [nameField, paramsField]]
|
||||||
|
// paramsField.type = ['switch', { compareTo: 'name', fields: { 包名: [字段...] } }]
|
||||||
|
const paramsField = toServer.types.packet[1][1];
|
||||||
|
const fields = paramsField.type[1].fields;
|
||||||
|
const def = fields[packetName];
|
||||||
|
if (!def) {
|
||||||
|
console.log(` (没有名为 ${packetName} 的包)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(` ${packetName}: ` + def.map((f) => `${f.name}:${JSON.stringify(f.type)}`).join(', '));
|
||||||
|
}
|
||||||
|
|
||||||
|
dumpPacketFields('teleport_confirm');
|
||||||
|
dumpPacketFields('position');
|
||||||
|
dumpPacketFields('position_look');
|
||||||
|
|
||||||
|
console.log('\n=== 客户端 ← 服务端 position 包的字段定义 ===');
|
||||||
|
function dumpToClientFields(packetName) {
|
||||||
|
const paramsField = mcData.protocol.play.toClient.types.packet[1][1];
|
||||||
|
const def = paramsField.type[1].fields[packetName];
|
||||||
|
if (!def) {
|
||||||
|
console.log(` (没有名为 ${packetName} 的包)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(` ${packetName}: ` + def.map((f) => `${f.name}:${JSON.stringify(f.type)}`).join(', '));
|
||||||
|
}
|
||||||
|
dumpToClientFields('position');
|
||||||
|
|
||||||
|
console.log('\n=== mineflayer 实际使用的包名 / 字段名 ===');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const physics = fs.readFileSync(path.join(__dirname, 'node_modules', 'mineflayer', 'lib', 'plugins', 'physics.js'), 'utf8');
|
||||||
|
const lines = physics.split('\n');
|
||||||
|
lines.forEach((line, index) => {
|
||||||
|
if (/teleport_confirm|teleportId|teleport_id/.test(line)) {
|
||||||
|
console.log(` physics.js:${index + 1} ${line.trim()}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 前端元素引用自检:核对 app.js 里 `$('id')` 引用的元素是否都存在于 index.html,
|
||||||
|
* 以及 index.html 里是否还有未被引用的 id(可能是改版后遗留的死元素)。
|
||||||
|
*
|
||||||
|
* 之所以放进 scripts/ 而不是测试里,是因为它检查的是静态资源一致性,
|
||||||
|
* 与运行时行为无关;出问题时直接跑 `node scripts/check-web-ids.js` 即可。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const publicDir = path.join(__dirname, '..', 'src', 'web', 'public');
|
||||||
|
const app = fs.readFileSync(path.join(publicDir, 'app.js'), 'utf8');
|
||||||
|
const html = fs.readFileSync(path.join(publicDir, 'index.html'), 'utf8');
|
||||||
|
|
||||||
|
const referenced = new Set();
|
||||||
|
for (const match of app.matchAll(/\$\('([^']+)'\)/g)) referenced.add(match[1]);
|
||||||
|
|
||||||
|
const declared = new Set();
|
||||||
|
for (const match of html.matchAll(/id="([^"]+)"/g)) declared.add(match[1]);
|
||||||
|
|
||||||
|
const missing = [...referenced].filter((id) => !declared.has(id));
|
||||||
|
const unused = [...declared].filter((id) => !referenced.has(id));
|
||||||
|
|
||||||
|
console.log(`app.js 引用 ${referenced.size} 个元素 id,index.html 声明 ${declared.size} 个`);
|
||||||
|
|
||||||
|
if (missing.length > 0) {
|
||||||
|
console.error('引用了不存在的元素 id:' + missing.join(', '));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
if (unused.length > 0) {
|
||||||
|
console.log('提示:index.html 中未被 app.js 直接引用的 id(可能由 CSS / 标签页逻辑使用):' + unused.join(', '));
|
||||||
|
}
|
||||||
|
console.log('前端元素引用自检通过');
|
||||||
+679
-24
@@ -3,18 +3,38 @@
|
|||||||
const { EventEmitter } = require('events');
|
const { EventEmitter } = require('events');
|
||||||
|
|
||||||
const mineflayer = require('mineflayer');
|
const mineflayer = require('mineflayer');
|
||||||
|
const { pathfinder: pathfinderPlugin } = require('mineflayer-pathfinder');
|
||||||
|
|
||||||
const { GrimCompat } = require('./grimCompat');
|
const { GrimCompat } = require('./grimCompat');
|
||||||
const { PathingService } = require('./pathingService');
|
const { PathingService } = require('./pathingService');
|
||||||
const { AutoEater } = require('./autoEat');
|
const { AutoEater } = require('./autoEat');
|
||||||
const { AutoBoatController } = require('./autoBoat');
|
const { AutoBoatController } = require('./autoBoat');
|
||||||
const { DurabilityGuard } = require('./durabilityGuard');
|
const { DurabilityGuard } = require('./durabilityGuard');
|
||||||
|
const { ChatKind } = require('../core/chatLog');
|
||||||
|
|
||||||
/** 服务器要求登录 / 注册时的提示关键词 */
|
/** 服务器要求登录 / 注册时的提示关键词 */
|
||||||
const LOGIN_PROMPT_PATTERN = /请(先)?(输入)?登录|请登录|请输入密码|login|登入/i;
|
const LOGIN_PROMPT_PATTERN = /请(先)?(输入)?登录|请登录|请输入密码|login|登入/i;
|
||||||
const REGISTER_PROMPT_PATTERN = /请(先)?(输入)?注册|请注册|register/i;
|
const REGISTER_PROMPT_PATTERN = /请(先)?(输入)?注册|请注册|register/i;
|
||||||
const LOGIN_OK_PATTERN = /登录成功|登陆成功|注册成功|欢迎回来|成功登录/;
|
const LOGIN_OK_PATTERN = /登录成功|登陆成功|注册成功|欢迎回来|成功登录/;
|
||||||
const ALREADY_LOGGED_IN_PATTERN = /已登录|无需登录|你已经登录/;
|
const ALREADY_LOGGED_IN_PATTERN = /已登录|无需登录|你已经登录/;
|
||||||
|
/** 登录 / 注册明确失败的关键词(命中后不再自动重试,也不放行操作) */
|
||||||
|
const LOGIN_FAIL_PATTERN = /密码(错误|不正确|有误)|登录失败|注册失败|wrong password|invalid password/i;
|
||||||
|
/**
|
||||||
|
* 服务器要求完成账号绑定 / 二次验证时的提示关键词。
|
||||||
|
*
|
||||||
|
* 典型场景:服务器用「简幻通」等外站账号体系,新账号必须先用 `/auth <UID> <验证码>`
|
||||||
|
* 或扫码完成绑定,否则会被反复传送、并在倒计时结束后踢出。
|
||||||
|
* 这种流程无法靠密码自动完成,因此只做「明确告知 + 可选模板」处理。
|
||||||
|
*/
|
||||||
|
const NEED_AUTH_PATTERN = /需要绑定|绑定您的|绑定你的|请使用命令:\s*\/?auth|扫描.{0,6}二维码|扫码|完成验证|身份验证/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「被服务器传送」事件的日志聚合窗口(毫秒)。
|
||||||
|
*
|
||||||
|
* 账号未通过验证时,服务器会每 tick 强推一次位置,逐条记日志会在几分钟内
|
||||||
|
* 刷掉整个日志环形缓冲并把 WebSocket 打满,因此这里按窗口聚合。
|
||||||
|
*/
|
||||||
|
const FORCED_MOVE_WINDOW_MS = 30000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 机器人管理器:负责连接、登录、断线重连,并把底层 mineflayer 事件
|
* 机器人管理器:负责连接、登录、断线重连,并把底层 mineflayer 事件
|
||||||
@@ -22,6 +42,9 @@ const ALREADY_LOGGED_IN_PATTERN = /已登录|无需登录|你已经登录/;
|
|||||||
*
|
*
|
||||||
* 同时在这里装配 Grim 合规层、寻路服务、自动进食、自动用船、耐久保护等子模块,
|
* 同时在这里装配 Grim 合规层、寻路服务、自动进食、自动用船、耐久保护等子模块,
|
||||||
* 使它们与 bot 实例的生命周期保持一致。
|
* 使它们与 bot 实例的生命周期保持一致。
|
||||||
|
*
|
||||||
|
* 一个 BotManager 实例对应**一个**游戏账号;同时挂多个玩家时由
|
||||||
|
* {@link module:bot/botPool.BotPool} 创建并持有多个实例。
|
||||||
*/
|
*/
|
||||||
class BotManager extends EventEmitter {
|
class BotManager extends EventEmitter {
|
||||||
/**
|
/**
|
||||||
@@ -30,16 +53,26 @@ class BotManager extends EventEmitter {
|
|||||||
* @param {number} connection.port
|
* @param {number} connection.port
|
||||||
* @param {string} connection.username
|
* @param {string} connection.username
|
||||||
* @param {string} connection.version
|
* @param {string} connection.version
|
||||||
|
* @param {string} [connection.id] 机器人 id(缺省取游戏名)
|
||||||
|
* @param {string} [connection.label] 备注名
|
||||||
* @param {string} [connection.auth]
|
* @param {string} [connection.auth]
|
||||||
* @param {string} [connection.password]
|
* @param {string} [connection.password]
|
||||||
* @param {object} config 完整配置对象
|
* @param {object} config 完整配置对象
|
||||||
* @param {import('../util/logger').Logger} [logger]
|
* @param {import('../util/logger').Logger} [logger]
|
||||||
|
* @param {{chatLog?: import('../core/chatLog').ChatLog}} [options]
|
||||||
*/
|
*/
|
||||||
constructor(connection, config, logger) {
|
constructor(connection, config, logger, options) {
|
||||||
super();
|
super();
|
||||||
|
const opts = options || {};
|
||||||
this.connection = connection;
|
this.connection = connection;
|
||||||
this.config = config;
|
this.config = config;
|
||||||
this.logger = logger || null;
|
this.logger = logger || null;
|
||||||
|
/** 机器人 id:用于 URL、文件名与日志归属 */
|
||||||
|
this.id = connection.id || connection.username;
|
||||||
|
/** 备注名(控制台展示) */
|
||||||
|
this.label = connection.label || connection.username;
|
||||||
|
/** @type {import('../core/chatLog').ChatLog|null} 游戏公屏完整日志 */
|
||||||
|
this.chatLog = opts.chatLog || null;
|
||||||
|
|
||||||
/** @type {import('mineflayer').Bot|null} */
|
/** @type {import('mineflayer').Bot|null} */
|
||||||
this.bot = null;
|
this.bot = null;
|
||||||
@@ -58,6 +91,10 @@ class BotManager extends EventEmitter {
|
|||||||
this.state = 'idle';
|
this.state = 'idle';
|
||||||
/** 是否已完成登录(离线服密码登录) */
|
/** 是否已完成登录(离线服密码登录) */
|
||||||
this.loggedIn = false;
|
this.loggedIn = false;
|
||||||
|
/** 登录 / 注册是否被服务器明确拒绝 */
|
||||||
|
this.loginFailed = false;
|
||||||
|
/** 登录确认兜底定时器(部分服务器不回成功提示) */
|
||||||
|
this._loginConfirmTimer = null;
|
||||||
/** 断线重连计数 */
|
/** 断线重连计数 */
|
||||||
this.reconnectAttempts = 0;
|
this.reconnectAttempts = 0;
|
||||||
/** 是否由用户主动断开(主动断开不重连) */
|
/** 是否由用户主动断开(主动断开不重连) */
|
||||||
@@ -66,9 +103,38 @@ class BotManager extends EventEmitter {
|
|||||||
this._lastCommandAt = 0;
|
this._lastCommandAt = 0;
|
||||||
/** 待发送的指令队列(保证最小间隔) */
|
/** 待发送的指令队列(保证最小间隔) */
|
||||||
this._commandQueue = [];
|
this._commandQueue = [];
|
||||||
|
/** 待发送的聊天队列(与指令共用同一个节流器,避免刷屏) */
|
||||||
|
this._chatQueue = [];
|
||||||
this._commandTimer = null;
|
this._commandTimer = null;
|
||||||
/** 最近一次收到过的聊天消息(供 Web 控制台展示) */
|
/** 最近一次收到过的聊天消息(供 Web 控制台展示) */
|
||||||
this.lastMessages = [];
|
this.lastMessages = [];
|
||||||
|
/** 本次连接内「被服务器传送」的计数与聚合定时器 */
|
||||||
|
this._forcedMoveCount = 0;
|
||||||
|
this._forcedMoveFirstAt = 0;
|
||||||
|
this._forcedMoveTimer = null;
|
||||||
|
/** 本次连接内是否已提示过「账号需要绑定 / 二次验证」 */
|
||||||
|
this._needAuthWarned = false;
|
||||||
|
/** 本次连接内是否已提示过「服务器要登录但没配密码」 */
|
||||||
|
this._noPasswordWarned = false;
|
||||||
|
/**
|
||||||
|
* 位置同步统计。
|
||||||
|
*
|
||||||
|
* 排查「机器人被服务端反复传送、走不动」这类问题时,最关键的信息是:
|
||||||
|
* 客户端到底有没有往外发位置包?只数入站包无法区分「服务端主动重置」
|
||||||
|
* 与「服务端等不到客户端回应而重发」这两种完全不同的原因。
|
||||||
|
*/
|
||||||
|
this.packetStats = {
|
||||||
|
inboundPosition: 0,
|
||||||
|
outboundPosition: 0,
|
||||||
|
lastInboundAt: 0,
|
||||||
|
lastOutboundAt: 0,
|
||||||
|
/** 出站包中 onGround 被世界数据纠正的次数 */
|
||||||
|
onGroundFixed: 0,
|
||||||
|
/** 每 tick 补发的 player_input 次数(1.21.2+ 移动预测必需) */
|
||||||
|
playerInput: 0,
|
||||||
|
/** 每 tick 补发的 tick_end 次数 */
|
||||||
|
tickEnd: 0
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 连接生命周期 ====================
|
// ==================== 连接生命周期 ====================
|
||||||
@@ -98,9 +164,27 @@ class BotManager extends EventEmitter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.bot = bot;
|
this.bot = bot;
|
||||||
|
this._loadPlugins(bot);
|
||||||
this._wireEvents(bot);
|
this._wireEvents(bot);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载 mineflayer-pathfinder 插件。
|
||||||
|
*
|
||||||
|
* mineflayer 的插件必须显式 `loadPlugin`,否则 `bot.pathfinder` 永远是 undefined,
|
||||||
|
* `isReady()` 会恒为 false,扩地任务连启动都启动不了(且失败原因只是含糊的
|
||||||
|
* 「机器人尚未就绪」)。这里在 createBot 之后立刻加载,并在失败时明确报错。
|
||||||
|
*
|
||||||
|
* @param {import('mineflayer').Bot} bot
|
||||||
|
*/
|
||||||
|
_loadPlugins(bot) {
|
||||||
|
try {
|
||||||
|
bot.loadPlugin(pathfinderPlugin);
|
||||||
|
} catch (err) {
|
||||||
|
this._log('加载 mineflayer-pathfinder 插件失败:' + (err && err.message ? err.message : String(err)), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 主动断开(不触发重连) */
|
/** 主动断开(不触发重连) */
|
||||||
disconnect() {
|
disconnect() {
|
||||||
this._manualDisconnect = true;
|
this._manualDisconnect = true;
|
||||||
@@ -108,7 +192,10 @@ class BotManager extends EventEmitter {
|
|||||||
clearTimeout(this._reconnectTimer);
|
clearTimeout(this._reconnectTimer);
|
||||||
this._reconnectTimer = null;
|
this._reconnectTimer = null;
|
||||||
}
|
}
|
||||||
|
this._clearLoginConfirmTimer();
|
||||||
|
this._clearForcedMoveTimer();
|
||||||
this._commandQueue.length = 0;
|
this._commandQueue.length = 0;
|
||||||
|
this._chatQueue.length = 0;
|
||||||
if (this.bot) {
|
if (this.bot) {
|
||||||
try {
|
try {
|
||||||
this.bot.quit('AutoStore 主动断开');
|
this.bot.quit('AutoStore 主动断开');
|
||||||
@@ -121,7 +208,8 @@ class BotManager extends EventEmitter {
|
|||||||
|
|
||||||
/** @returns {boolean} 是否已进入世界并完成登录,可以执行任务 */
|
/** @returns {boolean} 是否已进入世界并完成登录,可以执行任务 */
|
||||||
isReady() {
|
isReady() {
|
||||||
return this.state === 'online' && !!this.bot && !!this.bot.entity && !!this.bot.pathfinder;
|
return this.state === 'online' && !!this.bot && !!this.bot.entity
|
||||||
|
&& !!this.bot.pathfinder && this.loggedIn;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @returns {boolean} 是否已进入世界(未强制要求登录完成) */
|
/** @returns {boolean} 是否已进入世界(未强制要求登录完成) */
|
||||||
@@ -133,8 +221,8 @@ class BotManager extends EventEmitter {
|
|||||||
if (this._manualDisconnect || !this.config.server.autoReconnect) return;
|
if (this._manualDisconnect || !this.config.server.autoReconnect) return;
|
||||||
if (this._reconnectTimer) return;
|
if (this._reconnectTimer) return;
|
||||||
this.reconnectAttempts++;
|
this.reconnectAttempts++;
|
||||||
const delay = this.config.server.reconnectDelayMs;
|
const delay = computeReconnectDelay(this.config.server, this.reconnectAttempts);
|
||||||
this._log(`${delay / 1000} 秒后尝试第 ${this.reconnectAttempts} 次重连`);
|
this._log(`${Math.round(delay / 1000)} 秒后尝试第 ${this.reconnectAttempts} 次重连`);
|
||||||
this._reconnectTimer = setTimeout(() => {
|
this._reconnectTimer = setTimeout(() => {
|
||||||
this._reconnectTimer = null;
|
this._reconnectTimer = null;
|
||||||
this.bot = null;
|
this.bot = null;
|
||||||
@@ -146,8 +234,12 @@ class BotManager extends EventEmitter {
|
|||||||
// ==================== 事件装配 ====================
|
// ==================== 事件装配 ====================
|
||||||
|
|
||||||
_wireEvents(bot) {
|
_wireEvents(bot) {
|
||||||
|
this._wirePacketStats(bot);
|
||||||
|
|
||||||
bot.on('login', () => {
|
bot.on('login', () => {
|
||||||
this.state = 'online';
|
this.state = 'online';
|
||||||
|
this._needAuthWarned = false;
|
||||||
|
this._noPasswordWarned = false;
|
||||||
this._log('已连接到服务器,正在进入世界');
|
this._log('已连接到服务器,正在进入世界');
|
||||||
this._handleLogin();
|
this._handleLogin();
|
||||||
});
|
});
|
||||||
@@ -162,6 +254,8 @@ class BotManager extends EventEmitter {
|
|||||||
bot.on('end', (reason) => {
|
bot.on('end', (reason) => {
|
||||||
this.state = 'offline';
|
this.state = 'offline';
|
||||||
this.loggedIn = false;
|
this.loggedIn = false;
|
||||||
|
this._clearLoginConfirmTimer();
|
||||||
|
this._clearForcedMoveTimer();
|
||||||
this._log('连接已断开:' + (reason || '未知原因'));
|
this._log('连接已断开:' + (reason || '未知原因'));
|
||||||
this.emit('end', reason);
|
this.emit('end', reason);
|
||||||
this._scheduleReconnect();
|
this._scheduleReconnect();
|
||||||
@@ -184,11 +278,14 @@ class BotManager extends EventEmitter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ===== 聊天 / 字幕 / 动作栏:占领判定与登录提示的数据源 =====
|
// ===== 聊天 / 字幕 / 动作栏:占领判定与登录提示的数据源 =====
|
||||||
bot.on('messagestr', (message) => {
|
// messagestr 的第二参为消息通道(chat / system / game_info),第四参为发言者(正版签名聊天才有)。
|
||||||
|
bot.on('messagestr', (message, position, _jsonMsg, sender) => {
|
||||||
if (!message) return;
|
if (!message) return;
|
||||||
this._pushMessage(message);
|
this._pushMessage(message);
|
||||||
|
// 游戏公屏完整日志:所有消息(含系统公告)都留档,不只留开垦相关
|
||||||
|
this._recordChat(message, position, sender);
|
||||||
this._handleAuthPrompt(message);
|
this._handleAuthPrompt(message);
|
||||||
this.emit('chat', message);
|
this.emit('chat', message, position);
|
||||||
});
|
});
|
||||||
|
|
||||||
bot.on('title', (text, type) => {
|
bot.on('title', (text, type) => {
|
||||||
@@ -212,9 +309,49 @@ class BotManager extends EventEmitter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
bot.on('forcedMove', () => {
|
bot.on('forcedMove', () => {
|
||||||
this._log('被服务器传送(位置强制同步)');
|
|
||||||
this.emit('forcedMove');
|
this.emit('forcedMove');
|
||||||
|
this._countForcedMove();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 自动接受服务器资源包:不接会一直卡在「加载资源包」而进不了服务器
|
||||||
|
bot.on('resourcePack', (url) => {
|
||||||
|
const text = typeof url === 'string' ? url : '';
|
||||||
|
this._log('收到服务器资源包,自动接受:' + (text.length > 60 ? text.slice(0, 60) + '…' : text));
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
if (this.bot && typeof this.bot.acceptResourcePack === 'function') this.bot.acceptResourcePack();
|
||||||
|
} catch (err) {
|
||||||
|
this._log('接受资源包失败:' + (err && err.message ? err.message : String(err)), 'warn');
|
||||||
|
}
|
||||||
|
}, 200).unref?.();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 聚合「被服务器传送」事件:首次立即记一条,其余按 {@link FORCED_MOVE_WINDOW_MS}
|
||||||
|
* 窗口汇总成一条,避免账号未验证时把日志刷爆。
|
||||||
|
*/
|
||||||
|
_countForcedMove() {
|
||||||
|
this._forcedMoveCount++;
|
||||||
|
if (this._forcedMoveCount === 1) {
|
||||||
|
this._forcedMoveFirstAt = Date.now();
|
||||||
|
this._log('被服务器传送(位置强制同步)');
|
||||||
|
}
|
||||||
|
if (!this._forcedMoveTimer) {
|
||||||
|
this._forcedMoveTimer = setTimeout(() => this._flushForcedMove(), FORCED_MOVE_WINDOW_MS);
|
||||||
|
if (this._forcedMoveTimer.unref) this._forcedMoveTimer.unref();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 输出传送事件汇总并重置计数 */
|
||||||
|
_flushForcedMove() {
|
||||||
|
this._forcedMoveTimer = null;
|
||||||
|
const extra = this._forcedMoveCount - 1;
|
||||||
|
this._forcedMoveCount = 0;
|
||||||
|
if (extra <= 0) return;
|
||||||
|
const seconds = Math.max(1, Math.round((Date.now() - this._forcedMoveFirstAt) / 1000));
|
||||||
|
this._log(`被服务器传送另有 ${extra} 次(最近 ${seconds} 秒内):通常是账号未完成服务器验证`
|
||||||
|
+ `(如简幻通绑定)被反复拉回,或被 /spawn 类指令重置位置`, 'warn');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 在世界里装配各子模块(Grim 层、寻路、进食、用船、耐久) */
|
/** 在世界里装配各子模块(Grim 层、寻路、进食、用船、耐久) */
|
||||||
@@ -249,60 +386,313 @@ class BotManager extends EventEmitter {
|
|||||||
|
|
||||||
// ==================== 登录 ====================
|
// ==================== 登录 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计位置包的收发数量,并修正出站包的 onGround 标志。
|
||||||
|
*
|
||||||
|
* - 入站:服务端下发的 `position`(强制位置同步)
|
||||||
|
* - 出站:客户端上报的 `position` / `position_look`
|
||||||
|
*
|
||||||
|
* 两者一起看才能判断「走不动」的原因:
|
||||||
|
* 入站高、出站为 0 → 客户端根本没上报位置(mineflayer 只在位置变化时才发包);
|
||||||
|
* 出入都高 → 客户端在报,但服务端不认(被反作弊回弹)。
|
||||||
|
*
|
||||||
|
* @param {import('mineflayer').Bot} bot
|
||||||
|
*/
|
||||||
|
_wirePacketStats(bot) {
|
||||||
|
if (!bot._client) return;
|
||||||
|
bot._client.on('position', () => {
|
||||||
|
this.packetStats.inboundPosition++;
|
||||||
|
this.packetStats.lastInboundAt = Date.now();
|
||||||
|
});
|
||||||
|
const originalWrite = bot._client.write.bind(bot._client);
|
||||||
|
bot._client.write = (name, params) => {
|
||||||
|
if (name === 'position' || name === 'position_look') {
|
||||||
|
this.packetStats.outboundPosition++;
|
||||||
|
this.packetStats.lastOutboundAt = Date.now();
|
||||||
|
this._fixOutboundOnGround(params);
|
||||||
|
} else if (name === 'player_input') {
|
||||||
|
this.packetStats.playerInput++;
|
||||||
|
} else if (name === 'tick_end') {
|
||||||
|
this.packetStats.tickEnd++;
|
||||||
|
}
|
||||||
|
return originalWrite(name, params);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按世界数据修正出站位置包的 onGround 标志。
|
||||||
|
*
|
||||||
|
* mineflayer 收到服务端的 `position` 包时会把 `entity.onGround` 直接置为 false
|
||||||
|
* (见 mineflayer/lib/plugins/physics.js)。若服务端频繁下发位置包(实测本服
|
||||||
|
* 约 17 次/秒),这个值就永远来不及被物理引擎改回来,于是客户端持续上报
|
||||||
|
* 「我站在地上但我在空中」;带反作弊的服务器会判定为异常并把玩家拉回去,
|
||||||
|
* 拉回又触发下一次置 false,形成每 tick 一次的死循环 —— 表现就是「机器人原地抖动、走不动」。
|
||||||
|
*
|
||||||
|
* 这里在发包前用世界数据复核一次脚下是否有实体方块,只有确实不一致时才改写。
|
||||||
|
*
|
||||||
|
* @param {object} params 出站包内容
|
||||||
|
*/
|
||||||
|
_fixOutboundOnGround(params) {
|
||||||
|
const grim = this.config && this.config.grim;
|
||||||
|
if (!grim || grim.reportOnGroundFromWorld !== true) return;
|
||||||
|
if (!params || !params.flags || typeof params.flags.onGround !== 'boolean') return;
|
||||||
|
const actual = this._isStandingOnGround();
|
||||||
|
if (actual === null || params.flags.onGround === actual) return;
|
||||||
|
params.flags.onGround = actual;
|
||||||
|
this.packetStats.onGroundFixed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用世界数据判断脚下是否有实体方块。
|
||||||
|
*
|
||||||
|
* 取脚下方 0.05 格处的方块:既能容忍浮点误差,又不会因为站在半砖上而误判。
|
||||||
|
*
|
||||||
|
* @returns {boolean|null} 无法判断时返回 null
|
||||||
|
*/
|
||||||
|
_isStandingOnGround() {
|
||||||
|
const bot = this.bot;
|
||||||
|
if (!bot || !bot.entity || !bot.blockAt) return null;
|
||||||
|
const below = bot.blockAt(bot.entity.position.offset(0, -0.05, 0));
|
||||||
|
if (!below) return null;
|
||||||
|
return below.boundingBox !== 'empty';
|
||||||
|
}
|
||||||
|
|
||||||
/** 进入世界后按配置尝试登录 / 注册 */
|
/** 进入世界后按配置尝试登录 / 注册 */
|
||||||
_handleLogin() {
|
_handleLogin() {
|
||||||
const server = this.config.server;
|
const server = this.config.server;
|
||||||
const password = this.connection.password;
|
const password = this.connection.password;
|
||||||
this.loggedIn = !server.loginCommand;
|
this.loginFailed = false;
|
||||||
if (!server.loginCommand || !password) return;
|
this._noPasswordWarned = false;
|
||||||
|
if (!server.loginCommand) {
|
||||||
|
// 服务器不需要登录指令,直接视为已通过验证
|
||||||
|
this.loggedIn = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!password) {
|
||||||
|
// 配置了登录指令模板,但 .env 里没有该账号的密码:我们什么也发不出去。
|
||||||
|
// 很多服务器的身份验证由外部系统完成(如简幻通),账号一旦绑定就无需密码;
|
||||||
|
// 此时若把 loggedIn 永久置为 false,控制台会完全无法操作(连手动指令都发不出)。
|
||||||
|
// 因此走与其他「服务器不回提示」情形相同的兜底:超时后按「无需登录」放行,
|
||||||
|
// 若服务器随后真的要求登录,_handleAuthPrompt 会输出明确的错误提示。
|
||||||
|
this.loggedIn = false;
|
||||||
|
this._log('已配置登录指令模板,但 .env 中没有该账号的密码,无法自动登录;'
|
||||||
|
+ '若本账号无需密码(如已绑定外站账号),稍后将按「无需登录」放行', 'warn');
|
||||||
|
this._armLoginConfirmFallback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.loggedIn = false;
|
||||||
// 稍等片刻再发,避免与服务器的欢迎信息 / 传送抢时序
|
// 稍等片刻再发,避免与服务器的欢迎信息 / 传送抢时序
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!this.bot || this.state !== 'online') return;
|
if (!this.bot || this.state !== 'online') return;
|
||||||
this.sendCommand(fillTemplate(server.loginCommand, password));
|
this.sendCommand(fillTemplate(server.loginCommand, password), { internal: true });
|
||||||
|
this._armLoginConfirmFallback();
|
||||||
}, 1500).unref?.();
|
}, 1500).unref?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录确认兜底:部分离线服登录成功后只发一句普通提示,甚至什么都不发。
|
||||||
|
* 若在超时内既没收到成功提示也没收到失败提示,就按「已登录」放行,
|
||||||
|
* 否则机器人会永远卡在「未登录」而无法接受控制台操作。
|
||||||
|
*/
|
||||||
|
_armLoginConfirmFallback() {
|
||||||
|
const timeout = this.config.server.loginConfirmTimeoutMs;
|
||||||
|
if (!Number.isFinite(timeout) || timeout <= 0) return;
|
||||||
|
this._clearLoginConfirmTimer();
|
||||||
|
this._loginConfirmTimer = setTimeout(() => {
|
||||||
|
this._loginConfirmTimer = null;
|
||||||
|
if (this.loggedIn || this.loginFailed) return;
|
||||||
|
this.loggedIn = true;
|
||||||
|
this._log(`未在 ${timeout} 毫秒内收到登录结果提示,按已登录处理(可调小 server.loginConfirmTimeoutMs 观察)`, 'warn');
|
||||||
|
}, timeout);
|
||||||
|
if (this._loginConfirmTimer.unref) this._loginConfirmTimer.unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearLoginConfirmTimer() {
|
||||||
|
if (this._loginConfirmTimer) {
|
||||||
|
clearTimeout(this._loginConfirmTimer);
|
||||||
|
this._loginConfirmTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 断开前把未汇总的传送计数刷出去并清掉定时器 */
|
||||||
|
_clearForcedMoveTimer() {
|
||||||
|
if (this._forcedMoveTimer) {
|
||||||
|
clearTimeout(this._forcedMoveTimer);
|
||||||
|
this._forcedMoveTimer = null;
|
||||||
|
const extra = this._forcedMoveCount - 1;
|
||||||
|
this._forcedMoveCount = 0;
|
||||||
|
if (extra > 0) this._log(`被服务器传送另有 ${extra} 次(连接断开前)`, 'warn');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 从聊天消息里识别服务器的登录 / 注册提示 */
|
/** 从聊天消息里识别服务器的登录 / 注册提示 */
|
||||||
_handleAuthPrompt(message) {
|
_handleAuthPrompt(message) {
|
||||||
const server = this.config.server;
|
const server = this.config.server;
|
||||||
const password = this.connection.password;
|
const password = this.connection.password;
|
||||||
if (!password) return;
|
if (LOGIN_FAIL_PATTERN.test(message)) {
|
||||||
|
this.loginFailed = true;
|
||||||
|
this.loggedIn = false;
|
||||||
|
this._clearLoginConfirmTimer();
|
||||||
|
this._log('登录 / 注册被服务器拒绝:' + message, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 外站账号绑定 / 二次验证(如简幻通):密码无法自动完成,只能明确告知用户
|
||||||
|
if (NEED_AUTH_PATTERN.test(message)) {
|
||||||
|
if (!this._needAuthWarned) {
|
||||||
|
this._needAuthWarned = true;
|
||||||
|
this._log('服务器要求先完成账号绑定 / 二次验证(本服为「简幻通」体系):' + message, 'error');
|
||||||
|
this._log('该验证需要人工完成一次(游戏内扫码或 /auth <UID> <验证码>);'
|
||||||
|
+ '绑定成功后同一账号即可自动登录。若服务器提供固定验证码,可在配置的「账号绑定指令模板」里填写。', 'warn');
|
||||||
|
}
|
||||||
|
if (server.authCommand) {
|
||||||
|
this.sendCommand(fillTemplate(server.authCommand, password), { internal: true, immediate: true });
|
||||||
|
this._armLoginConfirmFallback();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!password) {
|
||||||
|
// 服务器要求登录 / 注册,但我们没有密码:明确报错,而不是静默卡死
|
||||||
|
if (!this._noPasswordWarned && (LOGIN_PROMPT_PATTERN.test(message) || REGISTER_PROMPT_PATTERN.test(message))) {
|
||||||
|
this._noPasswordWarned = true;
|
||||||
|
this._log('服务器要求登录 / 注册,但 .env 中没有该账号的密码,无法自动完成;'
|
||||||
|
+ '请在 MC_BOTS(或 MC_PASSWORD)里补上密码后重启', 'error');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (LOGIN_OK_PATTERN.test(message)) {
|
if (LOGIN_OK_PATTERN.test(message)) {
|
||||||
if (!this.loggedIn) this._log('登录成功');
|
if (!this.loggedIn) this._log('登录成功', 'success');
|
||||||
this.loggedIn = true;
|
this.loggedIn = true;
|
||||||
|
this.loginFailed = false;
|
||||||
|
this._clearLoginConfirmTimer();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (ALREADY_LOGGED_IN_PATTERN.test(message)) {
|
if (ALREADY_LOGGED_IN_PATTERN.test(message)) {
|
||||||
this.loggedIn = true;
|
this.loggedIn = true;
|
||||||
|
this._clearLoginConfirmTimer();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (server.registerCommand && REGISTER_PROMPT_PATTERN.test(message)) {
|
if (server.registerCommand && REGISTER_PROMPT_PATTERN.test(message)) {
|
||||||
this._log('服务器要求注册,正在注册');
|
this._log('服务器要求注册,正在注册');
|
||||||
this.sendCommand(fillTemplate(server.registerCommand, password));
|
this.sendCommand(fillTemplate(server.registerCommand, password), { internal: true });
|
||||||
|
this._armLoginConfirmFallback();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (server.loginCommand && LOGIN_PROMPT_PATTERN.test(message)) {
|
if (server.loginCommand && LOGIN_PROMPT_PATTERN.test(message)) {
|
||||||
this.sendCommand(fillTemplate(server.loginCommand, password));
|
this.sendCommand(fillTemplate(server.loginCommand, password), { internal: true });
|
||||||
|
this._armLoginConfirmFallback();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 指令发送 ====================
|
// ==================== 指令发送 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否已通过登录验证、允许操作该机器人的控制台 / 聊天框。
|
||||||
|
* 这是「登录验证后才允许操作」的统一判定入口。
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
canOperate() {
|
||||||
|
return this.state === 'online' && !!this.bot && this.loggedIn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断一条指令是否允许在「登录验证通过之前」发送。
|
||||||
|
*
|
||||||
|
* 登录 / 注册 / 账号绑定这类指令本身就是验证流程的一部分,必须放行,
|
||||||
|
* 否则会出现「要先登录才能发登录指令」的死锁。
|
||||||
|
*
|
||||||
|
* @param {string} command 不带斜杠的指令
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
isPreLoginCommand(command) {
|
||||||
|
const prefixes = (this.config.server && this.config.server.preLoginCommands) || [];
|
||||||
|
if (!Array.isArray(prefixes) || prefixes.length === 0) return false;
|
||||||
|
const head = String(command || '').replace(/^\//, '').trim().toLowerCase().split(/\s+/)[0];
|
||||||
|
if (!head) return false;
|
||||||
|
return prefixes.some((prefix) => head === prefix || head.startsWith(prefix + ':'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否允许发送该指令。
|
||||||
|
*
|
||||||
|
* 两个条件:
|
||||||
|
* 1. 必须已连接并进入世界——否则 `bot.chat()` 无处可发,只会被静默丢弃,
|
||||||
|
* 而调用方却会收到「已发送」,这是最误导人的失败方式;
|
||||||
|
* 2. 已完成登录验证,或它是验证流程自身的指令(见 {@link isPreLoginCommand})。
|
||||||
|
*
|
||||||
|
* @param {string} command
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
canSendCommand(command) {
|
||||||
|
if (this.state !== 'online' || !this.bot) return false;
|
||||||
|
return this.canOperate() || this.isPreLoginCommand(command);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送一条聊天指令(带最小间隔节流,防刷屏被服务器或 Grim 判定异常)。
|
* 发送一条聊天指令(带最小间隔节流,防刷屏被服务器或 Grim 判定异常)。
|
||||||
*
|
*
|
||||||
|
* 未完成登录验证时拒绝发送:离线服的 /login 是硬门槛,
|
||||||
|
* 未登录就发 /u claimmode 只会被服务器静默丢弃。
|
||||||
|
* 例外是 `server.preLoginCommands` 里列出的验证类指令(login / register / auth 等)。
|
||||||
|
*
|
||||||
* @param {string} command 不带斜杠的指令,例如 `u claimmode`
|
* @param {string} command 不带斜杠的指令,例如 `u claimmode`
|
||||||
* @param {{immediate?: boolean}} [options]
|
* @param {{immediate?: boolean, internal?: boolean}} [options] internal 为登录 / 注册流程自身使用,绕过门禁
|
||||||
|
* @returns {boolean} 是否已入队
|
||||||
*/
|
*/
|
||||||
sendCommand(command, options) {
|
sendCommand(command, options) {
|
||||||
if (!command) return;
|
if (!command) return false;
|
||||||
const opts = options || {};
|
const opts = options || {};
|
||||||
|
if (!opts.internal && !this.canSendCommand(command)) {
|
||||||
|
this._log('未连接或未完成登录验证,已拒绝发送指令:/' + command, 'warn');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (opts.immediate) {
|
if (opts.immediate) {
|
||||||
this._writeCommand(command);
|
this._writeCommand(command);
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
this._commandQueue.push(command);
|
this._commandQueue.push(command);
|
||||||
this._drainCommandQueue();
|
this._drainCommandQueue();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否允许发送公屏聊天。
|
||||||
|
*
|
||||||
|
* 除「已完成登录验证」外,还有两种情况要放行,否则会把用户彻底堵死:
|
||||||
|
* - 服务器已经明确要求验证(`_needAuthWarned`):很多服的验证码就是**直接当聊天内容发出去**的,
|
||||||
|
* 不是指令,若拦下聊天就永远无法完成验证;
|
||||||
|
* - 配置里显式打开 `server.allowChatBeforeLogin`:给那些没有任何提示、但必须先说话的服务器兜底。
|
||||||
|
*
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
canSendChat() {
|
||||||
|
if (this.state !== 'online' || !this.bot) return false;
|
||||||
|
if (this.canOperate()) return true;
|
||||||
|
if (this._needAuthWarned) return true;
|
||||||
|
return !!(this.config.server && this.config.server.allowChatBeforeLogin === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送一条公屏聊天消息(不是指令,不带斜杠)。
|
||||||
|
*
|
||||||
|
* 发送内容会写入该机器人的公屏日志(kind=out),便于事后核对「谁在什么时候发了什么」。
|
||||||
|
*
|
||||||
|
* @param {string} message 聊天正文
|
||||||
|
* @param {{internal?: boolean}} [options]
|
||||||
|
* @returns {boolean} 是否已入队
|
||||||
|
*/
|
||||||
|
sendChat(message, options) {
|
||||||
|
const text = message === undefined || message === null ? '' : String(message).trim();
|
||||||
|
if (!text) return false;
|
||||||
|
const opts = options || {};
|
||||||
|
if (!opts.internal && !this.canSendChat()) {
|
||||||
|
this._log('未连接或未完成登录验证,已拒绝发送聊天:' + text, 'warn');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 以 `/` 开头的内容交给 sendCommand,避免被当成普通聊天发出去
|
||||||
|
if (text.startsWith('/')) return this.sendCommand(text.replace(/^\//, ''), opts);
|
||||||
|
this._chatQueue.push(text);
|
||||||
|
this._drainCommandQueue();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
_drainCommandQueue() {
|
_drainCommandQueue() {
|
||||||
@@ -311,10 +701,14 @@ class BotManager extends EventEmitter {
|
|||||||
const wait = Math.max(0, this.config.server.commandCooldownMs - (now - this._lastCommandAt));
|
const wait = Math.max(0, this.config.server.commandCooldownMs - (now - this._lastCommandAt));
|
||||||
this._commandTimer = setTimeout(() => {
|
this._commandTimer = setTimeout(() => {
|
||||||
this._commandTimer = null;
|
this._commandTimer = null;
|
||||||
const next = this._commandQueue.shift();
|
const nextCommand = this._commandQueue.shift();
|
||||||
if (next === undefined) return;
|
if (nextCommand !== undefined) {
|
||||||
this._writeCommand(next);
|
this._writeCommand(nextCommand);
|
||||||
if (this._commandQueue.length > 0) this._drainCommandQueue();
|
} else {
|
||||||
|
const nextChat = this._chatQueue.shift();
|
||||||
|
if (nextChat !== undefined) this._writeChat(nextChat);
|
||||||
|
}
|
||||||
|
if (this._commandQueue.length > 0 || this._chatQueue.length > 0) this._drainCommandQueue();
|
||||||
}, wait);
|
}, wait);
|
||||||
if (this._commandTimer.unref) this._commandTimer.unref();
|
if (this._commandTimer.unref) this._commandTimer.unref();
|
||||||
}
|
}
|
||||||
@@ -324,6 +718,8 @@ class BotManager extends EventEmitter {
|
|||||||
this._lastCommandAt = Date.now();
|
this._lastCommandAt = Date.now();
|
||||||
const text = command.startsWith('/') ? command : '/' + command;
|
const text = command.startsWith('/') ? command : '/' + command;
|
||||||
this._log('发送指令:' + text);
|
this._log('发送指令:' + text);
|
||||||
|
// 指令也留档:出问题时能对照「指令发出后服务器回了什么」
|
||||||
|
this._recordOutgoing(text, ChatKind.COMMAND);
|
||||||
try {
|
try {
|
||||||
this.bot.chat(text);
|
this.bot.chat(text);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -331,13 +727,73 @@ class BotManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 发送普通聊天内容(不带斜杠) */
|
||||||
|
_writeChat(message) {
|
||||||
|
if (!this.bot) return;
|
||||||
|
this._lastCommandAt = Date.now();
|
||||||
|
this._log('发送聊天:' + message);
|
||||||
|
this._recordOutgoing(message, ChatKind.OUT);
|
||||||
|
try {
|
||||||
|
this.bot.chat(message);
|
||||||
|
} catch (err) {
|
||||||
|
this._log('发送聊天失败:' + (err && err.message ? err.message : String(err)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动移动测试:按住某个方向键若干秒,然后松开。
|
||||||
|
*
|
||||||
|
* 用于区分「寻路器算不出路径」与「客户端根本走不动」这两种完全不同的故障 ——
|
||||||
|
* 前者是规划问题,后者是协议 / 反作弊交互问题,处理方式完全不同。
|
||||||
|
* 走控制键而不是寻路器,可以绕开路径规划直接验证移动链路。
|
||||||
|
*
|
||||||
|
* @param {'forward'|'back'|'left'|'right'|'jump'|'sneak'} direction
|
||||||
|
* @param {number} [seconds] 按住时长(秒),默认 2,上限 10
|
||||||
|
* @returns {{ok: boolean, message: string, from: object|null, to: object|null}}
|
||||||
|
*/
|
||||||
|
async walkTest(direction, seconds) {
|
||||||
|
const allowed = ['forward', 'back', 'left', 'right', 'jump', 'sneak'];
|
||||||
|
if (!allowed.includes(direction)) {
|
||||||
|
return { ok: false, message: `方向只能是 ${allowed.join(' / ')}`, from: null, to: null };
|
||||||
|
}
|
||||||
|
if (this.state !== 'online' || !this.bot || !this.bot.entity) {
|
||||||
|
return { ok: false, message: `当前未连接(状态:${this.state})`, from: null, to: null };
|
||||||
|
}
|
||||||
|
const duration = Math.min(10, Math.max(0.5, Number(seconds) || 2)) * 1000;
|
||||||
|
const from = { x: this.bot.entity.position.x, y: this.bot.entity.position.y, z: this.bot.entity.position.z };
|
||||||
|
this._log(`手动移动测试:按住 ${direction} ${duration / 1000} 秒`);
|
||||||
|
this.setControlState(direction, true);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, duration));
|
||||||
|
this.setControlState(direction, false);
|
||||||
|
const to = { x: this.bot.entity.position.x, y: this.bot.entity.position.y, z: this.bot.entity.position.z };
|
||||||
|
const moved = Math.hypot(to.x - from.x, to.z - from.z);
|
||||||
|
const message = `位移 ${moved.toFixed(2)} 格`;
|
||||||
|
this._log(`手动移动测试结束:${message}`
|
||||||
|
+ (moved < 0.5 ? '(几乎没有移动,移动链路可能被服务端拒绝)' : ''), moved < 0.5 ? 'warn' : 'info');
|
||||||
|
return { ok: moved >= 0.5, message, from, to };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置控制键:优先走 Grim 合规层(它负责动作互斥与骑乘限制)。
|
||||||
|
* @param {string} control
|
||||||
|
* @param {boolean} on
|
||||||
|
*/
|
||||||
|
setControlState(control, on) {
|
||||||
|
if (this.grim) this.grim.setControlState(control, on);
|
||||||
|
else if (this.bot) this.bot.setControlState(control, on);
|
||||||
|
}
|
||||||
|
|
||||||
/** @returns {object} 连接状态快照(供 Web 控制台展示) */
|
/** @returns {object} 连接状态快照(供 Web 控制台展示) */
|
||||||
snapshot() {
|
snapshot() {
|
||||||
const bot = this.bot;
|
const bot = this.bot;
|
||||||
const entity = bot ? bot.entity : null;
|
const entity = bot ? bot.entity : null;
|
||||||
return {
|
return {
|
||||||
|
id: this.id,
|
||||||
|
label: this.label,
|
||||||
state: this.state,
|
state: this.state,
|
||||||
loggedIn: this.loggedIn,
|
loggedIn: this.loggedIn,
|
||||||
|
loginFailed: this.loginFailed,
|
||||||
|
canOperate: this.canOperate(),
|
||||||
username: this.connection.username,
|
username: this.connection.username,
|
||||||
host: `${this.connection.host}:${this.connection.port}`,
|
host: `${this.connection.host}:${this.connection.port}`,
|
||||||
version: this.connection.version,
|
version: this.connection.version,
|
||||||
@@ -355,20 +811,202 @@ class BotManager extends EventEmitter {
|
|||||||
} : null,
|
} : null,
|
||||||
inWater: entity ? entity.isInWater === true : false,
|
inWater: entity ? entity.isInWater === true : false,
|
||||||
riding: !!(bot && bot.vehicle),
|
riding: !!(bot && bot.vehicle),
|
||||||
|
onGround: entity ? entity.onGround === true : false,
|
||||||
|
/** 游戏模式:adventure / spectator 下移动会被服务端限制,排查「走不动」必看 */
|
||||||
|
gameMode: bot && bot.game ? bot.game.gameMode : null,
|
||||||
|
dimension: bot && bot.game ? bot.game.dimension : null,
|
||||||
|
isAlive: bot ? bot.isAlive === true : false,
|
||||||
|
packetStats: Object.assign({}, this.packetStats),
|
||||||
|
world: this._worldDiagnostics(),
|
||||||
pathing: this.pathing ? this.pathing.isPathing() : false,
|
pathing: this.pathing ? this.pathing.isPathing() : false,
|
||||||
eating: this.eater ? this.eater.eating : false,
|
eating: this.eater ? this.eater.eating : false,
|
||||||
grim: this.grim ? this.grim.snapshot() : null
|
grim: this.grim ? this.grim.snapshot() : null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 世界数据诊断。
|
||||||
|
*
|
||||||
|
* 「机器人走不动」有两种截然不同的原因,必须能区分开:
|
||||||
|
* - 客户端没有区块数据 → 本地碰撞检测失效 → 永远报告 onGround=false → 被反作弊回弹;
|
||||||
|
* - 客户端数据正常 → 问题在协议 / 反作弊交互层。
|
||||||
|
*
|
||||||
|
* @returns {object|null}
|
||||||
|
*/
|
||||||
|
_worldDiagnostics() {
|
||||||
|
const bot = this.bot;
|
||||||
|
if (!bot || !bot.entity || !bot.world) return null;
|
||||||
|
const columns = this._loadedColumns();
|
||||||
|
const below = bot.blockAt ? bot.blockAt(bot.entity.position.offset(0, -1, 0)) : null;
|
||||||
|
const at = bot.blockAt ? bot.blockAt(bot.entity.position) : null;
|
||||||
|
return {
|
||||||
|
loadedColumns: columns.length,
|
||||||
|
/** 已加载区块的包围盒:寻路器只能规划到已加载的范围内,这是「目标不可达」的常见原因 */
|
||||||
|
loadedBounds: boundsOfColumns(columns),
|
||||||
|
blockBelow: below ? below.name : null,
|
||||||
|
blockBelowSolid: below ? below.boundingBox !== 'empty' : null,
|
||||||
|
blockAtFeet: at ? at.name : null,
|
||||||
|
// 六个方向是否为实心:全实心说明机器人被关在封闭空间里,
|
||||||
|
// 而挖方块 / 放方块默认都是关的(Grim 安全),寻路器自然找不到任何路径
|
||||||
|
surroundings: this._surroundings(),
|
||||||
|
time: bot.time ? bot.time.timeOfDay : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取出当前已加载的全部区块列。
|
||||||
|
*
|
||||||
|
* prismarine-world 的列容器在不同版本里叫 `columns` / `columnStates`,
|
||||||
|
* 也可能是 Map 或普通对象,这里统一处理,避免诊断本身报错。
|
||||||
|
*
|
||||||
|
* @returns {Array<object>}
|
||||||
|
*/
|
||||||
|
_loadedColumns() {
|
||||||
|
const world = this.bot.world;
|
||||||
|
if (!world) return [];
|
||||||
|
try {
|
||||||
|
if (typeof world.getColumns === 'function') {
|
||||||
|
const result = world.getColumns();
|
||||||
|
if (result) return Array.from(result);
|
||||||
|
}
|
||||||
|
const container = world.columns || world.columnStates;
|
||||||
|
if (!container) return [];
|
||||||
|
if (container instanceof Map) return Array.from(container.values());
|
||||||
|
return Object.values(container);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 机器人六个方向的实心情况。
|
||||||
|
* @returns {object|null}
|
||||||
|
*/
|
||||||
|
_surroundings() {
|
||||||
|
const bot = this.bot;
|
||||||
|
if (!bot || !bot.entity || !bot.blockAt) return null;
|
||||||
|
const pos = bot.entity.position;
|
||||||
|
const solid = (dx, dy, dz) => {
|
||||||
|
const block = bot.blockAt(pos.offset(dx, dy, dz));
|
||||||
|
if (!block) return null;
|
||||||
|
return block.boundingBox !== 'empty';
|
||||||
|
};
|
||||||
|
const nameOf = (dx, dy, dz) => {
|
||||||
|
const block = bot.blockAt(pos.offset(dx, dy, dz));
|
||||||
|
return block ? block.name : null;
|
||||||
|
};
|
||||||
|
const dirs = [
|
||||||
|
['north', 0, -1],
|
||||||
|
['south', 0, 1],
|
||||||
|
['east', 1, 0],
|
||||||
|
['west', -1, 0]
|
||||||
|
];
|
||||||
|
/**
|
||||||
|
* 每个方向同时看脚部、头部、以及该方向脚下的地面:
|
||||||
|
* 只探测脚部无法区分「能走过去」与「一格高的槽里出不去」,
|
||||||
|
* 而后者会让寻路器在起点附近就穷尽搜索(表现为 noPath + 节点数极少)。
|
||||||
|
*/
|
||||||
|
const exits = {};
|
||||||
|
for (const [name, dx, dz] of dirs) {
|
||||||
|
exits[name] = {
|
||||||
|
feet: nameOf(dx, 0, dz),
|
||||||
|
head: nameOf(dx, 1, dz),
|
||||||
|
floor: nameOf(dx, -1, dz),
|
||||||
|
walkable: solid(dx, 0, dz) === false && solid(dx, 1, dz) === false && solid(dx, -1, dz) === true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
north: solid(0, 0, -1),
|
||||||
|
south: solid(0, 0, 1),
|
||||||
|
east: solid(1, 0, 0),
|
||||||
|
west: solid(-1, 0, 0),
|
||||||
|
above: solid(0, 1, 0),
|
||||||
|
below: solid(0, -0.05, 0),
|
||||||
|
exits
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
_pushMessage(message) {
|
_pushMessage(message) {
|
||||||
this.lastMessages.push({ time: Date.now(), message });
|
this.lastMessages.push({ time: Date.now(), message });
|
||||||
if (this.lastMessages.length > 200) this.lastMessages.shift();
|
if (this.lastMessages.length > 200) this.lastMessages.shift();
|
||||||
}
|
}
|
||||||
|
|
||||||
_log(message, level) {
|
/**
|
||||||
if (this.logger) this.logger.log(message, level);
|
* 把收到的消息写入游戏公屏完整日志。
|
||||||
|
*
|
||||||
|
* @param {string} message 消息正文
|
||||||
|
* @param {string} [position] mineflayer 的消息通道:chat / system / game_info
|
||||||
|
* @param {string} [sender] 发言人(正版签名聊天才有)
|
||||||
|
*/
|
||||||
|
_recordChat(message, position, sender) {
|
||||||
|
if (!this.chatLog) return;
|
||||||
|
this.chatLog.append({
|
||||||
|
botId: this.id,
|
||||||
|
bot: this.connection.username,
|
||||||
|
message,
|
||||||
|
kind: kindOfPosition(position),
|
||||||
|
username: typeof sender === 'string' ? sender : (sender && sender.name ? sender.name : '')
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 记录本机器人主动发出的内容 */
|
||||||
|
_recordOutgoing(message, kind) {
|
||||||
|
if (!this.chatLog) return;
|
||||||
|
this.chatLog.append({
|
||||||
|
botId: this.id,
|
||||||
|
bot: this.connection.username,
|
||||||
|
message,
|
||||||
|
kind
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_log(message, level) {
|
||||||
|
if (!this.logger) return;
|
||||||
|
// 多机器人共用一份运行日志:统一加机器人名前缀,便于区分来源
|
||||||
|
this.logger.log(`[${this.connection.username}] ${message}`, level);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算一组区块列的外接矩形(区块坐标)。
|
||||||
|
* @param {Array<object>} columns
|
||||||
|
* @returns {{minX: number, maxX: number, minZ: number, maxZ: number, radius: number}|null}
|
||||||
|
*/
|
||||||
|
function boundsOfColumns(columns) {
|
||||||
|
if (!columns || columns.length === 0) return null;
|
||||||
|
let minX = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let minZ = Infinity;
|
||||||
|
let maxZ = -Infinity;
|
||||||
|
for (const column of columns) {
|
||||||
|
const x = column.chunkX !== undefined ? column.chunkX : column.x;
|
||||||
|
const z = column.chunkZ !== undefined ? column.chunkZ : column.z;
|
||||||
|
if (!Number.isFinite(x) || !Number.isFinite(z)) continue;
|
||||||
|
if (x < minX) minX = x;
|
||||||
|
if (x > maxX) maxX = x;
|
||||||
|
if (z < minZ) minZ = z;
|
||||||
|
if (z > maxZ) maxZ = z;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(minX)) return null;
|
||||||
|
return {
|
||||||
|
minX,
|
||||||
|
maxX,
|
||||||
|
minZ,
|
||||||
|
maxZ,
|
||||||
|
/** 以当前所在区块为基准的近似可视半径(区块数) */
|
||||||
|
radius: Math.max(Math.abs(maxX - minX), Math.abs(maxZ - minZ)) / 2
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* mineflayer 的消息通道 → 公屏日志类型。
|
||||||
|
* @param {string} [position]
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function kindOfPosition(position) {
|
||||||
|
if (position === 'system') return ChatKind.SYSTEM;
|
||||||
|
if (position === 'game_info') return ChatKind.ACTION;
|
||||||
|
return ChatKind.CHAT;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -381,4 +1019,21 @@ function fillTemplate(template, password) {
|
|||||||
return String(template).replace(/\{password\}/g, password);
|
return String(template).replace(/\{password\}/g, password);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { BotManager, fillTemplate };
|
/**
|
||||||
|
* 计算第 n 次重连的等待时长:按指数退避逐步拉长,并钳制在上限内。
|
||||||
|
*
|
||||||
|
* 连续重连失败(例如账号未完成服务器验证被反复踢出)时,固定 10 秒一次地硬撞服务器
|
||||||
|
* 既无意义又可能触发风控;成功进入世界后 `reconnectAttempts` 会清零,间隔回到基准值。
|
||||||
|
*
|
||||||
|
* @param {{reconnectDelayMs: number, reconnectMaxDelayMs?: number}} serverConfig
|
||||||
|
* @param {number} attempts 本次是第几次重连(从 1 开始)
|
||||||
|
* @returns {number} 等待毫秒数
|
||||||
|
*/
|
||||||
|
function computeReconnectDelay(serverConfig, attempts) {
|
||||||
|
const base = Math.max(1, Number(serverConfig.reconnectDelayMs) || 10000);
|
||||||
|
const max = Math.max(base, Number(serverConfig.reconnectMaxDelayMs) || base);
|
||||||
|
const step = Math.max(1, Math.trunc(attempts) || 1);
|
||||||
|
return Math.min(max, base * Math.pow(2, Math.min(6, step - 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { BotManager, fillTemplate, kindOfPosition, computeReconnectDelay, FORCED_MOVE_WINDOW_MS };
|
||||||
|
|||||||
+599
@@ -0,0 +1,599 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { EventEmitter } = require('events');
|
||||||
|
|
||||||
|
const { BotManager } = require('./botManager');
|
||||||
|
const { ScheduledMessenger } = require('./scheduledMessenger');
|
||||||
|
const { ExpansionController } = require('../controller/expansionController');
|
||||||
|
const { normalizeConfig } = require('../config/expansionConfig');
|
||||||
|
const { sanitizeId } = require('../config/botsConfig');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 机器人池:同时挂多个玩家(机器人)。
|
||||||
|
*
|
||||||
|
* 每个机器人由一对 `BotManager + ExpansionController` 组成,各自独立连接、独立登录、
|
||||||
|
* 独立跑扩地状态机;但**共享**同一份领土数据服务(markers.json 是全局的,
|
||||||
|
* 没必要每个机器人各拉一遍)与同一份运行日志。
|
||||||
|
*
|
||||||
|
* 账号来源有两个,同名时以 `.env` 为准(环境变量优先级更高,避免「改了 .env 不生效」):
|
||||||
|
* 1. `.env` 的 `MC_BOTS` / `MC_USERNAME`(`source: 'env'`);
|
||||||
|
* 2. 控制台「添加玩家」写入的 `data/bots.credentials.json`(`source: 'store'`)。
|
||||||
|
*
|
||||||
|
* 每个机器人可以有自己的一套扩地配置覆盖(存在 `data/bots.json`),
|
||||||
|
* 未覆盖的字段回退到全局配置。
|
||||||
|
*/
|
||||||
|
class BotPool extends EventEmitter {
|
||||||
|
/**
|
||||||
|
* @param {object} deps
|
||||||
|
* @param {Array<object>} deps.connections 连接参数列表(来自 .env,见 botsConfig)
|
||||||
|
* @param {object} deps.config 全局配置
|
||||||
|
* @param {import('../util/logger').Logger} [deps.logger]
|
||||||
|
* @param {import('../core/territoryDataService').TerritoryDataService} deps.dataService
|
||||||
|
* @param {import('../core/expansionLog').ExpansionLog} [deps.expansionLog]
|
||||||
|
* @param {import('../core/chatLog').ChatLog} [deps.chatLog]
|
||||||
|
* @param {{load: Function, save: Function}} [deps.botStore] 机器人设置持久化
|
||||||
|
* @param {{load: Function, save: Function}} [deps.credentialStore] 控制台新增账号的凭据持久化
|
||||||
|
*/
|
||||||
|
constructor(deps) {
|
||||||
|
super();
|
||||||
|
this.config = deps.config;
|
||||||
|
this.logger = deps.logger || null;
|
||||||
|
this.dataService = deps.dataService;
|
||||||
|
this.expansionLog = deps.expansionLog || null;
|
||||||
|
this.chatLog = deps.chatLog || null;
|
||||||
|
this.botStore = deps.botStore || null;
|
||||||
|
this.credentialStore = deps.credentialStore || null;
|
||||||
|
|
||||||
|
/** @type {Map<string, object>} 机器人 id → 运行时条目 */
|
||||||
|
this.entries = new Map();
|
||||||
|
/** 已执行过「启动后自动开始扩地」的机器人(避免重连后把手动停止的任务又拉起来) */
|
||||||
|
this._autoStarted = new Set();
|
||||||
|
/** 机器人设置(非敏感部分) */
|
||||||
|
this.settings = this.botStore ? this.botStore.load().bots : {};
|
||||||
|
/** 控制台新增账号的凭据(含密码,落盘权限 0600) */
|
||||||
|
this.credentials = this.credentialStore ? this.credentialStore.load().bots : {};
|
||||||
|
/** 启动时间戳 */
|
||||||
|
this.startedAt = 0;
|
||||||
|
|
||||||
|
for (const connection of this._mergeConnections(deps.connections || [])) {
|
||||||
|
this._addBot(connection);
|
||||||
|
}
|
||||||
|
this.defaultId = this.entries.size > 0 ? this.entries.keys().next().value : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并两个账号来源:`.env` 优先,控制台新增的账号补齐。
|
||||||
|
* @param {Array<object>} envConnections
|
||||||
|
* @returns {Array<object>}
|
||||||
|
*/
|
||||||
|
_mergeConnections(envConnections) {
|
||||||
|
const merged = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (const connection of envConnections) {
|
||||||
|
const id = connection.id || sanitizeId(connection.username);
|
||||||
|
if (!id || seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
merged.push(Object.assign({}, connection, { id, source: 'env' }));
|
||||||
|
}
|
||||||
|
for (const [id, credential] of Object.entries(this.credentials)) {
|
||||||
|
if (seen.has(id) || !credential || !credential.username) continue;
|
||||||
|
seen.add(id);
|
||||||
|
merged.push(Object.assign({
|
||||||
|
auth: 'offline',
|
||||||
|
host: process.env.MC_HOST || 'bgjq.simpfun.cn',
|
||||||
|
port: Number.parseInt(process.env.MC_PORT || '25565', 10),
|
||||||
|
version: process.env.MC_VERSION || '1.21.4'
|
||||||
|
}, credential, { id, source: 'store' }));
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 组装 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建一个机器人条目:管理器 + 控制器,并接好事件。
|
||||||
|
* @param {object} connection
|
||||||
|
* @returns {object|null} 已存在同名机器人时返回 null
|
||||||
|
*/
|
||||||
|
_addBot(connection) {
|
||||||
|
const id = connection.id || sanitizeId(connection.username);
|
||||||
|
if (!id || this.entries.has(id)) return null;
|
||||||
|
const setting = this.settings[id] || {};
|
||||||
|
const manager = new BotManager(
|
||||||
|
Object.assign({}, connection, { label: setting.label || connection.label }),
|
||||||
|
this.getConfigFor(id),
|
||||||
|
this.logger,
|
||||||
|
{ chatLog: this.chatLog }
|
||||||
|
);
|
||||||
|
const controller = new ExpansionController({
|
||||||
|
botManager: manager,
|
||||||
|
dataService: this.dataService,
|
||||||
|
logger: this.logger,
|
||||||
|
botId: id,
|
||||||
|
botName: connection.username,
|
||||||
|
expansionLog: this.expansionLog
|
||||||
|
});
|
||||||
|
// 寻路事件转发给控制器(与 Baritone 的 PathEvent 语义一致)
|
||||||
|
manager.on('modulesReady', () => {
|
||||||
|
if (manager.pathing) {
|
||||||
|
manager.pathing.onPathEvent = (event, detail) => controller.onPathEvent(event, detail);
|
||||||
|
}
|
||||||
|
// 进入世界并装配完成后按设置决定是否自动开始扩地。
|
||||||
|
// 只在本次进程内首次进入世界时触发:断线重连后不自动拉起,
|
||||||
|
// 否则用户在控制台手动停止的任务会被重连事件重新启动。
|
||||||
|
if (this._settingOf(id).autoStart && !this._autoStarted.has(id)) {
|
||||||
|
this._autoStarted.add(id);
|
||||||
|
this.startTask(id).catch((err) => {
|
||||||
|
if (this.logger) this.logger.log(`[${connection.username}] 自动开始扩地失败:${err.message}`, 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
manager.on('spawn', () => this.emit('botSpawn', id));
|
||||||
|
manager.on('end', () => this.emit('botEnd', id));
|
||||||
|
|
||||||
|
// 定时消息器:按固定间隔重复发送同一条内容(如每 300 秒 /u restore confirm)
|
||||||
|
const messenger = new ScheduledMessenger(manager, this.logger);
|
||||||
|
messenger.setTasks(setting.schedules || []);
|
||||||
|
|
||||||
|
this.entries.set(id, {
|
||||||
|
id,
|
||||||
|
connection,
|
||||||
|
manager,
|
||||||
|
controller,
|
||||||
|
messenger,
|
||||||
|
/** 账号来源:env(.env 配置)/ store(控制台新增) */
|
||||||
|
source: connection.source || 'env',
|
||||||
|
/** 该机器人实际生效的配置(全局 + 覆盖) */
|
||||||
|
config: manager.config
|
||||||
|
});
|
||||||
|
return this.entries.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 控制台新增一个玩家(机器人):落盘凭据 → 建立条目 → 立即连接。
|
||||||
|
*
|
||||||
|
* 密码只写进 `data/bots.credentials.json`(权限 0600,`data/` 已被 .gitignore 忽略),
|
||||||
|
* 不会进入代码仓库,也不会出现在任何接口响应里。
|
||||||
|
*
|
||||||
|
* @param {{username: string, password?: string, label?: string, auth?: string, host?: string, port?: number, version?: string}} input
|
||||||
|
* @param {{connect?: boolean}} [options] connect=false 时只登记不连接(测试或先配好再手动连接)
|
||||||
|
* @returns {object} 新建的机器人条目
|
||||||
|
*/
|
||||||
|
addBot(input, options) {
|
||||||
|
const opts = options || {};
|
||||||
|
const username = String((input && input.username) || '').trim();
|
||||||
|
if (!username) throw badRequest('缺少用户名');
|
||||||
|
if (!/^[A-Za-z0-9_]{1,16}$/.test(username)) {
|
||||||
|
throw badRequest('用户名只能是 1-16 位字母、数字或下划线(Minecraft 账号名规则)');
|
||||||
|
}
|
||||||
|
const id = sanitizeId(username);
|
||||||
|
if (this.entries.has(id)) throw badRequest('该玩家已在列表中:' + username);
|
||||||
|
if (!this.credentialStore) throw badRequest('当前部署未启用凭据存储,无法在控制台新增玩家');
|
||||||
|
|
||||||
|
const connection = {
|
||||||
|
id,
|
||||||
|
username,
|
||||||
|
password: input.password === undefined || input.password === null ? '' : String(input.password),
|
||||||
|
label: input.label ? String(input.label) : username,
|
||||||
|
auth: String(input.auth || process.env.MC_AUTH || 'offline'),
|
||||||
|
host: String(input.host || process.env.MC_HOST || 'bgjq.simpfun.cn'),
|
||||||
|
port: Number.isFinite(Number(input.port))
|
||||||
|
? Number(input.port)
|
||||||
|
: Number.parseInt(process.env.MC_PORT || '25565', 10),
|
||||||
|
version: String(input.version || process.env.MC_VERSION || '1.21.4'),
|
||||||
|
source: 'store'
|
||||||
|
};
|
||||||
|
this.credentials[id] = {
|
||||||
|
username: connection.username,
|
||||||
|
password: connection.password,
|
||||||
|
auth: connection.auth,
|
||||||
|
host: connection.host,
|
||||||
|
port: connection.port,
|
||||||
|
version: connection.version
|
||||||
|
};
|
||||||
|
this.credentialStore.save({ bots: this.credentials });
|
||||||
|
|
||||||
|
const entry = this._addBot(connection);
|
||||||
|
if (!this.defaultId) this.defaultId = id;
|
||||||
|
this._log(`已添加玩家 ${username}(密码保存在服务器本地凭据文件,权限 0600)`);
|
||||||
|
if (opts.connect !== false && this._settingOf(id).enabled) {
|
||||||
|
entry.messenger.start();
|
||||||
|
entry.manager.connect();
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除一个机器人。
|
||||||
|
*
|
||||||
|
* 只允许移除控制台新增的账号;`.env` 里配置的账号必须改 `.env`,
|
||||||
|
* 否则重启后它又会回来,容易让人误以为「删不掉」。
|
||||||
|
*
|
||||||
|
* @param {string} id
|
||||||
|
* @returns {object} 被移除的条目
|
||||||
|
*/
|
||||||
|
removeBot(id) {
|
||||||
|
const entry = this.require(id);
|
||||||
|
if (entry.source === 'env') {
|
||||||
|
throw badRequest(`${entry.connection.username} 是在 .env 的 MC_BOTS 里配置的,`
|
||||||
|
+ '请修改 .env 后重启;控制台只能移除在这里添加的玩家');
|
||||||
|
}
|
||||||
|
entry.messenger.stop();
|
||||||
|
entry.manager.disconnect();
|
||||||
|
this.entries.delete(id);
|
||||||
|
delete this.settings[id];
|
||||||
|
this._persistSettings();
|
||||||
|
if (this.credentials[id]) {
|
||||||
|
delete this.credentials[id];
|
||||||
|
if (this.credentialStore) this.credentialStore.save({ bots: this.credentials });
|
||||||
|
}
|
||||||
|
if (this.defaultId === id) {
|
||||||
|
this.defaultId = this.entries.size > 0 ? this.entries.keys().next().value : '';
|
||||||
|
}
|
||||||
|
this._log(`已移除玩家 ${entry.connection.username}`);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 生命周期 ====================
|
||||||
|
|
||||||
|
/** 连接所有已启用的机器人 */
|
||||||
|
start() {
|
||||||
|
this.startedAt = Date.now();
|
||||||
|
for (const entry of this.entries.values()) {
|
||||||
|
if (!this._settingOf(entry.id).enabled) {
|
||||||
|
this._log(`${entry.connection.username} 已在控制台禁用,跳过连接`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
entry.messenger.start();
|
||||||
|
entry.manager.connect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 断开所有机器人 */
|
||||||
|
stop() {
|
||||||
|
for (const entry of this.entries.values()) {
|
||||||
|
try {
|
||||||
|
entry.controller.stop();
|
||||||
|
} catch {
|
||||||
|
// 忽略
|
||||||
|
}
|
||||||
|
entry.messenger.stop();
|
||||||
|
entry.manager.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 驱动所有机器人的扩地状态机(由外部按 tick 调用) */
|
||||||
|
tick() {
|
||||||
|
for (const entry of this.entries.values()) {
|
||||||
|
try {
|
||||||
|
entry.controller.tick();
|
||||||
|
} catch (err) {
|
||||||
|
if (this.logger) {
|
||||||
|
this.logger.log(`[${entry.connection.username}] tick 异常:` + (err && err.stack ? err.stack : err), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 访问 ====================
|
||||||
|
|
||||||
|
/** @returns {Array<string>} 全部机器人 id */
|
||||||
|
ids() {
|
||||||
|
return Array.from(this.entries.keys());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {number} 机器人数量 */
|
||||||
|
get size() {
|
||||||
|
return this.entries.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} [id] 机器人 id;缺省取第一个
|
||||||
|
* @returns {object|null} 运行时条目
|
||||||
|
*/
|
||||||
|
get(id) {
|
||||||
|
if (!id) return this.entries.get(this.defaultId) || null;
|
||||||
|
return this.entries.get(id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取机器人条目,找不到时抛错(供 REST 接口返回 404 用)。
|
||||||
|
* @param {string} id
|
||||||
|
* @returns {object}
|
||||||
|
*/
|
||||||
|
require(id) {
|
||||||
|
const entry = this.get(id);
|
||||||
|
if (!entry) {
|
||||||
|
const err = new Error('机器人不存在:' + id);
|
||||||
|
err.statusCode = 404;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 配置 ====================
|
||||||
|
|
||||||
|
/** @returns {object} 机器人的非敏感设置(启用 / 备注 / 自动开始 / 定时任务 / 配置覆盖) */
|
||||||
|
_settingOf(id) {
|
||||||
|
const raw = this.settings[id] || {};
|
||||||
|
return {
|
||||||
|
enabled: raw.enabled !== false,
|
||||||
|
autoStart: raw.autoStart === true,
|
||||||
|
label: raw.label || '',
|
||||||
|
schedules: Array.isArray(raw.schedules) ? raw.schedules : [],
|
||||||
|
config: raw.config && typeof raw.config === 'object' ? raw.config : {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算某机器人实际生效的配置:全局配置 + 该机器人的覆盖。
|
||||||
|
* @param {string} id
|
||||||
|
* @returns {object}
|
||||||
|
*/
|
||||||
|
getConfigFor(id) {
|
||||||
|
const setting = this._settingOf(id);
|
||||||
|
return normalizeConfig(setting.config, this.config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新全局配置并下发给所有机器人(各自再叠加自己的覆盖)。
|
||||||
|
* @param {object} config 新的全局配置
|
||||||
|
*/
|
||||||
|
applyGlobalConfig(config) {
|
||||||
|
this.config = config;
|
||||||
|
for (const entry of this.entries.values()) {
|
||||||
|
entry.config = this.getConfigFor(entry.id);
|
||||||
|
entry.manager.applyConfig(entry.config);
|
||||||
|
entry.controller.config = entry.config;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存某机器人的设置(启用 / 备注 / 自动开始 / 配置覆盖)并立即生效。
|
||||||
|
*
|
||||||
|
* @param {string} id
|
||||||
|
* @param {object} patch 部分设置;`config` 字段为配置覆盖(整体替换)
|
||||||
|
* @returns {object} 生效后的设置
|
||||||
|
*/
|
||||||
|
setBotSetting(id, patch) {
|
||||||
|
const entry = this.require(id);
|
||||||
|
const current = this.settings[id] || {};
|
||||||
|
const next = Object.assign({}, current, patch || {});
|
||||||
|
if (patch && patch.config !== undefined) {
|
||||||
|
// 配置覆盖只接受已知字段,避免脏数据污染运行状态
|
||||||
|
next.config = normalizeConfig(patch.config, this.config);
|
||||||
|
}
|
||||||
|
this.settings[id] = next;
|
||||||
|
this._persistSettings();
|
||||||
|
|
||||||
|
entry.config = this.getConfigFor(id);
|
||||||
|
entry.manager.applyConfig(entry.config);
|
||||||
|
entry.controller.config = entry.config;
|
||||||
|
// 定时任务列表整体替换:把归一化结果写回设置,
|
||||||
|
// 保证「接口返回值 / 落盘内容 / 运行时状态」三者一致
|
||||||
|
if (patch && patch.schedules !== undefined) {
|
||||||
|
const normalized = entry.messenger.setTasks(this._settingOf(id).schedules);
|
||||||
|
this.settings[id] = Object.assign({}, this.settings[id], { schedules: normalized });
|
||||||
|
this._persistSettings();
|
||||||
|
}
|
||||||
|
return this._settingOf(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {object} 机器人的设置快照(供控制台表单回填) */
|
||||||
|
settingSnapshot(id) {
|
||||||
|
const setting = this._settingOf(id);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label: setting.label,
|
||||||
|
enabled: setting.enabled,
|
||||||
|
autoStart: setting.autoStart,
|
||||||
|
hasOverrides: Object.keys(setting.config).length > 0,
|
||||||
|
config: this.getConfigFor(id),
|
||||||
|
schedules: this.entry(id) ? this.entry(id).messenger.snapshot() : []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取运行时条目(不存在时返回 null,不抛异常)。
|
||||||
|
* @param {string} id
|
||||||
|
* @returns {object|null}
|
||||||
|
*/
|
||||||
|
entry(id) {
|
||||||
|
return this.entries.get(id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_persistSettings() {
|
||||||
|
if (!this.botStore) return;
|
||||||
|
this.botStore.save({ bots: this.settings });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 任务与控制 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动某机器人的扩地任务。
|
||||||
|
* @param {string} id
|
||||||
|
* @param {object} [overrideConfig] 本次任务的配置覆盖(来自控制台表单)
|
||||||
|
* @returns {Promise<{ok: boolean, message: string, entry: object}>}
|
||||||
|
*/
|
||||||
|
async startTask(id, overrideConfig) {
|
||||||
|
const entry = this.require(id);
|
||||||
|
const config = overrideConfig
|
||||||
|
? normalizeConfig(overrideConfig, this.getConfigFor(id))
|
||||||
|
: this.getConfigFor(id);
|
||||||
|
if (overrideConfig) {
|
||||||
|
// 控制台点「开始扩地」时顺带保存该机器人的配置
|
||||||
|
this.settings[id] = Object.assign({}, this.settings[id] || {}, { config });
|
||||||
|
this._persistSettings();
|
||||||
|
entry.config = config;
|
||||||
|
entry.manager.applyConfig(config);
|
||||||
|
}
|
||||||
|
entry.controller.config = config;
|
||||||
|
const ok = await entry.controller.start(config);
|
||||||
|
return { ok, message: ok ? '任务已启动' : entry.controller.status, entry };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 停止某机器人的扩地任务 */
|
||||||
|
stopTask(id) {
|
||||||
|
const entry = this.require(id);
|
||||||
|
entry.controller.stop();
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重连某机器人 */
|
||||||
|
reconnect(id) {
|
||||||
|
const entry = this.require(id);
|
||||||
|
entry.manager.disconnect();
|
||||||
|
setTimeout(() => {
|
||||||
|
// 主动断开后 BotManager 不会自动重连,这里显式重建连接
|
||||||
|
entry.manager.connect();
|
||||||
|
}, 500).unref?.();
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 向某机器人发送游戏指令。
|
||||||
|
*
|
||||||
|
* 登录门禁由 BotManager 判定:验证类指令(login / register / auth 等)即使在
|
||||||
|
* 未通过验证时也放行,否则会出现「要先登录才能发登录指令」的死锁。
|
||||||
|
*
|
||||||
|
* @param {string} id
|
||||||
|
* @param {string} command 不带斜杠的指令
|
||||||
|
* @returns {{ok: boolean, message: string}}
|
||||||
|
*/
|
||||||
|
sendCommand(id, command) {
|
||||||
|
const entry = this.require(id);
|
||||||
|
const text = String(command || '').replace(/^\//, '').trim();
|
||||||
|
if (!text) return { ok: false, message: '指令为空' };
|
||||||
|
if (!entry.manager.canSendCommand(text)) {
|
||||||
|
return { ok: false, message: describeSendFailure(entry.manager, text) };
|
||||||
|
}
|
||||||
|
const ok = entry.manager.sendCommand(text);
|
||||||
|
return { ok, message: ok ? '已发送:' + command : '发送失败' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 向某机器人发送公屏聊天消息。
|
||||||
|
* @param {string} id
|
||||||
|
* @param {string} message 聊天正文(以 `/` 开头时按指令发送)
|
||||||
|
* @returns {{ok: boolean, message: string}}
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* 向某机器人发送内容:聊天与指令是同一个入口,**以 `/` 开头按指令发送**,否则按公屏消息发送。
|
||||||
|
*
|
||||||
|
* @param {string} id
|
||||||
|
* @param {string} message 内容(可以带或不带前导 `/`)
|
||||||
|
* @returns {{ok: boolean, kind: string, message: string}}
|
||||||
|
*/
|
||||||
|
sendChat(id, message) {
|
||||||
|
const entry = this.require(id);
|
||||||
|
const text = String(message || '').trim();
|
||||||
|
if (!text) return { ok: false, kind: 'chat', message: '内容为空' };
|
||||||
|
const isCommand = text.startsWith('/');
|
||||||
|
const kind = isCommand ? 'command' : 'chat';
|
||||||
|
if (!entry.manager.canSendChat()) {
|
||||||
|
if (entry.manager.state !== 'online' || !entry.manager.bot) {
|
||||||
|
return { ok: false, kind, message: `${entry.connection.username} 当前未连接(状态:${entry.manager.state}),未发送` };
|
||||||
|
}
|
||||||
|
return { ok: false, kind, message: `${entry.connection.username} 未完成登录验证,已拒绝发送(服务器要求验证时会自动放行,用于发送验证码)` };
|
||||||
|
}
|
||||||
|
const ok = entry.manager.sendChat(text);
|
||||||
|
return {
|
||||||
|
ok,
|
||||||
|
kind,
|
||||||
|
message: ok ? (isCommand ? '指令已发送:' + text : '消息已发送') : '发送失败'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动移动测试:按住方向键若干秒,返回实际位移。
|
||||||
|
* @param {string} id
|
||||||
|
* @param {string} direction
|
||||||
|
* @param {number} [seconds]
|
||||||
|
* @returns {Promise<{ok: boolean, message: string, from: object|null, to: object|null}>}
|
||||||
|
*/
|
||||||
|
walkTest(id, direction, seconds) {
|
||||||
|
const entry = this.require(id);
|
||||||
|
return entry.manager.walkTest(direction, seconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 状态快照 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {Array<object>} 所有机器人的状态快照(含连接、任务与位置)
|
||||||
|
*/
|
||||||
|
snapshot() {
|
||||||
|
const bots = [];
|
||||||
|
for (const entry of this.entries.values()) {
|
||||||
|
const setting = this._settingOf(entry.id);
|
||||||
|
bots.push(Object.assign({}, entry.manager.snapshot(), {
|
||||||
|
enabled: setting.enabled,
|
||||||
|
autoStart: setting.autoStart,
|
||||||
|
source: entry.source,
|
||||||
|
task: entry.controller.snapshot(),
|
||||||
|
schedules: entry.messenger.snapshot()
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return bots;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {Array<object>} 所有机器人的位置(大地图打点用)
|
||||||
|
*/
|
||||||
|
positions() {
|
||||||
|
const result = [];
|
||||||
|
for (const entry of this.entries.values()) {
|
||||||
|
const snap = entry.manager.snapshot();
|
||||||
|
result.push({
|
||||||
|
id: entry.id,
|
||||||
|
label: entry.manager.label,
|
||||||
|
username: entry.connection.username,
|
||||||
|
state: snap.state,
|
||||||
|
loggedIn: snap.loggedIn,
|
||||||
|
position: snap.position,
|
||||||
|
chunk: snap.chunk,
|
||||||
|
stateName: entry.controller.stateName,
|
||||||
|
taskState: entry.controller.state,
|
||||||
|
completedClaims: entry.controller.completedClaims
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
_log(message, level) {
|
||||||
|
if (this.logger) this.logger.log(message, level);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造一个 400 错误(由 Web 层转成对应 HTTP 状态码)。
|
||||||
|
* @param {string} message
|
||||||
|
* @returns {Error}
|
||||||
|
*/
|
||||||
|
function badRequest(message) {
|
||||||
|
const err = new Error(message);
|
||||||
|
err.statusCode = 400;
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成「为什么这条指令发不出去」的可操作说明。
|
||||||
|
*
|
||||||
|
* 区分「没连上」与「连上了但没过登录验证」——两者的处理方式完全不同,
|
||||||
|
* 笼统报一句「发送失败」会让用户无从下手。
|
||||||
|
*
|
||||||
|
* @param {import('./botManager').BotManager} manager
|
||||||
|
* @param {string} command 不带斜杠的指令
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function describeSendFailure(manager, command) { const name = manager.connection.username;
|
||||||
|
if (manager.state !== 'online' || !manager.bot) {
|
||||||
|
return `${name} 当前未连接(状态:${manager.state}),指令未发送;请先重连或等待自动重连`;
|
||||||
|
}
|
||||||
|
if (manager.loginFailed) {
|
||||||
|
return `${name} 登录被服务器拒绝,请检查 .env 中的账号密码`;
|
||||||
|
}
|
||||||
|
return `${name} 未完成登录验证,已拒绝发送 /${command};`
|
||||||
|
+ '若是验证类指令(login / register / auth),可在配置的「允许免登录发送的指令前缀」中放行';
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { BotPool, describeSendFailure, badRequest };
|
||||||
+83
-1
@@ -108,6 +108,9 @@ class GrimCompat {
|
|||||||
install() {
|
install() {
|
||||||
if (this._installed) return;
|
if (this._installed) return;
|
||||||
const bot = this.bot;
|
const bot = this.bot;
|
||||||
|
// 协议补包与「合规层开关」无关:缺了它们机器人根本无法移动,
|
||||||
|
// 属于「让客户端表现得像原版客户端」的必要动作,而不是可选的反作弊调优。
|
||||||
|
this._installProtocolPackets(bot);
|
||||||
if (!this.config.grim.enabled) {
|
if (!this.config.grim.enabled) {
|
||||||
this._installed = true;
|
this._installed = true;
|
||||||
return;
|
return;
|
||||||
@@ -137,12 +140,83 @@ class GrimCompat {
|
|||||||
if (!this._installed) return;
|
if (!this._installed) return;
|
||||||
const bot = this.bot;
|
const bot = this.bot;
|
||||||
bot.removeListener('physicsTick', this._onPhysicsTick);
|
bot.removeListener('physicsTick', this._onPhysicsTick);
|
||||||
|
if (this._onProtocolTick) bot.removeListener('physicsTick', this._onProtocolTick);
|
||||||
if (this._originalLook) bot.look = this._originalLook;
|
if (this._originalLook) bot.look = this._originalLook;
|
||||||
if (this._originalLookAt) bot.lookAt = this._originalLookAt;
|
if (this._originalLookAt) bot.lookAt = this._originalLookAt;
|
||||||
if (this._originalSetControlState) bot.setControlState = this._originalSetControlState;
|
if (this._originalSetControlState) bot.setControlState = this._originalSetControlState;
|
||||||
this._installed = false;
|
this._installed = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 协议补包 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 补发原版客户端每 tick 都会发、而 mineflayer 不发的两个包。
|
||||||
|
*
|
||||||
|
* 这是本项目在真实服务器上「机器人完全走不动」的根因(由旧版机器人实测得出):
|
||||||
|
*
|
||||||
|
* 1. **`player_input`**:1.21.2+ 服务端的移动预测依赖它,原版客户端每 tick 都发;
|
||||||
|
* mineflayer 只在骑乘 / 跳下 / 潜行时发送,地面移动的 forward / jump / sprint 完全不发。
|
||||||
|
* 带反作弊的服务器因此判定「没有任何输入却发生了位移」,
|
||||||
|
* 每 tick 把玩家拉回原位置(setback)——表现就是原地抖动、位移恒为 0。
|
||||||
|
* 2. **`tick_end`**:原版 1.21.2+ 客户端在每 tick 末尾发送,反作弊用它判定 tick 边界;
|
||||||
|
* mineflayer 完全不发,会导致 PacketOrder / Timer 类判定异常。
|
||||||
|
*
|
||||||
|
* @param {import('mineflayer').Bot} bot
|
||||||
|
*/
|
||||||
|
_installProtocolPackets(bot) {
|
||||||
|
const grim = this.config.grim;
|
||||||
|
if (grim.sendPlayerInput === false && grim.sendTickEnd === false) return;
|
||||||
|
if (!bot || !bot._client) return;
|
||||||
|
|
||||||
|
// 版本能力探测:newPlayerInputPacket 自 1.21.3 起存在
|
||||||
|
try {
|
||||||
|
this.playerInputSupported = bot.supportFeature('newPlayerInputPacket');
|
||||||
|
} catch {
|
||||||
|
this.playerInputSupported = false;
|
||||||
|
}
|
||||||
|
this.playerInputSent = 0;
|
||||||
|
this.tickEndSent = 0;
|
||||||
|
|
||||||
|
this._onProtocolTick = () => {
|
||||||
|
if (grim.sendPlayerInput !== false && this.playerInputSupported) {
|
||||||
|
try {
|
||||||
|
bot._client.write('player_input', {
|
||||||
|
inputs: {
|
||||||
|
forward: bot.getControlState('forward'),
|
||||||
|
backward: bot.getControlState('back'),
|
||||||
|
left: bot.getControlState('left'),
|
||||||
|
right: bot.getControlState('right'),
|
||||||
|
jump: bot.getControlState('jump'),
|
||||||
|
shift: bot.getControlState('sneak'),
|
||||||
|
sprint: bot.getControlState('sprint')
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.playerInputSent++;
|
||||||
|
} catch {
|
||||||
|
// 发送失败不影响主流程
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (grim.sendTickEnd !== false) {
|
||||||
|
// 用 nextTick 保证 tick_end 排在本帧的 position 包之后
|
||||||
|
// (mineflayer 在 physicsTick 处理完才发位置)
|
||||||
|
process.nextTick(() => {
|
||||||
|
try {
|
||||||
|
if (bot._client && bot.entity) {
|
||||||
|
bot._client.write('tick_end', {});
|
||||||
|
this.tickEndSent++;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
bot.on('physicsTick', this._onProtocolTick);
|
||||||
|
this._log('协议补包已启用(player_input '
|
||||||
|
+ (this.playerInputSupported ? '已支持' : '当前版本不支持')
|
||||||
|
+ ',tick_end 每帧发送)');
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 旋转 ====================
|
// ==================== 旋转 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -508,7 +582,15 @@ class GrimCompat {
|
|||||||
attackCooldownMs: this.attackCooldownRemainingMs(),
|
attackCooldownMs: this.attackCooldownRemainingMs(),
|
||||||
blockedActions: this.blockedActions,
|
blockedActions: this.blockedActions,
|
||||||
physicsOk: audit.ok,
|
physicsOk: audit.ok,
|
||||||
issues: audit.issues
|
issues: audit.issues,
|
||||||
|
/** 协议补包状态:线上服务器上「走不动」时首先看这里 */
|
||||||
|
protocolPackets: {
|
||||||
|
playerInputSupported: this.playerInputSupported === true,
|
||||||
|
playerInputEnabled: this.config.grim.sendPlayerInput !== false,
|
||||||
|
playerInputSent: this.playerInputSent || 0,
|
||||||
|
tickEndEnabled: this.config.grim.sendTickEnd !== false,
|
||||||
|
tickEndSent: this.tickEndSent || 0
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+177
-22
@@ -5,6 +5,11 @@ const pathfinderPlugin = require('mineflayer-pathfinder');
|
|||||||
const { GoalXZ, GoalNearXZ, Goal } = pathfinderPlugin.goals;
|
const { GoalXZ, GoalNearXZ, Goal } = pathfinderPlugin.goals;
|
||||||
const { Movements } = pathfinderPlugin;
|
const { Movements } = pathfinderPlugin;
|
||||||
|
|
||||||
|
/** 单次寻路最多允许分成多少段(防止「不可达」目标导致无限重规划) */
|
||||||
|
const MAX_SEGMENTS = 200;
|
||||||
|
/** 分段路径算出 noPath 时最多收缩几次段长 */
|
||||||
|
const MAX_SEGMENT_SHRINK = 3;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 寻路服务:替代原 Java 版的 BaritoneBridge。
|
* 寻路服务:替代原 Java 版的 BaritoneBridge。
|
||||||
*
|
*
|
||||||
@@ -24,7 +29,9 @@ const PathEvent = {
|
|||||||
CALC_FINISHED_NOW_EXECUTING: 'CALC_FINISHED_NOW_EXECUTING',
|
CALC_FINISHED_NOW_EXECUTING: 'CALC_FINISHED_NOW_EXECUTING',
|
||||||
CALC_FAILED: 'CALC_FAILED',
|
CALC_FAILED: 'CALC_FAILED',
|
||||||
AT_GOAL: 'AT_GOAL',
|
AT_GOAL: 'AT_GOAL',
|
||||||
CANCELED: 'CANCELED'
|
CANCELED: 'CANCELED',
|
||||||
|
/** 走完一段中途路径(长距离分段寻路时用,调用方应据此重置超时计时) */
|
||||||
|
SEGMENT_REACHED: 'SEGMENT_REACHED'
|
||||||
};
|
};
|
||||||
|
|
||||||
class PathingService {
|
class PathingService {
|
||||||
@@ -48,6 +55,26 @@ class PathingService {
|
|||||||
this._pathing = false;
|
this._pathing = false;
|
||||||
this._lastStatus = null;
|
this._lastStatus = null;
|
||||||
this._configured = false;
|
this._configured = false;
|
||||||
|
/** 事件监听是否已注册(configure 会被反复调用,不能重复注册) */
|
||||||
|
this._listenersBound = false;
|
||||||
|
/**
|
||||||
|
* 长距离分段寻路的状态。
|
||||||
|
*
|
||||||
|
* mineflayer-pathfinder 只能规划**已加载区块**范围内(实测本服约 8 区块 ≈ 130 格),
|
||||||
|
* 而扩地目标常常在几十个区块之外,于是 A* 瞬间返回 noPath(表现为「目标不可达」)。
|
||||||
|
* 解决办法是把整段旅程拆成若干段:先走到已加载范围边缘附近的中途点,
|
||||||
|
* 走完后新区块已加载,再规划下一段,直到抵达真正的目标。
|
||||||
|
*/
|
||||||
|
this.finalTarget = null;
|
||||||
|
/** 当前这一段的中途点(方块坐标) */
|
||||||
|
this.currentWaypoint = null;
|
||||||
|
/** 当前中途点是否就是最终目标 */
|
||||||
|
this._waypointIsFinal = false;
|
||||||
|
/** 本次 pathTo 已走的段数,用于防止无限循环 */
|
||||||
|
this._segmentCount = 0;
|
||||||
|
/** 段长收缩次数与系数(本段算出 noPath 时用来缩短段长重试) */
|
||||||
|
this._segmentShrink = 0;
|
||||||
|
this._segmentScale = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -82,21 +109,55 @@ class PathingService {
|
|||||||
movements.liquidCost = Math.max(movements.liquidCost, 200);
|
movements.liquidCost = Math.max(movements.liquidCost, 200);
|
||||||
}
|
}
|
||||||
bot.pathfinder.setMovements(movements);
|
bot.pathfinder.setMovements(movements);
|
||||||
// 搜索超时与半径:默认值偏保守,山区大地图容易 noPath
|
// 搜索预算:默认值偏保守,山区 / 建筑密集区容易在起点附近就剪枝掉所有分支
|
||||||
bot.pathfinder.thinkTimeout = 10000;
|
// (表现为 noPath 且「已探索节点数」极小)。searchRadius 为负表示不限制。
|
||||||
bot.pathfinder.tickTimeout = 40;
|
bot.pathfinder.thinkTimeout = this.config.pathing.thinkTimeoutMs;
|
||||||
bot.pathfinder.searchRadius = 256;
|
bot.pathfinder.tickTimeout = this.config.pathing.tickTimeoutMs;
|
||||||
|
bot.pathfinder.searchRadius = this.config.pathing.searchRadius;
|
||||||
|
|
||||||
bot.on('goal_reached', () => {
|
// 事件监听只注册一次:configure() 在每次配置变更时都会重跑,
|
||||||
|
// 重复注册会让同一个事件被处理多次(日志里同一秒出现多条相同事件就是这么来的)
|
||||||
|
if (!this._listenersBound) {
|
||||||
|
this._listenersBound = true;
|
||||||
|
bot.on('goal_reached', () => {
|
||||||
this._pathing = false;
|
this._pathing = false;
|
||||||
this._emit(PathEvent.AT_GOAL, { target: this.currentTarget });
|
if (this._waypointIsFinal || !this.finalTarget) {
|
||||||
|
this._emit(PathEvent.AT_GOAL, { target: this.currentTarget });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 只走到了中途点:新区块已加载,继续朝最终目标规划下一段
|
||||||
|
this._segmentCount++;
|
||||||
|
if (this._segmentCount > MAX_SEGMENTS) {
|
||||||
|
this._log(`分段寻路超过 ${MAX_SEGMENTS} 段仍未到达目标,判定为不可达`);
|
||||||
|
this.calcFailed = true;
|
||||||
|
this._emit(PathEvent.CALC_FAILED, { target: this.currentTarget, reason: 'too-many-segments' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this._log(`已到达中途点 ${Math.round(this.currentWaypoint.x)},${Math.round(this.currentWaypoint.z)},`
|
||||||
|
+ `继续朝目标 ${this.finalTarget.x},${this.finalTarget.z} 前进(第 ${this._segmentCount} 段)`);
|
||||||
|
this._emit(PathEvent.SEGMENT_REACHED, { segment: this._segmentCount, target: this.currentTarget });
|
||||||
|
this._planSegment();
|
||||||
});
|
});
|
||||||
bot.on('path_update', (r) => {
|
bot.on('path_update', (r) => {
|
||||||
this._lastStatus = r.status;
|
this._lastStatus = r.status;
|
||||||
|
// 完整记录寻路器状态:长距离寻路卡住时,只看成功 / 失败是查不出原因的
|
||||||
|
this._log(`寻路状态:${r.status}`
|
||||||
|
+ (r.visitedNodes !== undefined ? `(已探索 ${r.visitedNodes} 节点)` : '')
|
||||||
|
+ (r.cost !== undefined ? `(代价 ${r.cost})` : '')
|
||||||
|
+ `,本段目标 ${this.currentWaypoint ? Math.round(this.currentWaypoint.x) + ',' + Math.round(this.currentWaypoint.z) : '无'}`
|
||||||
|
+ `,最终目标 ${this.finalTarget ? Math.round(this.finalTarget.x) + ',' + Math.round(this.finalTarget.z) : '无'}`);
|
||||||
if (r.status === 'success') {
|
if (r.status === 'success') {
|
||||||
this.calcFailed = false;
|
this.calcFailed = false;
|
||||||
this._emit(PathEvent.CALC_FINISHED_NOW_EXECUTING, { cost: r.cost, nodes: r.visitedNodes });
|
this._emit(PathEvent.CALC_FINISHED_NOW_EXECUTING, { cost: r.cost, nodes: r.visitedNodes });
|
||||||
} else if (r.status === 'noPath') {
|
} else if (r.status === 'noPath') {
|
||||||
|
// 分段后仍不可达:可能是已加载范围的探测偏乐观,缩短段长再试一次
|
||||||
|
if (!this._waypointIsFinal && this.finalTarget && this._segmentShrink < MAX_SEGMENT_SHRINK) {
|
||||||
|
this._segmentShrink++;
|
||||||
|
this._segmentScale *= 0.6;
|
||||||
|
this._log(`本段不可达,缩短段长后重试(第 ${this._segmentShrink}/${MAX_SEGMENT_SHRINK} 次)`);
|
||||||
|
this._planSegment();
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.calcFailed = true;
|
this.calcFailed = true;
|
||||||
this._pathing = false;
|
this._pathing = false;
|
||||||
this._emit(PathEvent.CALC_FAILED, { target: this.currentTarget });
|
this._emit(PathEvent.CALC_FAILED, { target: this.currentTarget });
|
||||||
@@ -106,11 +167,12 @@ class PathingService {
|
|||||||
this._emit(PathEvent.CALC_FAILED, { target: this.currentTarget, timeout: true });
|
this._emit(PathEvent.CALC_FAILED, { target: this.currentTarget, timeout: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
bot.on('path_reset', (reason) => {
|
bot.on('path_reset', (reason) => {
|
||||||
if (reason !== 'chunk_loaded' && reason !== 'movement_update') {
|
if (reason !== 'chunk_loaded' && reason !== 'movement_update') {
|
||||||
this._emit(PathEvent.CANCELED, { reason });
|
this._emit(PathEvent.CANCELED, { reason });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
this.ready = true;
|
this.ready = true;
|
||||||
this._configured = true;
|
this._configured = true;
|
||||||
this._log(`寻路器就绪(挖方块 ${this.config.allowBreak ? '开' : '关'},`
|
this._log(`寻路器就绪(挖方块 ${this.config.allowBreak ? '开' : '关'},`
|
||||||
@@ -150,20 +212,37 @@ class PathingService {
|
|||||||
const bot = this.bot;
|
const bot = this.bot;
|
||||||
if (!this.isAvailable()) return false;
|
if (!this.isAvailable()) return false;
|
||||||
const opts = options || {};
|
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.currentTarget = { x: chunk.x, z: chunk.z };
|
||||||
|
this.finalTarget = { x: chunk.x * 16 + 8, z: chunk.z * 16 + 8 };
|
||||||
|
this._segmentCount = 0;
|
||||||
|
this._segmentShrink = 0;
|
||||||
|
this._segmentScale = 1;
|
||||||
this.calcFailed = false;
|
this.calcFailed = false;
|
||||||
this._pathing = true;
|
this._pathing = true;
|
||||||
this._emit(PathEvent.CALC_STARTED, { target: this.currentTarget });
|
this._emit(PathEvent.CALC_STARTED, { target: this.currentTarget });
|
||||||
|
return this._planSegment(opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规划「当前这一段」的路径:把最终目标裁剪到已加载范围内。
|
||||||
|
*
|
||||||
|
* @param {{y?: number}} [options]
|
||||||
|
* @returns {boolean} 是否成功下发
|
||||||
|
*/
|
||||||
|
_planSegment(options) {
|
||||||
|
const bot = this.bot;
|
||||||
|
const opts = options || {};
|
||||||
|
const goalBlock = this._segmentGoal(this.finalTarget);
|
||||||
|
this.currentWaypoint = goalBlock;
|
||||||
|
this._waypointIsFinal = goalBlock.x === this.finalTarget.x && goalBlock.z === this.finalTarget.z;
|
||||||
|
|
||||||
|
let goal = new GoalNearXZ(goalBlock.x, goalBlock.z, 2);
|
||||||
|
if (this.config.altitudePreference && this.config.altitudeWeight > 0) {
|
||||||
|
const startY = Math.floor(bot.entity.position.y);
|
||||||
|
goal = new AltitudeGoal(goal, this.config.altitudeWeight, startY);
|
||||||
|
}
|
||||||
|
this.calcFailed = false;
|
||||||
|
this._pathing = true;
|
||||||
try {
|
try {
|
||||||
bot.pathfinder.setGoal(goal);
|
bot.pathfinder.setGoal(goal);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -176,11 +255,87 @@ class PathingService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把最终目标裁剪到「当前已加载的范围内」。
|
||||||
|
*
|
||||||
|
* 不做任何世界内部结构的猜测:沿目标方向直接探测最远的已加载位置
|
||||||
|
* (`bot.blockAt` 对未加载区块返回 null),再留出安全边距。
|
||||||
|
*
|
||||||
|
* @param {{x: number, z: number}} finalBlock 最终目标(方块坐标)
|
||||||
|
* @returns {{x: number, z: number}} 本段实际要去的点
|
||||||
|
*/
|
||||||
|
_segmentGoal(finalBlock) {
|
||||||
|
const bot = this.bot;
|
||||||
|
if (!bot.entity || !finalBlock) return finalBlock;
|
||||||
|
const pos = bot.entity.position;
|
||||||
|
const dx = finalBlock.x - pos.x;
|
||||||
|
const dz = finalBlock.z - pos.z;
|
||||||
|
const distance = Math.hypot(dx, dz);
|
||||||
|
if (distance <= 1) return finalBlock;
|
||||||
|
|
||||||
|
const pathing = this.config.pathing || {};
|
||||||
|
const maxSegment = Math.max(16, pathing.maxSegmentBlocks || 512);
|
||||||
|
const margin = Math.max(0, pathing.segmentMarginBlocks === undefined ? 24 : pathing.segmentMarginBlocks);
|
||||||
|
const unitX = dx / distance;
|
||||||
|
const unitZ = dz / distance;
|
||||||
|
// 只需要探测到「可能用到的最大段长」就够了
|
||||||
|
const loaded = this._probeLoadedDistance(unitX, unitZ, maxSegment + margin);
|
||||||
|
// 段长同时受「已加载范围 - 边距」和「单段上限」两个约束
|
||||||
|
const usable = Math.max(16, Math.min(loaded - margin, maxSegment) * this._segmentScale);
|
||||||
|
if (usable >= distance) return finalBlock;
|
||||||
|
return {
|
||||||
|
x: Math.round(pos.x + unitX * usable),
|
||||||
|
z: Math.round(pos.z + unitZ * usable)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 沿给定方向二分探测最远的「已加载」距离(方块)。
|
||||||
|
*
|
||||||
|
* 依据是已加载区块构成一个以玩家为中心的圆,因此「越远越可能未加载」是单调的。
|
||||||
|
* 同时在脚下与脚上一格取样,避免刚好落在空气层里被误判为未加载。
|
||||||
|
*
|
||||||
|
* @param {number} unitX 单位方向 X
|
||||||
|
* @param {number} unitZ 单位方向 Z
|
||||||
|
* @param {number} maxBlocks 探测上限
|
||||||
|
* @returns {number} 最远的已加载距离
|
||||||
|
*/
|
||||||
|
_probeLoadedDistance(unitX, unitZ, maxBlocks) {
|
||||||
|
const bot = this.bot;
|
||||||
|
if (!bot.blockAt || !bot.entity) return 0;
|
||||||
|
const pos = bot.entity.position;
|
||||||
|
const isLoaded = (d) => {
|
||||||
|
try {
|
||||||
|
const feet = bot.blockAt(pos.offset(unitX * d, 0, unitZ * d));
|
||||||
|
if (feet) return true;
|
||||||
|
return !!bot.blockAt(pos.offset(unitX * d, 1, unitZ * d));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (!isLoaded(0)) return 0;
|
||||||
|
let lo = 0;
|
||||||
|
let hi = maxBlocks;
|
||||||
|
// 迭代次数决定精度:maxBlocks / 2^14 ≈ 0.25 格,足够贴合区块加载边界
|
||||||
|
for (let i = 0; i < 14; i++) {
|
||||||
|
const mid = (lo + hi) / 2;
|
||||||
|
if (isLoaded(mid)) lo = mid;
|
||||||
|
else hi = mid;
|
||||||
|
}
|
||||||
|
return lo;
|
||||||
|
}
|
||||||
|
|
||||||
/** 取消当前寻路 */
|
/** 取消当前寻路 */
|
||||||
cancel() {
|
cancel() {
|
||||||
if (!this.isAvailable()) return;
|
if (!this.isAvailable()) return;
|
||||||
this._pathing = false;
|
this._pathing = false;
|
||||||
this.currentTarget = null;
|
this.currentTarget = null;
|
||||||
|
this.finalTarget = null;
|
||||||
|
this.currentWaypoint = null;
|
||||||
|
this._waypointIsFinal = false;
|
||||||
|
this._segmentCount = 0;
|
||||||
|
this._segmentShrink = 0;
|
||||||
|
this._segmentScale = 1;
|
||||||
try {
|
try {
|
||||||
this.bot.pathfinder.setGoal(null);
|
this.bot.pathfinder.setGoal(null);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/** 单个任务的间隔下限(秒):再短就近似刷屏,容易被服务器或反作弊盯上 */
|
||||||
|
const MIN_INTERVAL_SECONDS = 5;
|
||||||
|
/** 单个机器人最多允许的定时任务数 */
|
||||||
|
const MAX_TASKS = 20;
|
||||||
|
/** 调度器的心跳间隔(毫秒):只做「到点了吗」的判断,开销可忽略 */
|
||||||
|
const TICK_INTERVAL_MS = 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 定时消息器:按固定间隔重复发送同一条内容(公屏消息或游戏指令)。
|
||||||
|
*
|
||||||
|
* 典型用法是服务器要求周期性执行的指令,例如每 300 秒发一次 `/u restore confirm`
|
||||||
|
* 来维持疆土民心。这类「心跳式」指令是挂机机器人的刚需,
|
||||||
|
* 但绝不能简单地 setInterval —— 断线、未登录、正在回城时都不该发,
|
||||||
|
* 否则要么白发(服务端丢弃),要么在错误的时机打断状态机。
|
||||||
|
*
|
||||||
|
* 因此这里做成:
|
||||||
|
* - 每个任务独立计时,按「上次成功发送时间 + 间隔」判断是否到点;
|
||||||
|
* - 只在 `canOperate()`(已连接 + 已通过登录验证)时才发;
|
||||||
|
* - 未就绪时跳过本次并顺延,不补发(避免重连后一次性连发多条);
|
||||||
|
* - 与机器人共用指令节流队列,不会绕过 `server.commandCooldownMs` 刷屏。
|
||||||
|
*/
|
||||||
|
class ScheduledMessenger {
|
||||||
|
/**
|
||||||
|
* @param {import('./botManager').BotManager} manager
|
||||||
|
* @param {import('../util/logger').Logger} [logger]
|
||||||
|
*/
|
||||||
|
constructor(manager, logger) {
|
||||||
|
this.manager = manager;
|
||||||
|
this.logger = logger || null;
|
||||||
|
/** @type {Array<object>} 定时任务列表 */
|
||||||
|
this.tasks = [];
|
||||||
|
/** @type {Map<string, number>} 任务 id → 下次应发送的时间戳 */
|
||||||
|
this._nextDueAt = new Map();
|
||||||
|
this._timer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置任务列表(整体替换),并重置各任务的计时。
|
||||||
|
*
|
||||||
|
* @param {Array<object>} tasks 原始任务定义
|
||||||
|
* @param {number} [now] 当前时间戳(默认取系统时间;测试里注入固定值即可不必真等)
|
||||||
|
* @returns {Array<object>} 归一化后的任务列表
|
||||||
|
*/
|
||||||
|
setTasks(tasks, now) {
|
||||||
|
this.tasks = normalizeTasks(tasks);
|
||||||
|
this._nextDueAt.clear();
|
||||||
|
const current = Number.isFinite(now) ? now : Date.now();
|
||||||
|
for (const task of this.tasks) {
|
||||||
|
// 首次不立即发送:给机器人留出进入世界与登录的时间
|
||||||
|
this._nextDueAt.set(task.id, current + task.intervalSeconds * 1000);
|
||||||
|
}
|
||||||
|
return this.tasks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 启动调度心跳 */
|
||||||
|
start() {
|
||||||
|
if (this._timer) return;
|
||||||
|
this._timer = setInterval(() => this.tick(), TICK_INTERVAL_MS);
|
||||||
|
if (this._timer.unref) this._timer.unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 停止调度心跳 */
|
||||||
|
stop() {
|
||||||
|
if (this._timer) clearInterval(this._timer);
|
||||||
|
this._timer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 心跳:把所有到点的任务发出去。
|
||||||
|
*
|
||||||
|
* 抽成公开方法是为了可测 —— 测试里直接调 `tick()` 并注入当前时间,
|
||||||
|
* 不必真的等 300 秒。
|
||||||
|
*
|
||||||
|
* @param {number} [now] 当前时间戳(默认取系统时间)
|
||||||
|
* @returns {number} 本次实际发出的条数
|
||||||
|
*/
|
||||||
|
tick(now) {
|
||||||
|
const current = Number.isFinite(now) ? now : Date.now();
|
||||||
|
let sent = 0;
|
||||||
|
for (const task of this.tasks) {
|
||||||
|
if (!task.enabled) continue;
|
||||||
|
const due = this._nextDueAt.get(task.id);
|
||||||
|
if (due === undefined) {
|
||||||
|
this._nextDueAt.set(task.id, current + task.intervalSeconds * 1000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (current < due) continue;
|
||||||
|
|
||||||
|
// 未就绪就顺延到下一个周期,不补发
|
||||||
|
if (!this.manager.canSendChat()) {
|
||||||
|
this._nextDueAt.set(task.id, current + task.intervalSeconds * 1000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const ok = this.manager.sendChat(task.content);
|
||||||
|
this._nextDueAt.set(task.id, current + task.intervalSeconds * 1000);
|
||||||
|
if (ok) {
|
||||||
|
sent++;
|
||||||
|
this._log(`定时任务「${task.label || task.id}」已发送:${task.content}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {object} 供控制台展示的状态快照 */
|
||||||
|
snapshot() {
|
||||||
|
const now = Date.now();
|
||||||
|
return this.tasks.map((task) => {
|
||||||
|
const due = this._nextDueAt.get(task.id);
|
||||||
|
return Object.assign({}, task, {
|
||||||
|
nextDueAt: due || 0,
|
||||||
|
nextInSeconds: due ? Math.max(0, Math.round((due - now) / 1000)) : null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_log(message) {
|
||||||
|
if (this.logger) this.logger.log(`[${this.manager.connection.username}] ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 归一化任务列表:丢弃非法项、补齐默认值、限制数量。
|
||||||
|
*
|
||||||
|
* @param {Array<object>} tasks
|
||||||
|
* @returns {Array<object>}
|
||||||
|
*/
|
||||||
|
function normalizeTasks(tasks) {
|
||||||
|
if (!Array.isArray(tasks)) return [];
|
||||||
|
const result = [];
|
||||||
|
const seen = new Set();
|
||||||
|
for (let i = 0; i < tasks.length && result.length < MAX_TASKS; i++) {
|
||||||
|
const raw = tasks[i];
|
||||||
|
if (!raw || typeof raw !== 'object') continue;
|
||||||
|
const content = String(raw.content === undefined || raw.content === null ? '' : raw.content).trim();
|
||||||
|
if (!content) continue;
|
||||||
|
let id = String(raw.id || '').trim();
|
||||||
|
if (!id || seen.has(id)) id = `task-${i + 1}`;
|
||||||
|
while (seen.has(id)) id += '_';
|
||||||
|
seen.add(id);
|
||||||
|
result.push({
|
||||||
|
id,
|
||||||
|
label: String(raw.label || '').trim(),
|
||||||
|
content,
|
||||||
|
enabled: raw.enabled !== false,
|
||||||
|
intervalSeconds: clampInterval(raw.intervalSeconds),
|
||||||
|
/** 以 `/` 开头的内容会被 sendChat 自动转成指令 */
|
||||||
|
kind: content.startsWith('/') ? 'command' : 'chat'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把间隔钳制到合理范围。
|
||||||
|
* @param {*} value
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
function clampInterval(value) {
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n)) return 300;
|
||||||
|
return Math.min(86400, Math.max(MIN_INTERVAL_SECONDS, Math.trunc(n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { ScheduledMessenger, normalizeTasks, clampInterval, MIN_INTERVAL_SECONDS, MAX_TASKS };
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 机器人注册表持久化:保存「非敏感」的机器人设置。
|
||||||
|
*
|
||||||
|
* 存的是:是否启用、备注名、启动后是否自动开始扩地、以及每个机器人自己的扩地配置覆盖。
|
||||||
|
* **绝不存账号密码** —— 密码只从 `.env` 读取(见 {@link module:config/botsConfig})。
|
||||||
|
*
|
||||||
|
* 默认路径 `data/bots.json`(`data/` 已在 .gitignore 中忽略)。
|
||||||
|
*/
|
||||||
|
class BotStore {
|
||||||
|
/**
|
||||||
|
* @param {string} [filePath] 配置文件绝对路径
|
||||||
|
*/
|
||||||
|
constructor(filePath) {
|
||||||
|
this.filePath = filePath || path.join(__dirname, '..', '..', 'data', 'bots.json');
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 读取注册表;文件不存在或损坏时返回空表。
|
||||||
|
* @returns {{bots: Record<string, object>}}
|
||||||
|
*/
|
||||||
|
load() {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(fs.readFileSync(this.filePath, 'utf8'));
|
||||||
|
if (parsed && typeof parsed === 'object' && parsed.bots && typeof parsed.bots === 'object') {
|
||||||
|
return { bots: parsed.bots };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 首次运行或文件损坏:回退到空表
|
||||||
|
}
|
||||||
|
return { bots: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入注册表。
|
||||||
|
* @param {{bots: Record<string, object>}} data
|
||||||
|
* @returns {boolean} 是否写入成功
|
||||||
|
*/
|
||||||
|
save(data) {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
|
||||||
|
fs.writeFileSync(this.filePath, JSON.stringify({ bots: data.bots || {} }, null, 2), 'utf8');
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 控制台新增账号的凭据持久化。
|
||||||
|
*
|
||||||
|
* 与 {@link BotStore} 分开存是刻意的:
|
||||||
|
* - `data/bots.json` 只放非敏感设置,可以随手打开看;
|
||||||
|
* - 本文件含密码,落盘权限强制 0600,且 `data/` 已在 .gitignore 中忽略。
|
||||||
|
*
|
||||||
|
* `.env` 里的 `MC_BOTS` / `MC_USERNAME` 仍然是首选来源;两者同名时以 `.env` 为准
|
||||||
|
* (环境变量优先级更高,避免「改了半天 .env 不生效」)。
|
||||||
|
*/
|
||||||
|
class BotCredentialStore {
|
||||||
|
/**
|
||||||
|
* @param {string} [filePath] 凭据文件绝对路径
|
||||||
|
*/
|
||||||
|
constructor(filePath) {
|
||||||
|
this.filePath = filePath || path.join(__dirname, '..', '..', 'data', 'bots.credentials.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取凭据;文件不存在或损坏时返回空表。
|
||||||
|
* @returns {{bots: Record<string, object>}}
|
||||||
|
*/
|
||||||
|
load() {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(fs.readFileSync(this.filePath, 'utf8'));
|
||||||
|
if (parsed && typeof parsed === 'object' && parsed.bots && typeof parsed.bots === 'object') {
|
||||||
|
return { bots: parsed.bots };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 首次运行或文件损坏:回退到空表
|
||||||
|
}
|
||||||
|
return { bots: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入凭据并强制 0600 权限(仅文件属主可读写)。
|
||||||
|
* @param {{bots: Record<string, object>}} data
|
||||||
|
* @returns {boolean} 是否写入成功
|
||||||
|
*/
|
||||||
|
save(data) {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
|
||||||
|
fs.writeFileSync(this.filePath, JSON.stringify({ bots: data.bots || {} }, null, 2), { encoding: 'utf8', mode: 0o600 });
|
||||||
|
// writeFileSync 的 mode 只在新文件时生效,已存在时需显式 chmod
|
||||||
|
try {
|
||||||
|
fs.chmodSync(this.filePath, 0o600);
|
||||||
|
} catch {
|
||||||
|
// Windows 等平台不支持 chmod,忽略
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { BotStore, BotCredentialStore };
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 机器人凭据解析。
|
||||||
|
*
|
||||||
|
* 设计原则(与项目其它部分一致):**账号密码只从环境变量读取,绝不落代码、绝不落配置文件**。
|
||||||
|
* 因此这里只负责把 `.env` 里的描述解析成内存中的连接参数列表,
|
||||||
|
* 机器人列表本身(哪个启用、备注名、各自的扩地配置)才存到 `data/bots.json`。
|
||||||
|
*
|
||||||
|
* 支持的写法(按优先级):
|
||||||
|
*
|
||||||
|
* 1. JSON 数组(推荐,用户名/密码含逗号冒号时也安全)
|
||||||
|
* ```
|
||||||
|
* MC_BOTS=[{"username":"BotA","password":"p1"},{"username":"BotB","password":"p2"}]
|
||||||
|
* ```
|
||||||
|
* 2. `用户名:密码` 逗号分隔
|
||||||
|
* ```
|
||||||
|
* MC_BOTS=BotA:p1,BotB:p2
|
||||||
|
* ```
|
||||||
|
* 3. 单机器人兜底:`MC_USERNAME` + `MC_PASSWORD`
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从环境变量解析机器人连接参数列表。
|
||||||
|
*
|
||||||
|
* @param {NodeJS.ProcessEnv|object} [env] 环境变量(默认取 process.env,便于测试注入)
|
||||||
|
* @returns {Array<{id: string, username: string, password: string, auth: string, host: string, port: number, version: string, label: string}>}
|
||||||
|
*/
|
||||||
|
function parseBotsFromEnv(env) {
|
||||||
|
const source = env || process.env;
|
||||||
|
const defaults = {
|
||||||
|
host: source.MC_HOST || 'bgjq.simpfun.cn',
|
||||||
|
port: normalizePort(source.MC_PORT),
|
||||||
|
version: source.MC_VERSION || '1.21.4',
|
||||||
|
auth: source.MC_AUTH || 'offline'
|
||||||
|
};
|
||||||
|
|
||||||
|
const raw = typeof source.MC_BOTS === 'string' ? source.MC_BOTS.trim() : '';
|
||||||
|
let parsed = [];
|
||||||
|
if (raw) {
|
||||||
|
parsed = raw.startsWith('[') ? parseJsonList(raw) : parseCsvList(raw);
|
||||||
|
}
|
||||||
|
if (parsed.length === 0) {
|
||||||
|
const username = (source.MC_USERNAME || '').trim();
|
||||||
|
if (username) {
|
||||||
|
parsed = [{ username, password: source.MC_PASSWORD || '' }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set();
|
||||||
|
const bots = [];
|
||||||
|
for (const item of parsed) {
|
||||||
|
const username = String(item.username || '').trim();
|
||||||
|
if (!username) continue;
|
||||||
|
const id = sanitizeId(username);
|
||||||
|
if (!id || seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
bots.push({
|
||||||
|
id,
|
||||||
|
username,
|
||||||
|
password: item.password === undefined || item.password === null ? '' : String(item.password),
|
||||||
|
auth: String(item.auth || defaults.auth),
|
||||||
|
host: String(item.host || defaults.host),
|
||||||
|
port: normalizePort(item.port === undefined ? defaults.port : item.port),
|
||||||
|
version: String(item.version || defaults.version),
|
||||||
|
label: String(item.label || username)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return bots;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析 JSON 数组写法;格式错误时返回空数组(由兜底逻辑接管) */
|
||||||
|
function parseJsonList(raw) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
return parsed.filter((item) => item && typeof item === 'object');
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析 `用户名:密码` 逗号分隔写法;没有冒号时视为「只有用户名」 */
|
||||||
|
function parseCsvList(raw) {
|
||||||
|
const result = [];
|
||||||
|
for (const part of raw.split(',')) {
|
||||||
|
const text = part.trim();
|
||||||
|
if (!text) continue;
|
||||||
|
const index = text.indexOf(':');
|
||||||
|
if (index < 0) {
|
||||||
|
result.push({ username: text, password: '' });
|
||||||
|
} else {
|
||||||
|
result.push({ username: text.slice(0, index).trim(), password: text.slice(index + 1).trim() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成机器人 id:用于 URL、文件名与日志归属,必须稳定且不含特殊字符。
|
||||||
|
* @param {string} username
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function sanitizeId(username) {
|
||||||
|
return String(username).trim().replace(/[^\w\u4e00-\u9fa5.-]/g, '_').slice(0, 48);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePort(value) {
|
||||||
|
const n = Number.parseInt(value, 10);
|
||||||
|
return Number.isFinite(n) && n > 0 && n < 65536 ? n : 25565;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { parseBotsFromEnv, sanitizeId, normalizePort };
|
||||||
+155
-3
@@ -116,6 +116,16 @@ function defaultConfig() {
|
|||||||
apiKey: '',
|
apiKey: '',
|
||||||
/** API 领土数据轮询间隔(秒),最小 60 */
|
/** API 领土数据轮询间隔(秒),最小 60 */
|
||||||
apiRefreshIntervalSeconds: 180,
|
apiRefreshIntervalSeconds: 180,
|
||||||
|
/**
|
||||||
|
* 是否启用 SimpCore API-V2(ServerOpenAPI 插件)。
|
||||||
|
*
|
||||||
|
* 注意:**API 与 API-V2 是两套独立、互不通用的密钥体系**,需要在密钥中心分别申请。
|
||||||
|
* V2 用于查询服务器实时状态(在线玩家 / 装备 / 游戏时间 / 末影龙),
|
||||||
|
* 接口级配额很小(末影龙仅 1 次/分钟),因此不做自动轮询,只在控制台按需查询。
|
||||||
|
*/
|
||||||
|
apiV2Enabled: false,
|
||||||
|
/** API-V2 Key(留空时回退读环境变量 SIMP_API_V2_KEY) */
|
||||||
|
apiV2Key: '',
|
||||||
|
|
||||||
// ==================== 寻路器专属(新增) ====================
|
// ==================== 寻路器专属(新增) ====================
|
||||||
pathing: {
|
pathing: {
|
||||||
@@ -128,7 +138,30 @@ function defaultConfig() {
|
|||||||
/** 进入液体格的额外代价(越大越倾向绕开水域) */
|
/** 进入液体格的额外代价(越大越倾向绕开水域) */
|
||||||
liquidCost: 40,
|
liquidCost: 40,
|
||||||
/** 是否允许打开门/栅栏门 */
|
/** 是否允许打开门/栅栏门 */
|
||||||
canOpenDoors: false
|
canOpenDoors: false,
|
||||||
|
/**
|
||||||
|
* 单段寻路的最长方块数(默认 512)。
|
||||||
|
*
|
||||||
|
* mineflayer-pathfinder 只能规划**已加载区块**范围内(实测本服约 8 区块 ≈ 130 格),
|
||||||
|
* 而扩地目标常在几十个区块之外,A* 会瞬间返回 noPath。
|
||||||
|
* 因此长距离会被拆成若干段:走到已加载范围边缘附近再重新规划下一段。
|
||||||
|
* 这个值只是单段的上限,实际段长由「已加载范围」动态决定。
|
||||||
|
*/
|
||||||
|
maxSegmentBlocks: 96,
|
||||||
|
/** 分段寻路时距已加载范围边缘留出的安全边距(方块),避免踩在未加载边界上 */
|
||||||
|
segmentMarginBlocks: 24,
|
||||||
|
/**
|
||||||
|
* A* 单次搜索的时间预算(毫秒)。
|
||||||
|
* 太小会在复杂地形里搜不完就放弃,表现为「目标不可达」。
|
||||||
|
*/
|
||||||
|
thinkTimeoutMs: 10000,
|
||||||
|
/** A* 每 tick 的时间片(毫秒),越大单帧越卡但搜得更快 */
|
||||||
|
tickTimeoutMs: 40,
|
||||||
|
/**
|
||||||
|
* A* 的代价搜索半径:节点代价超过「起点到目标的启发值 + 该值」就被剪枝。
|
||||||
|
* 负数表示不限制(复杂地形里最稳,但搜索更慢)。
|
||||||
|
*/
|
||||||
|
searchRadius: 256
|
||||||
},
|
},
|
||||||
|
|
||||||
// ==================== 战斗辅助(新增) ====================
|
// ==================== 战斗辅助(新增) ====================
|
||||||
@@ -170,7 +203,62 @@ function defaultConfig() {
|
|||||||
/** 是否在启动时校验物理参数未被篡改 */
|
/** 是否在启动时校验物理参数未被篡改 */
|
||||||
checkPhysicsIntegrity: true,
|
checkPhysicsIntegrity: true,
|
||||||
/** 是否禁止在骑乘状态下攻击 / 进食(防 Vehicle 类检查) */
|
/** 是否禁止在骑乘状态下攻击 / 进食(防 Vehicle 类检查) */
|
||||||
blockActionsWhileRiding: true
|
blockActionsWhileRiding: true,
|
||||||
|
/**
|
||||||
|
* 是否按世界数据修正出站位置包的 onGround 标志(默认开启)。
|
||||||
|
*
|
||||||
|
* mineflayer 收到服务端 `position` 包时会把 `entity.onGround` 置为 false,
|
||||||
|
* 在频繁下发位置包的服务器上会导致客户端持续上报「站在地上却在空中」,
|
||||||
|
* 被反作弊判定异常后每 tick 拉回一次,表现为机器人原地抖动、完全走不动。
|
||||||
|
* 开启后发包前会用脚下方块复核,只有确实不一致时才改写。
|
||||||
|
*/
|
||||||
|
reportOnGroundFromWorld: true,
|
||||||
|
/**
|
||||||
|
* 是否每 tick 补发 `player_input` 包(默认开启,**线上服务器必需**)。
|
||||||
|
*
|
||||||
|
* 1.21.2+ 服务端的移动预测依赖它,原版客户端每 tick 都发,而 mineflayer 只在
|
||||||
|
* 骑乘 / 跳下 / 潜行时发 —— 地面移动的 forward / jump / sprint 完全不发。
|
||||||
|
* 带反作弊的服务器会判定「无输入却位移」并每 tick 把玩家拉回,
|
||||||
|
* 表现就是机器人原地抖动、怎么按方向键都不动。
|
||||||
|
*/
|
||||||
|
sendPlayerInput: true,
|
||||||
|
/**
|
||||||
|
* 是否每 tick 补发 `tick_end` 包(默认开启)。
|
||||||
|
*
|
||||||
|
* 原版 1.21.2+ 客户端在每 tick 末尾发送,反作弊用它判定 tick 边界;
|
||||||
|
* mineflayer 完全不发,会导致 PacketOrder / Timer 类判定异常。
|
||||||
|
*/
|
||||||
|
sendTickEnd: true
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==================== 大地图(新增) ====================
|
||||||
|
bigMap: {
|
||||||
|
/** 是否启用大地图(爬取服务器地图站点的瓦片图片与领土标记) */
|
||||||
|
enabled: true,
|
||||||
|
/** 地图站点上的世界目录名;留空则回退环境变量 DYNMAP_WORLD */
|
||||||
|
world: '',
|
||||||
|
/** 启动时预爬取的层级(0 = 全世界一张大图,本站为 10×10=100 张瓦片) */
|
||||||
|
zoom: 0,
|
||||||
|
/** 允许浏览的最大层级(本站 squaremap 的 zoom.max 为 3) */
|
||||||
|
maxZoom: 3,
|
||||||
|
/**
|
||||||
|
* 爬取半径(方块),**以机器人为中心**(见 followBot)。
|
||||||
|
*
|
||||||
|
* 层级 0 每张瓦片覆盖 4096 格,所以:
|
||||||
|
* 1024 格半径 = 1 张瓦片,4096 格 = 4 张,8192 格 = 9 张,20480 格 = 100 张。
|
||||||
|
* 只关心机器人周边时把半径调小,爬取和传输开销会差一个数量级。
|
||||||
|
*/
|
||||||
|
rangeBlocks: 4096,
|
||||||
|
/**
|
||||||
|
* 是否以机器人当前位置为中心爬取与显示大地图(默认开启)。
|
||||||
|
*
|
||||||
|
* 关闭时以世界原点 (0,0) 为中心 —— 只有在想看整张世界地图时才需要。
|
||||||
|
*/
|
||||||
|
followBot: true,
|
||||||
|
/** 启动时是否自动预爬取 */
|
||||||
|
crawlOnStart: true,
|
||||||
|
/** 瓦片下载并发数(对源站友好,默认 4) */
|
||||||
|
concurrency: 4
|
||||||
},
|
},
|
||||||
|
|
||||||
// ==================== 服务器交互(新增) ====================
|
// ==================== 服务器交互(新增) ====================
|
||||||
@@ -181,12 +269,49 @@ function defaultConfig() {
|
|||||||
loginCommand: 'login {password}',
|
loginCommand: 'login {password}',
|
||||||
/** 注册指令模板,`{password}` 会被替换;留空则不发送 */
|
/** 注册指令模板,`{password}` 会被替换;留空则不发送 */
|
||||||
registerCommand: '',
|
registerCommand: '',
|
||||||
|
/**
|
||||||
|
* 账号绑定 / 二次验证指令模板(`{password}` 会被替换),留空则不发送。
|
||||||
|
*
|
||||||
|
* 用于「简幻通」这类外站账号体系:新账号必须先用 `/auth <UID> <验证码>` 或扫码绑定。
|
||||||
|
* 扫码类验证无法自动完成,此时保持留空即可——程序会在日志里明确提示需要人工验证一次;
|
||||||
|
* 若该服务器提供固定验证码,把 `auth {password}` 之类填进来就能自动过。
|
||||||
|
*/
|
||||||
|
authCommand: '',
|
||||||
|
/**
|
||||||
|
* 允许在「登录验证通过之前」发送的指令前缀。
|
||||||
|
*
|
||||||
|
* 存在先有鸡还是先有蛋的问题:有些服务器的登录流程本身就是一条指令
|
||||||
|
* (`/login`、`/register`,简幻通体系还要先 `/auth`),
|
||||||
|
* 若把登录门禁一刀切,这些指令就永远发不出去,账号会卡在未验证状态被反复踢出。
|
||||||
|
* 因此这里列出的前缀可以绕过门禁,其余指令仍要求已完成验证。
|
||||||
|
*/
|
||||||
|
preLoginCommands: ['login', 'l', 'register', 'reg', 'auth'],
|
||||||
|
/**
|
||||||
|
* 是否允许在登录验证通过之前发送公屏聊天。
|
||||||
|
*
|
||||||
|
* 默认关闭。服务器**明确要求验证**时会自动放行(验证码常常是直接当聊天内容发出去的),
|
||||||
|
* 这个开关用于兜底那些没有任何提示、却必须先说话才能完成登录的服务器。
|
||||||
|
*/
|
||||||
|
allowChatBeforeLogin: false,
|
||||||
|
/**
|
||||||
|
* 登录结果确认超时(毫秒)。
|
||||||
|
* 部分离线服登录成功后不回明确提示,超时后按「已登录」放行,
|
||||||
|
* 否则机器人会永远卡在未登录状态而无法接受控制台操作;设为 0 表示禁用兜底。
|
||||||
|
*/
|
||||||
|
loginConfirmTimeoutMs: 10000,
|
||||||
/** 聊天指令最小间隔(毫秒),防刷屏 */
|
/** 聊天指令最小间隔(毫秒),防刷屏 */
|
||||||
commandCooldownMs: 1200,
|
commandCooldownMs: 1200,
|
||||||
/** 断线后是否自动重连 */
|
/** 断线后是否自动重连 */
|
||||||
autoReconnect: true,
|
autoReconnect: true,
|
||||||
/** 重连间隔(毫秒) */
|
/** 重连间隔(毫秒) */
|
||||||
reconnectDelayMs: 10000
|
reconnectDelayMs: 10000,
|
||||||
|
/**
|
||||||
|
* 重连间隔上限(毫秒)。
|
||||||
|
*
|
||||||
|
* 连续重连失败(例如账号未完成服务器验证被反复踢出)时按指数退避拉长间隔,
|
||||||
|
* 避免每 10 秒一次地硬撞服务器;成功进入世界后计数清零、间隔回到 reconnectDelayMs。
|
||||||
|
*/
|
||||||
|
reconnectMaxDelayMs: 120000
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -271,20 +396,47 @@ function validateConfig(config) {
|
|||||||
|
|
||||||
config.pathing.maxDropDown = clampInt(config.pathing.maxDropDown, 0, 64, 3);
|
config.pathing.maxDropDown = clampInt(config.pathing.maxDropDown, 0, 64, 3);
|
||||||
config.pathing.liquidCost = clampInt(config.pathing.liquidCost, 1, 1000, 40);
|
config.pathing.liquidCost = clampInt(config.pathing.liquidCost, 1, 1000, 40);
|
||||||
|
config.pathing.maxSegmentBlocks = clampInt(config.pathing.maxSegmentBlocks, 32, 4096, 512);
|
||||||
|
config.pathing.segmentMarginBlocks = clampInt(config.pathing.segmentMarginBlocks, 0, 256, 24);
|
||||||
|
config.pathing.thinkTimeoutMs = clampInt(config.pathing.thinkTimeoutMs, 100, 300000, 10000);
|
||||||
|
config.pathing.tickTimeoutMs = clampInt(config.pathing.tickTimeoutMs, 5, 5000, 40);
|
||||||
|
// searchRadius 允许为负(不限制),因此单独钳制下限
|
||||||
|
config.pathing.searchRadius = clampInt(config.pathing.searchRadius, -1, 100000, 256);
|
||||||
|
|
||||||
config.combat.scanRange = clampNumber(config.combat.scanRange, 1, 32, 4.5);
|
config.combat.scanRange = clampNumber(config.combat.scanRange, 1, 32, 4.5);
|
||||||
|
|
||||||
config.grim.maxRotationPerTickDeg = clampNumber(config.grim.maxRotationPerTickDeg, 1, 180, 8.6);
|
config.grim.maxRotationPerTickDeg = clampNumber(config.grim.maxRotationPerTickDeg, 1, 180, 8.6);
|
||||||
config.grim.attackReach = clampNumber(config.grim.attackReach, 1, 6, 3.0);
|
config.grim.attackReach = clampNumber(config.grim.attackReach, 1, 6, 3.0);
|
||||||
|
|
||||||
|
config.bigMap.zoom = clampInt(config.bigMap.zoom, 0, 8, 0);
|
||||||
|
config.bigMap.maxZoom = clampInt(config.bigMap.maxZoom, 0, 8, 3);
|
||||||
|
// 预爬取层级不可能超过可浏览的最大层级,否则会爬到源站不存在的瓦片
|
||||||
|
if (config.bigMap.zoom > config.bigMap.maxZoom) config.bigMap.zoom = config.bigMap.maxZoom;
|
||||||
|
config.bigMap.rangeBlocks = clampInt(config.bigMap.rangeBlocks, 256, 200000, 4096);
|
||||||
|
config.bigMap.concurrency = clampInt(config.bigMap.concurrency, 1, 16, 4);
|
||||||
|
if (typeof config.bigMap.world !== 'string') config.bigMap.world = '';
|
||||||
|
if (typeof config.bigMap.followBot !== 'boolean') config.bigMap.followBot = true;
|
||||||
|
|
||||||
config.server.commandCooldownMs = clampInt(config.server.commandCooldownMs, 0, 60000, 1200);
|
config.server.commandCooldownMs = clampInt(config.server.commandCooldownMs, 0, 60000, 1200);
|
||||||
config.server.reconnectDelayMs = clampInt(config.server.reconnectDelayMs, 1000, 600000, 10000);
|
config.server.reconnectDelayMs = clampInt(config.server.reconnectDelayMs, 1000, 600000, 10000);
|
||||||
|
config.server.reconnectMaxDelayMs = clampInt(config.server.reconnectMaxDelayMs, config.server.reconnectDelayMs, 3600000, 120000);
|
||||||
|
config.server.loginConfirmTimeoutMs = clampInt(config.server.loginConfirmTimeoutMs, 0, 300000, 10000);
|
||||||
|
if (typeof config.server.authCommand !== 'string') config.server.authCommand = '';
|
||||||
|
if (typeof config.server.allowChatBeforeLogin !== 'boolean') config.server.allowChatBeforeLogin = false;
|
||||||
|
if (!Array.isArray(config.server.preLoginCommands)) {
|
||||||
|
config.server.preLoginCommands = defaultConfig().server.preLoginCommands;
|
||||||
|
} else {
|
||||||
|
config.server.preLoginCommands = config.server.preLoginCommands
|
||||||
|
.filter((item) => typeof item === 'string' && item.trim())
|
||||||
|
.map((item) => item.trim().toLowerCase().replace(/^\//, ''));
|
||||||
|
}
|
||||||
|
|
||||||
if (config.waterMode !== WATER_MODE.SWIM && config.waterMode !== WATER_MODE.BOAT) {
|
if (config.waterMode !== WATER_MODE.SWIM && config.waterMode !== WATER_MODE.BOAT) {
|
||||||
config.waterMode = WATER_MODE.AVOID;
|
config.waterMode = WATER_MODE.AVOID;
|
||||||
}
|
}
|
||||||
if (typeof config.stateName !== 'string') config.stateName = '';
|
if (typeof config.stateName !== 'string') config.stateName = '';
|
||||||
if (typeof config.apiKey !== 'string') config.apiKey = '';
|
if (typeof config.apiKey !== 'string') config.apiKey = '';
|
||||||
|
if (typeof config.apiV2Key !== 'string') config.apiV2Key = '';
|
||||||
if (typeof config.server.claimCommand !== 'string') config.server.claimCommand = '';
|
if (typeof config.server.claimCommand !== 'string') config.server.claimCommand = '';
|
||||||
if (typeof config.server.loginCommand !== 'string') config.server.loginCommand = '';
|
if (typeof config.server.loginCommand !== 'string') config.server.loginCommand = '';
|
||||||
if (typeof config.server.registerCommand !== 'string') config.server.registerCommand = '';
|
if (typeof config.server.registerCommand !== 'string') config.server.registerCommand = '';
|
||||||
|
|||||||
@@ -50,11 +50,22 @@ class ExpansionController {
|
|||||||
* @param {import('../bot/botManager').BotManager} deps.botManager
|
* @param {import('../bot/botManager').BotManager} deps.botManager
|
||||||
* @param {import('../core/territoryDataService').TerritoryDataService} deps.dataService
|
* @param {import('../core/territoryDataService').TerritoryDataService} deps.dataService
|
||||||
* @param {import('../util/logger').Logger} deps.logger
|
* @param {import('../util/logger').Logger} deps.logger
|
||||||
|
* @param {string} [deps.botId] 所属机器人 id(多机器人场景,用于日志归属)
|
||||||
|
* @param {string} [deps.botName] 所属机器人游戏名
|
||||||
|
* @param {import('../core/expansionLog').ExpansionLog} [deps.expansionLog] 开拓日志
|
||||||
*/
|
*/
|
||||||
constructor(deps) {
|
constructor(deps) {
|
||||||
this.botManager = deps.botManager;
|
this.botManager = deps.botManager;
|
||||||
this.dataService = deps.dataService;
|
this.dataService = deps.dataService;
|
||||||
this.logger = deps.logger;
|
this.logger = deps.logger;
|
||||||
|
/** 所属机器人 id(默认取游戏名) */
|
||||||
|
this.botId = deps.botId || (deps.botManager && deps.botManager.connection
|
||||||
|
? deps.botManager.connection.username : 'bot');
|
||||||
|
/** 所属机器人游戏名 */
|
||||||
|
this.botName = deps.botName || (deps.botManager && deps.botManager.connection
|
||||||
|
? deps.botManager.connection.username : this.botId);
|
||||||
|
/** @type {import('../core/expansionLog').ExpansionLog|null} */
|
||||||
|
this.expansionLog = deps.expansionLog || null;
|
||||||
this.planner = new ExpansionPlanner();
|
this.planner = new ExpansionPlanner();
|
||||||
/** @type {import('../core/subtitleChatMonitor').SubtitleChatMonitor} */
|
/** @type {import('../core/subtitleChatMonitor').SubtitleChatMonitor} */
|
||||||
this.monitor = new SubtitleChatMonitor({
|
this.monitor = new SubtitleChatMonitor({
|
||||||
@@ -111,6 +122,13 @@ class ExpansionController {
|
|||||||
this.combatTargetEntityId = -1;
|
this.combatTargetEntityId = -1;
|
||||||
/** 当前是否正持续按住跳跃键(自动游泳用) */
|
/** 当前是否正持续按住跳跃键(自动游泳用) */
|
||||||
this.holdingJump = false;
|
this.holdingJump = false;
|
||||||
|
/**
|
||||||
|
* 本机器人实际开拓的邦国名(start() 时解析并固化)。
|
||||||
|
*
|
||||||
|
* 多机器人场景下每个机器人可能属于不同邦国,绝不能依赖领土模型的全局
|
||||||
|
* selfStateName(那会被最后启动的机器人覆盖)。
|
||||||
|
*/
|
||||||
|
this.stateName = '';
|
||||||
/** 是否已经发送过开垦模式指令 */
|
/** 是否已经发送过开垦模式指令 */
|
||||||
this._claimModeSent = false;
|
this._claimModeSent = false;
|
||||||
/** @type {((status: object) => void)|null} 状态变化回调(Web 控制台用) */
|
/** @type {((status: object) => void)|null} 状态变化回调(Web 控制台用) */
|
||||||
@@ -177,34 +195,36 @@ class ExpansionController {
|
|||||||
this.playerChunk = this._playerChunk();
|
this.playerChunk = this._playerChunk();
|
||||||
|
|
||||||
// ===== 识别己方邦国 =====
|
// ===== 识别己方邦国 =====
|
||||||
|
// 解析结果固化到 this.stateName,并写回本次任务配置,
|
||||||
|
// 使规划器不再依赖领土模型的全局 selfStateName(多机器人会互相覆盖)。
|
||||||
const model = this.dataService.getModel();
|
const model = this.dataService.getModel();
|
||||||
|
let resolvedName = '';
|
||||||
if (!this.config.stateName) {
|
if (!this.config.stateName) {
|
||||||
const auto = model.findContainingState(this.playerChunk);
|
const auto = model.findContainingState(this.playerChunk);
|
||||||
if (auto) {
|
if (auto) {
|
||||||
model.setSelfStateName(auto.name);
|
resolvedName = auto.name;
|
||||||
this.dataService.setSelectedStateName(auto.name);
|
|
||||||
} else {
|
} else {
|
||||||
// 兜底:通过 SimpCore API 按玩家名识别
|
// 兜底:通过 SimpCore API 按玩家名识别
|
||||||
const detected = await this.dataService.detectStateByPlayer(bm.connection.username);
|
const detected = await this.dataService.detectStateByPlayer(bm.connection.username);
|
||||||
if (detected) {
|
if (detected) {
|
||||||
this.dataService.getModel().setSelfStateName(detected);
|
resolvedName = detected;
|
||||||
this.dataService.setSelectedStateName(detected);
|
|
||||||
} else {
|
} else {
|
||||||
this.status = '无法自动识别邦国,请在控制台手动选择';
|
this.status = '无法自动识别邦国,请在控制台手动选择';
|
||||||
this.state = State.IDLE;
|
this.state = State.IDLE;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (!model.state(this.config.stateName)) {
|
||||||
|
this.status = '所选邦国不存在:' + this.config.stateName;
|
||||||
|
this.state = State.IDLE;
|
||||||
|
return false;
|
||||||
} else {
|
} else {
|
||||||
const model2 = this.dataService.getModel();
|
resolvedName = this.config.stateName;
|
||||||
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.stateName = resolvedName;
|
||||||
|
this.config = Object.assign({}, requestedConfig, { stateName: resolvedName });
|
||||||
|
// 全局选中邦国跟随最近启动的机器人,供控制台地图与数据源展示使用
|
||||||
|
this.dataService.setSelectedStateName(resolvedName);
|
||||||
|
|
||||||
// ===== 复位任务状态 =====
|
// ===== 复位任务状态 =====
|
||||||
this.completedClaims = 0;
|
this.completedClaims = 0;
|
||||||
@@ -231,7 +251,7 @@ class ExpansionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.setState(State.PLANNING, '正在生成扩张计划');
|
this.setState(State.PLANNING, '正在生成扩张计划');
|
||||||
this._log('开始自动扩地,邦国:' + this.dataService.getModel().selfStateNameValue());
|
this._log(`开始自动扩地,邦国:` + this.stateName);
|
||||||
await this.plan();
|
await this.plan();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -669,7 +689,7 @@ class ExpansionController {
|
|||||||
*/
|
*/
|
||||||
_isAdjacentToOwn(chunk) {
|
_isAdjacentToOwn(chunk) {
|
||||||
const model = this.dataService.getModel();
|
const model = this.dataService.getModel();
|
||||||
const self = model.selfState();
|
const self = model.state(this.stateName);
|
||||||
const ownChunks = self ? self.chunks : null;
|
const ownChunks = self ? self.chunks : null;
|
||||||
const sessionClaims = model.sessionClaimsSet();
|
const sessionClaims = model.sessionClaimsSet();
|
||||||
const x = chunk.x;
|
const x = chunk.x;
|
||||||
@@ -698,7 +718,7 @@ class ExpansionController {
|
|||||||
*/
|
*/
|
||||||
_ringDistance(chunk, model) {
|
_ringDistance(chunk, model) {
|
||||||
let min = Infinity;
|
let min = Infinity;
|
||||||
const self = model.selfState();
|
const self = model.state(this.stateName);
|
||||||
if (self) {
|
if (self) {
|
||||||
for (const k of self.chunks) {
|
for (const k of self.chunks) {
|
||||||
const pos = chunkPos.decode(k);
|
const pos = chunkPos.decode(k);
|
||||||
@@ -727,6 +747,10 @@ class ExpansionController {
|
|||||||
this._log('寻路事件:AT_GOAL');
|
this._log('寻路事件:AT_GOAL');
|
||||||
} else if (event === PathEvent.CALC_FINISHED_NOW_EXECUTING) {
|
} else if (event === PathEvent.CALC_FINISHED_NOW_EXECUTING) {
|
||||||
this._log(`寻路事件:路径已生成(代价 ${detail ? detail.cost : '?'})`);
|
this._log(`寻路事件:路径已生成(代价 ${detail ? detail.cost : '?'})`);
|
||||||
|
} else if (event === PathEvent.SEGMENT_REACHED) {
|
||||||
|
// 长距离分段寻路走完一段:这是「有进展」而不是卡住,
|
||||||
|
// 必须重置超时计时,否则走几十段的长途会被 waitTimeoutSeconds 误判为寻路超时
|
||||||
|
this.stageStartedMillis = Date.now();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -740,8 +764,10 @@ class ExpansionController {
|
|||||||
/**
|
/**
|
||||||
* 开垦成功(带具体坐标):把新占领区块加入模型与数据服务,累计完成数并进入 SUCCESS 状态。
|
* 开垦成功(带具体坐标):把新占领区块加入模型与数据服务,累计完成数并进入 SUCCESS 状态。
|
||||||
* @param {{x: number, z: number}} chunk
|
* @param {{x: number, z: number}} chunk
|
||||||
|
* @param {number} [blockX] 方块坐标 X(服务器回报值)
|
||||||
|
* @param {number} [blockZ] 方块坐标 Z(服务器回报值)
|
||||||
*/
|
*/
|
||||||
onSuccess(chunk) {
|
onSuccess(chunk, blockX, blockZ) {
|
||||||
if (!this.isRunning()) return;
|
if (!this.isRunning()) return;
|
||||||
const target = chunk || this.target;
|
const target = chunk || this.target;
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
@@ -749,8 +775,8 @@ class ExpansionController {
|
|||||||
// 去重:同一区块只计一次
|
// 去重:同一区块只计一次
|
||||||
if (this.claimedSet.has(key)) return;
|
if (this.claimedSet.has(key)) return;
|
||||||
this.claimedSet.add(key);
|
this.claimedSet.add(key);
|
||||||
this.dataService.getModel().addSessionClaim(target);
|
this.dataService.getModel().addSessionClaim(target, this.stateName);
|
||||||
this.dataService.addSessionClaim(target);
|
this.dataService.addSessionClaim(target, this.stateName);
|
||||||
// 占领成功后立即触发一次地图刷新(请求侧已带缓存击穿),缩短「会话占领等待确认」窗口
|
// 占领成功后立即触发一次地图刷新(请求侧已带缓存击穿),缩短「会话占领等待确认」窗口
|
||||||
this.dataService.refreshAsync();
|
this.dataService.refreshAsync();
|
||||||
this.completedClaims++;
|
this.completedClaims++;
|
||||||
@@ -758,6 +784,7 @@ class ExpansionController {
|
|||||||
this.target = target;
|
this.target = target;
|
||||||
this.lastProgress = 100;
|
this.lastProgress = 100;
|
||||||
this._log(`占领成功 ${target.x},${target.z}`);
|
this._log(`占领成功 ${target.x},${target.z}`);
|
||||||
|
this._recordClaim(target, blockX, blockZ, 'chat');
|
||||||
this.setState(State.SUCCESS, `疆土已开拓 ${target.x},${target.z}`);
|
this.setState(State.SUCCESS, `疆土已开拓 ${target.x},${target.z}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -774,16 +801,40 @@ class ExpansionController {
|
|||||||
// 去重:同一区块只计一次
|
// 去重:同一区块只计一次
|
||||||
if (this.claimedSet.has(key)) return;
|
if (this.claimedSet.has(key)) return;
|
||||||
this.claimedSet.add(key);
|
this.claimedSet.add(key);
|
||||||
this.dataService.getModel().addSessionClaim(this.target);
|
this.dataService.getModel().addSessionClaim(this.target, this.stateName);
|
||||||
this.dataService.addSessionClaim(this.target);
|
this.dataService.addSessionClaim(this.target, this.stateName);
|
||||||
this.dataService.refreshAsync();
|
this.dataService.refreshAsync();
|
||||||
this.completedClaims++;
|
this.completedClaims++;
|
||||||
this.consecutiveTimeoutMillis = 0;
|
this.consecutiveTimeoutMillis = 0;
|
||||||
this.lastProgress = 100;
|
this.lastProgress = 100;
|
||||||
this._log(`占领成功 ${this.target.x},${this.target.z}`);
|
this._log(`占领成功 ${this.target.x},${this.target.z}`);
|
||||||
|
this._recordClaim(this.target, null, null, 'chat-generic');
|
||||||
this.setState(State.SUCCESS, `疆土已开拓 ${this.target.x},${this.target.z}`);
|
this.setState(State.SUCCESS, `疆土已开拓 ${this.target.x},${this.target.z}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 写入开拓日志:详细记录开拓玩家、区块、时间与来源。
|
||||||
|
*
|
||||||
|
* @param {{x: number, z: number}} chunk 区块坐标
|
||||||
|
* @param {number|null} blockX 方块坐标 X
|
||||||
|
* @param {number|null} blockZ 方块坐标 Z
|
||||||
|
* @param {string} source 判定来源
|
||||||
|
*/
|
||||||
|
_recordClaim(chunk, blockX, blockZ, source) {
|
||||||
|
if (!this.expansionLog) return;
|
||||||
|
this.expansionLog.record({
|
||||||
|
botId: this.botId,
|
||||||
|
bot: this.botName,
|
||||||
|
state: this.stateName,
|
||||||
|
chunk,
|
||||||
|
block: Number.isFinite(blockX) && Number.isFinite(blockZ) ? { x: blockX, z: blockZ } : null,
|
||||||
|
source,
|
||||||
|
progress: 100,
|
||||||
|
// 本轮任务已开拓区块数,便于在日志里看出第几个
|
||||||
|
sessionCount: this.completedClaims
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 生存辅助 ====================
|
// ==================== 生存辅助 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -943,7 +994,9 @@ class ExpansionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_log(message, level) {
|
_log(message, level) {
|
||||||
if (this.logger) this.logger.log(message, level);
|
if (!this.logger) return;
|
||||||
|
// 多机器人共用一份运行日志:统一加机器人名前缀,便于区分来源
|
||||||
|
this.logger.log(`[${this.botName}] ${message}`, level);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -951,6 +1004,9 @@ class ExpansionController {
|
|||||||
*/
|
*/
|
||||||
snapshot() {
|
snapshot() {
|
||||||
return {
|
return {
|
||||||
|
botId: this.botId,
|
||||||
|
botName: this.botName,
|
||||||
|
stateName: this.stateName,
|
||||||
state: this.state,
|
state: this.state,
|
||||||
status: this.status,
|
status: this.status,
|
||||||
running: this.isRunning(),
|
running: this.isRunning(),
|
||||||
|
|||||||
@@ -0,0 +1,401 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 大地图瓦片服务:爬取 squaremap 站点(bgjq.simpfun.cn)的地图图片并本地缓存,
|
||||||
|
* 供控制台在一张大地图上展示所有机器人的坐标与领土标记。
|
||||||
|
*
|
||||||
|
* ## 坐标系(已对 source 站点实测标定)
|
||||||
|
*
|
||||||
|
* squaremap 前端用 `L.CRS.Simple` + `tileSize: 512`,瓦片路径为
|
||||||
|
* `tiles/<world>/<z>/<x>_<y>.png`,并且:
|
||||||
|
*
|
||||||
|
* - 瓦片图片固定 512×512 像素;
|
||||||
|
* - 最大原生层级 `zoom.max`(本站为 3),在该层级 1 方块 = 1 像素;
|
||||||
|
* - 因此层级 z 的每张瓦片覆盖 `512 * 2^(maxZoom - z)` 个方块,
|
||||||
|
* 瓦片 (x, y) 覆盖方块区间 `[x*B, (x+1)*B) × [y*B, (y+1)*B)`(B 即上式)。
|
||||||
|
*
|
||||||
|
* 本站实测:王城「现代大陆」(10722, 1547) 落在 z=3 的瓦片 (20, 3) 内,
|
||||||
|
* 与上述换算一致(20*512=10240 ≤ 10722 < 10752,3*512=1536 ≤ 1547 < 2048)。
|
||||||
|
*
|
||||||
|
* ## 缓存策略
|
||||||
|
*
|
||||||
|
* - 磁盘缓存 `data/mapcache/<world>/<z>/<x>_<y>.png`,命中即直接返回,不再回源;
|
||||||
|
* - 低层级(默认 z=0,全世界 10×10=100 张)在启动时批量爬取,保证大地图秒开;
|
||||||
|
* - 高层级按需穿透拉取(浏览器缩放时才请求),拉过就永久缓存。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 瓦片图片边长(像素) */
|
||||||
|
const TILE_PIXELS = 512;
|
||||||
|
/** 本站 squaremap 的最大原生层级(settings.json 的 zoom.max) */
|
||||||
|
const DEFAULT_MAX_ZOOM = 3;
|
||||||
|
/** 默认覆盖半径(方块):与服务器世界边界 ±20480 一致 */
|
||||||
|
const DEFAULT_RANGE_BLOCKS = 20480;
|
||||||
|
/** 单张瓦片下载超时(毫秒) */
|
||||||
|
const TILE_TIMEOUT_MS = 20000;
|
||||||
|
|
||||||
|
class BigMapService {
|
||||||
|
/**
|
||||||
|
* @param {object} config 完整配置对象
|
||||||
|
* @param {import('../util/logger').Logger} [logger]
|
||||||
|
* @param {{baseUrl?: string, world?: string, cacheDir?: string, fetchImpl?: Function}} [options]
|
||||||
|
*/
|
||||||
|
constructor(config, logger, options) {
|
||||||
|
const opts = options || {};
|
||||||
|
this.config = config;
|
||||||
|
this.logger = logger || null;
|
||||||
|
this.baseUrl = String(opts.baseUrl || process.env.DYNMAP_BASE || 'http://bgjq.simpfun.cn').replace(/\/+$/, '');
|
||||||
|
this.world = opts.world || (config && config.bigMap && config.bigMap.world) || process.env.DYNMAP_WORLD || 'minecraft_overworld';
|
||||||
|
this.cacheDir = opts.cacheDir || path.join(__dirname, '..', '..', 'data', 'mapcache');
|
||||||
|
this.fetchImpl = opts.fetchImpl || globalThis.fetch;
|
||||||
|
|
||||||
|
/** @type {Map<string, Buffer>} 内存 LRU(瓦片体积小,按层级分目录缓存到磁盘为主) */
|
||||||
|
this._memory = new Map();
|
||||||
|
this._memoryLimit = 256;
|
||||||
|
/** 爬取进度 */
|
||||||
|
this.crawl = {
|
||||||
|
running: false,
|
||||||
|
zoom: 0,
|
||||||
|
total: 0,
|
||||||
|
done: 0,
|
||||||
|
failed: 0,
|
||||||
|
startedAt: 0,
|
||||||
|
finishedAt: 0,
|
||||||
|
lastError: ''
|
||||||
|
};
|
||||||
|
this._crawlAbort = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 坐标系 ====================
|
||||||
|
|
||||||
|
/** @returns {number} 当前允许浏览的最大层级 */
|
||||||
|
get maxZoom() {
|
||||||
|
const configured = this.config && this.config.bigMap ? this.config.bigMap.maxZoom : undefined;
|
||||||
|
return Number.isFinite(configured) ? configured : DEFAULT_MAX_ZOOM;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 层级 z 下每张瓦片覆盖的方块数。
|
||||||
|
* @param {number} zoom
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
blocksPerTile(zoom) {
|
||||||
|
return TILE_PIXELS * Math.pow(2, this.maxZoom - clampZoom(zoom, this.maxZoom));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算覆盖以 `center` 为中心、半径 `rangeBlocks` 的方形区域所需的瓦片下标范围(闭区间)。
|
||||||
|
*
|
||||||
|
* 默认中心是原点(全世界),但实际使用时通常只关心**机器人所在位置周边**的图幅 ——
|
||||||
|
* 全世界层级 0 是 100 张瓦片,而 1024 格半径只需 1 张、4096 格半径只需 4 张,
|
||||||
|
* 爬取和传输的开销差一个数量级。
|
||||||
|
*
|
||||||
|
* @param {number} zoom 层级
|
||||||
|
* @param {number} [rangeBlocks] 覆盖半径(方块),缺省取配置
|
||||||
|
* @param {number} [centerBlockX] 中心方块 X,缺省 0
|
||||||
|
* @param {number} [centerBlockZ] 中心方块 Z,缺省 0
|
||||||
|
* @returns {{minX: number, maxX: number, minZ: number, maxZ: number, count: number,
|
||||||
|
* centerBlockX: number, centerBlockZ: number, rangeBlocks: number}}
|
||||||
|
*/
|
||||||
|
tileRange(zoom, rangeBlocks, centerBlockX, centerBlockZ) {
|
||||||
|
const radius = Number.isFinite(rangeBlocks)
|
||||||
|
? Math.abs(rangeBlocks)
|
||||||
|
: (this.config && this.config.bigMap ? this.config.bigMap.rangeBlocks : DEFAULT_RANGE_BLOCKS);
|
||||||
|
const centerX = Number.isFinite(centerBlockX) ? centerBlockX : 0;
|
||||||
|
const centerZ = Number.isFinite(centerBlockZ) ? centerBlockZ : 0;
|
||||||
|
const perTile = this.blocksPerTile(zoom);
|
||||||
|
const minX = Math.floor((centerX - radius) / perTile);
|
||||||
|
const maxX = Math.ceil((centerX + radius) / perTile) - 1;
|
||||||
|
const minZ = Math.floor((centerZ - radius) / perTile);
|
||||||
|
const maxZ = Math.ceil((centerZ + radius) / perTile) - 1;
|
||||||
|
return {
|
||||||
|
minX,
|
||||||
|
maxX,
|
||||||
|
minZ,
|
||||||
|
maxZ,
|
||||||
|
count: (maxX - minX + 1) * (maxZ - minZ + 1),
|
||||||
|
centerBlockX: centerX,
|
||||||
|
centerBlockZ: centerZ,
|
||||||
|
rangeBlocks: radius
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 瓦片覆盖的方块区间(闭开区间 `[min, max)`)。
|
||||||
|
* @param {number} zoom
|
||||||
|
* @param {number} tileX
|
||||||
|
* @param {number} tileZ
|
||||||
|
* @returns {{minBlockX: number, maxBlockX: number, minBlockZ: number, maxBlockZ: number}}
|
||||||
|
*/
|
||||||
|
tileBlockBounds(zoom, tileX, tileZ) {
|
||||||
|
const perTile = this.blocksPerTile(zoom);
|
||||||
|
return {
|
||||||
|
minBlockX: tileX * perTile,
|
||||||
|
maxBlockX: (tileX + 1) * perTile,
|
||||||
|
minBlockZ: tileZ * perTile,
|
||||||
|
maxBlockZ: (tileZ + 1) * perTile
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {string} 某个瓦片的回源地址 */
|
||||||
|
tileUrl(zoom, tileX, tileZ) {
|
||||||
|
return `${this.baseUrl}/tiles/${encodeURIComponent(this.world)}/${clampZoom(zoom, this.maxZoom)}/${tileX}_${tileZ}.png`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {string} 某个瓦片的磁盘缓存路径 */
|
||||||
|
tilePath(zoom, tileX, tileZ) {
|
||||||
|
return path.join(this.cacheDir, sanitize(this.world), String(clampZoom(zoom, this.maxZoom)), `${tileX}_${tileZ}.png`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 瓦片读取 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取一张瓦片:内存 → 磁盘 → 回源。
|
||||||
|
*
|
||||||
|
* @param {number} zoom
|
||||||
|
* @param {number} tileX
|
||||||
|
* @param {number} tileZ
|
||||||
|
* @returns {Promise<Buffer|null>} 瓦片 PNG 数据;失败返回 null
|
||||||
|
*/
|
||||||
|
async getTile(zoom, tileX, tileZ) {
|
||||||
|
const z = clampZoom(zoom, this.maxZoom);
|
||||||
|
const key = `${z}/${tileX}_${tileZ}`;
|
||||||
|
const cached = this._memory.get(key);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const file = this.tilePath(z, tileX, tileZ);
|
||||||
|
try {
|
||||||
|
const data = fs.readFileSync(file);
|
||||||
|
if (data.length > 0) {
|
||||||
|
this._remember(key, data);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 未缓存,继续回源
|
||||||
|
}
|
||||||
|
return this._download(z, tileX, tileZ);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从源站下载并落盘;失败返回 null(控制台会画占位色块) */
|
||||||
|
async _download(zoom, tileX, tileZ) {
|
||||||
|
const url = this.tileUrl(zoom, tileX, tileZ);
|
||||||
|
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
||||||
|
const timer = controller ? setTimeout(() => controller.abort(), TILE_TIMEOUT_MS) : null;
|
||||||
|
try {
|
||||||
|
const response = await this.fetchImpl(url, {
|
||||||
|
signal: controller ? controller.signal : undefined,
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 autostore-node/1.0',
|
||||||
|
Accept: 'image/png,image/*,*/*'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!response.ok) return null;
|
||||||
|
const buffer = Buffer.from(await response.arrayBuffer());
|
||||||
|
if (buffer.length === 0) return null;
|
||||||
|
this._remember(`${zoom}/${tileX}_${tileZ}`, buffer);
|
||||||
|
this._writeCache(zoom, tileX, tileZ, buffer);
|
||||||
|
return buffer;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 落盘缓存(失败静默:缓存只是加速手段,不影响功能) */
|
||||||
|
_writeCache(zoom, tileX, tileZ, buffer) {
|
||||||
|
try {
|
||||||
|
const file = this.tilePath(zoom, tileX, tileZ);
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
fs.writeFileSync(file, buffer);
|
||||||
|
} catch {
|
||||||
|
// 忽略
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写入内存 LRU(简单实现:超限时丢掉最早插入的若干项) */
|
||||||
|
_remember(key, buffer) {
|
||||||
|
this._memory.set(key, buffer);
|
||||||
|
while (this._memory.size > this._memoryLimit) {
|
||||||
|
const oldest = this._memory.keys().next();
|
||||||
|
if (oldest.done) break;
|
||||||
|
this._memory.delete(oldest.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 批量爬取 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量爬取某一层级、某一区域内的全部瓦片(已缓存则跳过)。
|
||||||
|
*
|
||||||
|
* 区域默认以原点为中心;实际使用时通常传机器人所在坐标,只爬它周边那几平方公里,
|
||||||
|
* 这样图幅可以自由调整而不用把整个世界都拉下来。
|
||||||
|
*
|
||||||
|
* 重复调用时若已有爬取在途则直接返回当前进度,不会并发起多轮。
|
||||||
|
*
|
||||||
|
* @param {number} [zoom] 层级,缺省取配置里的 bigMap.zoom
|
||||||
|
* @param {{force?: boolean, centerBlockX?: number, centerBlockZ?: number, rangeBlocks?: number}} [options]
|
||||||
|
* @returns {Promise<object>} 爬取结果统计
|
||||||
|
*/
|
||||||
|
async crawlAll(zoom, options) {
|
||||||
|
if (this.crawl.running) return this.snapshot().crawl;
|
||||||
|
const opts = options || {};
|
||||||
|
const z = clampZoom(Number.isFinite(zoom) ? zoom : (this.config.bigMap.zoom || 0), this.maxZoom);
|
||||||
|
const range = this.tileRange(z, opts.rangeBlocks, opts.centerBlockX, opts.centerBlockZ);
|
||||||
|
this._crawlAbort = false;
|
||||||
|
this.crawl = {
|
||||||
|
running: true,
|
||||||
|
zoom: z,
|
||||||
|
total: range.count,
|
||||||
|
done: 0,
|
||||||
|
failed: 0,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
finishedAt: 0,
|
||||||
|
lastError: '',
|
||||||
|
centerBlockX: range.centerBlockX,
|
||||||
|
centerBlockZ: range.centerBlockZ,
|
||||||
|
rangeBlocks: range.rangeBlocks
|
||||||
|
};
|
||||||
|
if (this.logger) {
|
||||||
|
this.logger.log(`开始爬取大地图瓦片:层级 ${z},中心 ${range.centerBlockX},${range.centerBlockZ},`
|
||||||
|
+ `半径 ${range.rangeBlocks} 格,瓦片 ${range.minX}..${range.maxX} × ${range.minZ}..${range.maxZ},共 ${range.count} 张`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks = [];
|
||||||
|
for (let x = range.minX; x <= range.maxX; x++) {
|
||||||
|
for (let y = range.minZ; y <= range.maxZ; y++) tasks.push([x, y]);
|
||||||
|
}
|
||||||
|
const concurrency = Math.max(1, Math.min(16, (this.config.bigMap && this.config.bigMap.concurrency) || 4));
|
||||||
|
let cursor = 0;
|
||||||
|
const worker = async () => {
|
||||||
|
while (cursor < tasks.length && !this._crawlAbort) {
|
||||||
|
const task = tasks[cursor++];
|
||||||
|
const buffer = await this._fetchForCrawl(z, task[0], task[1], opts.force === true);
|
||||||
|
if (buffer) this.crawl.done++;
|
||||||
|
else this.crawl.failed++;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const workers = [];
|
||||||
|
for (let i = 0; i < concurrency; i++) workers.push(worker());
|
||||||
|
await Promise.all(workers);
|
||||||
|
|
||||||
|
this.crawl.running = false;
|
||||||
|
this.crawl.finishedAt = Date.now();
|
||||||
|
if (this.logger) {
|
||||||
|
const seconds = ((this.crawl.finishedAt - this.crawl.startedAt) / 1000).toFixed(1);
|
||||||
|
this.logger.log(`大地图瓦片爬取完成:成功 ${this.crawl.done} / 失败 ${this.crawl.failed},用时 ${seconds} 秒`, 'success');
|
||||||
|
}
|
||||||
|
return this.snapshot().crawl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 爬取单张瓦片:非强制模式下已缓存则直接计入成功 */
|
||||||
|
async _fetchForCrawl(zoom, tileX, tileZ, force) {
|
||||||
|
if (!force) {
|
||||||
|
try {
|
||||||
|
if (fs.statSync(this.tilePath(zoom, tileX, tileZ)).size > 0) return true;
|
||||||
|
} catch {
|
||||||
|
// 未缓存
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (await this._download(zoom, tileX, tileZ)) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 中断进行中的爬取 */
|
||||||
|
abortCrawl() {
|
||||||
|
this._crawlAbort = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 状态 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{centerBlockX?: number, centerBlockZ?: number, rangeBlocks?: number}} [options]
|
||||||
|
* @returns {object} 大地图状态快照(供控制台与前端渲染使用)
|
||||||
|
*/
|
||||||
|
snapshot(options) {
|
||||||
|
const opts = options || {};
|
||||||
|
const zoom = this.config && this.config.bigMap ? this.config.bigMap.zoom : 0;
|
||||||
|
const centerX = Number.isFinite(opts.centerBlockX) ? opts.centerBlockX : 0;
|
||||||
|
const centerZ = Number.isFinite(opts.centerBlockZ) ? opts.centerBlockZ : 0;
|
||||||
|
const range = this.tileRange(zoom, opts.rangeBlocks, centerX, centerZ);
|
||||||
|
return {
|
||||||
|
enabled: !!(this.config && this.config.bigMap && this.config.bigMap.enabled),
|
||||||
|
baseUrl: this.baseUrl,
|
||||||
|
world: this.world,
|
||||||
|
tilePixels: TILE_PIXELS,
|
||||||
|
maxZoom: this.maxZoom,
|
||||||
|
zoom,
|
||||||
|
blocksPerTile: this.blocksPerTile(zoom),
|
||||||
|
rangeBlocks: range.rangeBlocks,
|
||||||
|
followBot: !!(this.config && this.config.bigMap && this.config.bigMap.followBot),
|
||||||
|
tiles: range,
|
||||||
|
cached: this.countCached(zoom),
|
||||||
|
source: {
|
||||||
|
tileUrlTemplate: `${this.baseUrl}/tiles/${this.world}/{z}/{x}_{y}.png`,
|
||||||
|
markersUrl: `${this.baseUrl}/tiles/${this.world}/markers.json`
|
||||||
|
},
|
||||||
|
crawl: Object.assign({}, this.crawl)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计某层级已缓存的瓦片数量。
|
||||||
|
* @param {number} zoom
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
countCached(zoom) {
|
||||||
|
const dir = path.join(this.cacheDir, sanitize(this.world), String(clampZoom(zoom, this.maxZoom)));
|
||||||
|
try {
|
||||||
|
return fs.readdirSync(dir).filter((name) => name.endsWith('.png')).length;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空磁盘与内存缓存。
|
||||||
|
* @returns {number} 删除的瓦片数
|
||||||
|
*/
|
||||||
|
clearCache() {
|
||||||
|
let removed = 0;
|
||||||
|
const root = path.join(this.cacheDir, sanitize(this.world));
|
||||||
|
try {
|
||||||
|
removed = countFiles(root);
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// 忽略
|
||||||
|
}
|
||||||
|
this._memory.clear();
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 递归统计目录下的文件数 */
|
||||||
|
function countFiles(dir) {
|
||||||
|
let total = 0;
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
if (entry.isDirectory()) total += countFiles(path.join(dir, entry.name));
|
||||||
|
else total++;
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 世界名可能含路径分隔符,落盘前统一安全化 */
|
||||||
|
function sanitize(name) {
|
||||||
|
return String(name).replace(/[\\/:*?"<>|]/g, '_') || 'world';
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampZoom(zoom, max) {
|
||||||
|
const n = Number(zoom);
|
||||||
|
if (!Number.isFinite(n)) return 0;
|
||||||
|
return Math.min(max, Math.max(0, Math.trunc(n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
BigMapService,
|
||||||
|
TILE_PIXELS,
|
||||||
|
DEFAULT_MAX_ZOOM,
|
||||||
|
DEFAULT_RANGE_BLOCKS,
|
||||||
|
TILE_TIMEOUT_MS
|
||||||
|
};
|
||||||
+225
@@ -0,0 +1,225 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
/** 每个机器人在内存中保留的公屏消息条数上限 */
|
||||||
|
const MAX_ENTRIES_PER_BOT = 2000;
|
||||||
|
/** 单个机器人聊天日志文件大小上限:超过后轮转为 `.1` */
|
||||||
|
const MAX_FILE_BYTES = 16 * 1024 * 1024;
|
||||||
|
|
||||||
|
/** 消息类型 */
|
||||||
|
const ChatKind = {
|
||||||
|
/** 公屏聊天(其他玩家发言) */
|
||||||
|
CHAT: 'chat',
|
||||||
|
/** 系统消息 / 服务器广播 */
|
||||||
|
SYSTEM: 'system',
|
||||||
|
/** 动作栏、Boss 条等游戏信息 */
|
||||||
|
ACTION: 'action',
|
||||||
|
/** 本机器人发出的聊天内容 */
|
||||||
|
OUT: 'out',
|
||||||
|
/** 本机器人发出的指令 */
|
||||||
|
COMMAND: 'command'
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 游戏公屏日志:把每个机器人收到的**全部**公屏 / 系统消息按机器人分文件留档,
|
||||||
|
* 同时保留内存环形缓冲供控制台实时查看。
|
||||||
|
*
|
||||||
|
* 与原 Java 版只留「开垦相关」消息不同,这里要求「完整日志」:
|
||||||
|
* 所有 messagestr / message 事件都落盘,便于事后核对谁在什么时候说了什么、
|
||||||
|
* 服务器在什么时候发了什么公告。
|
||||||
|
*/
|
||||||
|
class ChatLog {
|
||||||
|
/**
|
||||||
|
* @param {{dir?: string, logger?: import('../util/logger').Logger, maxEntriesPerBot?: number}} [options]
|
||||||
|
*/
|
||||||
|
constructor(options) {
|
||||||
|
const opts = options || {};
|
||||||
|
this.dir = opts.dir || path.join(__dirname, '..', '..', 'data', 'chat');
|
||||||
|
this.logger = opts.logger || null;
|
||||||
|
this.maxEntriesPerBot = opts.maxEntriesPerBot || MAX_ENTRIES_PER_BOT;
|
||||||
|
/** @type {Map<string, Array<object>>} 机器人 id → 消息环形缓冲 */
|
||||||
|
this.buffers = new Map();
|
||||||
|
/** @type {Map<string, number>} 机器人 id → 累计消息数 */
|
||||||
|
this.counters = new Map();
|
||||||
|
/** @type {((entry: object) => void)|null} 新消息回调(WebSocket 实时推送用) */
|
||||||
|
this.onEntry = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追加一条公屏消息。
|
||||||
|
*
|
||||||
|
* @param {object} event
|
||||||
|
* @param {string} event.botId 机器人 id
|
||||||
|
* @param {string} [event.bot] 机器人游戏名
|
||||||
|
* @param {string} event.message 消息正文
|
||||||
|
* @param {string} [event.kind] 消息类型,见 {@link ChatKind}
|
||||||
|
* @param {string} [event.username] 发言人(公屏消息)
|
||||||
|
* @param {number} [event.time] 时间戳(缺省取当前时间)
|
||||||
|
* @returns {object|null} 写入的记录;消息为空时返回 null
|
||||||
|
*/
|
||||||
|
append(event) {
|
||||||
|
const text = event.message === undefined || event.message === null ? '' : String(event.message);
|
||||||
|
if (!text) return null;
|
||||||
|
const botId = String(event.botId || event.bot || 'bot');
|
||||||
|
const entry = {
|
||||||
|
t: Number.isFinite(event.time) ? event.time : Date.now(),
|
||||||
|
botId,
|
||||||
|
bot: String(event.bot || botId),
|
||||||
|
kind: event.kind || ChatKind.CHAT,
|
||||||
|
username: event.username ? String(event.username) : '',
|
||||||
|
message: text
|
||||||
|
};
|
||||||
|
let buffer = this.buffers.get(botId);
|
||||||
|
if (!buffer) {
|
||||||
|
buffer = [];
|
||||||
|
this.buffers.set(botId, buffer);
|
||||||
|
}
|
||||||
|
buffer.push(entry);
|
||||||
|
while (buffer.length > this.maxEntriesPerBot) buffer.shift();
|
||||||
|
this.counters.set(botId, (this.counters.get(botId) || 0) + 1);
|
||||||
|
this._appendToFile(entry);
|
||||||
|
if (this.onEntry) {
|
||||||
|
try {
|
||||||
|
this.onEntry(entry);
|
||||||
|
} catch {
|
||||||
|
// 订阅者异常不得影响主流程
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询某机器人的公屏消息(时间升序,最新在最后)。
|
||||||
|
*
|
||||||
|
* @param {string} botId
|
||||||
|
* @param {{limit?: number, since?: number, kind?: string, keyword?: string}} [filter]
|
||||||
|
* @returns {Array<object>}
|
||||||
|
*/
|
||||||
|
list(botId, filter) {
|
||||||
|
const opts = filter || {};
|
||||||
|
const buffer = this.buffers.get(String(botId)) || [];
|
||||||
|
let result = buffer;
|
||||||
|
if (Number.isFinite(opts.since)) result = result.filter((e) => e.t >= opts.since);
|
||||||
|
if (opts.kind) result = result.filter((e) => e.kind === opts.kind);
|
||||||
|
if (opts.keyword) {
|
||||||
|
const needle = String(opts.keyword).toLowerCase();
|
||||||
|
result = result.filter((e) => e.message.toLowerCase().includes(needle));
|
||||||
|
}
|
||||||
|
const limit = Number.isFinite(opts.limit) && opts.limit > 0 ? Math.trunc(opts.limit) : 300;
|
||||||
|
return result.length > limit ? result.slice(result.length - limit) : result.slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {object} 公屏日志状态快照(供控制台展示) */
|
||||||
|
snapshot() {
|
||||||
|
const bots = [];
|
||||||
|
for (const [botId, buffer] of this.buffers) {
|
||||||
|
bots.push({
|
||||||
|
botId,
|
||||||
|
count: this.counters.get(botId) || 0,
|
||||||
|
buffered: buffer.length,
|
||||||
|
lastAt: buffer.length > 0 ? buffer[buffer.length - 1].t : 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
bots.sort((a, b) => b.count - a.count);
|
||||||
|
return { dir: this.dir, bots, total: sumCounters(this.counters) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出某机器人的公屏日志为纯文本文件。
|
||||||
|
*
|
||||||
|
* @param {string} botId
|
||||||
|
* @param {string} filePath 目标路径
|
||||||
|
* @returns {string|null} 导出文件路径;失败返回 null
|
||||||
|
*/
|
||||||
|
exportTo(botId, filePath) {
|
||||||
|
try {
|
||||||
|
const rows = this.list(botId, { limit: Number.MAX_SAFE_INTEGER });
|
||||||
|
const lines = [`==== ${botId} 游戏公屏日志(${rows.length} 条)====`];
|
||||||
|
for (const e of rows) {
|
||||||
|
lines.push(`[${formatDateTime(e.t)}] ${kindLabel(e.kind)}${e.username ? e.username + ': ' : ''}${e.message}`);
|
||||||
|
}
|
||||||
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
|
fs.writeFileSync(filePath, lines.join('\n'), 'utf8');
|
||||||
|
return filePath;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 机器人对应的落盘文件名(把不安全字符替换为下划线) */
|
||||||
|
filePathOf(botId) {
|
||||||
|
return path.join(this.dir, `${sanitize(String(botId))}.jsonl`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 追加一行到磁盘(失败只记日志,绝不影响机器人运行) */
|
||||||
|
_appendToFile(entry) {
|
||||||
|
const file = this.filePathOf(entry.botId);
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(this.dir, { recursive: true });
|
||||||
|
rotateIfNeeded(file);
|
||||||
|
fs.appendFileSync(file, JSON.stringify(entry) + '\n', 'utf8');
|
||||||
|
} catch (err) {
|
||||||
|
if (this.logger) {
|
||||||
|
this.logger.log('公屏日志写入失败:' + (err && err.message ? err.message : String(err)), 'warn');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文件名安全化:去掉路径分隔符与 Windows 保留字符 */
|
||||||
|
function sanitize(name) {
|
||||||
|
return name.replace(/[\\/:*?"<>|]/g, '_') || 'bot';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文件超过上限时轮转为 `.1` */
|
||||||
|
function rotateIfNeeded(file) {
|
||||||
|
let size = 0;
|
||||||
|
try {
|
||||||
|
size = fs.statSync(file).size;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (size < MAX_FILE_BYTES) return;
|
||||||
|
try {
|
||||||
|
fs.renameSync(file, file + '.1');
|
||||||
|
} catch {
|
||||||
|
// 轮转失败时继续追加,不阻塞
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function kindLabel(kind) {
|
||||||
|
if (kind === ChatKind.OUT) return '【发言】';
|
||||||
|
if (kind === ChatKind.COMMAND) return '【指令】';
|
||||||
|
if (kind === ChatKind.SYSTEM) return '【系统】';
|
||||||
|
if (kind === ChatKind.ACTION) return '【动作栏】';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumCounters(counters) {
|
||||||
|
let total = 0;
|
||||||
|
for (const value of counters.values()) total += value;
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化为 `YYYY-MM-DD HH:mm:ss`。
|
||||||
|
* @param {number} millis
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function formatDateTime(millis) {
|
||||||
|
const d = new Date(millis);
|
||||||
|
const pad = (n) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} `
|
||||||
|
+ `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ChatLog,
|
||||||
|
ChatKind,
|
||||||
|
sanitize,
|
||||||
|
formatDateTime,
|
||||||
|
MAX_ENTRIES_PER_BOT,
|
||||||
|
MAX_FILE_BYTES
|
||||||
|
};
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
/** 内存中保留的开拓日志条数上限(磁盘上的 JSONL 不受此限制) */
|
||||||
|
const MAX_ENTRIES = 5000;
|
||||||
|
/** 单文件大小上限:超过后轮转为 `.1`,避免长期挂机把磁盘写满 */
|
||||||
|
const MAX_FILE_BYTES = 16 * 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开拓日志:逐条记录「哪个机器人、在什么时间、开拓了哪个区块」。
|
||||||
|
*
|
||||||
|
* 之所以单独成模块而不是复用 {@link module:util/logger}:
|
||||||
|
* - 运行日志是给人看的文本,会被环形缓冲滚掉;
|
||||||
|
* - 开拓日志是结构化数据,需要按机器人 / 时间 / 邦国筛选、统计与导出,
|
||||||
|
* 并且必须落盘(挂机数天后仍能查到第一个区块是谁开的)。
|
||||||
|
*
|
||||||
|
* 落盘格式为 JSONL(每行一条 JSON),追加写入,崩溃时最多丢最后一行。
|
||||||
|
*/
|
||||||
|
class ExpansionLog {
|
||||||
|
/**
|
||||||
|
* @param {{filePath?: string, logger?: import('../util/logger').Logger, maxEntries?: number}} [options]
|
||||||
|
*/
|
||||||
|
constructor(options) {
|
||||||
|
const opts = options || {};
|
||||||
|
this.filePath = opts.filePath || path.join(__dirname, '..', '..', 'data', 'expansion-log.jsonl');
|
||||||
|
this.logger = opts.logger || null;
|
||||||
|
this.maxEntries = opts.maxEntries || MAX_ENTRIES;
|
||||||
|
/** @type {Array<object>} 最近的开拓记录(时间升序) */
|
||||||
|
this.entries = [];
|
||||||
|
/** @type {((entry: object) => void)|null} 新记录回调(WebSocket 实时推送用) */
|
||||||
|
this.onEntry = null;
|
||||||
|
/** 累计写入条数(含被环形缓冲滚掉与进程重启前写入的) */
|
||||||
|
this.totalRecorded = 0;
|
||||||
|
/** 按机器人统计的开拓数 */
|
||||||
|
this.byBot = new Map();
|
||||||
|
this._load();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录一次开拓。
|
||||||
|
*
|
||||||
|
* @param {object} event
|
||||||
|
* @param {string} event.botId 机器人 id
|
||||||
|
* @param {string} event.bot 机器人游戏名
|
||||||
|
* @param {string} [event.state] 邦国名
|
||||||
|
* @param {{x: number, z: number}} event.chunk 区块坐标
|
||||||
|
* @param {{x: number, z: number}|null} [event.block] 方块坐标(服务器回报)
|
||||||
|
* @param {string} [event.source] 判定来源:chat / chat-generic / subtitle
|
||||||
|
* @param {number} [event.sessionCount] 本轮任务已开拓数
|
||||||
|
* @returns {object} 写入的记录
|
||||||
|
*/
|
||||||
|
record(event) {
|
||||||
|
const chunk = event.chunk || { x: 0, z: 0 };
|
||||||
|
const entry = {
|
||||||
|
t: Date.now(),
|
||||||
|
botId: String(event.botId || event.bot || 'bot'),
|
||||||
|
bot: String(event.bot || event.botId || 'bot'),
|
||||||
|
state: event.state || '',
|
||||||
|
cx: toInt(chunk.x),
|
||||||
|
cz: toInt(chunk.z),
|
||||||
|
bx: event.block ? toInt(event.block.x) : null,
|
||||||
|
bz: event.block ? toInt(event.block.z) : null,
|
||||||
|
source: event.source || 'chat',
|
||||||
|
sessionCount: Number.isFinite(event.sessionCount) ? event.sessionCount : null
|
||||||
|
};
|
||||||
|
this.entries.push(entry);
|
||||||
|
while (this.entries.length > this.maxEntries) this.entries.shift();
|
||||||
|
this.totalRecorded++;
|
||||||
|
this.byBot.set(entry.botId, (this.byBot.get(entry.botId) || 0) + 1);
|
||||||
|
this._appendToFile(entry);
|
||||||
|
if (this.onEntry) {
|
||||||
|
try {
|
||||||
|
this.onEntry(entry);
|
||||||
|
} catch {
|
||||||
|
// 订阅者异常不得影响主流程
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询开拓日志(时间升序返回,最新在最后)。
|
||||||
|
*
|
||||||
|
* @param {{limit?: number, botId?: string, state?: string, since?: number, until?: number}} [filter]
|
||||||
|
* @returns {Array<object>}
|
||||||
|
*/
|
||||||
|
list(filter) {
|
||||||
|
const opts = filter || {};
|
||||||
|
let result = this.entries;
|
||||||
|
if (opts.botId) result = result.filter((e) => e.botId === opts.botId);
|
||||||
|
if (opts.state) result = result.filter((e) => e.state === opts.state);
|
||||||
|
if (Number.isFinite(opts.since)) result = result.filter((e) => e.t >= opts.since);
|
||||||
|
if (Number.isFinite(opts.until)) result = result.filter((e) => e.t <= opts.until);
|
||||||
|
const limit = Number.isFinite(opts.limit) && opts.limit > 0 ? Math.trunc(opts.limit) : 200;
|
||||||
|
// 取最新的 limit 条,但仍按时间升序返回,方便前端直接追加渲染
|
||||||
|
return result.length > limit ? result.slice(result.length - limit) : result.slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计信息:总数、按机器人分布、时间跨度、今日开拓数。
|
||||||
|
* @returns {object}
|
||||||
|
*/
|
||||||
|
stats() {
|
||||||
|
const bots = [];
|
||||||
|
for (const [botId, count] of this.byBot) {
|
||||||
|
let name = botId;
|
||||||
|
for (let i = this.entries.length - 1; i >= 0; i--) {
|
||||||
|
if (this.entries[i].botId === botId) {
|
||||||
|
name = this.entries[i].bot;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bots.push({ botId, bot: name, count });
|
||||||
|
}
|
||||||
|
bots.sort((a, b) => b.count - a.count);
|
||||||
|
const first = this.entries.length > 0 ? this.entries[0].t : 0;
|
||||||
|
const last = this.entries.length > 0 ? this.entries[this.entries.length - 1].t : 0;
|
||||||
|
return {
|
||||||
|
total: this.totalRecorded,
|
||||||
|
buffered: this.entries.length,
|
||||||
|
byBot: bots,
|
||||||
|
firstAt: first,
|
||||||
|
lastAt: last,
|
||||||
|
today: this._countToday()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {number} 本地时区「今天」的开拓条数 */
|
||||||
|
_countToday() {
|
||||||
|
const start = new Date();
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
const since = start.getTime();
|
||||||
|
let count = 0;
|
||||||
|
for (let i = this.entries.length - 1; i >= 0; i--) {
|
||||||
|
if (this.entries[i].t < since) break;
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清空内存缓冲(磁盘文件保留,便于事后追溯) */
|
||||||
|
clearBuffer() {
|
||||||
|
this.entries.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出为 CSV 文件。
|
||||||
|
*
|
||||||
|
* @param {string} filePath 目标路径
|
||||||
|
* @param {{botId?: string, since?: number}} [filter]
|
||||||
|
* @returns {string|null} 导出文件路径;失败返回 null
|
||||||
|
*/
|
||||||
|
exportCsv(filePath, filter) {
|
||||||
|
try {
|
||||||
|
const rows = this.list(Object.assign({ limit: Number.MAX_SAFE_INTEGER }, filter || {}));
|
||||||
|
const lines = ['时间,时间戳,机器人,邦国,区块X,区块Z,方块X,方块Z,来源,本轮第几个'];
|
||||||
|
for (const e of rows) {
|
||||||
|
lines.push([
|
||||||
|
formatDateTime(e.t),
|
||||||
|
e.t,
|
||||||
|
csvCell(e.bot),
|
||||||
|
csvCell(e.state),
|
||||||
|
e.cx,
|
||||||
|
e.cz,
|
||||||
|
e.bx === null ? '' : e.bx,
|
||||||
|
e.bz === null ? '' : e.bz,
|
||||||
|
e.source,
|
||||||
|
e.sessionCount === null ? '' : e.sessionCount
|
||||||
|
].join(','));
|
||||||
|
}
|
||||||
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||||
|
// 加 BOM,Excel 打开中文不乱码
|
||||||
|
fs.writeFileSync(filePath, '\ufeff' + lines.join('\r\n'), 'utf8');
|
||||||
|
return filePath;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 持久化 ====================
|
||||||
|
|
||||||
|
/** 启动时把磁盘上的历史记录读回内存(只保留最后 maxEntries 条) */
|
||||||
|
_load() {
|
||||||
|
let raw;
|
||||||
|
try {
|
||||||
|
raw = fs.readFileSync(this.filePath, 'utf8');
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lines = raw.split('\n');
|
||||||
|
for (const line of lines) {
|
||||||
|
const text = line.trim();
|
||||||
|
if (!text) continue;
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
// 进程被杀时最后一行可能不完整:跳过即可
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!parsed || typeof parsed !== 'object') continue;
|
||||||
|
this.entries.push(parsed);
|
||||||
|
this.totalRecorded++;
|
||||||
|
this.byBot.set(parsed.botId, (this.byBot.get(parsed.botId) || 0) + 1);
|
||||||
|
}
|
||||||
|
while (this.entries.length > this.maxEntries) this.entries.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 追加一行到磁盘(失败只记日志,绝不影响扩地主流程) */
|
||||||
|
_appendToFile(entry) {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
|
||||||
|
this._rotateIfNeeded();
|
||||||
|
fs.appendFileSync(this.filePath, JSON.stringify(entry) + '\n', 'utf8');
|
||||||
|
} catch (err) {
|
||||||
|
if (this.logger) {
|
||||||
|
this.logger.log('开拓日志写入失败:' + (err && err.message ? err.message : String(err)), 'warn');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文件超过上限时轮转为 `.1`(只保留一份历史,避免无限增长) */
|
||||||
|
_rotateIfNeeded() {
|
||||||
|
let size = 0;
|
||||||
|
try {
|
||||||
|
size = fs.statSync(this.filePath).size;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (size < MAX_FILE_BYTES) return;
|
||||||
|
try {
|
||||||
|
fs.renameSync(this.filePath, this.filePath + '.1');
|
||||||
|
} catch {
|
||||||
|
// 轮转失败时继续追加,不阻塞
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化为 `YYYY-MM-DD HH:mm:ss`。
|
||||||
|
* @param {number} millis
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function formatDateTime(millis) {
|
||||||
|
const d = new Date(millis);
|
||||||
|
const pad = (n) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} `
|
||||||
|
+ `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CSV 字段转义:含逗号 / 引号 / 换行时用双引号包裹 */
|
||||||
|
function csvCell(value) {
|
||||||
|
const text = value === undefined || value === null ? '' : String(value);
|
||||||
|
return /[",\r\n]/.test(text) ? '"' + text.replace(/"/g, '""') + '"' : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toInt(value) {
|
||||||
|
const n = Number(value);
|
||||||
|
return Number.isFinite(n) ? Math.trunc(n) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { ExpansionLog, formatDateTime, MAX_ENTRIES, MAX_FILE_BYTES };
|
||||||
+43
-32
@@ -21,33 +21,37 @@ class ExpansionPlanner {
|
|||||||
/**
|
/**
|
||||||
* 根据配置的策略分发到对应的扩地算法。
|
* 根据配置的策略分发到对应的扩地算法。
|
||||||
*
|
*
|
||||||
|
* 己方邦国的解析顺序为「配置里的 stateName → 模型全局 selfStateName」。
|
||||||
|
* 多机器人同时挂机时,每个机器人可能属于不同邦国,因此绝不能只读全局值。
|
||||||
|
*
|
||||||
* @param {import('./territoryModel').TerritoryModel} model 当前邦国领土模型
|
* @param {import('./territoryModel').TerritoryModel} model 当前邦国领土模型
|
||||||
* @param {object} config 扩地配置
|
* @param {object} config 扩地配置
|
||||||
* @param {{x: number, z: number}} playerChunk 玩家当前所在区块
|
* @param {{x: number, z: number}} playerChunk 玩家当前所在区块
|
||||||
* @returns {Array<{x: number, z: number}>} 建议占领的区块列表(按优先级排序)
|
* @returns {Array<{x: number, z: number}>} 建议占领的区块列表(按优先级排序)
|
||||||
*/
|
*/
|
||||||
generate(model, config, playerChunk) {
|
generate(model, config, playerChunk) {
|
||||||
|
const stateName = resolveStateName(model, config);
|
||||||
let result;
|
let result;
|
||||||
if (config.strategy === 1) {
|
if (config.strategy === 1) {
|
||||||
result = this.generateHolePriority(model, config, playerChunk);
|
result = this.generateHolePriority(model, config, playerChunk, stateName);
|
||||||
} else if (config.strategy === 4) {
|
} else if (config.strategy === 4) {
|
||||||
result = this.generatePerimeter(model, config, playerChunk);
|
result = this.generatePerimeter(model, config, playerChunk, stateName);
|
||||||
} else if (config.strategy === 3) {
|
} else if (config.strategy === 3) {
|
||||||
result = this.generateAroundPlayer(model, config, playerChunk);
|
result = this.generateAroundPlayer(model, config, playerChunk, stateName);
|
||||||
} else if (config.strategy === 2 && config.regionMaxX >= config.regionMinX) {
|
} else if (config.strategy === 2 && config.regionMaxX >= config.regionMinX) {
|
||||||
result = this.generateRectangle(model, config, playerChunk);
|
result = this.generateRectangle(model, config, playerChunk, stateName);
|
||||||
} else {
|
} else {
|
||||||
result = this.generateLine(model, config, playerChunk);
|
result = this.generateLine(model, config, playerChunk, stateName);
|
||||||
}
|
}
|
||||||
return this.prefixFlyingChunks(model, config, playerChunk, result);
|
return this.prefixFlyingChunks(model, config, playerChunk, result, stateName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将己方领土内的「飞地(疏漏区块)」提到队列最前,优先填补。
|
* 将己方领土内的「飞地(疏漏区块)」提到队列最前,优先填补。
|
||||||
* @returns {Array<{x: number, z: number}>}
|
* @returns {Array<{x: number, z: number}>}
|
||||||
*/
|
*/
|
||||||
prefixFlyingChunks(model, config, playerChunk, planned) {
|
prefixFlyingChunks(model, config, playerChunk, planned, stateName) {
|
||||||
const flying = model.selfFlyingChunks();
|
const flying = model.flyingChunksOf(stateName || model.selfStateNameValue());
|
||||||
if (!flying || flying.size === 0) return planned;
|
if (!flying || flying.size === 0) return planned;
|
||||||
const limit = config.targetCount > 0
|
const limit = config.targetCount > 0
|
||||||
? config.targetCount
|
? config.targetCount
|
||||||
@@ -79,8 +83,8 @@ class ExpansionPlanner {
|
|||||||
* 候选必须未被占领、与己方领土相邻、且在配置区域内。
|
* 候选必须未被占领、与己方领土相邻、且在配置区域内。
|
||||||
* 先排矩形内部的空洞,再排外部边界,最后按「离玩家近、己方邻居多」排序。
|
* 先排矩形内部的空洞,再排外部边界,最后按「离玩家近、己方邻居多」排序。
|
||||||
*/
|
*/
|
||||||
generateHolePriority(model, config, playerChunk) {
|
generateHolePriority(model, config, playerChunk, stateName) {
|
||||||
const actualOwn = this.ownChunks(model);
|
const actualOwn = this.ownChunks(model, stateName);
|
||||||
const allClaimed = model.allClaimedChunks();
|
const allClaimed = model.allClaimedChunks();
|
||||||
if (actualOwn.size === 0) return [];
|
if (actualOwn.size === 0) return [];
|
||||||
const limit = config.targetCount > 0 ? config.targetCount : 256;
|
const limit = config.targetCount > 0 ? config.targetCount : 256;
|
||||||
@@ -135,12 +139,12 @@ class ExpansionPlanner {
|
|||||||
* 收集所有与己方领土相邻的合法区块,以领土质心(或王城)为中心按方位角排序,
|
* 收集所有与己方领土相邻的合法区块,以领土质心(或王城)为中心按方位角排序,
|
||||||
* 使扩张点均匀分布在领土四周。支持多圈扩张。
|
* 使扩张点均匀分布在领土四周。支持多圈扩张。
|
||||||
*/
|
*/
|
||||||
generatePerimeter(model, config, playerChunk) {
|
generatePerimeter(model, config, playerChunk, stateName) {
|
||||||
const actualOwn = this.ownChunks(model);
|
const actualOwn = this.ownChunks(model, stateName);
|
||||||
const allClaimed = model.allClaimedChunks();
|
const allClaimed = model.allClaimedChunks();
|
||||||
const plannedOwn = new Set(actualOwn);
|
const plannedOwn = new Set(actualOwn);
|
||||||
const candidates = new Set();
|
const candidates = new Set();
|
||||||
const center = this.capital(model, this.centroid(actualOwn, playerChunk));
|
const center = this.capital(model, stateName, this.centroid(actualOwn, playerChunk));
|
||||||
const limit = config.targetCount > 0 ? config.targetCount : 128;
|
const limit = config.targetCount > 0 ? config.targetCount : 128;
|
||||||
const rings = config.maxRings > 0 ? config.maxRings : 1;
|
const rings = config.maxRings > 0 ? config.maxRings : 1;
|
||||||
const result = [];
|
const result = [];
|
||||||
@@ -169,8 +173,8 @@ class ExpansionPlanner {
|
|||||||
* 收集所有与己方领土相邻的合法区块,以玩家当前位置为中心,
|
* 收集所有与己方领土相邻的合法区块,以玩家当前位置为中心,
|
||||||
* 按与玩家的曼哈顿距离由近到远排序(距离相同者优先己方邻居多的),围绕玩家一圈圈向外铺开。
|
* 按与玩家的曼哈顿距离由近到远排序(距离相同者优先己方邻居多的),围绕玩家一圈圈向外铺开。
|
||||||
*/
|
*/
|
||||||
generateAroundPlayer(model, config, playerChunk) {
|
generateAroundPlayer(model, config, playerChunk, stateName) {
|
||||||
const actualOwn = this.ownChunks(model);
|
const actualOwn = this.ownChunks(model, stateName);
|
||||||
const allClaimed = model.allClaimedChunks();
|
const allClaimed = model.allClaimedChunks();
|
||||||
if (actualOwn.size === 0) return [];
|
if (actualOwn.size === 0) return [];
|
||||||
const limit = config.targetCount > 0 ? config.targetCount : 64;
|
const limit = config.targetCount > 0 ? config.targetCount : 64;
|
||||||
@@ -195,8 +199,8 @@ class ExpansionPlanner {
|
|||||||
* 每步挑选评分最优的区块:距离近、方向一致、邻居多、形状规整者优先。
|
* 每步挑选评分最优的区块:距离近、方向一致、邻居多、形状规整者优先。
|
||||||
* 当扩张形状长宽比超过 2:1 时切换方向补短边,避免领土过于细长。
|
* 当扩张形状长宽比超过 2:1 时切换方向补短边,避免领土过于细长。
|
||||||
*/
|
*/
|
||||||
generateLine(model, config, playerChunk) {
|
generateLine(model, config, playerChunk, stateName) {
|
||||||
const actualOwn = this.ownChunks(model);
|
const actualOwn = this.ownChunks(model, stateName);
|
||||||
const allClaimed = model.allClaimedChunks();
|
const allClaimed = model.allClaimedChunks();
|
||||||
const plannedOwn = new Set(actualOwn);
|
const plannedOwn = new Set(actualOwn);
|
||||||
const candidates = new Set();
|
const candidates = new Set();
|
||||||
@@ -247,8 +251,8 @@ class ExpansionPlanner {
|
|||||||
* - 首条扫描线的行内起始端同样选取离王城更近的一端;
|
* - 首条扫描线的行内起始端同样选取离王城更近的一端;
|
||||||
* - 每个入选区块必须与「己方领土(含本次已选)」四正方向接壤,保证服务器接受开拓。
|
* - 每个入选区块必须与「己方领土(含本次已选)」四正方向接壤,保证服务器接受开拓。
|
||||||
*/
|
*/
|
||||||
generateRectangle(model, config, playerChunk) {
|
generateRectangle(model, config, playerChunk, stateName) {
|
||||||
const actualOwn = this.ownChunks(model);
|
const actualOwn = this.ownChunks(model, stateName);
|
||||||
const allClaimed = model.allClaimedChunks();
|
const allClaimed = model.allClaimedChunks();
|
||||||
if (actualOwn.size === 0) return [];
|
if (actualOwn.size === 0) return [];
|
||||||
const minX = config.regionMinX;
|
const minX = config.regionMinX;
|
||||||
@@ -256,7 +260,7 @@ class ExpansionPlanner {
|
|||||||
const minZ = config.regionMinZ;
|
const minZ = config.regionMinZ;
|
||||||
const maxZ = config.regionMaxZ;
|
const maxZ = config.regionMaxZ;
|
||||||
const limit = config.targetCount > 0 ? config.targetCount : Infinity;
|
const limit = config.targetCount > 0 ? config.targetCount : Infinity;
|
||||||
const capital = this.capital(model, playerChunk);
|
const capital = this.capital(model, stateName, playerChunk);
|
||||||
|
|
||||||
// ===== 1. 四条边到王城的距离(点到线段距离的平方),选最近者为起始边 =====
|
// ===== 1. 四条边到王城的距离(点到线段距离的平方),选最近者为起始边 =====
|
||||||
const dxEdge = Math.max(0, Math.max(minX - capital.x, capital.x - maxX));
|
const dxEdge = Math.max(0, Math.max(minX - capital.x, capital.x - maxX));
|
||||||
@@ -525,21 +529,28 @@ class ExpansionPlanner {
|
|||||||
return { x: Math.trunc(x / chunkKeys.size), z: Math.trunc(z / chunkKeys.size) };
|
return { x: Math.trunc(x / chunkKeys.size), z: Math.trunc(z / chunkKeys.size) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 汇总「己方」区块:本次会话新占领的区块 + 己方邦国已拥有的区块 */
|
/** 汇总「己方」区块:本次会话新占领的区块 + 指定邦国已拥有的区块 */
|
||||||
ownChunks(model) {
|
ownChunks(model, stateName) {
|
||||||
const result = new Set(model.sessionClaimsSet());
|
return model.ownChunksOf(stateName || model.selfStateNameValue());
|
||||||
const self = model.selfState();
|
|
||||||
if (self) {
|
|
||||||
for (const k of self.chunks) result.add(k);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取己方邦国的王城位置;未识别邦国时回退到 fallback */
|
/** 获取指定邦国的王城位置;未识别邦国时回退到 fallback */
|
||||||
capital(model, fallback) {
|
capital(model, stateName, fallback) {
|
||||||
const self = model.selfState();
|
const self = model.state(stateName || model.selfStateNameValue());
|
||||||
return self && self.capital ? self.capital : fallback;
|
return self && self.capital ? self.capital : fallback;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { ExpansionPlanner, WORLD_BORDER_CHUNK_RADIUS };
|
/**
|
||||||
|
* 解析本次规划使用的邦国名:配置里显式指定的优先,其次回退到模型的全局己方邦国。
|
||||||
|
*
|
||||||
|
* @param {import('./territoryModel').TerritoryModel} model
|
||||||
|
* @param {object} config
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function resolveStateName(model, config) {
|
||||||
|
if (config && typeof config.stateName === 'string' && config.stateName) return config.stateName;
|
||||||
|
return model.selfStateNameValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { ExpansionPlanner, WORLD_BORDER_CHUNK_RADIUS, resolveStateName };
|
||||||
|
|||||||
+618
-12
@@ -6,24 +6,48 @@ const chunkPos = require('./chunkPos');
|
|||||||
* SimpCore 开放 API 客户端(文档:https://open.bgjq.top/api-docs.html)。
|
* SimpCore 开放 API 客户端(文档:https://open.bgjq.top/api-docs.html)。
|
||||||
*
|
*
|
||||||
* 邦国崛起服务器的只读查询服务,作为占领判定的第四数据源
|
* 邦国崛起服务器的只读查询服务,作为占领判定的第四数据源
|
||||||
* (游戏字幕、游戏消息、Dynmap JSON 之外的补充),提供:
|
* (游戏字幕、游戏消息、Dynmap JSON 之外的补充),同时把服务端的公开信息
|
||||||
* - 己方邦国领土区块明细(区块坐标、忠诚度、占领时间)——修正本地领土模型
|
* 完整暴露到控制台供人工查阅。
|
||||||
* - 全服领土区块(手动触发)——交叉修正他人区块归属
|
|
||||||
* - 玩家所属邦国查询——辅助识别玩家邦国
|
|
||||||
* - 邦国列表(含王城坐标)——王城数据兜底
|
|
||||||
*
|
*
|
||||||
* 鉴权:`Authorization: Bearer <KEY>`;限流:按 Key × 接口每分钟限流。
|
* 已实现的接口(与官方文档一一对应):
|
||||||
|
*
|
||||||
|
* | 分类 | 接口 | 用途 |
|
||||||
|
* | --- | --- | --- |
|
||||||
|
* | 健康 | `GET /health` | 连通性测试(免鉴权) |
|
||||||
|
* | 玩家 | `GET /info/players` | 全部玩家(含邦国、职位、职业、称号) |
|
||||||
|
* | 玩家 | `GET /info/players/{key}` | 单个玩家详情 |
|
||||||
|
* | 玩家 | `GET /info/players/{key}/union` | 玩家所属邦国 |
|
||||||
|
* | 邦国 | `GET /info/unions` | 全部邦国(君主、成员数、区块数、面积、特产) |
|
||||||
|
* | 邦国 | `GET /info/unions/{key}` | 单个邦国详情 |
|
||||||
|
* | 邦国 | `GET /info/unions/{key}/members` | 成员列表(职位 / 职业) |
|
||||||
|
* | 邦国 | `GET /info/unions/{key}/territory` | 领土区块明细(忠诚度、占领时间) |
|
||||||
|
* | 邦国 | `GET /info/unions/{key}/tech` | 已激活科技 |
|
||||||
|
* | 邦国 | `GET /info/unions/{key}/contributions` | 捐献记录 |
|
||||||
|
* | 全服 | `GET /info/territory` | 全服领土(可按 union_id 过滤) |
|
||||||
|
* | 全服 | `GET /info/buffs` | 全服生效加成 |
|
||||||
|
* | 全服 | `GET /info/diplomacy` | 邦国外交关系 |
|
||||||
|
* | 全服 | `GET /info/bans` | 封禁记录 |
|
||||||
|
* | 全服 | `GET /info/logs` | 玩家上下线日志 |
|
||||||
|
* | 全服 | `GET /info/announcements` | 公告历史 |
|
||||||
|
*
|
||||||
|
* 鉴权:`Authorization: Bearer <KEY>`(也支持 `X-API-Key`);限流:按 Key × 接口每分钟限流。
|
||||||
* 全服数据类接口限流较严且数据量大(实测全服 territory 单次请求可超 2 分钟),
|
* 全服数据类接口限流较严且数据量大(实测全服 territory 单次请求可超 2 分钟),
|
||||||
* 因此本客户端**只适合低频调用**:自动轮询仅用单邦国轻接口,全服接口仅手动触发。
|
* 因此本客户端**只适合低频调用**:自动轮询仅用单邦国轻接口,其余全部按需手动触发。
|
||||||
* 服务端响应带缓存,数据最多有 1 小时延迟。
|
* 服务端响应带缓存,数据最多有 1 小时延迟。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** API 服务基础地址 */
|
/** API 服务基础地址 */
|
||||||
const BASE_URL = 'https://bgjq.simpfun.cn/api/';
|
const BASE_URL = 'https://bgjq.simpfun.cn/api/';
|
||||||
|
|
||||||
|
/** API-V2(ServerOpenAPI 插件,独立密钥体系)服务基础地址 */
|
||||||
|
const BASE_URL_V2 = 'https://bgjq.simpfun.cn/api-v2/';
|
||||||
|
|
||||||
/** 默认缓存 TTL:5 分钟。服务端数据本身最多延迟 1 小时,更短的 TTL 意义不大 */
|
/** 默认缓存 TTL:5 分钟。服务端数据本身最多延迟 1 小时,更短的 TTL 意义不大 */
|
||||||
const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000;
|
const DEFAULT_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
/** API-V2 的接口级配额很小(最低 1 QPM),缓存给长一点 */
|
||||||
|
const DEFAULT_CACHE_TTL_V2_MS = 30 * 1000;
|
||||||
|
|
||||||
class SimpApiClient {
|
class SimpApiClient {
|
||||||
/**
|
/**
|
||||||
* @param {import('../util/logger').Logger} [logger]
|
* @param {import('../util/logger').Logger} [logger]
|
||||||
@@ -42,6 +66,10 @@ class SimpApiClient {
|
|||||||
this._lastHttpStatus = 0;
|
this._lastHttpStatus = 0;
|
||||||
/** 最近一次请求失败的错误描述(空串表示无错误) */
|
/** 最近一次请求失败的错误描述(空串表示无错误) */
|
||||||
this._lastError = '';
|
this._lastError = '';
|
||||||
|
/** 累计请求数(含缓存命中统计,供控制台展示用量) */
|
||||||
|
this._requestCount = 0;
|
||||||
|
/** 缓存命中次数 */
|
||||||
|
this._cacheHits = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -187,6 +215,265 @@ class SimpApiClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 查询接口(补充:官方文档里的其余接口) ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全部玩家:`GET /info/players`。
|
||||||
|
* 实测字段:`uuid, username, union_id, union_name, role, profession, profession_level,
|
||||||
|
* title, manual_claiming, first_join_time, last_join_time, last_quit_time`
|
||||||
|
*
|
||||||
|
* @returns {Promise<Array<object>>}
|
||||||
|
*/
|
||||||
|
async getPlayers() {
|
||||||
|
const body = await this._getCachedOrFetch('info/players?limit=1000');
|
||||||
|
if (body === null) return [];
|
||||||
|
return parseArray(body).map((element) => ({
|
||||||
|
name: stringOr(element, 'username', ''),
|
||||||
|
uuid: stringOr(element, 'uuid', ''),
|
||||||
|
unionId: intOr(element, 'union_id', -1),
|
||||||
|
unionName: stringOr(element, 'union_name', ''),
|
||||||
|
role: stringOr(element, 'role', ''),
|
||||||
|
job: stringOr(element, 'profession', ''),
|
||||||
|
jobLevel: intOr(element, 'profession_level', 0),
|
||||||
|
title: stringOr(element, 'title', ''),
|
||||||
|
firstJoinAt: stringOr(element, 'first_join_time', ''),
|
||||||
|
lastJoinAt: stringOr(element, 'last_join_time', ''),
|
||||||
|
lastQuitAt: stringOr(element, 'last_quit_time', '')
|
||||||
|
})).filter((player) => player.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 邦国成员列表:`GET /info/unions/{key}/members`。
|
||||||
|
*
|
||||||
|
* 实测字段:`uuid, username, union_id, union_name, role, profession, profession_level,
|
||||||
|
* title, manual_claiming, union_action_cooldown, first_join_time, last_join_time, last_quit_time`
|
||||||
|
*
|
||||||
|
* @param {string|number} key
|
||||||
|
* @returns {Promise<Array<object>>}
|
||||||
|
*/
|
||||||
|
async getUnionMembers(key) {
|
||||||
|
const body = await this._getCachedOrFetch(`info/unions/${encodeURIComponent(String(key))}/members`);
|
||||||
|
if (body === null) return [];
|
||||||
|
return parseArray(body).map((element) => ({
|
||||||
|
name: stringOr(element, 'username', ''),
|
||||||
|
uuid: stringOr(element, 'uuid', ''),
|
||||||
|
role: stringOr(element, 'role', ''),
|
||||||
|
job: stringOr(element, 'profession', ''),
|
||||||
|
jobLevel: intOr(element, 'profession_level', 0),
|
||||||
|
title: stringOr(element, 'title', ''),
|
||||||
|
firstJoinAt: stringOr(element, 'first_join_time', ''),
|
||||||
|
lastJoinAt: stringOr(element, 'last_join_time', ''),
|
||||||
|
lastQuitAt: stringOr(element, 'last_quit_time', '')
|
||||||
|
})).filter((member) => member.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 邦国已激活(未过期)科技:`GET /info/unions/{key}/tech`。
|
||||||
|
* 实测多数邦国返回空数组(当前没有生效中的科技)。
|
||||||
|
* @param {string|number} key
|
||||||
|
* @returns {Promise<Array<object>>}
|
||||||
|
*/
|
||||||
|
async getUnionTech(key) {
|
||||||
|
const body = await this._getCachedOrFetch(`info/unions/${encodeURIComponent(String(key))}/tech`);
|
||||||
|
if (body === null) return [];
|
||||||
|
return parseArray(body).map((element) => ({
|
||||||
|
name: stringOr(element, 'name', stringOr(element, 'buff_name', stringOr(element, 'tech_name', ''))),
|
||||||
|
effect: stringOr(element, 'effect', stringOr(element, 'description', '')),
|
||||||
|
level: intOr(element, 'level', intOr(element, 'buff_level', 0)),
|
||||||
|
expiresAt: stringOr(element, 'expires_at', stringOr(element, 'expire_at', ''))
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 邦国捐献记录:`GET /info/unions/{key}/contributions?limit=N`。
|
||||||
|
*
|
||||||
|
* **实测响应里只有 `player_uuid`,没有玩家名**;数量可以为负(负数代表支出,
|
||||||
|
* 例如「恢复民心」消耗金锭 / 钻石),并带 `reason` 说明用途。
|
||||||
|
*
|
||||||
|
* @param {string|number} key
|
||||||
|
* @param {number} [limit]
|
||||||
|
* @returns {Promise<Array<object>>}
|
||||||
|
*/
|
||||||
|
async getUnionContributions(key, limit) {
|
||||||
|
const path = `info/unions/${encodeURIComponent(String(key))}/contributions?limit=${normalizeLimit(limit)}`;
|
||||||
|
const body = await this._getCachedOrFetch(path);
|
||||||
|
if (body === null) return [];
|
||||||
|
return parseArray(body).map((element) => ({
|
||||||
|
uuid: stringOr(element, 'player_uuid', stringOr(element, 'uuid', '')),
|
||||||
|
material: stringOr(element, 'material', ''),
|
||||||
|
amount: round4(doubleOr(element, 'amount', 0)),
|
||||||
|
reason: stringOr(element, 'reason', ''),
|
||||||
|
createdAt: stringOr(element, 'contributed_at', stringOr(element, 'created_at', ''))
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全服玩家表(uuid → 玩家名)。
|
||||||
|
*
|
||||||
|
* 捐献记录只有 UUID,要显示玩家名得查这张表;它数据量大(实测 1600+ 行)但有 5 分钟缓存,
|
||||||
|
* 因此只在确实有未知 UUID 时才懒加载一次。
|
||||||
|
*
|
||||||
|
* @returns {Promise<Map<string, string>>}
|
||||||
|
*/
|
||||||
|
async getPlayerNameMap() {
|
||||||
|
if (this._nameMap) return this._nameMap;
|
||||||
|
const body = await this._getCachedOrFetch('info/players?limit=1000');
|
||||||
|
const map = new Map();
|
||||||
|
if (body !== null) {
|
||||||
|
for (const element of parseArray(body)) {
|
||||||
|
const uuid = stringOr(element, 'uuid', '');
|
||||||
|
const name = stringOr(element, 'username', '');
|
||||||
|
if (uuid && name) map.set(uuid, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._nameMap = map;
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全服生效加成:`GET /info/buffs`。
|
||||||
|
* @returns {Promise<Array<object>>}
|
||||||
|
*/
|
||||||
|
async getBuffs() {
|
||||||
|
const body = await this._getCachedOrFetch('info/buffs');
|
||||||
|
if (body === null) return [];
|
||||||
|
return parseArray(body).map((element) => ({
|
||||||
|
unionId: intOr(element, 'union_id', -1),
|
||||||
|
unionName: stringOr(element, 'union_name', ''),
|
||||||
|
name: stringOr(element, 'name', stringOr(element, 'buff_name', '')),
|
||||||
|
effect: stringOr(element, 'effect', ''),
|
||||||
|
level: intOr(element, 'level', 0),
|
||||||
|
expiresAt: stringOr(element, 'expires_at', '')
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 邦国外交关系:`GET /info/diplomacy`。
|
||||||
|
* 实测字段:`grantor_union_id, grantor_name, grantee_union_id, grantee_name, permission_level`
|
||||||
|
*
|
||||||
|
* @returns {Promise<Array<object>>}
|
||||||
|
*/
|
||||||
|
async getDiplomacy() {
|
||||||
|
const body = await this._getCachedOrFetch('info/diplomacy');
|
||||||
|
if (body === null) return [];
|
||||||
|
return parseArray(body).map((element) => ({
|
||||||
|
grantor: stringOr(element, 'grantor_name', ''),
|
||||||
|
grantee: stringOr(element, 'grantee_name', ''),
|
||||||
|
level: stringOr(element, 'permission_level', stringOr(element, 'level', ''))
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 封禁记录:`GET /info/bans?limit=N`。
|
||||||
|
* 实测字段:`banned_player_name, banned_by_player_name, reason, banned_at, unbanned, unbanned_at`
|
||||||
|
*
|
||||||
|
* @param {number} [limit]
|
||||||
|
* @returns {Promise<Array<object>>}
|
||||||
|
*/
|
||||||
|
async getBans(limit) {
|
||||||
|
const body = await this._getCachedOrFetch(`info/bans?limit=${normalizeLimit(limit)}`);
|
||||||
|
if (body === null) return [];
|
||||||
|
return parseArray(body).map((element) => ({
|
||||||
|
name: stringOr(element, 'banned_player_name', ''),
|
||||||
|
reason: stringOr(element, 'reason', ''),
|
||||||
|
operator: stringOr(element, 'banned_by_player_name', ''),
|
||||||
|
createdAt: stringOr(element, 'banned_at', ''),
|
||||||
|
unbanned: element.unbanned === true,
|
||||||
|
unbannedAt: stringOr(element, 'unbanned_at', '')
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 玩家上下线等日志:`GET /info/logs?limit=N`。
|
||||||
|
* 实测字段:`username, action(JOIN/QUIT), timestamp`
|
||||||
|
*
|
||||||
|
* @param {number} [limit]
|
||||||
|
* @returns {Promise<Array<object>>}
|
||||||
|
*/
|
||||||
|
async getLogs(limit) {
|
||||||
|
const body = await this._getCachedOrFetch(`info/logs?limit=${normalizeLimit(limit)}`);
|
||||||
|
if (body === null) return [];
|
||||||
|
return parseArray(body).map((element) => ({
|
||||||
|
name: stringOr(element, 'username', ''),
|
||||||
|
action: stringOr(element, 'action', ''),
|
||||||
|
detail: stringOr(element, 'detail', ''),
|
||||||
|
createdAt: stringOr(element, 'timestamp', stringOr(element, 'created_at', ''))
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 公告历史:`GET /info/announcements?limit=N`。
|
||||||
|
*
|
||||||
|
* **实测 `message` 是 Minecraft 聊天组件的 JSON 字符串**(带 color / extra 等),
|
||||||
|
* 这里统一提取成纯文本再返回,免得前端拿到一串乱码。
|
||||||
|
*
|
||||||
|
* @param {number} [limit]
|
||||||
|
* @returns {Promise<Array<object>>}
|
||||||
|
*/
|
||||||
|
async getAnnouncements(limit) {
|
||||||
|
const body = await this._getCachedOrFetch(`info/announcements?limit=${normalizeLimit(limit)}`);
|
||||||
|
if (body === null) return [];
|
||||||
|
return parseArray(body).map((element) => ({
|
||||||
|
content: componentToText(stringOr(element, 'message', stringOr(element, 'content', ''))),
|
||||||
|
author: stringOr(element, 'author_name', ''),
|
||||||
|
createdAt: stringOr(element, 'created_at', '')
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 邦国领土的统计摘要(不把几万个区块全丢给前端)。
|
||||||
|
* @param {string|number} key
|
||||||
|
* @returns {Promise<object|null>}
|
||||||
|
*/
|
||||||
|
async getUnionTerritoryStats(key) {
|
||||||
|
const chunks = await this.getUnionTerritory(key);
|
||||||
|
if (chunks.size === 0) return null;
|
||||||
|
let minX = Infinity;
|
||||||
|
let maxX = -Infinity;
|
||||||
|
let minZ = Infinity;
|
||||||
|
let maxZ = -Infinity;
|
||||||
|
let loyaltySum = 0;
|
||||||
|
let loyaltyCount = 0;
|
||||||
|
let earliest = '';
|
||||||
|
let latest = '';
|
||||||
|
for (const chunk of chunks.values()) {
|
||||||
|
if (chunk.x < minX) minX = chunk.x;
|
||||||
|
if (chunk.x > maxX) maxX = chunk.x;
|
||||||
|
if (chunk.z < minZ) minZ = chunk.z;
|
||||||
|
if (chunk.z > maxZ) maxZ = chunk.z;
|
||||||
|
if (chunk.loyalty >= 0) {
|
||||||
|
loyaltySum += chunk.loyalty;
|
||||||
|
loyaltyCount++;
|
||||||
|
}
|
||||||
|
if (chunk.lastClaimedAt) {
|
||||||
|
if (!earliest || chunk.lastClaimedAt < earliest) earliest = chunk.lastClaimedAt;
|
||||||
|
if (!latest || chunk.lastClaimedAt > latest) latest = chunk.lastClaimedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
chunkCount: chunks.size,
|
||||||
|
blockArea: chunks.size * 256,
|
||||||
|
bounds: { minX, maxX, minZ, maxZ },
|
||||||
|
avgLoyalty: loyaltyCount > 0 ? Math.round((loyaltySum / loyaltyCount) * 10) / 10 : null,
|
||||||
|
earliestClaimAt: earliest,
|
||||||
|
latestClaimAt: latest
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {object} 客户端状态快照(供控制台「API 数据」面板展示) */
|
||||||
|
snapshot() {
|
||||||
|
return {
|
||||||
|
baseUrl: this.baseUrl,
|
||||||
|
hasKey: this.hasApiKey(),
|
||||||
|
lastHttpStatus: this._lastHttpStatus,
|
||||||
|
lastError: this._lastError,
|
||||||
|
requestCount: this._requestCount,
|
||||||
|
cacheHits: this._cacheHits,
|
||||||
|
cachedPaths: this.cache.size,
|
||||||
|
cacheTtlMs: this.cacheTtlMs
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 网络与缓存底层 ====================
|
// ==================== 网络与缓存底层 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -201,12 +488,16 @@ class SimpApiClient {
|
|||||||
const opts = options || {};
|
const opts = options || {};
|
||||||
const entry = this.cache.get(path);
|
const entry = this.cache.get(path);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (entry && entry.expiresAt > now) return entry.body;
|
if (entry && entry.expiresAt > now) {
|
||||||
|
this._cacheHits++;
|
||||||
|
return entry.body;
|
||||||
|
}
|
||||||
if (!this.hasApiKey()) {
|
if (!this.hasApiKey()) {
|
||||||
this._lastError = '未配置 API Key';
|
this._lastError = '未配置 API Key';
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
this._requestCount++;
|
||||||
const response = await this._request(path, { auth: true, timeoutMs: opts.timeoutMs || 30000 });
|
const response = await this._request(path, { auth: true, timeoutMs: opts.timeoutMs || 30000 });
|
||||||
this._lastHttpStatus = response.status;
|
this._lastHttpStatus = response.status;
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
@@ -252,6 +543,248 @@ class SimpApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== API-V2(ServerOpenAPI 插件,独立密钥体系) ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SimpCore API-V2 客户端。
|
||||||
|
*
|
||||||
|
* 官方文档明确说明:**API 与 API-V2 是两套相互独立、互不通用的接口系统**,
|
||||||
|
* 需要在密钥中心分别申请。V2 由 ServerOpenAPI 插件提供,用于查询服务器**实时**状态:
|
||||||
|
*
|
||||||
|
* | 接口 | 说明 | 接口级限速 |
|
||||||
|
* | --- | --- | --- |
|
||||||
|
* | `GET /players` | 在线玩家列表 | 10 QPM |
|
||||||
|
* | `GET /players/{name}/equipment` | 玩家装备与手持物品 | 30 QPM |
|
||||||
|
* | `GET /time` | 游戏内时间 | 10 QPM |
|
||||||
|
* | `GET /dragon` | 末影龙是否存活 | **1 QPM** |
|
||||||
|
*
|
||||||
|
* 因为配额很小(末影龙接口每分钟只能问一次),这里**一律不做自动轮询**,
|
||||||
|
* 只在控制台里按需手动查询,并带 30 秒本地缓存兜底。
|
||||||
|
*/
|
||||||
|
class SimpApiV2Client {
|
||||||
|
/**
|
||||||
|
* @param {import('../util/logger').Logger} [logger]
|
||||||
|
* @param {{baseUrl?: string, cacheTtlMs?: number, fetchImpl?: Function}} [options]
|
||||||
|
*/
|
||||||
|
constructor(logger, options) {
|
||||||
|
const opts = options || {};
|
||||||
|
this.logger = logger || null;
|
||||||
|
this.baseUrl = opts.baseUrl || BASE_URL_V2;
|
||||||
|
this.cacheTtlMs = opts.cacheTtlMs || DEFAULT_CACHE_TTL_V2_MS;
|
||||||
|
/** 可注入的 fetch,便于测试替换 */
|
||||||
|
this.fetchImpl = opts.fetchImpl || globalThis.fetch;
|
||||||
|
this.apiKey = '';
|
||||||
|
this.cache = new Map();
|
||||||
|
this._lastHttpStatus = 0;
|
||||||
|
this._lastError = '';
|
||||||
|
this._requestCount = 0;
|
||||||
|
this._cacheHits = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} key */
|
||||||
|
setApiKey(key) {
|
||||||
|
const fresh = key ? key.trim() : '';
|
||||||
|
if (fresh !== this.apiKey) this.cache.clear();
|
||||||
|
this.apiKey = fresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {boolean} */
|
||||||
|
hasApiKey() {
|
||||||
|
return this.apiKey.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {number} */
|
||||||
|
lastHttpStatus() {
|
||||||
|
return this._lastHttpStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {string} */
|
||||||
|
lastError() {
|
||||||
|
return this._lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在线玩家:`GET /players`。
|
||||||
|
*
|
||||||
|
* 实测响应是**对象**而不是数组:
|
||||||
|
* `{"online":8,"max":5000,"players":[{"name","displayName","uuid","world"}]}`
|
||||||
|
* 注意里面**没有坐标与延迟**,只有所在世界。
|
||||||
|
*
|
||||||
|
* @returns {Promise<{online: number, max: number, players: Array<object>}>}
|
||||||
|
*/
|
||||||
|
async getOnlinePlayers() {
|
||||||
|
const body = await this._getCachedOrFetch('players');
|
||||||
|
const object = body === null ? null : parseObject(body);
|
||||||
|
if (!object) return { online: 0, max: 0, players: [] };
|
||||||
|
const list = Array.isArray(object.players) ? object.players : [];
|
||||||
|
return {
|
||||||
|
online: intOr(object, 'online', list.length),
|
||||||
|
max: intOr(object, 'max', 0),
|
||||||
|
players: list.map((element) => ({
|
||||||
|
name: stringOr(element, 'name', stringOr(element, 'displayName', '')),
|
||||||
|
displayName: stringOr(element, 'displayName', ''),
|
||||||
|
uuid: stringOr(element, 'uuid', ''),
|
||||||
|
world: stringOr(element, 'world', '')
|
||||||
|
})).filter((player) => player.name)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 玩家装备与手持物品:`GET /players/{name}/equipment`。
|
||||||
|
*
|
||||||
|
* 实测响应:
|
||||||
|
* `{"name","displayName","uuid","equipment":{"mainHand":{...},"offHand":null,"helmet":{...},...}}`
|
||||||
|
* 物品结构为 `{type, key, amount, damage, maxDurability, unbreakable}` ——
|
||||||
|
* 注意 `damage` 是**已损耗值**,剩余耐久要自己算 `maxDurability - damage`。
|
||||||
|
*
|
||||||
|
* @param {string} name
|
||||||
|
* @returns {Promise<object|null>}
|
||||||
|
*/
|
||||||
|
async getPlayerEquipment(name) {
|
||||||
|
const body = await this._getCachedOrFetch(`players/${encodeURIComponent(String(name))}/equipment`);
|
||||||
|
const object = body === null ? null : parseObject(body);
|
||||||
|
if (!object) return null;
|
||||||
|
const equipment = object.equipment || {};
|
||||||
|
return {
|
||||||
|
player: stringOr(object, 'name', String(name)),
|
||||||
|
displayName: stringOr(object, 'displayName', ''),
|
||||||
|
uuid: stringOr(object, 'uuid', ''),
|
||||||
|
mainHand: v2ItemOf(equipment.mainHand),
|
||||||
|
offHand: v2ItemOf(equipment.offHand),
|
||||||
|
helmet: v2ItemOf(equipment.helmet),
|
||||||
|
chestplate: v2ItemOf(equipment.chestplate),
|
||||||
|
leggings: v2ItemOf(equipment.leggings),
|
||||||
|
boots: v2ItemOf(equipment.boots)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 游戏内时间:`GET /time`。
|
||||||
|
*
|
||||||
|
* 实测响应:`{"world":"world","tick":10085,"phase":"DAY","time12":"04:05 PM","time24":"16:05"}`
|
||||||
|
* —— 服务端已经把时刻算好了,不需要客户端再用 tick 换算。
|
||||||
|
*
|
||||||
|
* @returns {Promise<object|null>}
|
||||||
|
*/
|
||||||
|
async getGameTime() {
|
||||||
|
const body = await this._getCachedOrFetch('time');
|
||||||
|
const object = body === null ? null : parseObject(body);
|
||||||
|
if (!object) return null;
|
||||||
|
return {
|
||||||
|
world: stringOr(object, 'world', ''),
|
||||||
|
tick: longOr(object, 'tick', 0),
|
||||||
|
phase: stringOr(object, 'phase', ''),
|
||||||
|
time12: stringOr(object, 'time12', ''),
|
||||||
|
time24: stringOr(object, 'time24', '')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 末影龙状态:`GET /dragon`。
|
||||||
|
*
|
||||||
|
* 实测响应:`{"dragonAlive":false,"world":"world_the_end"}`。
|
||||||
|
* **该接口限速 1 次/分钟**,超限返回 429 并带 `retryAfterSeconds`。
|
||||||
|
*
|
||||||
|
* @returns {Promise<object|null>}
|
||||||
|
*/
|
||||||
|
async getDragon() {
|
||||||
|
const body = await this._getCachedOrFetch('dragon');
|
||||||
|
const object = body === null ? null : parseObject(body);
|
||||||
|
if (!object) return null;
|
||||||
|
return {
|
||||||
|
alive: object.dragonAlive === true,
|
||||||
|
world: stringOr(object, 'world', '')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {object} 状态快照 */
|
||||||
|
snapshot() {
|
||||||
|
return {
|
||||||
|
baseUrl: this.baseUrl,
|
||||||
|
hasKey: this.hasApiKey(),
|
||||||
|
lastHttpStatus: this._lastHttpStatus,
|
||||||
|
lastError: this._lastError,
|
||||||
|
requestCount: this._requestCount,
|
||||||
|
cacheHits: this._cacheHits,
|
||||||
|
cachedPaths: this.cache.size,
|
||||||
|
cacheTtlMs: this.cacheTtlMs
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 与 v1 相同的「缓存优先 + 鉴权 + 错误归类」逻辑 */
|
||||||
|
async _getCachedOrFetch(path) {
|
||||||
|
const entry = this.cache.get(path);
|
||||||
|
const now = Date.now();
|
||||||
|
if (entry && entry.expiresAt > now) {
|
||||||
|
this._cacheHits++;
|
||||||
|
return entry.body;
|
||||||
|
}
|
||||||
|
if (!this.hasApiKey()) {
|
||||||
|
this._lastError = '未配置 API-V2 Key';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), 20000);
|
||||||
|
try {
|
||||||
|
this._requestCount++;
|
||||||
|
const response = await this.fetchImpl(this.baseUrl + path, {
|
||||||
|
headers: { 'User-Agent': 'AutoStore-Node/1.0', Authorization: 'Bearer ' + this.apiKey },
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
this._lastHttpStatus = response.status;
|
||||||
|
if (response.status === 200) {
|
||||||
|
this.cache.set(path, { body: text, expiresAt: now + this.cacheTtlMs });
|
||||||
|
this._lastError = '';
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
if (response.status === 401) {
|
||||||
|
this._lastError = 'API-V2 Key 无效(401)——注意 V2 需要单独申请密钥';
|
||||||
|
this.cache.clear();
|
||||||
|
} else if (response.status === 429) {
|
||||||
|
// 服务端会带 retryAfterSeconds,直接告诉用户还要等多久,比「稍后重试」有用得多
|
||||||
|
const wait = intOr(parseObject(text) || {}, 'retryAfterSeconds', -1);
|
||||||
|
this._lastError = '触发限流(429)'
|
||||||
|
+ (wait > 0 ? `:还需等待 ${wait} 秒` : '')
|
||||||
|
+ '。V2 接口配额很小(末影龙仅 1 次/分钟)';
|
||||||
|
} else {
|
||||||
|
const detail = parseObject(text);
|
||||||
|
this._lastError = detail && detail.error ? String(detail.error) : 'HTTP ' + response.status;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this._lastError = err && err.message ? err.message : String(err);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 API-V2 的物品对象。
|
||||||
|
*
|
||||||
|
* 实测结构:`{type, key, amount, damage, maxDurability, unbreakable}`。
|
||||||
|
* `damage` 是**已损耗值**而不是剩余耐久,所以这里同时给出两者,
|
||||||
|
* 免得界面上把「损耗 157」显示成「剩余 157」。
|
||||||
|
*
|
||||||
|
* @param {object|null} item
|
||||||
|
* @returns {object|null}
|
||||||
|
*/
|
||||||
|
function v2ItemOf(item) {
|
||||||
|
if (!item || typeof item !== 'object') return null;
|
||||||
|
const maxDurability = intOr(item, 'maxDurability', 0);
|
||||||
|
const damage = intOr(item, 'damage', 0);
|
||||||
|
return {
|
||||||
|
type: stringOr(item, 'type', ''),
|
||||||
|
key: stringOr(item, 'key', ''),
|
||||||
|
amount: intOr(item, 'amount', 1),
|
||||||
|
damage,
|
||||||
|
maxDurability,
|
||||||
|
remainingDurability: maxDurability > 0 ? Math.max(0, maxDurability - damage) : -1,
|
||||||
|
unbreakable: item.unbreakable === true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== JSON 解析(字段名以实测响应为准) ====================
|
// ==================== JSON 解析(字段名以实测响应为准) ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -263,8 +796,8 @@ function parseUnionSummary(element) {
|
|||||||
if (!element || typeof element !== 'object') return null;
|
if (!element || typeof element !== 'object') return null;
|
||||||
const name = stringOr(element, 'name', '');
|
const name = stringOr(element, 'name', '');
|
||||||
if (!name) return null;
|
if (!name) return null;
|
||||||
const capitalX = doubleOr(element, 'capital_x', Number.NaN);
|
const capitalX = optionalNumber(element, 'capital_x');
|
||||||
const capitalZ = doubleOr(element, 'capital_z', Number.NaN);
|
const capitalZ = optionalNumber(element, 'capital_z');
|
||||||
return {
|
return {
|
||||||
id: intOr(element, 'id', -1),
|
id: intOr(element, 'id', -1),
|
||||||
name,
|
name,
|
||||||
@@ -273,12 +806,30 @@ function parseUnionSummary(element) {
|
|||||||
chunkCount: intOr(element, 'territory_chunk_count', 0),
|
chunkCount: intOr(element, 'territory_chunk_count', 0),
|
||||||
blockArea: longOr(element, 'territory_block_area', 0),
|
blockArea: longOr(element, 'territory_block_area', 0),
|
||||||
specialOutput: stringOr(element, 'special_output_material', ''),
|
specialOutput: stringOr(element, 'special_output_material', ''),
|
||||||
capital: Number.isNaN(capitalX) || Number.isNaN(capitalZ)
|
// 王城坐标可能为 null(文档明确「空值输出 null」),此时不能换算成 (0,0) 假装有效
|
||||||
|
capital: capitalX === null || capitalZ === null
|
||||||
? null
|
? null
|
||||||
: chunkPos.fromBlock(Math.floor(capitalX), Math.floor(capitalZ))
|
: chunkPos.fromBlock(Math.floor(capitalX), Math.floor(capitalZ))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取一个可能为 null 的数值字段。
|
||||||
|
*
|
||||||
|
* 直接用 `Number(null)` 会得到 0,把「没有王城」误判成「王城在 (0,0)」——
|
||||||
|
* 而 (0,0) 正好是世界原点,看起来完全像个合法坐标,排查起来很坑。
|
||||||
|
*
|
||||||
|
* @param {object} object
|
||||||
|
* @param {string} key
|
||||||
|
* @returns {number|null}
|
||||||
|
*/
|
||||||
|
function optionalNumber(object, key) {
|
||||||
|
const value = object[key];
|
||||||
|
if (value === undefined || value === null || value === '') return null;
|
||||||
|
const n = Number(value);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析领土区块。实测字段:`id, x, z, union_id, union_name, last_claimed_at, loyalty`
|
* 解析领土区块。实测字段:`id, x, z, union_id, union_name, last_claimed_at, loyalty`
|
||||||
*/
|
*/
|
||||||
@@ -338,10 +889,65 @@ function doubleOr(object, key, fallback) {
|
|||||||
return Number.isFinite(value) ? value : fallback;
|
return Number.isFinite(value) ? value : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 Minecraft 聊天组件提取成纯文本。
|
||||||
|
*
|
||||||
|
* 公告接口返回的 `message` 是组件 JSON(形如 `{"color":"gold","extra":[{"text":"公告: "}, ...]}`),
|
||||||
|
* 直接显示会是一串带转义符的 JSON,必须先压平。
|
||||||
|
*
|
||||||
|
* @param {string} raw
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function componentToText(raw) {
|
||||||
|
const text = String(raw || '');
|
||||||
|
if (!text) return '';
|
||||||
|
const walk = (value) => {
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
if (Array.isArray(value)) return value.map(walk).join('');
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
const parts = [];
|
||||||
|
if (typeof value.text === 'string') parts.push(value.text);
|
||||||
|
if (value.translate && typeof value.translate === 'string') parts.push(value.translate);
|
||||||
|
for (const key of ['extra', 'with', 'children']) {
|
||||||
|
if (value[key] !== undefined) parts.push(walk(value[key]));
|
||||||
|
}
|
||||||
|
return parts.join('');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
// 先尝试按 JSON 解析(公告字段是 JSON 字符串,不是对象)
|
||||||
|
try {
|
||||||
|
return walk(JSON.parse(text));
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把 limit 参数钳制到 1..1000(文档默认 100)。
|
||||||
|
* @param {*} value
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
function normalizeLimit(value) {
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n)) return 100;
|
||||||
|
return Math.min(1000, Math.max(1, Math.trunc(n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保留 4 位小数,避免捐献数量出现 -0.5520000000000004 这种浮点尾巴 */
|
||||||
|
function round4(value) {
|
||||||
|
return Math.round(value * 10000) / 10000;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
SimpApiClient,
|
SimpApiClient,
|
||||||
|
SimpApiV2Client,
|
||||||
BASE_URL,
|
BASE_URL,
|
||||||
|
BASE_URL_V2,
|
||||||
DEFAULT_CACHE_TTL_MS,
|
DEFAULT_CACHE_TTL_MS,
|
||||||
|
DEFAULT_CACHE_TTL_V2_MS,
|
||||||
parseUnionSummary,
|
parseUnionSummary,
|
||||||
parseTerritoryChunk
|
parseTerritoryChunk,
|
||||||
|
normalizeLimit,
|
||||||
|
componentToText
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const crypto = require('crypto');
|
|||||||
|
|
||||||
const chunkPos = require('./chunkPos');
|
const chunkPos = require('./chunkPos');
|
||||||
const { TerritoryModel } = require('./territoryModel');
|
const { TerritoryModel } = require('./territoryModel');
|
||||||
const { SimpApiClient } = require('./simpApiClient');
|
const { SimpApiClient, SimpApiV2Client } = require('./simpApiClient');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 领土数据服务:定时从远程地图服务拉取邦国领土数据(Dynmap markers.json),
|
* 领土数据服务:定时从远程地图服务拉取邦国领土数据(Dynmap markers.json),
|
||||||
@@ -71,6 +71,13 @@ class TerritoryDataService {
|
|||||||
|
|
||||||
/** @type {SimpApiClient} SimpCore 开放 API 客户端(第四占领判定数据源) */
|
/** @type {SimpApiClient} SimpCore 开放 API 客户端(第四占领判定数据源) */
|
||||||
this.apiClient = new SimpApiClient(logger);
|
this.apiClient = new SimpApiClient(logger);
|
||||||
|
/**
|
||||||
|
* @type {SimpApiV2Client} SimpCore API-V2 客户端(实时状态查询)
|
||||||
|
*
|
||||||
|
* 与上面那套是**互相独立的两套密钥体系**,所以单独一个实例、单独一个 Key。
|
||||||
|
* 只在控制台按需查询,不参与自动轮询。
|
||||||
|
*/
|
||||||
|
this.apiV2Client = new SimpApiV2Client(logger);
|
||||||
/** API 最近一次成功拉取的 邦国名 → 区块键集合 */
|
/** API 最近一次成功拉取的 邦国名 → 区块键集合 */
|
||||||
this.apiTerritory = new Map();
|
this.apiTerritory = new Map();
|
||||||
/** API 最近一次成功拉取的 邦国名 → 王城区块 */
|
/** API 最近一次成功拉取的 邦国名 → 王城区块 */
|
||||||
@@ -79,6 +86,8 @@ class TerritoryDataService {
|
|||||||
this.lastApiSuccessMillis = 0;
|
this.lastApiSuccessMillis = 0;
|
||||||
/** API 轮询间隔(秒);0 表示未启用 */
|
/** API 轮询间隔(秒);0 表示未启用 */
|
||||||
this._apiPollIntervalSeconds = 0;
|
this._apiPollIntervalSeconds = 0;
|
||||||
|
/** API-V2 是否启用 */
|
||||||
|
this.apiV2Enabled = false;
|
||||||
|
|
||||||
this._refreshTimer = null;
|
this._refreshTimer = null;
|
||||||
this._apiTimer = null;
|
this._apiTimer = null;
|
||||||
@@ -134,6 +143,25 @@ class TerritoryDataService {
|
|||||||
this._rescheduleApi();
|
this._rescheduleApi();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置 API-V2(ServerOpenAPI)。
|
||||||
|
*
|
||||||
|
* V2 的接口级配额极小(末影龙 1 次/分钟),因此**只设置 Key,不做任何自动轮询**,
|
||||||
|
* 全部由控制台按需触发。
|
||||||
|
*
|
||||||
|
* @param {boolean} enabled
|
||||||
|
* @param {string} apiKey
|
||||||
|
*/
|
||||||
|
applyApiV2Config(enabled, apiKey) {
|
||||||
|
this.apiV2Enabled = !!enabled && !!(apiKey && apiKey.trim());
|
||||||
|
this.apiV2Client.setApiKey(this.apiV2Enabled ? apiKey : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {boolean} API-V2 是否已启用(开关打开且配了 Key) */
|
||||||
|
isApiV2Enabled() {
|
||||||
|
return this.apiV2Enabled === true && this.apiV2Client.hasApiKey();
|
||||||
|
}
|
||||||
|
|
||||||
/** 按 _apiPollIntervalSeconds 重新调度 API 轮询(≤0 表示停用) */
|
/** 按 _apiPollIntervalSeconds 重新调度 API 轮询(≤0 表示停用) */
|
||||||
_rescheduleApi() {
|
_rescheduleApi() {
|
||||||
if (this._apiTimer) clearInterval(this._apiTimer);
|
if (this._apiTimer) clearInterval(this._apiTimer);
|
||||||
@@ -367,10 +395,11 @@ class TerritoryDataService {
|
|||||||
/**
|
/**
|
||||||
* 记录一次会话内新占领的区块。
|
* 记录一次会话内新占领的区块。
|
||||||
* @param {{x: number, z: number}} chunk
|
* @param {{x: number, z: number}} chunk
|
||||||
|
* @param {string} [stateName] 归属邦国名(多机器人各自记录时使用)
|
||||||
*/
|
*/
|
||||||
addSessionClaim(chunk) {
|
addSessionClaim(chunk, stateName) {
|
||||||
this.sessionClaimTimes.set(chunkPos.key(chunk.x, chunk.z), Date.now());
|
this.sessionClaimTimes.set(chunkPos.key(chunk.x, chunk.z), Date.now());
|
||||||
this.model.addSessionClaim(chunk);
|
this.model.addSessionClaim(chunk, stateName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @returns {object} 数据源状态快照(供 Web 控制台展示) */
|
/** @returns {object} 数据源状态快照(供 Web 控制台展示) */
|
||||||
@@ -389,7 +418,29 @@ class TerritoryDataService {
|
|||||||
apiLastSuccessMillis: this.lastApiSuccessMillis,
|
apiLastSuccessMillis: this.lastApiSuccessMillis,
|
||||||
apiError: this.apiClient.lastError(),
|
apiError: this.apiClient.lastError(),
|
||||||
apiHttpStatus: this.apiClient.lastHttpStatus(),
|
apiHttpStatus: this.apiClient.lastHttpStatus(),
|
||||||
apiTerritoryCount: this.apiTerritory.size
|
apiTerritoryCount: this.apiTerritory.size,
|
||||||
|
apiV2Enabled: this.isApiV2Enabled(),
|
||||||
|
apiV2Error: this.apiV2Client.lastError(),
|
||||||
|
apiV2HttpStatus: this.apiV2Client.lastHttpStatus()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 相关状态快照(供控制台「API 数据」面板展示用量与配额情况)。
|
||||||
|
* @returns {object}
|
||||||
|
*/
|
||||||
|
apiSnapshot() {
|
||||||
|
return {
|
||||||
|
v1: Object.assign(this.apiClient.snapshot(), {
|
||||||
|
enabled: this.isApiEnabled(),
|
||||||
|
pollIntervalSeconds: this._apiPollIntervalSeconds,
|
||||||
|
lastSuccessMillis: this.lastApiSuccessMillis,
|
||||||
|
territoryUnions: this.apiTerritory.size,
|
||||||
|
capitalUnions: this.apiCapitals.size
|
||||||
|
}),
|
||||||
|
v2: Object.assign(this.apiV2Client.snapshot(), { enabled: this.isApiV2Enabled() }),
|
||||||
|
/** 官方文档说明:API 与 API-V2 是两套独立、互不通用的密钥体系 */
|
||||||
|
note: 'API 与 API-V2 是两套独立密钥体系,需在密钥中心分别申请;列表类接口支持 limit,响应带最多 1 小时缓存延迟'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -428,13 +479,15 @@ function parseMarkers(json) {
|
|||||||
const claimColors = new Map();
|
const claimColors = new Map();
|
||||||
/** @type {Map<string, {x: number, z: number}>} */
|
/** @type {Map<string, {x: number, z: number}>} */
|
||||||
const capitals = new Map();
|
const capitals = new Map();
|
||||||
|
/** @type {Map<string, number[][]>} 邦国名 → 扁平多边形列表(大地图直接绘制用) */
|
||||||
|
const claimPolygons = new Map();
|
||||||
|
|
||||||
for (const layer of root) {
|
for (const layer of root) {
|
||||||
if (!layer || typeof layer !== 'object') continue;
|
if (!layer || typeof layer !== 'object') continue;
|
||||||
const markers = layer.markers;
|
const markers = layer.markers;
|
||||||
if (!Array.isArray(markers)) continue;
|
if (!Array.isArray(markers)) continue;
|
||||||
if (layer.id === 'union_claims') {
|
if (layer.id === 'union_claims') {
|
||||||
for (const marker of markers) parseClaimMarker(marker, claims, claimColors);
|
for (const marker of markers) parseClaimMarker(marker, claims, claimColors, claimPolygons);
|
||||||
} else if (layer.id === 'union_capitals') {
|
} else if (layer.id === 'union_capitals') {
|
||||||
for (const marker of markers) parseCapitalMarker(marker, capitals);
|
for (const marker of markers) parseCapitalMarker(marker, capitals);
|
||||||
}
|
}
|
||||||
@@ -448,7 +501,13 @@ function parseMarkers(json) {
|
|||||||
capital = first.done ? { x: 0, z: 0 } : chunkPos.decode(first.value);
|
capital = first.done ? { x: 0, z: 0 } : chunkPos.decode(first.value);
|
||||||
}
|
}
|
||||||
const color = claimColors.has(name) ? claimColors.get(name) : DEFAULT_COLOR;
|
const color = claimColors.has(name) ? claimColors.get(name) : DEFAULT_COLOR;
|
||||||
states.set(name, { name, capital, chunks: chunkKeys, color });
|
states.set(name, {
|
||||||
|
name,
|
||||||
|
capital,
|
||||||
|
chunks: chunkKeys,
|
||||||
|
color,
|
||||||
|
polygons: claimPolygons.get(name) || []
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return new TerritoryModel(states);
|
return new TerritoryModel(states);
|
||||||
}
|
}
|
||||||
@@ -456,8 +515,11 @@ function parseMarkers(json) {
|
|||||||
/**
|
/**
|
||||||
* 解析一个领土多边形 marker:从弹窗文本提取邦国名,
|
* 解析一个领土多边形 marker:从弹窗文本提取邦国名,
|
||||||
* 将多边形覆盖的区块按 even-odd 规则填充进该邦国的区块集合,并记录颜色。
|
* 将多边形覆盖的区块按 even-odd 规则填充进该邦国的区块集合,并记录颜色。
|
||||||
|
*
|
||||||
|
* 同时把原始多边形(方块坐标,扁平为 `[x1, z1, x2, z2, ...]`)保留下来,
|
||||||
|
* 供大地图按几何直接绘制——低缩放级别下逐个区块画矩形既慢又糊。
|
||||||
*/
|
*/
|
||||||
function parseClaimMarker(marker, claims, colors) {
|
function parseClaimMarker(marker, claims, colors, polygons) {
|
||||||
if (!marker || typeof marker !== 'object') return;
|
if (!marker || typeof marker !== 'object') return;
|
||||||
const popup = typeof marker.popup === 'string' ? marker.popup : '';
|
const popup = typeof marker.popup === 'string' ? marker.popup : '';
|
||||||
const matcher = CLAIM_PATTERN.exec(popup);
|
const matcher = CLAIM_PATTERN.exec(popup);
|
||||||
@@ -471,12 +533,36 @@ function parseClaimMarker(marker, claims, colors) {
|
|||||||
}
|
}
|
||||||
// 一个 marker 的多个多边形遵循 dynmap 的 even-odd 填充规则:
|
// 一个 marker 的多个多边形遵循 dynmap 的 even-odd 填充规则:
|
||||||
// 外圈为正,内圈多边形(国中飞地的边界)作为孔洞被减去,而非作为独立领土并集填充。
|
// 外圈为正,内圈多边形(国中飞地的边界)作为孔洞被减去,而非作为独立领土并集填充。
|
||||||
fillPolygonEvenOdd(chunks, extractPolygons(marker.points));
|
const extracted = extractPolygons(marker.points);
|
||||||
|
fillPolygonEvenOdd(chunks, extracted);
|
||||||
|
if (polygons) {
|
||||||
|
let list = polygons.get(name);
|
||||||
|
if (!list) {
|
||||||
|
list = [];
|
||||||
|
polygons.set(name, list);
|
||||||
|
}
|
||||||
|
for (const polygon of extracted) list.push(flattenPolygon(polygon));
|
||||||
|
}
|
||||||
if (!colors.has(name)) {
|
if (!colors.has(name)) {
|
||||||
colors.set(name, parseColor(typeof marker.color === 'string' ? marker.color : '#75bd20'));
|
colors.set(name, parseColor(typeof marker.color === 'string' ? marker.color : '#75bd20'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把多边形压平成 `[x1, z1, x2, z2, ...]`,减少 JSON 体积与对象开销。
|
||||||
|
* @param {Array<[number, number]>} polygon
|
||||||
|
* @returns {number[]}
|
||||||
|
*/
|
||||||
|
function flattenPolygon(polygon) {
|
||||||
|
const flat = new Array(polygon.length * 2);
|
||||||
|
let i = 0;
|
||||||
|
for (const point of polygon) {
|
||||||
|
flat[i++] = point[0];
|
||||||
|
flat[i++] = point[1];
|
||||||
|
}
|
||||||
|
return flat;
|
||||||
|
}
|
||||||
|
|
||||||
/** 解析一个王城 marker:从弹窗文本提取邦国名,记录王城的区块坐标 */
|
/** 解析一个王城 marker:从弹窗文本提取邦国名,记录王城的区块坐标 */
|
||||||
function parseCapitalMarker(marker, capitals) {
|
function parseCapitalMarker(marker, capitals) {
|
||||||
if (!marker || typeof marker !== 'object') return;
|
if (!marker || typeof marker !== 'object') return;
|
||||||
@@ -613,6 +699,7 @@ module.exports = {
|
|||||||
fillPolygonEvenOdd,
|
fillPolygonEvenOdd,
|
||||||
pointInPolygon,
|
pointInPolygon,
|
||||||
extractPolygons,
|
extractPolygons,
|
||||||
|
flattenPolygon,
|
||||||
parseColor,
|
parseColor,
|
||||||
buildMarkersUrl,
|
buildMarkersUrl,
|
||||||
CLAIM_CONFIRM_GRACE_MS,
|
CLAIM_CONFIRM_GRACE_MS,
|
||||||
|
|||||||
+41
-7
@@ -19,10 +19,12 @@ const API_DEFAULT_COLOR = 0xff43de;
|
|||||||
|
|
||||||
class TerritoryModel {
|
class TerritoryModel {
|
||||||
/**
|
/**
|
||||||
* @param {Map<string, {name: string, capital: {x: number, z: number}|null, chunks: Set<number>, color: number}>} [states]
|
* @param {Map<string, {name: string, capital: {x: number, z: number}|null, chunks: Set<number>, color: number, polygons?: number[][]}>} [states]
|
||||||
*/
|
*/
|
||||||
constructor(states) {
|
constructor(states) {
|
||||||
/** @type {Map<string, {name: string, capital: {x: number, z: number}|null, chunks: Set<number>, color: number}>} */
|
/**
|
||||||
|
* @type {Map<string, {name: string, capital: {x: number, z: number}|null, chunks: Set<number>, color: number, polygons?: number[][]}>}
|
||||||
|
*/
|
||||||
this.states = new Map(states || []);
|
this.states = new Map(states || []);
|
||||||
/** @type {Set<number>} 所有邦国已占领区块的并集 */
|
/** @type {Set<number>} 所有邦国已占领区块的并集 */
|
||||||
this.allClaimed = new Set();
|
this.allClaimed = new Set();
|
||||||
@@ -126,7 +128,36 @@ class TerritoryModel {
|
|||||||
|
|
||||||
/** @returns {Set<number>} 己方邦国的飞地(疏漏区块);未选择邦国时为空集 */
|
/** @returns {Set<number>} 己方邦国的飞地(疏漏区块);未选择邦国时为空集 */
|
||||||
selfFlyingChunks() {
|
selfFlyingChunks() {
|
||||||
return this._flyingByStateMap().get(this.selfStateName) || EMPTY_SET;
|
return this.flyingChunksOf(this.selfStateName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 指定邦国的飞地(疏漏区块)。
|
||||||
|
*
|
||||||
|
* 多机器人场景下每个机器人可能在不同邦国,因此不能只依赖全局的 selfStateName,
|
||||||
|
* 必须支持按邦国名查询。
|
||||||
|
*
|
||||||
|
* @param {string} name 邦国名
|
||||||
|
* @returns {Set<number>}
|
||||||
|
*/
|
||||||
|
flyingChunksOf(name) {
|
||||||
|
if (!name) return EMPTY_SET;
|
||||||
|
return this._flyingByStateMap().get(name) || EMPTY_SET;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取「己方」区块集合:指定邦国领土 ∪ 会话新占领区块。
|
||||||
|
*
|
||||||
|
* @param {string} name 邦国名
|
||||||
|
* @returns {Set<number>} 新区块集合(调用方可安全修改)
|
||||||
|
*/
|
||||||
|
ownChunksOf(name) {
|
||||||
|
const result = new Set(this.sessionClaims);
|
||||||
|
const state = name ? this.states.get(name) : null;
|
||||||
|
if (state) {
|
||||||
|
for (const k of state.chunks) result.add(k);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @returns {Map<string, Set<number>>} 各邦国飞地区块(懒计算并缓存) */
|
/** @returns {Map<string, Set<number>>} 各邦国飞地区块(懒计算并缓存) */
|
||||||
@@ -176,14 +207,16 @@ class TerritoryModel {
|
|||||||
/**
|
/**
|
||||||
* 记录一次会话内新占领的区块:加入会话集合与全局已占领集合,
|
* 记录一次会话内新占领的区块:加入会话集合与全局已占领集合,
|
||||||
* 并同步更新己方邦国的区块集合。
|
* 并同步更新己方邦国的区块集合。
|
||||||
|
*
|
||||||
* @param {{x: number, z: number}} chunk
|
* @param {{x: number, z: number}} chunk
|
||||||
|
* @param {string} [stateName] 归属邦国名;缺省时使用全局 selfStateName
|
||||||
*/
|
*/
|
||||||
addSessionClaim(chunk) {
|
addSessionClaim(chunk, stateName) {
|
||||||
const k = chunkPos.key(chunk.x, chunk.z);
|
const k = chunkPos.key(chunk.x, chunk.z);
|
||||||
this.sessionClaims.add(k);
|
this.sessionClaims.add(k);
|
||||||
this.allClaimed.add(k);
|
this.allClaimed.add(k);
|
||||||
this._invalidateFlying();
|
this._invalidateFlying();
|
||||||
const self = this.states.get(this.selfStateName);
|
const self = this.states.get(stateName || this.selfStateName);
|
||||||
if (self) {
|
if (self) {
|
||||||
self.chunks.add(k);
|
self.chunks.add(k);
|
||||||
}
|
}
|
||||||
@@ -224,7 +257,7 @@ class TerritoryModel {
|
|||||||
const first = chunks.values().next();
|
const first = chunks.values().next();
|
||||||
capital = first.done ? { x: 0, z: 0 } : chunkPos.decode(first.value);
|
capital = first.done ? { x: 0, z: 0 } : chunkPos.decode(first.value);
|
||||||
}
|
}
|
||||||
rebuilt.set(name, { name, capital, chunks, color });
|
rebuilt.set(name, { name, capital, chunks, color, polygons: [] });
|
||||||
} else {
|
} else {
|
||||||
const chunks = new Set(existing.chunks);
|
const chunks = new Set(existing.chunks);
|
||||||
for (const k of chunkKeys) chunks.add(k);
|
for (const k of chunkKeys) chunks.add(k);
|
||||||
@@ -232,7 +265,8 @@ class TerritoryModel {
|
|||||||
if (apiCapital && (!capital || (capital.x === 0 && capital.z === 0))) {
|
if (apiCapital && (!capital || (capital.x === 0 && capital.z === 0))) {
|
||||||
capital = apiCapital;
|
capital = apiCapital;
|
||||||
}
|
}
|
||||||
rebuilt.set(name, { name, capital, chunks, color: existing.color });
|
// 多边形是 Dynmap 独有的原始几何,API 合并时原样保留(大地图绘制用)
|
||||||
|
rebuilt.set(name, { name, capital, chunks, color: existing.color, polygons: existing.polygons || [] });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 未被 API 覆盖的邦国原样保留
|
// 未被 API 覆盖的邦国原样保留
|
||||||
|
|||||||
+100
-31
@@ -7,30 +7,30 @@ require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
|
|||||||
const chalk = require('chalk');
|
const chalk = require('chalk');
|
||||||
|
|
||||||
const { ConfigStore } = require('./config/configStore');
|
const { ConfigStore } = require('./config/configStore');
|
||||||
|
const { BotStore, BotCredentialStore } = require('./config/botStore');
|
||||||
|
const { parseBotsFromEnv } = require('./config/botsConfig');
|
||||||
const { normalizeConfig } = require('./config/expansionConfig');
|
const { normalizeConfig } = require('./config/expansionConfig');
|
||||||
const { Logger } = require('./util/logger');
|
const { Logger } = require('./util/logger');
|
||||||
const { BotManager } = require('./bot/botManager');
|
const { BotPool } = require('./bot/botPool');
|
||||||
const { TerritoryDataService } = require('./core/territoryDataService');
|
const { TerritoryDataService } = require('./core/territoryDataService');
|
||||||
const { ExpansionController } = require('./controller/expansionController');
|
const { ExpansionLog } = require('./core/expansionLog');
|
||||||
|
const { ChatLog } = require('./core/chatLog');
|
||||||
|
const { BigMapService } = require('./core/bigMapService');
|
||||||
const { WebConsole } = require('./web/server');
|
const { WebConsole } = require('./web/server');
|
||||||
|
|
||||||
/** 控制器 tick 间隔(毫秒),50ms ≈ 20 TPS */
|
/** 控制器 tick 间隔(毫秒),50ms ≈ 20 TPS */
|
||||||
const TICK_INTERVAL_MS = 50;
|
const TICK_INTERVAL_MS = 50;
|
||||||
|
/** 启动时等待机器人进入世界的上限(毫秒),超时后大地图退回以世界原点为中心 */
|
||||||
|
const WAIT_FOR_BOT_MS = 60000;
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const logger = new Logger({ echo: true });
|
const logger = new Logger({ echo: true });
|
||||||
logger.log(chalk.bold('==== AutoStore Bot 启动 ===='));
|
logger.log(chalk.bold('==== AutoStore Bot 启动 ===='));
|
||||||
|
|
||||||
// ==================== 连接参数(全部来自环境变量,密钥不落代码) ====================
|
const dataDir = path.join(__dirname, '..', 'data');
|
||||||
const connection = {
|
|
||||||
host: process.env.MC_HOST || 'bgjq.simpfun.cn',
|
// ==================== 机器人凭据(全部来自环境变量,密钥不落代码 / 不落配置文件) ====================
|
||||||
port: Number.parseInt(process.env.MC_PORT || '25565', 10),
|
const connections = parseBotsFromEnv(process.env);
|
||||||
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();
|
const configStore = new ConfigStore();
|
||||||
@@ -38,29 +38,54 @@ async function main() {
|
|||||||
config = normalizeConfig(config, config);
|
config = normalizeConfig(config, config);
|
||||||
// 环境变量里的 API Key 作为兜底(配置文件为空时)
|
// 环境变量里的 API Key 作为兜底(配置文件为空时)
|
||||||
if (!config.apiKey && process.env.SIMP_API_KEY) config.apiKey = process.env.SIMP_API_KEY.trim();
|
if (!config.apiKey && process.env.SIMP_API_KEY) config.apiKey = process.env.SIMP_API_KEY.trim();
|
||||||
|
// API-V2 是独立的一套密钥体系,单独兜底
|
||||||
|
if (!config.apiV2Key && process.env.SIMP_API_V2_KEY) {
|
||||||
|
config.apiV2Key = process.env.SIMP_API_V2_KEY.trim();
|
||||||
|
config.apiV2Enabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
logger.log(`配置文件:${configStore.filePath}`);
|
logger.log(`配置文件:${configStore.filePath}`);
|
||||||
logger.log(`服务器:${connection.host}:${connection.port} / ${connection.username} / ${connection.version} / ${connection.auth}`);
|
|
||||||
|
|
||||||
// ==================== 各模块 ====================
|
// ==================== 各模块 ====================
|
||||||
const botManager = new BotManager(connection, config, logger);
|
const expansionLog = new ExpansionLog({ filePath: path.join(dataDir, 'expansion-log.jsonl'), logger });
|
||||||
|
const chatLog = new ChatLog({ dir: path.join(dataDir, 'chat'), logger });
|
||||||
const dataService = new TerritoryDataService(config, logger);
|
const dataService = new TerritoryDataService(config, logger);
|
||||||
const controller = new ExpansionController({ botManager, dataService, logger });
|
const bigMap = new BigMapService(config, logger, {
|
||||||
|
baseUrl: process.env.DYNMAP_BASE,
|
||||||
// 寻路事件转发给控制器(与 Baritone 的 PathEvent 语义一致)
|
world: config.bigMap.world || process.env.DYNMAP_WORLD,
|
||||||
botManager.on('modulesReady', () => {
|
cacheDir: path.join(dataDir, 'mapcache')
|
||||||
if (botManager.pathing) {
|
|
||||||
botManager.pathing.onPathEvent = (event, detail) => controller.onPathEvent(event, detail);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
const botStore = new BotStore();
|
||||||
|
// 控制台新增玩家的凭据单独落盘(0600),与 .env 互为补充、.env 优先
|
||||||
|
const credentialStore = new BotCredentialStore();
|
||||||
|
const pool = new BotPool({
|
||||||
|
connections,
|
||||||
|
config,
|
||||||
|
logger,
|
||||||
|
dataService,
|
||||||
|
expansionLog,
|
||||||
|
chatLog,
|
||||||
|
botStore,
|
||||||
|
credentialStore
|
||||||
|
});
|
||||||
|
|
||||||
|
// 账号来源有两个:.env 的 MC_BOTS / MC_USERNAME,以及控制台「添加玩家」写入的本地凭据文件
|
||||||
|
const botNames = pool.snapshot().map((b) => `${b.username}(${b.source === 'env' ? '.env' : '控制台'})`);
|
||||||
|
logger.log(`机器人数量:${pool.size}`
|
||||||
|
+ (botNames.length > 0 ? '(' + botNames.join('、') + ')' : ''));
|
||||||
|
if (pool.size === 0) {
|
||||||
|
logger.log('未配置任何机器人:请在 .env 设置 MC_BOTS,或启动后到控制台用「添加玩家」新增', 'error');
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Web 控制台 ====================
|
// ==================== Web 控制台 ====================
|
||||||
const webConsole = new WebConsole(
|
const webConsole = new WebConsole(
|
||||||
{
|
{
|
||||||
botManager,
|
pool,
|
||||||
controller,
|
|
||||||
dataService,
|
dataService,
|
||||||
logger,
|
logger,
|
||||||
|
expansionLog,
|
||||||
|
chatLog,
|
||||||
|
bigMap,
|
||||||
getConfig: () => config,
|
getConfig: () => config,
|
||||||
applyConfig: (next) => applyConfig(next)
|
applyConfig: (next) => applyConfig(next)
|
||||||
},
|
},
|
||||||
@@ -68,7 +93,8 @@ async function main() {
|
|||||||
host: process.env.WEB_HOST || '127.0.0.1',
|
host: process.env.WEB_HOST || '127.0.0.1',
|
||||||
port: Number.parseInt(process.env.WEB_PORT || '3081', 10),
|
port: Number.parseInt(process.env.WEB_PORT || '3081', 10),
|
||||||
token: process.env.WEB_TOKEN || '',
|
token: process.env.WEB_TOKEN || '',
|
||||||
exportDir: path.join(__dirname, '..', 'data')
|
password: process.env.WEB_PASSWORD || '',
|
||||||
|
exportDir: dataDir
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -80,23 +106,34 @@ async function main() {
|
|||||||
function applyConfig(next) {
|
function applyConfig(next) {
|
||||||
config = normalizeConfig(next, config);
|
config = normalizeConfig(next, config);
|
||||||
configStore.save(config);
|
configStore.save(config);
|
||||||
botManager.applyConfig(config);
|
pool.applyGlobalConfig(config);
|
||||||
controller.config = config;
|
|
||||||
dataService.config = config;
|
dataService.config = config;
|
||||||
dataService.applyApiConfig(config.apiEnabled, config.apiKey, config.apiRefreshIntervalSeconds);
|
dataService.applyApiConfig(config.apiEnabled, config.apiKey, config.apiRefreshIntervalSeconds);
|
||||||
|
dataService.applyApiV2Config(config.apiV2Enabled, config.apiV2Key || process.env.SIMP_API_V2_KEY);
|
||||||
dataService.reschedule(config.refreshIntervalSeconds);
|
dataService.reschedule(config.refreshIntervalSeconds);
|
||||||
|
if (bigMap) {
|
||||||
|
bigMap.config = config;
|
||||||
|
bigMap.world = config.bigMap.world || process.env.DYNMAP_WORLD || bigMap.world;
|
||||||
|
}
|
||||||
logger.log('配置已更新并应用');
|
logger.log('配置已更新并应用');
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 启动 ====================
|
// ==================== 启动 ====================
|
||||||
dataService.applyApiConfig(config.apiEnabled, config.apiKey, config.apiRefreshIntervalSeconds);
|
dataService.applyApiConfig(config.apiEnabled, config.apiKey, config.apiRefreshIntervalSeconds);
|
||||||
|
dataService.applyApiV2Config(config.apiV2Enabled, config.apiV2Key || process.env.SIMP_API_V2_KEY);
|
||||||
dataService.start(config.refreshIntervalSeconds);
|
dataService.start(config.refreshIntervalSeconds);
|
||||||
botManager.connect();
|
pool.start();
|
||||||
|
|
||||||
|
// 大地图:启动时预爬取「机器人所在位置周边」的瓦片。
|
||||||
|
// 机器人还没进世界时先等一会儿 —— 以世界原点为中心爬是没意义的。
|
||||||
|
if (config.bigMap.enabled && config.bigMap.crawlOnStart) {
|
||||||
|
scheduleInitialCrawl(pool, bigMap, config, logger);
|
||||||
|
}
|
||||||
|
|
||||||
const tickTimer = setInterval(() => {
|
const tickTimer = setInterval(() => {
|
||||||
try {
|
try {
|
||||||
controller.tick();
|
pool.tick();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('tick 异常:' + (err && err.stack ? err.stack : err));
|
logger.error('tick 异常:' + (err && err.stack ? err.stack : err));
|
||||||
}
|
}
|
||||||
@@ -114,8 +151,7 @@ async function main() {
|
|||||||
shuttingDown = true;
|
shuttingDown = true;
|
||||||
logger.log(`收到 ${signal},正在退出…`);
|
logger.log(`收到 ${signal},正在退出…`);
|
||||||
clearInterval(tickTimer);
|
clearInterval(tickTimer);
|
||||||
controller.stop();
|
pool.stop();
|
||||||
botManager.disconnect();
|
|
||||||
dataService.stop();
|
dataService.stop();
|
||||||
try {
|
try {
|
||||||
await webConsole.stop();
|
await webConsole.stop();
|
||||||
@@ -131,6 +167,39 @@ async function main() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动时预爬取大地图瓦片:优先等机器人进入世界,以它的位置为中心。
|
||||||
|
*
|
||||||
|
* 大地图的用途是看自己机器人周边,所以「以世界原点为中心爬」几乎总是爬错地方。
|
||||||
|
* 这里最多等 `WAIT_FOR_BOT_MS`,等到任意一个机器人有坐标就用它当中心;
|
||||||
|
* 等不到(比如机器人被禁用)再退回世界原点,保证控制台至少不是空白。
|
||||||
|
*
|
||||||
|
* @param {import('./bot/botPool').BotPool} pool
|
||||||
|
* @param {import('./core/bigMapService').BigMapService} bigMap
|
||||||
|
* @param {object} config
|
||||||
|
* @param {import('./util/logger').Logger} logger
|
||||||
|
*/
|
||||||
|
function scheduleInitialCrawl(pool, bigMap, config, logger) {
|
||||||
|
const deadline = Date.now() + WAIT_FOR_BOT_MS;
|
||||||
|
const attempt = () => {
|
||||||
|
const position = pool.positions().find((bot) => bot.position) || null;
|
||||||
|
if (!position && Date.now() < deadline) {
|
||||||
|
setTimeout(attempt, 3000).unref?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const center = position
|
||||||
|
? { centerBlockX: position.position.x, centerBlockZ: position.position.z }
|
||||||
|
: { centerBlockX: 0, centerBlockZ: 0 };
|
||||||
|
if (!position) {
|
||||||
|
logger.log('等待机器人进入世界超时,大地图改以世界原点为中心预爬取', 'warn');
|
||||||
|
}
|
||||||
|
bigMap.crawlAll(config.bigMap.zoom, center).catch((err) => {
|
||||||
|
logger.log('大地图预爬取失败:' + (err && err.message ? err.message : String(err)), 'warn');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
setTimeout(attempt, 3000).unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
// 仅在作为入口运行时启动(被 require 时只导出工厂,便于测试与语法自检)
|
// 仅在作为入口运行时启动(被 require 时只导出工厂,便于测试与语法自检)
|
||||||
if (require.main === module) {
|
if (require.main === module) {
|
||||||
main().catch((err) => {
|
main().catch((err) => {
|
||||||
@@ -139,4 +208,4 @@ if (require.main === module) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { main };
|
module.exports = { main, scheduleInitialCrawl };
|
||||||
|
|||||||
+2085
-138
文件差异内容过多而无法显示
加载差异
+237
-48
@@ -2,7 +2,8 @@
|
|||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
|
<meta name="theme-color" content="#0d1117">
|
||||||
<title>AutoStore Bot 控制台</title>
|
<title>AutoStore Bot 控制台</title>
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
</head>
|
</head>
|
||||||
@@ -10,23 +11,64 @@
|
|||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<span class="logo">AS</span>
|
<span class="logo">AS</span>
|
||||||
<div>
|
<div class="brand-text">
|
||||||
<h1>AutoStore Bot</h1>
|
<h1>AutoStore Bot</h1>
|
||||||
<p>Minecraft 自动扩地机器人 · mineflayer · Grim 合规</p>
|
<p>多机器人自动扩地 · mineflayer · Grim 合规 · 大地图</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="topbar-right">
|
<div class="topbar-right">
|
||||||
<span id="connBadge" class="badge badge-offline">未连接</span>
|
<label class="field-inline">
|
||||||
<span id="taskBadge" class="badge badge-idle">空闲</span>
|
<span class="field-label">当前机器人</span>
|
||||||
<button id="btnStart" class="btn btn-primary">开始扩地</button>
|
<select id="botSelect" class="input input-sm"></select>
|
||||||
<button id="btnStop" class="btn btn-danger">停止</button>
|
</label>
|
||||||
<button id="btnReconnect" class="btn">重连</button>
|
<div class="badges">
|
||||||
|
<span id="connBadge" class="badge badge-offline">未连接</span>
|
||||||
|
<span id="taskBadge" class="badge badge-idle">空闲</span>
|
||||||
|
<span id="authBadge" class="badge badge-idle" title="控制台登录状态">未登录</span>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<button id="btnStart" class="btn btn-primary">开始扩地</button>
|
||||||
|
<button id="btnStop" class="btn btn-danger">停止</button>
|
||||||
|
<button id="btnReconnect" class="btn">重连</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<nav class="mobile-nav" id="mobileNav">
|
||||||
|
<button class="mobile-nav-item active" data-scroll="panelStatus">状态</button>
|
||||||
|
<button class="mobile-nav-item" data-scroll="panelMap">地图</button>
|
||||||
|
<button class="mobile-nav-item" data-scroll="panelLogs">日志</button>
|
||||||
|
<button class="mobile-nav-item" data-scroll="panelConfig">配置</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<main class="layout">
|
<main class="layout">
|
||||||
<!-- ==================== 左栏:状态面板 ==================== -->
|
<!-- ==================== 左栏:状态面板 ==================== -->
|
||||||
<section class="column col-left">
|
<section class="column col-left" id="panelStatus">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>机器人(<span id="botCount">0</span>)</h2>
|
||||||
|
<div class="row row-compact">
|
||||||
|
<button id="btnStartAll" class="btn btn-sm">全部开始</button>
|
||||||
|
<button id="btnStopAll" class="btn btn-sm">全部停止</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="botList" class="bot-list"></div>
|
||||||
|
<details class="add-bot">
|
||||||
|
<summary>+ 添加玩家</summary>
|
||||||
|
<div class="field">
|
||||||
|
<input id="newBotName" class="input" type="text" placeholder="玩家名(1-16 位字母/数字/下划线)" maxlength="16">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<input id="newBotPass" class="input" type="password" placeholder="服务器密码(可留空)" autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<input id="newBotLabel" class="input" type="text" placeholder="备注(可留空)">
|
||||||
|
</div>
|
||||||
|
<button id="btnAddBot" class="btn btn-sm btn-primary btn-block">添加</button>
|
||||||
|
<p class="hint">密码只保存在服务器本地凭据文件(权限 0600),不会进仓库、也不会出现在任何接口响应里。.env 里配置的账号需改 .env 才能移除。</p>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>连接</h2>
|
<h2>连接</h2>
|
||||||
<dl class="kv">
|
<dl class="kv">
|
||||||
@@ -37,8 +79,14 @@
|
|||||||
<dt>所在区块</dt><dd id="kChunk">-</dd>
|
<dt>所在区块</dt><dd id="kChunk">-</dd>
|
||||||
<dt>生命 / 饥饿</dt><dd id="kHealth">-</dd>
|
<dt>生命 / 饥饿</dt><dd id="kHealth">-</dd>
|
||||||
<dt>状态</dt><dd id="kBotState">-</dd>
|
<dt>状态</dt><dd id="kBotState">-</dd>
|
||||||
|
<dt>登录验证</dt><dd id="kLogin">-</dd>
|
||||||
<dt>重连次数</dt><dd id="kReconnect">0</dd>
|
<dt>重连次数</dt><dd id="kReconnect">0</dd>
|
||||||
</dl>
|
</dl>
|
||||||
|
<div class="row">
|
||||||
|
<label class="chk"><input type="checkbox" id="chkEnabled"> 启用该机器人</label>
|
||||||
|
<label class="chk"><input type="checkbox" id="chkAutoStart"> 启动后自动扩地</label>
|
||||||
|
<button id="btnSaveBotSetting" class="btn btn-sm">保存</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -46,6 +94,7 @@
|
|||||||
<dl class="kv">
|
<dl class="kv">
|
||||||
<dt>状态机</dt><dd id="kTaskState">IDLE</dd>
|
<dt>状态机</dt><dd id="kTaskState">IDLE</dd>
|
||||||
<dt>描述</dt><dd id="kTaskStatus">未启动</dd>
|
<dt>描述</dt><dd id="kTaskStatus">未启动</dd>
|
||||||
|
<dt>开拓邦国</dt><dd id="kTaskStateName">-</dd>
|
||||||
<dt>当前目标</dt><dd id="kTarget">-</dd>
|
<dt>当前目标</dt><dd id="kTarget">-</dd>
|
||||||
<dt>队列</dt><dd id="kQueue">0</dd>
|
<dt>队列</dt><dd id="kQueue">0</dd>
|
||||||
<dt>已完成</dt><dd id="kCompleted">0</dd>
|
<dt>已完成</dt><dd id="kCompleted">0</dd>
|
||||||
@@ -57,6 +106,35 @@
|
|||||||
<div class="progress"><div id="progressBar" class="progress-fill"></div></div>
|
<div class="progress"><div id="progressBar" class="progress-fill"></div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>定时消息</h2>
|
||||||
|
<button id="btnAddSchedule" class="btn btn-sm">+ 新增</button>
|
||||||
|
</div>
|
||||||
|
<div id="scheduleList" class="schedule-list"></div>
|
||||||
|
<div id="scheduleEditor" class="schedule-editor hidden">
|
||||||
|
<div class="field">
|
||||||
|
<label>内容(以 / 开头按游戏指令发送)</label>
|
||||||
|
<input id="schedContent" class="input" type="text" placeholder="/u restore confirm">
|
||||||
|
</div>
|
||||||
|
<div class="field-row">
|
||||||
|
<div class="field">
|
||||||
|
<label>间隔(秒)</label>
|
||||||
|
<input id="schedInterval" class="input" type="number" min="5" value="300">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>备注</label>
|
||||||
|
<input id="schedLabel" class="input" type="text" placeholder="恢复民心">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row row-compact">
|
||||||
|
<button id="btnSaveSchedule" class="btn btn-sm btn-primary">保存</button>
|
||||||
|
<button id="btnCancelSchedule" class="btn btn-sm">取消</button>
|
||||||
|
</div>
|
||||||
|
<p class="hint">只会在机器人已连接且通过登录验证时发送;未就绪的周期会跳过,不会补发。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>领土数据源</h2>
|
<h2>领土数据源</h2>
|
||||||
<dl class="kv">
|
<dl class="kv">
|
||||||
@@ -69,9 +147,10 @@
|
|||||||
<dt>己方飞地</dt><dd id="kFlying">0</dd>
|
<dt>己方飞地</dt><dd id="kFlying">0</dd>
|
||||||
<dt>SimpCore API</dt><dd id="kApi">未启用</dd>
|
<dt>SimpCore API</dt><dd id="kApi">未启用</dd>
|
||||||
</dl>
|
</dl>
|
||||||
<div class="row">
|
<div class="row row-compact">
|
||||||
<button id="btnRefreshMap" class="btn btn-sm">刷新地图</button>
|
<button id="btnRefreshMap" class="btn btn-sm">刷新地图</button>
|
||||||
<button id="btnFetchAll" class="btn btn-sm">拉取全服领土</button>
|
<button id="btnFetchAll" class="btn btn-sm">拉取全服领土</button>
|
||||||
|
<button id="btnOpenSimp" class="btn btn-sm btn-primary">查看 API 数据</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -83,6 +162,7 @@
|
|||||||
<dt>朝向已同步</dt><dd id="kGrimSettled">-</dd>
|
<dt>朝向已同步</dt><dd id="kGrimSettled">-</dd>
|
||||||
<dt>攻击距离</dt><dd id="kGrimReach">-</dd>
|
<dt>攻击距离</dt><dd id="kGrimReach">-</dd>
|
||||||
<dt>攻击冷却</dt><dd id="kGrimCooldown">-</dd>
|
<dt>攻击冷却</dt><dd id="kGrimCooldown">-</dd>
|
||||||
|
<dt>协议补包</dt><dd id="kGrimPackets">-</dd>
|
||||||
<dt>已拦下动作</dt><dd id="kGrimBlocked">0</dd>
|
<dt>已拦下动作</dt><dd id="kGrimBlocked">0</dd>
|
||||||
<dt>物理自检</dt><dd id="kGrimAudit">-</dd>
|
<dt>物理自检</dt><dd id="kGrimAudit">-</dd>
|
||||||
</dl>
|
</dl>
|
||||||
@@ -90,82 +170,191 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ==================== 中栏:地图 ==================== -->
|
<!-- ==================== 中栏:地图与日志 ==================== -->
|
||||||
<section class="column col-center">
|
<section class="column col-center" id="panelMap">
|
||||||
<div class="card card-map">
|
<div class="card card-map">
|
||||||
<div class="card-head">
|
<div class="card-head card-head-wrap">
|
||||||
<h2>领土地图</h2>
|
<div class="tabs tabs-inline" id="mapTabs">
|
||||||
<div class="row">
|
<button class="tab active" data-view="big">大地图</button>
|
||||||
<label class="chk"><input type="checkbox" id="chkFull" checked> 显示全部邦国</label>
|
<button class="tab" data-view="territory">领土地图</button>
|
||||||
<label class="chk"><input type="checkbox" id="chkFlying" checked> 标出飞地</label>
|
</div>
|
||||||
|
<div class="row row-compact" id="bigMapControls">
|
||||||
|
<label class="field-inline">
|
||||||
|
<span class="field-label">图幅</span>
|
||||||
|
<select id="bigMapRange" class="input input-sm">
|
||||||
|
<option value="512">±512 格</option>
|
||||||
|
<option value="1024">±1024 格</option>
|
||||||
|
<option value="2048">±2048 格</option>
|
||||||
|
<option value="4096" selected>±4096 格</option>
|
||||||
|
<option value="8192">±8192 格</option>
|
||||||
|
<option value="20480">全世界</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="chk"><input type="checkbox" id="chkTerritory" checked> 领土标记</label>
|
||||||
|
<label class="chk"><input type="checkbox" id="chkBotLabels" checked> 名字</label>
|
||||||
|
<button id="btnCrawlAround" class="btn btn-sm btn-primary">爬取周边</button>
|
||||||
|
<button id="btnMapCenterBots" class="btn btn-sm">回到机器人</button>
|
||||||
|
</div>
|
||||||
|
<div class="row row-compact hidden" id="territoryMapControls">
|
||||||
|
<label class="chk"><input type="checkbox" id="chkFull" checked> 全部邦国</label>
|
||||||
|
<label class="chk"><input type="checkbox" id="chkFlying" checked> 飞地</label>
|
||||||
<label class="chk"><input type="checkbox" id="chkGrid" checked> 网格</label>
|
<label class="chk"><input type="checkbox" id="chkGrid" checked> 网格</label>
|
||||||
<button id="btnMapCenter" class="btn btn-sm">回到玩家</button>
|
<button id="btnMapCenter" class="btn btn-sm">回到机器人</button>
|
||||||
<button id="btnMapFit" class="btn btn-sm">全图</button>
|
<button id="btnMapFit" class="btn btn-sm">看己方全境</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<canvas id="mapCanvas"></canvas>
|
<canvas id="mapCanvas"></canvas>
|
||||||
<div class="map-legend">
|
<div class="map-legend">
|
||||||
<span><i class="sw sw-self"></i>己方领土</span>
|
<span id="legendBig">
|
||||||
<span><i class="sw sw-session"></i>本次占领</span>
|
<i class="sw sw-self"></i>己方领土
|
||||||
<span><i class="sw sw-flying"></i>飞地(疏漏)</span>
|
<i class="sw sw-session"></i>本次占领
|
||||||
<span><i class="sw sw-target"></i>当前目标</span>
|
<i class="sw sw-capital"></i>王城
|
||||||
<span><i class="sw sw-player"></i>机器人</span>
|
<i class="sw sw-bot"></i>机器人
|
||||||
<span><i class="sw sw-capital"></i>王城</span>
|
</span>
|
||||||
|
<span id="legendTerritory" class="hidden">
|
||||||
|
<i class="sw sw-self"></i>己方领土
|
||||||
|
<i class="sw sw-session"></i>本次占领
|
||||||
|
<i class="sw sw-flying"></i>飞地(疏漏)
|
||||||
|
<i class="sw sw-target"></i>当前目标
|
||||||
|
<i class="sw sw-player"></i>机器人
|
||||||
|
<i class="sw sw-capital"></i>王城
|
||||||
|
</span>
|
||||||
<span id="mapInfo" class="mono small"></span>
|
<span id="mapInfo" class="mono small"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card card-log">
|
<div class="card card-log" id="panelLogs">
|
||||||
<div class="card-head">
|
<div class="card-head card-head-wrap">
|
||||||
<h2>运行日志</h2>
|
<div class="tabs tabs-inline" id="logTabs">
|
||||||
<div class="row">
|
<button class="tab active" data-pane="log">运行日志</button>
|
||||||
|
<button class="tab" data-pane="chat">公屏聊天</button>
|
||||||
|
<button class="tab" data-pane="exp">开拓日志</button>
|
||||||
|
</div>
|
||||||
|
<div class="row row-compact" id="logControls">
|
||||||
<label class="chk"><input type="checkbox" id="chkAutoScroll" checked> 自动滚动</label>
|
<label class="chk"><input type="checkbox" id="chkAutoScroll" checked> 自动滚动</label>
|
||||||
<button id="btnClearLog" class="btn btn-sm">清屏</button>
|
<button id="btnClearLog" class="btn btn-sm">清屏</button>
|
||||||
<button id="btnExportLog" class="btn btn-sm">导出日志</button>
|
<button id="btnExportLog" class="btn btn-sm">导出</button>
|
||||||
|
</div>
|
||||||
|
<div class="row row-compact hidden" id="chatControls">
|
||||||
|
<label class="chk"><input type="checkbox" id="chkChatAutoScroll" checked> 自动滚动</label>
|
||||||
|
<label class="chk"><input type="checkbox" id="chkChatAllBots"> 全部机器人</label>
|
||||||
|
<button id="btnClearChat" class="btn btn-sm">清屏</button>
|
||||||
|
<button id="btnExportChat" class="btn btn-sm">导出</button>
|
||||||
|
</div>
|
||||||
|
<div class="row row-compact hidden" id="expControls">
|
||||||
|
<label class="field-inline">
|
||||||
|
<span class="field-label">机器人</span>
|
||||||
|
<select id="expFilterBot" class="input input-sm"><option value="">全部</option></select>
|
||||||
|
</label>
|
||||||
|
<label class="chk"><input type="checkbox" id="chkExpAutoScroll" checked> 自动滚动</label>
|
||||||
|
<button id="btnExportExp" class="btn btn-sm">导出 CSV</button>
|
||||||
|
<button id="btnClearExp" class="btn btn-sm">清屏</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="logView" class="log-view"></div>
|
<div id="logView" class="log-view"></div>
|
||||||
|
<div id="chatView" class="log-view hidden"></div>
|
||||||
|
<div id="expView" class="log-view log-table hidden">
|
||||||
|
<table class="exp-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>时间</th><th>机器人</th><th>邦国</th><th>区块</th><th>方块坐标</th><th>来源</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="expBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ==================== 右栏:配置 ==================== -->
|
<!-- ==================== 右栏:配置与控制 ==================== -->
|
||||||
<section class="column col-right">
|
<section class="column col-right" id="panelConfig">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>控制台</h2>
|
||||||
|
<span class="mono small" id="consoleTarget">-</span>
|
||||||
|
</div>
|
||||||
|
<p class="hint" id="consoleHint">以 / 开头按游戏指令发送,否则按公屏消息发送。登录验证通过后才允许发送。</p>
|
||||||
|
<div class="row row-compact">
|
||||||
|
<input id="cmdInput" class="input input-grow" type="text" placeholder="输入内容或 /指令,回车发送">
|
||||||
|
<button id="btnSendCmd" class="btn btn-sm btn-primary">发送</button>
|
||||||
|
</div>
|
||||||
|
<div class="row row-compact">
|
||||||
|
<span class="field-label">快捷发送</span>
|
||||||
|
<button class="btn btn-sm" data-cmd="/u return">/u return</button>
|
||||||
|
<button class="btn btn-sm" data-cmd="/u claimmode">/u claimmode</button>
|
||||||
|
<button class="btn btn-sm" data-cmd="/u restore confirm">/u restore confirm</button>
|
||||||
|
</div>
|
||||||
|
<div class="row row-compact">
|
||||||
|
<span class="field-label">移动测试</span>
|
||||||
|
<button class="btn btn-sm" data-move="forward">前</button>
|
||||||
|
<button class="btn btn-sm" data-move="back">后</button>
|
||||||
|
<button class="btn btn-sm" data-move="left">左</button>
|
||||||
|
<button class="btn btn-sm" data-move="right">右</button>
|
||||||
|
<button class="btn btn-sm" data-move="jump">跳</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card card-config">
|
<div class="card card-config">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h2>配置</h2>
|
<h2>配置</h2>
|
||||||
<div class="row">
|
<div class="row row-compact">
|
||||||
<button id="btnSaveConfig" class="btn btn-sm btn-primary">保存配置</button>
|
<button id="btnSaveConfig" class="btn btn-sm btn-primary">保存</button>
|
||||||
<button id="btnReloadConfig" class="btn btn-sm">重新载入</button>
|
<button id="btnReloadConfig" class="btn btn-sm">重新载入</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<label class="chk config-scope">
|
||||||
|
<input type="checkbox" id="chkBotOverride">
|
||||||
|
仅对当前机器人生效(不勾选则写入全局默认配置)
|
||||||
|
</label>
|
||||||
<div class="tabs" id="configTabs">
|
<div class="tabs" id="configTabs">
|
||||||
<button class="tab active" data-tab="strategy">策略</button>
|
<button class="tab active" data-tab="strategy">策略</button>
|
||||||
<button class="tab" data-tab="timing">超时</button>
|
<button class="tab" data-tab="timing">超时</button>
|
||||||
<button class="tab" data-tab="pathing">寻路</button>
|
<button class="tab" data-tab="pathing">寻路</button>
|
||||||
<button class="tab" data-tab="survival">生存</button>
|
<button class="tab" data-tab="survival">生存</button>
|
||||||
<button class="tab" data-tab="data">数据</button>
|
<button class="tab" data-tab="data">数据</button>
|
||||||
|
<button class="tab" data-tab="bigmap">大地图</button>
|
||||||
<button class="tab" data-tab="grim">Grim</button>
|
<button class="tab" data-tab="grim">Grim</button>
|
||||||
<button class="tab" data-tab="server">服务器</button>
|
<button class="tab" data-tab="server">服务器</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="configBody" class="config-body"></div>
|
<div id="configBody" class="config-body"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>手动指令</h2>
|
|
||||||
<div class="row">
|
|
||||||
<input id="cmdInput" class="input" type="text" placeholder="u claimmode(不带斜杠)">
|
|
||||||
<button id="btnSendCmd" class="btn btn-sm">发送</button>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<button class="btn btn-sm" data-cmd="u return">/u return</button>
|
|
||||||
<button class="btn btn-sm" data-cmd="u claimmode">/u claimmode</button>
|
|
||||||
<button class="btn btn-sm" data-cmd="spawn">/spawn</button>
|
|
||||||
<button class="btn btn-sm" data-cmd="list">/list</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<!-- SimpCore 开放 API 数据浏览:数据量大且限流严格,做成按需打开的弹层而不是常驻面板 -->
|
||||||
|
<div id="simpOverlay" class="overlay hidden">
|
||||||
|
<div class="overlay-box overlay-box-lg">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>SimpCore 开放 API</h2>
|
||||||
|
<div class="row row-compact">
|
||||||
|
<span id="simpStatus" class="mono small"></span>
|
||||||
|
<button id="btnCloseSimp" class="btn btn-sm">关闭</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tabs" id="simpTabs">
|
||||||
|
<button class="tab active" data-simp="overview">概览</button>
|
||||||
|
<button class="tab" data-simp="unions">邦国</button>
|
||||||
|
<button class="tab" data-simp="players">玩家</button>
|
||||||
|
<button class="tab" data-simp="global">全服</button>
|
||||||
|
<button class="tab" data-simp="v2">实时 V2</button>
|
||||||
|
</div>
|
||||||
|
<div id="simpBody" class="simp-body"></div>
|
||||||
|
<div id="simpV2Result" class="simp-detail hidden"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 登录遮罩:控制台配置了 WEB_TOKEN / WEB_PASSWORD 时显示 -->
|
||||||
|
<div id="loginOverlay" class="overlay hidden">
|
||||||
|
<div class="overlay-box">
|
||||||
|
<h2>控制台登录</h2>
|
||||||
|
<p class="hint">验证通过后才允许操作各机器人的控制台、聊天框与扩地任务。</p>
|
||||||
|
<input id="loginPassword" class="input" type="password" placeholder="控制台密码 / 令牌" autocomplete="current-password">
|
||||||
|
<div class="row">
|
||||||
|
<button id="btnLogin" class="btn btn-primary btn-grow">登录</button>
|
||||||
|
<button id="btnLogout" class="btn">退出登录</button>
|
||||||
|
</div>
|
||||||
|
<p id="loginError" class="hint error-text"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="toast" class="toast"></div>
|
<div id="toast" class="toast"></div>
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+429
-91
@@ -1,16 +1,38 @@
|
|||||||
|
/* ==========================================================================
|
||||||
|
AutoStore Bot 控制台样式
|
||||||
|
设计要点:
|
||||||
|
- 深色主题,卡片式布局,三层栅格(左状态 / 中地图日志 / 右配置)
|
||||||
|
- 响应式:≤1200px 收成两栏,≤820px 收成单栏并启用底部导航
|
||||||
|
- 移动端:触控目标 ≥ 36px、地图自适应高度、底部安全区避让
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bg: #0d1117;
|
--bg: #0b0f16;
|
||||||
--panel: #161b22;
|
--bg-soft: #10151d;
|
||||||
--panel-2: #1c2330;
|
--panel: #151b25;
|
||||||
--border: #30363d;
|
--panel-2: #1b2230;
|
||||||
--text: #c9d1d9;
|
--panel-3: #212a3a;
|
||||||
--muted: #8b949e;
|
--border: #2b3444;
|
||||||
|
--border-soft: #222a37;
|
||||||
|
--text: #d7dee8;
|
||||||
|
--text-dim: #9aa7b8;
|
||||||
|
--muted: #6b7787;
|
||||||
|
|
||||||
--accent: #e94560;
|
--accent: #e94560;
|
||||||
--accent-2: #2f81f7;
|
--accent-hover: #f2586f;
|
||||||
|
--accent-2: #3b8ef7;
|
||||||
|
--accent-2-hover: #5aa0f9;
|
||||||
--green: #3fb950;
|
--green: #3fb950;
|
||||||
--yellow: #d29922;
|
--yellow: #d6a01f;
|
||||||
--red: #f85149;
|
--red: #f85149;
|
||||||
--purple: #bc8cff;
|
--purple: #bc8cff;
|
||||||
|
|
||||||
|
--radius-sm: 6px;
|
||||||
|
--radius: 10px;
|
||||||
|
--radius-lg: 14px;
|
||||||
|
--shadow: 0 1px 2px rgba(0, 0, 0, .3), 0 8px 24px -12px rgba(0, 0, 0, .5);
|
||||||
|
--gap: 12px;
|
||||||
|
--topbar-h: 58px;
|
||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
@@ -18,114 +40,141 @@
|
|||||||
html, body {
|
html, body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
height: 100%;
|
min-height: 100%;
|
||||||
background: var(--bg);
|
background: radial-gradient(1200px 600px at 20% -10%, #16202e 0%, var(--bg) 55%);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-family: "Segoe UI", "Microsoft YaHei", system-ui, -apple-system, sans-serif;
|
font-family: "Segoe UI", "Microsoft YaHei", system-ui, -apple-system, "Helvetica Neue", sans-serif;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mono { font-family: Consolas, "Cascadia Mono", monospace; }
|
.mono { font-family: Consolas, "Cascadia Mono", ui-monospace, monospace; }
|
||||||
.small { font-size: 12px; }
|
.small { font-size: 12px; }
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
.error-text { color: var(--red); }
|
||||||
|
|
||||||
/* ==================== 顶栏 ==================== */
|
/* ==================== 顶栏 ==================== */
|
||||||
.topbar {
|
.topbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 30;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 10px 18px;
|
gap: 12px;
|
||||||
background: linear-gradient(90deg, #161b22, #1b2230);
|
flex-wrap: wrap;
|
||||||
border-bottom: 1px solid var(--border);
|
padding: 10px 16px;
|
||||||
position: sticky;
|
background: rgba(16, 21, 29, .92);
|
||||||
top: 0;
|
backdrop-filter: blur(10px);
|
||||||
z-index: 10;
|
border-bottom: 1px solid var(--border-soft);
|
||||||
}
|
}
|
||||||
.brand { display: flex; align-items: center; gap: 12px; }
|
|
||||||
|
.brand { display: flex; align-items: center; gap: 11px; min-width: 0; }
|
||||||
.logo {
|
.logo {
|
||||||
width: 38px; height: 38px;
|
width: 36px; height: 36px;
|
||||||
display: grid; place-items: center;
|
display: grid; place-items: center;
|
||||||
border-radius: 9px;
|
border-radius: var(--radius);
|
||||||
background: var(--accent);
|
background: linear-gradient(135deg, var(--accent), #b5324a);
|
||||||
color: #fff; font-weight: 700; letter-spacing: 1px;
|
color: #fff; font-weight: 700; letter-spacing: .5px;
|
||||||
|
box-shadow: 0 4px 14px -6px rgba(233, 69, 96, .9);
|
||||||
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
.brand h1 { font-size: 16px; margin: 0; }
|
.brand-text { min-width: 0; }
|
||||||
.brand p { font-size: 12px; margin: 2px 0 0; color: var(--muted); }
|
.brand h1 { font-size: 15px; margin: 0; white-space: nowrap; }
|
||||||
.topbar-right { display: flex; align-items: center; gap: 8px; }
|
.brand p { font-size: 11.5px; margin: 1px 0 0; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
|
||||||
|
.topbar-right { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.badges { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.topbar-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
padding: 4px 10px;
|
padding: 3px 10px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-size: 12px;
|
font-size: 11.5px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: var(--panel-2);
|
background: var(--panel-2);
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.badge-online { color: var(--green); border-color: #1f6f34; }
|
.badge-online { color: var(--green); border-color: #1f6f34; background: rgba(63, 185, 80, .1); }
|
||||||
.badge-offline { color: var(--muted); }
|
.badge-offline { color: var(--muted); }
|
||||||
.badge-running { color: var(--accent-2); border-color: #1f4f8f; }
|
.badge-running { color: var(--accent-2); border-color: #1f4f8f; background: rgba(59, 142, 247, .12); }
|
||||||
.badge-idle { color: var(--muted); }
|
.badge-idle { color: var(--muted); }
|
||||||
.badge-error { color: var(--red); border-color: #8f2b26; }
|
.badge-error { color: var(--red); border-color: #8f2b26; }
|
||||||
|
|
||||||
|
/* ==================== 按钮 ==================== */
|
||||||
.btn {
|
.btn {
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: var(--panel-2);
|
background: var(--panel-2);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
transition: background .15s, border-color .15s;
|
font-family: inherit;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background .15s, border-color .15s, transform .06s, opacity .15s;
|
||||||
}
|
}
|
||||||
.btn:hover { background: #232b39; border-color: #48505e; }
|
.btn:hover:not(:disabled) { background: var(--panel-3); border-color: #3c4759; }
|
||||||
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
.btn:active:not(:disabled) { transform: translateY(1px); }
|
||||||
.btn-primary:hover { background: #d63851; border-color: #d63851; }
|
.btn:disabled { opacity: .55; cursor: not-allowed; }
|
||||||
.btn-danger { background: #3a1d22; border-color: #7a2b34; color: #ff8b98; }
|
.btn-primary { background: var(--accent-2); border-color: var(--accent-2); color: #fff; }
|
||||||
.btn-danger:hover { background: #4a2329; }
|
.btn-primary:hover:not(:disabled) { background: var(--accent-2-hover); border-color: var(--accent-2-hover); }
|
||||||
|
.btn-danger { background: rgba(248, 81, 73, .12); border-color: #7a2b34; color: #ff9aa2; }
|
||||||
|
.btn-danger:hover:not(:disabled) { background: rgba(248, 81, 73, .2); }
|
||||||
.btn-sm { padding: 4px 9px; font-size: 12px; }
|
.btn-sm { padding: 4px 9px; font-size: 12px; }
|
||||||
|
.btn-block { width: 100%; }
|
||||||
|
.btn-grow { flex: 1 1 auto; }
|
||||||
|
|
||||||
/* ==================== 布局 ==================== */
|
/* ==================== 布局 ==================== */
|
||||||
.layout {
|
.layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 300px minmax(420px, 1fr) 360px;
|
grid-template-columns: 320px minmax(420px, 1fr) 380px;
|
||||||
gap: 12px;
|
gap: var(--gap);
|
||||||
padding: 12px;
|
padding: var(--gap);
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
.column { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
|
.column { display: flex; flex-direction: column; gap: var(--gap); min-width: 0; }
|
||||||
|
|
||||||
|
/* ==================== 卡片 ==================== */
|
||||||
.card {
|
.card {
|
||||||
background: var(--panel);
|
background: linear-gradient(180deg, var(--panel) 0%, var(--bg-soft) 100%);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border-soft);
|
||||||
border-radius: 10px;
|
border-radius: var(--radius-lg);
|
||||||
padding: 12px;
|
padding: 13px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
}
|
}
|
||||||
.card h2 {
|
.card h2 {
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
margin: 0 0 8px;
|
margin: 0 0 9px;
|
||||||
color: var(--muted);
|
color: var(--text-dim);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: .6px;
|
letter-spacing: .7px;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.card-head {
|
.card-head {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
gap: 8px; margin-bottom: 8px;
|
gap: 8px; margin-bottom: 9px;
|
||||||
}
|
}
|
||||||
.card-head h2 { margin: 0; }
|
.card-head h2 { margin: 0; }
|
||||||
|
.card-head-wrap { flex-wrap: wrap; }
|
||||||
.row { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-top: 8px; }
|
.row { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-top: 8px; }
|
||||||
|
.row-compact { margin-top: 6px; }
|
||||||
|
|
||||||
/* ==================== 键值表 ==================== */
|
/* ==================== 键值表 ==================== */
|
||||||
.kv {
|
.kv {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 88px 1fr;
|
grid-template-columns: 92px 1fr;
|
||||||
gap: 4px 8px;
|
gap: 4px 8px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.kv dt { color: var(--muted); }
|
.kv dt { color: var(--muted); }
|
||||||
.kv dd { margin: 0; word-break: break-all; }
|
.kv dd { margin: 0; word-break: break-word; }
|
||||||
|
|
||||||
.progress {
|
.progress {
|
||||||
margin-top: 10px; height: 6px; border-radius: 3px;
|
margin-top: 10px; height: 6px; border-radius: 3px;
|
||||||
background: #0b0f14; overflow: hidden;
|
background: #080b11; overflow: hidden;
|
||||||
}
|
}
|
||||||
.progress-fill {
|
.progress-fill {
|
||||||
height: 100%; width: 0%;
|
height: 100%; width: 0%;
|
||||||
@@ -136,104 +185,393 @@ html, body {
|
|||||||
.issues { margin: 8px 0 0; padding-left: 18px; color: var(--yellow); font-size: 12px; }
|
.issues { margin: 8px 0 0; padding-left: 18px; color: var(--yellow); font-size: 12px; }
|
||||||
.issues li { margin-bottom: 3px; }
|
.issues li { margin-bottom: 3px; }
|
||||||
|
|
||||||
|
/* ==================== 机器人列表 ==================== */
|
||||||
|
.bot-list { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.bot-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 10px minmax(0, 1fr) auto;
|
||||||
|
gap: 9px; align-items: center;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--panel-2);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color .15s, background .15s;
|
||||||
|
}
|
||||||
|
.bot-item:hover { border-color: #3c4759; }
|
||||||
|
.bot-item.active { border-color: var(--accent-2); background: rgba(59, 142, 247, .08); }
|
||||||
|
.bot-item .dot { width: 9px; height: 9px; border-radius: 50%; background: var(--muted); }
|
||||||
|
.bot-item .dot.online { background: var(--green); box-shadow: 0 0 8px rgba(63, 185, 80, .7); }
|
||||||
|
.bot-item .dot.connecting { background: var(--yellow); }
|
||||||
|
.bot-item .dot.offline { background: var(--red); }
|
||||||
|
.bot-item .dot.disabled { background: #454e5e; }
|
||||||
|
.bot-item .name { font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.bot-item .sub { font-size: 11px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.bot-item .meta { font-size: 11px; color: var(--muted); text-align: right; white-space: nowrap; }
|
||||||
|
.bot-item .remove {
|
||||||
|
margin-left: 6px; padding: 0 6px;
|
||||||
|
border: 1px solid var(--border); border-radius: 4px;
|
||||||
|
background: transparent; color: var(--muted); cursor: pointer;
|
||||||
|
font-size: 13px; line-height: 18px;
|
||||||
|
}
|
||||||
|
.bot-item .remove:hover { color: var(--red); border-color: #7a2b34; }
|
||||||
|
|
||||||
|
.add-bot { margin-top: 10px; padding-top: 10px; border-top: 1px dashed var(--border); }
|
||||||
|
.add-bot summary {
|
||||||
|
cursor: pointer; font-size: 12.5px; color: var(--accent-2);
|
||||||
|
list-style: none; user-select: none;
|
||||||
|
}
|
||||||
|
.add-bot summary::-webkit-details-marker { display: none; }
|
||||||
|
.add-bot summary::before { content: '▸ '; }
|
||||||
|
.add-bot[open] summary::before { content: '▾ '; }
|
||||||
|
.add-bot .field { margin-top: 8px; }
|
||||||
|
.add-bot .hint { font-size: 11px; color: var(--muted); margin: 8px 0 0; }
|
||||||
|
|
||||||
|
/* ==================== 定时消息 ==================== */
|
||||||
|
.schedule-list { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.schedule-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||||
|
gap: 8px; align-items: center;
|
||||||
|
padding: 7px 9px;
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--panel-2);
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.schedule-item.off { opacity: .55; }
|
||||||
|
.schedule-item .content { min-width: 0; }
|
||||||
|
.schedule-item .content code {
|
||||||
|
display: block;
|
||||||
|
font-family: Consolas, ui-monospace, monospace;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.schedule-item .sub { font-size: 11px; color: var(--muted); }
|
||||||
|
.schedule-item .remove {
|
||||||
|
padding: 0 6px; border: 1px solid var(--border); border-radius: 4px;
|
||||||
|
background: transparent; color: var(--muted); cursor: pointer; line-height: 18px;
|
||||||
|
}
|
||||||
|
.schedule-item .remove:hover { color: var(--red); border-color: #7a2b34; }
|
||||||
|
.schedule-empty { font-size: 12px; color: var(--muted); padding: 4px 0; }
|
||||||
|
.schedule-editor {
|
||||||
|
margin-top: 10px; padding-top: 10px;
|
||||||
|
border-top: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
.schedule-editor .hint { font-size: 11px; color: var(--muted); margin: 6px 0 0; }
|
||||||
|
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||||
|
|
||||||
/* ==================== 地图 ==================== */
|
/* ==================== 地图 ==================== */
|
||||||
.card-map { display: flex; flex-direction: column; }
|
.card-map { display: flex; flex-direction: column; }
|
||||||
#mapCanvas {
|
#mapCanvas {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 380px;
|
height: clamp(300px, 46vh, 520px);
|
||||||
background: #05070a;
|
background: #05070a;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border-soft);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius);
|
||||||
display: block;
|
display: block;
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
}
|
}
|
||||||
#mapCanvas:active { cursor: grabbing; }
|
#mapCanvas:active { cursor: grabbing; }
|
||||||
.map-legend {
|
.map-legend {
|
||||||
display: flex; gap: 14px; flex-wrap: wrap;
|
display: flex; gap: 12px; flex-wrap: wrap;
|
||||||
margin-top: 8px; font-size: 12px; color: var(--muted);
|
margin-top: 8px; font-size: 11.5px; color: var(--muted);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
.sw {
|
.sw {
|
||||||
display: inline-block; width: 10px; height: 10px;
|
display: inline-block; width: 10px; height: 10px;
|
||||||
border-radius: 2px; margin-right: 4px; vertical-align: -1px;
|
border-radius: 2px; margin-right: 4px; vertical-align: -1px;
|
||||||
}
|
}
|
||||||
.sw-self { background: #2f81f7; }
|
.sw-self { background: #3b8ef7; }
|
||||||
.sw-session { background: #3fb950; }
|
.sw-session { background: #3fb950; }
|
||||||
.sw-flying { background: #f85149; }
|
.sw-flying { background: #f85149; }
|
||||||
.sw-target { background: #d29922; }
|
.sw-target { background: #d6a01f; }
|
||||||
.sw-player { background: #ffffff; }
|
.sw-player { background: #ffffff; }
|
||||||
.sw-capital { background: #bc8cff; }
|
.sw-capital { background: #bc8cff; }
|
||||||
|
.sw-bot { background: #ffd33d; }
|
||||||
|
|
||||||
.chk { display: flex; align-items: center; gap: 4px; font-size: 12px; color: var(--muted); cursor: pointer; }
|
/* ==================== 日志 / 聊天 / 表格 ==================== */
|
||||||
|
|
||||||
/* ==================== 日志 ==================== */
|
|
||||||
.card-log { display: flex; flex-direction: column; }
|
.card-log { display: flex; flex-direction: column; }
|
||||||
.log-view {
|
.log-view {
|
||||||
height: 260px;
|
height: clamp(220px, 34vh, 380px);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: #05070a;
|
background: #05070a;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border-soft);
|
||||||
border-radius: 8px;
|
border-radius: var(--radius);
|
||||||
padding: 8px;
|
padding: 8px 10px;
|
||||||
font-family: Consolas, "Cascadia Mono", monospace;
|
font-family: Consolas, "Cascadia Mono", ui-monospace, monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.55;
|
line-height: 1.6;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-all;
|
word-break: break-word;
|
||||||
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
.log-line { display: block; }
|
.log-line { display: block; }
|
||||||
.log-time { color: #586069; margin-right: 6px; }
|
.log-time { color: #55606f; margin-right: 6px; }
|
||||||
.log-info { color: var(--text); }
|
.log-info { color: var(--text); }
|
||||||
.log-warn { color: var(--yellow); }
|
.log-warn { color: var(--yellow); }
|
||||||
.log-error { color: var(--red); }
|
.log-error { color: var(--red); }
|
||||||
.log-success { color: var(--green); }
|
.log-success { color: var(--green); }
|
||||||
|
|
||||||
/* ==================== 配置 ==================== */
|
.chat-line { display: block; }
|
||||||
|
.chat-time { color: #55606f; margin-right: 6px; }
|
||||||
|
.chat-bot { color: var(--accent-2); margin-right: 4px; }
|
||||||
|
.chat-user { color: var(--purple); margin-right: 4px; }
|
||||||
|
.chat-out { color: var(--green); }
|
||||||
|
.chat-command { color: var(--yellow); }
|
||||||
|
.chat-system { color: var(--text-dim); }
|
||||||
|
.chat-action { color: var(--muted); }
|
||||||
|
|
||||||
|
.log-table { white-space: normal; padding: 0; }
|
||||||
|
.exp-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||||
|
.exp-table th {
|
||||||
|
position: sticky; top: 0; z-index: 1;
|
||||||
|
text-align: left; color: var(--text-dim); font-weight: 600;
|
||||||
|
background: #05070a; padding: 5px 7px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.exp-table td { padding: 3px 7px; border-bottom: 1px solid #10151d; white-space: nowrap; }
|
||||||
|
.exp-table tbody tr:hover { background: #0d1219; }
|
||||||
|
.exp-empty { color: var(--muted); padding: 8px; }
|
||||||
|
|
||||||
|
/* ==================== 表单 ==================== */
|
||||||
.card-config { display: flex; flex-direction: column; }
|
.card-config { display: flex; flex-direction: column; }
|
||||||
.tabs { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 8px; }
|
.config-scope { margin-bottom: 8px; }
|
||||||
|
.tabs { display: flex; gap: 5px; flex-wrap: wrap; margin-bottom: 9px; }
|
||||||
|
.tabs-inline { margin-bottom: 0; }
|
||||||
.tab {
|
.tab {
|
||||||
padding: 4px 10px; font-size: 12px; cursor: pointer;
|
padding: 4px 10px; font-size: 12px; cursor: pointer;
|
||||||
background: var(--panel-2); border: 1px solid var(--border);
|
background: var(--panel-2); border: 1px solid var(--border-soft);
|
||||||
border-radius: 6px; color: var(--muted);
|
border-radius: 999px; color: var(--muted);
|
||||||
|
font-family: inherit;
|
||||||
|
transition: background .15s, color .15s, border-color .15s;
|
||||||
}
|
}
|
||||||
|
.tab:hover { color: var(--text); }
|
||||||
.tab.active { color: #fff; background: var(--accent-2); border-color: var(--accent-2); }
|
.tab.active { color: #fff; background: var(--accent-2); border-color: var(--accent-2); }
|
||||||
.config-body { max-height: 520px; overflow-y: auto; }
|
.config-body { max-height: min(58vh, 560px); overflow-y: auto; padding-right: 2px; }
|
||||||
|
|
||||||
.field { margin-bottom: 9px; }
|
.field { margin-bottom: 9px; }
|
||||||
.field label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 3px; }
|
.field label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 3px; }
|
||||||
.field .hint { font-size: 11px; color: #5d6672; margin-top: 2px; }
|
.field .hint { font-size: 11px; color: var(--muted); margin-top: 3px; line-height: 1.45; }
|
||||||
.input, select.input {
|
.input, select.input {
|
||||||
width: 100%; padding: 5px 8px;
|
width: 100%; padding: 6px 9px;
|
||||||
background: #0b0f14; border: 1px solid var(--border);
|
background: #080b11; border: 1px solid var(--border);
|
||||||
border-radius: 6px; color: var(--text); font-size: 13px;
|
border-radius: var(--radius-sm); color: var(--text); font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
transition: border-color .15s, box-shadow .15s;
|
||||||
}
|
}
|
||||||
.input:focus { border-color: var(--accent-2); }
|
.input:focus { border-color: var(--accent-2); box-shadow: 0 0 0 3px rgba(59, 142, 247, .15); }
|
||||||
.field-inline { display: flex; align-items: center; gap: 8px; }
|
.field-inline { display: flex; align-items: center; gap: 6px; }
|
||||||
.field-inline input[type=checkbox] { width: 15px; height: 15px; }
|
.field-inline input[type=checkbox] { width: 15px; height: 15px; accent-color: var(--accent-2); }
|
||||||
|
.field-label { font-size: 12px; color: var(--muted); white-space: nowrap; }
|
||||||
|
.input-sm { width: auto; min-width: 104px; padding: 4px 8px; font-size: 12px; }
|
||||||
|
.input-grow { flex: 1 1 120px; min-width: 0; width: auto; }
|
||||||
.group-title {
|
.group-title {
|
||||||
font-size: 12px; color: var(--accent-2);
|
font-size: 12px; color: var(--accent-2);
|
||||||
margin: 12px 0 6px; padding-bottom: 4px;
|
margin: 13px 0 7px; padding-bottom: 4px;
|
||||||
border-bottom: 1px dashed var(--border);
|
border-bottom: 1px dashed var(--border);
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.group-title:first-child { margin-top: 0; }
|
.group-title:first-child { margin-top: 0; }
|
||||||
|
|
||||||
|
.chk { display: flex; align-items: center; gap: 5px; font-size: 12px; color: var(--text-dim); cursor: pointer; }
|
||||||
|
.chk input[type=checkbox] { accent-color: var(--accent-2); }
|
||||||
|
|
||||||
/* ==================== 提示 ==================== */
|
/* ==================== 提示 ==================== */
|
||||||
.toast {
|
.toast {
|
||||||
position: fixed; right: 18px; bottom: 18px;
|
position: fixed; right: 16px; bottom: 16px;
|
||||||
background: var(--panel-2); border: 1px solid var(--border);
|
background: var(--panel-3); border: 1px solid var(--border);
|
||||||
border-left: 3px solid var(--accent-2);
|
border-left: 3px solid var(--accent-2);
|
||||||
padding: 10px 14px; border-radius: 8px;
|
padding: 10px 14px; border-radius: var(--radius);
|
||||||
font-size: 13px; max-width: 380px;
|
font-size: 13px; max-width: min(380px, calc(100vw - 32px));
|
||||||
opacity: 0; transform: translateY(8px);
|
opacity: 0; transform: translateY(8px);
|
||||||
transition: opacity .2s, transform .2s;
|
transition: opacity .2s, transform .2s;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
}
|
}
|
||||||
.toast.show { opacity: 1; transform: translateY(0); }
|
.toast.show { opacity: 1; transform: translateY(0); }
|
||||||
.toast.error { border-left-color: var(--red); }
|
.toast.error { border-left-color: var(--red); }
|
||||||
|
|
||||||
@media (max-width: 1280px) {
|
/* ==================== 登录遮罩 ==================== */
|
||||||
.layout { grid-template-columns: 1fr; }
|
.overlay {
|
||||||
.config-body { max-height: none; }
|
position: fixed; inset: 0; z-index: 200;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
background: rgba(5, 7, 10, .8);
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
}
|
||||||
|
.overlay-box {
|
||||||
|
width: min(380px, 100%);
|
||||||
|
background: linear-gradient(180deg, var(--panel) 0%, var(--bg-soft) 100%);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.overlay-box h2 { font-size: 16px; margin: 0 0 6px; color: var(--text); text-transform: none; letter-spacing: 0; }
|
||||||
|
.overlay-box .input { margin: 12px 0; }
|
||||||
|
|
||||||
|
/* ==================== SimpCore API 数据弹层 ==================== */
|
||||||
|
.overlay-box-lg {
|
||||||
|
width: min(1100px, 100%);
|
||||||
|
max-height: min(86vh, 900px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.overlay-box-lg .card-head h2 { text-transform: none; letter-spacing: 0; color: var(--text); font-size: 15px; }
|
||||||
|
.simp-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
.simp-section { margin-bottom: 14px; }
|
||||||
|
.simp-section h3 {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--accent-2);
|
||||||
|
margin: 0 0 7px;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
.simp-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.simp-stat {
|
||||||
|
padding: 9px 11px;
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--panel-2);
|
||||||
|
}
|
||||||
|
.simp-stat .label { font-size: 11px; color: var(--muted); }
|
||||||
|
.simp-stat .value { font-size: 15px; margin-top: 2px; word-break: break-word; }
|
||||||
|
.simp-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
|
||||||
|
.simp-table th {
|
||||||
|
position: sticky; top: 0; z-index: 1;
|
||||||
|
text-align: left; color: var(--text-dim); font-weight: 600;
|
||||||
|
background: var(--panel-2); padding: 6px 8px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.simp-table td { padding: 5px 8px; border-bottom: 1px solid #10151d; }
|
||||||
|
.simp-table tbody tr:hover { background: #0d1219; }
|
||||||
|
.simp-table tbody tr.clickable { cursor: pointer; }
|
||||||
|
.simp-table .num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.simp-table .mono { font-size: 11.5px; }
|
||||||
|
.simp-empty { color: var(--muted); padding: 10px 2px; font-size: 12.5px; }
|
||||||
|
.simp-error { color: var(--red); font-size: 12.5px; padding: 8px 2px; }
|
||||||
|
.simp-detail {
|
||||||
|
margin-top: 10px; padding: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: #0d1219;
|
||||||
|
}
|
||||||
|
.simp-detail h4 { margin: 0 0 8px; font-size: 13px; }
|
||||||
|
.simp-detail .close-row { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
|
||||||
|
.simp-sub { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 9px; }
|
||||||
|
.tag {
|
||||||
|
display: inline-block; padding: 1px 7px; border-radius: 999px;
|
||||||
|
font-size: 11px; border: 1px solid var(--border); color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.tag-owner { color: var(--yellow); border-color: #6b5518; }
|
||||||
|
.tag-admin { color: var(--accent-2); border-color: #1f4f8f; }
|
||||||
|
|
||||||
|
/* ==================== 移动端底部导航 ==================== */
|
||||||
|
.mobile-nav { display: none; }
|
||||||
|
|
||||||
|
/* ==================== 响应式 ==================== */
|
||||||
|
|
||||||
|
/* 中等屏幕:配置栏移到下方,两栏布局 */
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.layout { grid-template-columns: minmax(280px, 320px) minmax(0, 1fr); }
|
||||||
|
.col-right { grid-column: 1 / -1; }
|
||||||
|
.col-right { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); align-items: start; }
|
||||||
|
.config-body { max-height: min(46vh, 460px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 平板 / 手机:单栏 + 底部导航 */
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
:root { --gap: 10px; }
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
padding: 8px 12px;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.brand p { display: none; }
|
||||||
|
.topbar-right { width: 100%; justify-content: space-between; }
|
||||||
|
.topbar-right .field-inline { flex: 1 1 100%; }
|
||||||
|
.topbar-right .field-inline select { flex: 1 1 auto; width: auto; }
|
||||||
|
.topbar-actions { width: 100%; }
|
||||||
|
.topbar-actions .btn { flex: 1 1 0; text-align: center; }
|
||||||
|
.badges { width: 100%; }
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
padding: 10px 10px calc(64px + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
.col-right { grid-column: auto; display: flex; }
|
||||||
|
|
||||||
|
/* 底部导航:把「状态 / 地图 / 日志 / 配置」四块一键定位 */
|
||||||
|
.mobile-nav {
|
||||||
|
display: flex;
|
||||||
|
position: fixed;
|
||||||
|
left: 0; right: 0; bottom: 0;
|
||||||
|
z-index: 40;
|
||||||
|
background: rgba(16, 21, 29, .96);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-top: 1px solid var(--border-soft);
|
||||||
|
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
}
|
||||||
|
.mobile-nav-item {
|
||||||
|
flex: 1 1 0;
|
||||||
|
padding: 11px 4px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
border-top: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.mobile-nav-item.active { color: var(--accent-2); border-top-color: var(--accent-2); }
|
||||||
|
|
||||||
|
/* 触控目标放大 */
|
||||||
|
.btn { padding: 8px 13px; font-size: 13px; }
|
||||||
|
.btn-sm { padding: 6px 11px; font-size: 12.5px; }
|
||||||
|
.input, select.input { padding: 8px 10px; font-size: 14px; }
|
||||||
|
.input-sm { padding: 6px 9px; font-size: 12.5px; }
|
||||||
|
.tab { padding: 6px 12px; font-size: 12.5px; }
|
||||||
|
|
||||||
|
#mapCanvas { height: clamp(260px, 52vh, 420px); }
|
||||||
|
.log-view { height: clamp(240px, 46vh, 400px); font-size: 12.5px; }
|
||||||
|
.config-body { max-height: none; }
|
||||||
|
|
||||||
|
.card { padding: 12px; }
|
||||||
|
.kv { grid-template-columns: 84px 1fr; }
|
||||||
|
.toast { left: 12px; right: 12px; bottom: calc(64px + env(safe-area-inset-bottom, 0px)); max-width: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.brand h1 { font-size: 14px; }
|
||||||
|
.schedule-item { grid-template-columns: auto minmax(0, 1fr) auto; }
|
||||||
|
.schedule-item .next { display: none; }
|
||||||
|
.field-row { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 横屏手机:地图压矮一点,避免看不到下面的日志 */
|
||||||
|
@media (max-height: 520px) and (orientation: landscape) {
|
||||||
|
#mapCanvas { height: 46vh; }
|
||||||
|
.log-view { height: 40vh; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 尊重「减少动效」偏好 */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
* { transition: none !important; animation: none !important; }
|
||||||
}
|
}
|
||||||
|
|||||||
+914
-55
文件差异内容过多而无法显示
加载差异
@@ -0,0 +1,220 @@
|
|||||||
|
'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 { BigMapService, TILE_PIXELS, DEFAULT_MAX_ZOOM } = require('../src/core/bigMapService');
|
||||||
|
const { defaultConfig } = require('../src/config/expansionConfig');
|
||||||
|
|
||||||
|
/** 造一张假的 PNG 内容(只需非空即可,服务端只做字节透传) */
|
||||||
|
function fakePng(size) {
|
||||||
|
const buffer = Buffer.alloc(size || 64);
|
||||||
|
buffer[0] = 0x89;
|
||||||
|
buffer[1] = 0x50;
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 建一个使用临时缓存目录、带假 fetch 的大地图服务 */
|
||||||
|
function setup(options) {
|
||||||
|
const opts = options || {};
|
||||||
|
const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-bigmap-'));
|
||||||
|
const config = defaultConfig();
|
||||||
|
Object.assign(config.bigMap, opts.bigMap || {});
|
||||||
|
const calls = [];
|
||||||
|
const fetchImpl = opts.fetchImpl || (async (url) => {
|
||||||
|
calls.push(url);
|
||||||
|
if (opts.failAll) return { ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) };
|
||||||
|
const data = fakePng(opts.tileSize || 64);
|
||||||
|
return { ok: true, status: 200, arrayBuffer: async () => data.buffer.slice(data.byteOffset, data.byteOffset + data.length) };
|
||||||
|
});
|
||||||
|
const service = new BigMapService(config, null, {
|
||||||
|
baseUrl: 'http://bgjq.simpfun.cn',
|
||||||
|
world: 'minecraft_overworld',
|
||||||
|
cacheDir,
|
||||||
|
fetchImpl
|
||||||
|
});
|
||||||
|
return { service, config, cacheDir, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('坐标系:最大层级 1 方块 = 1 像素,逐级翻倍', () => {
|
||||||
|
const { service } = setup();
|
||||||
|
assert.strictEqual(service.maxZoom, DEFAULT_MAX_ZOOM);
|
||||||
|
assert.strictEqual(service.blocksPerTile(3), TILE_PIXELS, '最大层级每张瓦片覆盖 512 方块');
|
||||||
|
assert.strictEqual(service.blocksPerTile(2), TILE_PIXELS * 2);
|
||||||
|
assert.strictEqual(service.blocksPerTile(1), TILE_PIXELS * 4);
|
||||||
|
assert.strictEqual(service.blocksPerTile(0), TILE_PIXELS * 8, '层级 0 每张瓦片覆盖 4096 方块');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('坐标系:王城 (10722, 1547) 落在 z=3 的瓦片 (20, 3) 内(对源站实测标定)', () => {
|
||||||
|
const { service } = setup();
|
||||||
|
const bounds = service.tileBlockBounds(3, 20, 3);
|
||||||
|
assert.deepStrictEqual(bounds, { minBlockX: 10240, maxBlockX: 10752, minBlockZ: 1536, maxBlockZ: 2048 });
|
||||||
|
assert.ok(10722 >= bounds.minBlockX && 10722 < bounds.maxBlockX);
|
||||||
|
assert.ok(1547 >= bounds.minBlockZ && 1547 < bounds.maxBlockZ);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tileRange 覆盖 ±rangeBlocks:层级 0 全世界为 10×10=100 张', () => {
|
||||||
|
const { service } = setup();
|
||||||
|
const range = service.tileRange(0, 20480, 0, 0);
|
||||||
|
assert.strictEqual(range.minX, -5);
|
||||||
|
assert.strictEqual(range.maxX, 4);
|
||||||
|
assert.strictEqual(range.minZ, -5);
|
||||||
|
assert.strictEqual(range.maxZ, 4);
|
||||||
|
assert.strictEqual(range.count, 100);
|
||||||
|
// 瓦片范围必须完整覆盖 -20480..20480
|
||||||
|
assert.strictEqual(range.minX * service.blocksPerTile(0), -20480);
|
||||||
|
assert.strictEqual((range.maxX + 1) * service.blocksPerTile(0), 20480);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tileRange 支持以机器人位置为中心:图幅越小爬的瓦片越少', () => {
|
||||||
|
const { service } = setup();
|
||||||
|
// 层级 0 每张瓦片 4096 格:半径 20480 → 100 张,4096 → 4 张
|
||||||
|
assert.strictEqual(service.tileRange(0, 20480, 0, 0).count, 100);
|
||||||
|
assert.strictEqual(service.tileRange(0, 4096, 0, 0).count, 4);
|
||||||
|
// 半径 1024 且中心落在某张瓦片正中(瓦片 4 覆盖 16384..20480,中心 18432)→ 只要 1 张
|
||||||
|
const centered = service.tileRange(0, 1024, 18432, 18432);
|
||||||
|
assert.strictEqual(centered.count, 1);
|
||||||
|
assert.strictEqual(centered.minX, 4);
|
||||||
|
assert.strictEqual(centered.minZ, 4);
|
||||||
|
assert.strictEqual(centered.centerBlockX, 18432);
|
||||||
|
// 该瓦片必须真的覆盖机器人坐标
|
||||||
|
const bounds = service.tileBlockBounds(0, centered.minX, centered.minZ);
|
||||||
|
assert.ok(18432 >= bounds.minBlockX && 18432 < bounds.maxBlockX);
|
||||||
|
assert.ok(18432 >= bounds.minBlockZ && 18432 < bounds.maxBlockZ);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tileRange 中心移到远处时瓦片下标整体平移,不再盯着世界原点', () => {
|
||||||
|
const { service } = setup();
|
||||||
|
const atOrigin = service.tileRange(0, 4096, 0, 0);
|
||||||
|
const atBot = service.tileRange(0, 4096, 20000, -20000);
|
||||||
|
assert.notStrictEqual(atOrigin.minX, atBot.minX);
|
||||||
|
// 机器人所在的瓦片(正 X、负 Z 象限)必须落在范围内
|
||||||
|
const tileX = Math.floor(20000 / 4096);
|
||||||
|
const tileZ = Math.floor(-20000 / 4096);
|
||||||
|
assert.ok(tileX >= atBot.minX && tileX <= atBot.maxX, '瓦片 X 下标应覆盖机器人');
|
||||||
|
assert.ok(tileZ >= atBot.minZ && tileZ <= atBot.maxZ, '瓦片 Z 下标应覆盖机器人');
|
||||||
|
assert.ok(tileX > 0 && tileZ < 0, '机器人位于正 X、负 Z 象限');
|
||||||
|
const bounds = service.tileBlockBounds(0, tileX, tileZ);
|
||||||
|
assert.ok(20000 >= bounds.minBlockX && 20000 < bounds.maxBlockX);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tileRange 缺省中心为世界原点', () => {
|
||||||
|
const { service } = setup();
|
||||||
|
const range = service.tileRange(0, 4096);
|
||||||
|
assert.strictEqual(range.centerBlockX, 0);
|
||||||
|
assert.strictEqual(range.centerBlockZ, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tileUrl / tilePath 拼接源站地址与磁盘缓存路径', () => {
|
||||||
|
const { service } = setup();
|
||||||
|
assert.strictEqual(
|
||||||
|
service.tileUrl(2, -3, 7),
|
||||||
|
'http://bgjq.simpfun.cn/tiles/minecraft_overworld/2/-3_7.png'
|
||||||
|
);
|
||||||
|
assert.ok(service.tilePath(2, -3, 7).endsWith(path.join('minecraft_overworld', '2', '-3_7.png')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getTile 首次回源并落盘,第二次命中磁盘不再回源', async () => {
|
||||||
|
const { service, calls } = setup();
|
||||||
|
const first = await service.getTile(1, 2, 3);
|
||||||
|
assert.ok(first && first.length > 0);
|
||||||
|
assert.strictEqual(calls.length, 1);
|
||||||
|
|
||||||
|
// 清掉内存缓存,验证磁盘缓存生效
|
||||||
|
service._memory.clear();
|
||||||
|
const second = await service.getTile(1, 2, 3);
|
||||||
|
assert.ok(second && second.length > 0);
|
||||||
|
assert.strictEqual(calls.length, 1, '第二次不应再回源');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getTile 回源失败返回 null(控制台画占位色块)', async () => {
|
||||||
|
const { service } = setup({ failAll: true });
|
||||||
|
assert.strictEqual(await service.getTile(0, 0, 0), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('crawlAll 批量爬取范围内全部瓦片并统计进度', async () => {
|
||||||
|
const { service, calls } = setup({ bigMap: { rangeBlocks: 4096 } });
|
||||||
|
const result = await service.crawlAll(0);
|
||||||
|
// rangeBlocks=4096、层级 0 每张 4096 方块 → 2×2=4 张
|
||||||
|
assert.strictEqual(result.total, 4);
|
||||||
|
assert.strictEqual(result.done, 4);
|
||||||
|
assert.strictEqual(result.failed, 0);
|
||||||
|
assert.strictEqual(result.running, false);
|
||||||
|
assert.strictEqual(calls.length, 4);
|
||||||
|
assert.strictEqual(service.countCached(0), 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('crawlAll 已缓存的瓦片不重复下载', async () => {
|
||||||
|
const { service, calls } = setup({ bigMap: { rangeBlocks: 4096 } });
|
||||||
|
await service.crawlAll(0);
|
||||||
|
assert.strictEqual(calls.length, 4);
|
||||||
|
await service.crawlAll(0);
|
||||||
|
assert.strictEqual(calls.length, 4, '第二次爬取应全部命中缓存');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('crawlAll 统计失败张数', async () => {
|
||||||
|
const { service } = setup({ failAll: true, bigMap: { rangeBlocks: 4096 } });
|
||||||
|
const result = await service.crawlAll(0);
|
||||||
|
assert.strictEqual(result.total, 4);
|
||||||
|
assert.strictEqual(result.done, 0);
|
||||||
|
assert.strictEqual(result.failed, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('crawlAll 支持以指定中心爬取(只拉机器人周边那几张)', async () => {
|
||||||
|
const { service, calls } = setup();
|
||||||
|
const result = await service.crawlAll(0, {
|
||||||
|
centerBlockX: 18432,
|
||||||
|
centerBlockZ: 18432,
|
||||||
|
rangeBlocks: 1024
|
||||||
|
});
|
||||||
|
assert.strictEqual(result.total, 1, '中心落在瓦片正中且半径 1024 时只需 1 张瓦片');
|
||||||
|
assert.strictEqual(result.done, 1);
|
||||||
|
assert.strictEqual(result.centerBlockX, 18432);
|
||||||
|
assert.strictEqual(result.rangeBlocks, 1024);
|
||||||
|
// 只请求了机器人所在的那一张,而不是世界原点附近
|
||||||
|
assert.strictEqual(calls.length, 1);
|
||||||
|
assert.ok(calls[0].includes('/4_4.png'), '实际请求的瓦片 URL:' + calls[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('snapshot 暴露坐标系、瓦片范围与缓存数量', () => {
|
||||||
|
const { service } = setup({ bigMap: { rangeBlocks: 20480, zoom: 0 } });
|
||||||
|
const snap = service.snapshot();
|
||||||
|
assert.strictEqual(snap.world, 'minecraft_overworld');
|
||||||
|
assert.strictEqual(snap.tilePixels, TILE_PIXELS);
|
||||||
|
assert.strictEqual(snap.blocksPerTile, 4096);
|
||||||
|
assert.strictEqual(snap.tiles.count, 100);
|
||||||
|
assert.strictEqual(snap.cached, 0);
|
||||||
|
assert.ok(snap.source.tileUrlTemplate.includes('{z}/{x}_{y}.png'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('snapshot 接受中心参数,瓦片范围随机器人位置变化', () => {
|
||||||
|
const { service } = setup({ bigMap: { rangeBlocks: 1024, zoom: 0 } });
|
||||||
|
const atOrigin = service.snapshot({ centerBlockX: 0, centerBlockZ: 0 });
|
||||||
|
const atBot = service.snapshot({ centerBlockX: 18432, centerBlockZ: 18432 });
|
||||||
|
assert.strictEqual(atBot.tiles.count, 1, '中心落在瓦片正中时只要 1 张');
|
||||||
|
assert.notStrictEqual(atOrigin.tiles.minX, atBot.tiles.minX, '中心不同,瓦片下标必须不同');
|
||||||
|
assert.strictEqual(atBot.tiles.centerBlockX, 18432);
|
||||||
|
assert.strictEqual(atBot.tiles.minX, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clearCache 清空磁盘与内存缓存', async () => {
|
||||||
|
const { service } = setup({ bigMap: { rangeBlocks: 4096 } });
|
||||||
|
await service.crawlAll(0);
|
||||||
|
assert.strictEqual(service.countCached(0), 4);
|
||||||
|
const removed = service.clearCache();
|
||||||
|
assert.strictEqual(removed, 4);
|
||||||
|
assert.strictEqual(service.countCached(0), 0);
|
||||||
|
assert.strictEqual(service._memory.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('配置里 zoom 大于 maxZoom 时被钳制(避免爬到源站不存在的瓦片)', () => {
|
||||||
|
const { service, config } = setup({ bigMap: { zoom: 7, maxZoom: 3 } });
|
||||||
|
// 直接走一次配置归一化,验证钳制逻辑
|
||||||
|
const { normalizeConfig } = require('../src/config/expansionConfig');
|
||||||
|
const normalized = normalizeConfig(config);
|
||||||
|
assert.strictEqual(normalized.bigMap.zoom, 3);
|
||||||
|
assert.strictEqual(service.maxZoom, 3);
|
||||||
|
});
|
||||||
@@ -0,0 +1,571 @@
|
|||||||
|
'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 { BotManager, kindOfPosition, computeReconnectDelay } = require('../src/bot/botManager');
|
||||||
|
const { FakeBot } = require('./helpers/fakeBot');
|
||||||
|
const { ChatLog, ChatKind } = require('../src/core/chatLog');
|
||||||
|
const { defaultConfig, normalizeConfig } = require('../src/config/expansionConfig');
|
||||||
|
const { Logger } = require('../src/util/logger');
|
||||||
|
|
||||||
|
/** 建一个不连接的机器人管理器(含公屏日志) */
|
||||||
|
function setup(options) {
|
||||||
|
const opts = options || {};
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-botmgr-'));
|
||||||
|
const chatLog = new ChatLog({ dir });
|
||||||
|
const config = normalizeConfig(Object.assign(defaultConfig(), opts.config || {}));
|
||||||
|
const logger = new Logger({ echo: false });
|
||||||
|
const manager = new BotManager({
|
||||||
|
id: 'BotA',
|
||||||
|
username: 'BotA',
|
||||||
|
password: 'pw',
|
||||||
|
host: 'mc.example.com',
|
||||||
|
port: 25565,
|
||||||
|
version: '1.21.4',
|
||||||
|
label: '主号'
|
||||||
|
}, config, logger, { chatLog });
|
||||||
|
const sent = [];
|
||||||
|
manager.bot = { chat: (text) => sent.push(text) };
|
||||||
|
return { manager, chatLog, config, sent, logger };
|
||||||
|
}
|
||||||
|
/** 等一轮宏任务,让指令节流定时器真正把内容写出 */
|
||||||
|
function drainQueue() {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('kindOfPosition 把消息通道映射为公屏日志类型', () => {
|
||||||
|
assert.strictEqual(kindOfPosition('chat'), ChatKind.CHAT);
|
||||||
|
assert.strictEqual(kindOfPosition('system'), ChatKind.SYSTEM);
|
||||||
|
assert.strictEqual(kindOfPosition('game_info'), ChatKind.ACTION);
|
||||||
|
assert.strictEqual(kindOfPosition(undefined), ChatKind.CHAT);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isReady 要求已连接、已进入世界、寻路器就绪且完成登录验证', () => {
|
||||||
|
const { manager } = setup();
|
||||||
|
manager.state = 'online';
|
||||||
|
manager.bot = { entity: {}, pathfinder: {} };
|
||||||
|
manager.loggedIn = false;
|
||||||
|
assert.strictEqual(manager.isReady(), false, '未完成登录验证不算就绪');
|
||||||
|
|
||||||
|
manager.loggedIn = true;
|
||||||
|
assert.strictEqual(manager.isReady(), true);
|
||||||
|
|
||||||
|
manager.state = 'connecting';
|
||||||
|
assert.strictEqual(manager.isReady(), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('未完成登录验证时拒绝发送指令与聊天', () => {
|
||||||
|
const { manager, sent } = setup();
|
||||||
|
manager.state = 'online';
|
||||||
|
manager.loggedIn = false;
|
||||||
|
assert.strictEqual(manager.canOperate(), false);
|
||||||
|
assert.strictEqual(manager.sendCommand('u claimmode'), false);
|
||||||
|
assert.strictEqual(manager.sendChat('大家好'), false);
|
||||||
|
assert.deepStrictEqual(sent, [], '不应真的写出任何内容');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('登录验证通过后指令与聊天都会被写出并留档', () => {
|
||||||
|
const { manager, chatLog, sent } = setup({ config: { server: { commandCooldownMs: 0 } } });
|
||||||
|
manager.state = 'online';
|
||||||
|
manager.loggedIn = true;
|
||||||
|
|
||||||
|
manager.sendCommand('u claimmode', { immediate: true });
|
||||||
|
manager.sendChat('大家好', { internal: true });
|
||||||
|
manager._chatQueue.length = 0;
|
||||||
|
manager._writeChat('大家好');
|
||||||
|
|
||||||
|
assert.deepStrictEqual(sent, ['/u claimmode', '大家好']);
|
||||||
|
const rows = chatLog.list('BotA');
|
||||||
|
assert.strictEqual(rows.length, 2);
|
||||||
|
assert.strictEqual(rows[0].kind, ChatKind.COMMAND);
|
||||||
|
assert.strictEqual(rows[0].message, '/u claimmode');
|
||||||
|
assert.strictEqual(rows[1].kind, ChatKind.OUT);
|
||||||
|
assert.strictEqual(rows[1].message, '大家好');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('以斜杠开头的聊天内容被当作指令发送', () => {
|
||||||
|
const { manager, sent } = setup();
|
||||||
|
manager.state = 'online';
|
||||||
|
manager.loggedIn = true;
|
||||||
|
manager.sendChat('/spawn', { internal: true });
|
||||||
|
manager._chatQueue.length = 0;
|
||||||
|
// 指令走队列,直接驱动写入
|
||||||
|
manager._writeCommand(manager._commandQueue.pop() || 'spawn');
|
||||||
|
assert.deepStrictEqual(sent, ['/spawn']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('internal 选项绕过登录门禁(登录 / 注册流程自身使用)', () => {
|
||||||
|
const { manager, sent } = setup();
|
||||||
|
manager.state = 'online';
|
||||||
|
manager.loggedIn = false;
|
||||||
|
assert.strictEqual(manager.sendCommand('login pw', { internal: true, immediate: true }), true);
|
||||||
|
assert.deepStrictEqual(sent, ['/login pw']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('收到「登录成功」提示后置为已登录', () => {
|
||||||
|
const { manager } = setup();
|
||||||
|
manager.config.server.loginCommand = 'login {password}';
|
||||||
|
manager.loggedIn = false;
|
||||||
|
manager._handleAuthPrompt('登录成功,欢迎回来');
|
||||||
|
assert.strictEqual(manager.loggedIn, true);
|
||||||
|
assert.strictEqual(manager.loginFailed, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('收到「已登录」提示同样视为通过验证', () => {
|
||||||
|
const { manager } = setup();
|
||||||
|
manager.loggedIn = false;
|
||||||
|
manager._handleAuthPrompt('你已登录,无需重复登录');
|
||||||
|
assert.strictEqual(manager.loggedIn, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('收到密码错误提示时标记登录失败且不放行操作', () => {
|
||||||
|
const { manager } = setup();
|
||||||
|
manager.loggedIn = false;
|
||||||
|
manager._handleAuthPrompt('密码错误,请重新输入');
|
||||||
|
assert.strictEqual(manager.loginFailed, true);
|
||||||
|
assert.strictEqual(manager.loggedIn, false);
|
||||||
|
assert.strictEqual(manager.canOperate(), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('服务器提示注册时自动发送注册指令(绕过门禁)', async () => {
|
||||||
|
const { manager, sent } = setup({ config: { server: { commandCooldownMs: 0 } } });
|
||||||
|
manager.config.server.registerCommand = 'register {password}';
|
||||||
|
manager.config.server.loginCommand = 'login {password}';
|
||||||
|
manager.loggedIn = false;
|
||||||
|
manager._handleAuthPrompt('请先注册');
|
||||||
|
await drainQueue();
|
||||||
|
assert.deepStrictEqual(sent, ['/register pw']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('服务器提示登录时自动发送登录指令(绕过门禁)', async () => {
|
||||||
|
const { manager, sent } = setup({ config: { server: { commandCooldownMs: 0 } } });
|
||||||
|
manager.config.server.loginCommand = 'login {password}';
|
||||||
|
manager.loggedIn = false;
|
||||||
|
manager._handleAuthPrompt('请先输入登录密码');
|
||||||
|
await drainQueue();
|
||||||
|
assert.deepStrictEqual(sent, ['/login pw']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('登录确认兜底:超时未收到任何提示时按已登录放行', async () => {
|
||||||
|
const { manager } = setup({ config: { server: { loginConfirmTimeoutMs: 20 } } });
|
||||||
|
manager.loggedIn = false;
|
||||||
|
manager.loginFailed = false;
|
||||||
|
manager._armLoginConfirmFallback();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||||
|
assert.strictEqual(manager.loggedIn, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('登录确认兜底不会覆盖已明确失败的登录', async () => {
|
||||||
|
const { manager } = setup({ config: { server: { loginConfirmTimeoutMs: 20 } } });
|
||||||
|
manager.loggedIn = false;
|
||||||
|
manager._armLoginConfirmFallback();
|
||||||
|
manager.loginFailed = true;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||||
|
assert.strictEqual(manager.loggedIn, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('收到消息时写入公屏日志(含系统消息与发言者)', () => {
|
||||||
|
const { manager, chatLog } = setup();
|
||||||
|
manager._recordChat('服务器将在 5 分钟后重启', 'system', null);
|
||||||
|
manager._recordChat('你好', 'chat', 'Steve');
|
||||||
|
const rows = chatLog.list('BotA');
|
||||||
|
assert.strictEqual(rows.length, 2);
|
||||||
|
assert.strictEqual(rows[0].kind, ChatKind.SYSTEM);
|
||||||
|
assert.strictEqual(rows[1].kind, ChatKind.CHAT);
|
||||||
|
assert.strictEqual(rows[1].username, 'Steve');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('snapshot 暴露多机器人需要的 id / 备注 / 登录与操作许可', () => {
|
||||||
|
const { manager } = setup();
|
||||||
|
const snap = manager.snapshot();
|
||||||
|
assert.strictEqual(snap.id, 'BotA');
|
||||||
|
assert.strictEqual(snap.label, '主号');
|
||||||
|
assert.strictEqual(snap.username, 'BotA');
|
||||||
|
assert.strictEqual(snap.loggedIn, false);
|
||||||
|
assert.strictEqual(snap.canOperate, false);
|
||||||
|
assert.strictEqual(snap.loginFailed, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== 被服务器反复传送时的日志聚合 ====================
|
||||||
|
|
||||||
|
test('被服务器传送:首次立即记录,其余聚合为一条汇总', () => {
|
||||||
|
const { manager, logger } = setup();
|
||||||
|
manager._countForcedMove();
|
||||||
|
const afterFirst = logger.list().filter((e) => e.message.includes('被服务器传送'));
|
||||||
|
assert.strictEqual(afterFirst.length, 1, '首次传送应立即记一条');
|
||||||
|
|
||||||
|
// 模拟账号未验证时被每 tick 强推位置:再触发 200 次不应继续刷日志
|
||||||
|
for (let i = 0; i < 200; i++) manager._countForcedMove();
|
||||||
|
assert.strictEqual(logger.list().filter((e) => e.message.includes('被服务器传送')).length, 1,
|
||||||
|
'同一窗口内的重复传送不得逐条记录');
|
||||||
|
|
||||||
|
manager._flushForcedMove();
|
||||||
|
const lines = logger.list().filter((e) => e.message.includes('被服务器传送'));
|
||||||
|
assert.strictEqual(lines.length, 2, '窗口结束时汇总成一条');
|
||||||
|
assert.ok(lines[1].message.includes('另有 200 次'), '汇总应包含次数');
|
||||||
|
assert.strictEqual(lines[1].level, 'warn');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('被服务器传送:窗口内只有一次时不输出多余汇总', () => {
|
||||||
|
const { manager, logger } = setup();
|
||||||
|
manager._countForcedMove();
|
||||||
|
manager._flushForcedMove();
|
||||||
|
assert.strictEqual(logger.list().filter((e) => e.message.includes('被服务器传送')).length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('被服务器传送:断开连接时把未汇总的计数刷出去并清掉定时器', () => {
|
||||||
|
const { manager, logger } = setup();
|
||||||
|
manager._countForcedMove();
|
||||||
|
for (let i = 0; i < 9; i++) manager._countForcedMove();
|
||||||
|
manager._clearForcedMoveTimer();
|
||||||
|
assert.strictEqual(manager._forcedMoveTimer, null);
|
||||||
|
assert.strictEqual(manager._forcedMoveCount, 0);
|
||||||
|
const lines = logger.list().filter((e) => e.message.includes('被服务器传送'));
|
||||||
|
assert.ok(lines.some((e) => e.message.includes('另有 9 次')));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== 账号绑定 / 二次验证 ====================
|
||||||
|
|
||||||
|
test('服务器要求账号绑定时给出可操作的中文告警(且只提示一次)', () => {
|
||||||
|
const { manager, logger } = setup();
|
||||||
|
manager.config.server.loginCommand = 'login {password}';
|
||||||
|
manager.loggedIn = false;
|
||||||
|
manager._handleAuthPrompt('为了保障服务器安全,您需要绑定您的简幻通账号。');
|
||||||
|
manager._handleAuthPrompt('请使用命令: /auth <简幻通UID> <验证码>');
|
||||||
|
manager._handleAuthPrompt('请在 120 秒内完成验证,否则将被踢出服务器。');
|
||||||
|
|
||||||
|
const errors = logger.list().filter((e) => e.level === 'error' && e.message.includes('账号绑定'));
|
||||||
|
assert.strictEqual(errors.length, 1, '同一连接内只提示一次,避免刷屏');
|
||||||
|
assert.ok(logger.list().some((e) => e.message.includes('需要人工完成一次')));
|
||||||
|
assert.strictEqual(manager.loggedIn, false, '未完成验证时不得放行操作');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('配置了账号绑定指令模板时自动发送(绕过门禁)', () => {
|
||||||
|
const { manager, sent } = setup();
|
||||||
|
manager.config.server.authCommand = 'auth {password}';
|
||||||
|
manager.loggedIn = false;
|
||||||
|
manager._handleAuthPrompt('请使用命令: /auth <简幻通UID> <验证码>');
|
||||||
|
assert.deepStrictEqual(sent, ['/auth pw']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('未配置账号绑定模板时不乱发指令', () => {
|
||||||
|
const { manager, sent } = setup();
|
||||||
|
manager.config.server.authCommand = '';
|
||||||
|
manager._handleAuthPrompt('请使用命令: /auth <简幻通UID> <验证码>');
|
||||||
|
assert.deepStrictEqual(sent, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('重新登录后绑定告警标记被复位', () => {
|
||||||
|
const { manager } = setup();
|
||||||
|
manager._handleAuthPrompt('您需要绑定您的简幻通账号');
|
||||||
|
assert.strictEqual(manager._needAuthWarned, true);
|
||||||
|
// 模拟 login 事件的处理
|
||||||
|
manager._needAuthWarned = false;
|
||||||
|
assert.strictEqual(manager._needAuthWarned, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== 重连退避 ====================
|
||||||
|
|
||||||
|
test('computeReconnectDelay 按指数退避并在上限处钳制', () => {
|
||||||
|
const server = { reconnectDelayMs: 10000, reconnectMaxDelayMs: 120000 };
|
||||||
|
assert.strictEqual(computeReconnectDelay(server, 1), 10000);
|
||||||
|
assert.strictEqual(computeReconnectDelay(server, 2), 20000);
|
||||||
|
assert.strictEqual(computeReconnectDelay(server, 3), 40000);
|
||||||
|
assert.strictEqual(computeReconnectDelay(server, 4), 80000);
|
||||||
|
assert.strictEqual(computeReconnectDelay(server, 5), 120000, '超过上限后钳制');
|
||||||
|
assert.strictEqual(computeReconnectDelay(server, 30), 120000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('computeReconnectDelay 缺省上限时退化为固定间隔', () => {
|
||||||
|
assert.strictEqual(computeReconnectDelay({ reconnectDelayMs: 5000 }, 1), 5000);
|
||||||
|
// 未配置上限时 max 取基准值,等价于固定间隔(保持旧行为,不突然拉长重连)
|
||||||
|
assert.strictEqual(computeReconnectDelay({ reconnectDelayMs: 5000 }, 4), 5000);
|
||||||
|
assert.strictEqual(computeReconnectDelay({ reconnectDelayMs: 5000, reconnectMaxDelayMs: 5000 }, 4), 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('config 校验补全 authCommand 与 reconnectMaxDelayMs', () => {
|
||||||
|
const { config } = setup();
|
||||||
|
assert.strictEqual(config.server.authCommand, '');
|
||||||
|
assert.strictEqual(config.server.reconnectMaxDelayMs, 120000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== 免登录指令白名单(避免「先登录才能发登录指令」死锁)====================
|
||||||
|
|
||||||
|
test('验证类指令在未登录时也放行(login / register / auth)', () => {
|
||||||
|
const { manager, sent } = setup({ config: { server: { commandCooldownMs: 0 } } });
|
||||||
|
manager.state = 'online';
|
||||||
|
manager.loggedIn = false;
|
||||||
|
|
||||||
|
assert.strictEqual(manager.canSendCommand('login mypass'), true);
|
||||||
|
assert.strictEqual(manager.canSendCommand('/auth 12345 6789'), true);
|
||||||
|
assert.strictEqual(manager.canSendCommand('register mypass'), true);
|
||||||
|
assert.strictEqual(manager.canSendCommand('l mypass'), true);
|
||||||
|
|
||||||
|
assert.strictEqual(manager.sendCommand('auth 12345 6789', { immediate: true }), true);
|
||||||
|
assert.deepStrictEqual(sent, ['/auth 12345 6789']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('任务类指令在未登录时仍被拒绝', () => {
|
||||||
|
const { manager, sent } = setup();
|
||||||
|
manager.state = 'online';
|
||||||
|
manager.loggedIn = false;
|
||||||
|
|
||||||
|
assert.strictEqual(manager.canSendCommand('u claimmode'), false);
|
||||||
|
assert.strictEqual(manager.canSendCommand('u return'), false);
|
||||||
|
assert.strictEqual(manager.canSendCommand('spawn'), false);
|
||||||
|
assert.strictEqual(manager.sendCommand('u claimmode'), false);
|
||||||
|
assert.deepStrictEqual(sent, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('前缀匹配不会误放行同前缀的其它指令', () => {
|
||||||
|
const { manager } = setup();
|
||||||
|
manager.state = 'online';
|
||||||
|
manager.loggedIn = false;
|
||||||
|
// auth 放行,但 authx / authenticate 不属于白名单里的精确前缀
|
||||||
|
assert.strictEqual(manager.canSendCommand('auth 1 2'), true);
|
||||||
|
assert.strictEqual(manager.canSendCommand('authx 1 2'), false);
|
||||||
|
assert.strictEqual(manager.canSendCommand('login'), true);
|
||||||
|
assert.strictEqual(manager.canSendCommand('loginx'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('白名单可通过配置清空(全部指令都要求登录验证)', () => {
|
||||||
|
const { manager } = setup({ config: { server: { preLoginCommands: [] } } });
|
||||||
|
manager.state = 'online';
|
||||||
|
manager.loggedIn = false;
|
||||||
|
assert.strictEqual(manager.canSendCommand('login mypass'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('登录验证通过后白名单不再是唯一通道', () => {
|
||||||
|
const { manager } = setup();
|
||||||
|
manager.state = 'online';
|
||||||
|
manager.loggedIn = true;
|
||||||
|
assert.strictEqual(manager.canSendCommand('u claimmode'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preLoginCommands 配置被归一化(去斜杠、去空项、转小写)', () => {
|
||||||
|
const { config } = setup({ config: { server: { preLoginCommands: ['/Login', ' AUTH ', '', ' ', 'reg'] } } });
|
||||||
|
assert.deepStrictEqual(config.server.preLoginCommands, ['login', 'auth', 'reg']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== 没有密码 / 不需要密码的登录流程 ====================
|
||||||
|
|
||||||
|
test('配置了登录指令模板但没密码时,超时后按「无需登录」放行', async () => {
|
||||||
|
const { manager } = setup({ config: { server: { loginConfirmTimeoutMs: 20 } } });
|
||||||
|
manager.config.server.loginCommand = 'login {password}';
|
||||||
|
manager.connection.password = '';
|
||||||
|
manager.state = 'online';
|
||||||
|
manager.bot = { entity: {}, pathfinder: {} };
|
||||||
|
|
||||||
|
manager._handleLogin();
|
||||||
|
assert.strictEqual(manager.loggedIn, false, '初始仍视为未验证');
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||||
|
assert.strictEqual(manager.loggedIn, true, '超时后必须放行,否则控制台完全无法操作');
|
||||||
|
assert.strictEqual(manager.canOperate(), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('服务器不需要登录指令时直接视为已通过验证', () => {
|
||||||
|
const { manager } = setup();
|
||||||
|
manager.config.server.loginCommand = '';
|
||||||
|
manager._handleLogin();
|
||||||
|
assert.strictEqual(manager.loggedIn, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('服务器要登录但没配密码时给出明确错误提示(只提示一次)', () => {
|
||||||
|
const { manager, logger } = setup();
|
||||||
|
manager.connection.password = '';
|
||||||
|
manager.loggedIn = false;
|
||||||
|
manager._handleAuthPrompt('请先输入登录密码');
|
||||||
|
manager._handleAuthPrompt('请先输入登录密码');
|
||||||
|
const errors = logger.list().filter((e) => e.level === 'error' && e.message.includes('没有该账号的密码'));
|
||||||
|
assert.strictEqual(errors.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== 验证码是聊天内容而非指令 ====================
|
||||||
|
|
||||||
|
test('服务器要求验证时放行公屏聊天(验证码常直接当聊天发出)', () => {
|
||||||
|
const { manager, sent } = setup();
|
||||||
|
manager.state = 'online';
|
||||||
|
manager.loggedIn = false;
|
||||||
|
manager.bot = { chat: (text) => sent.push(text) };
|
||||||
|
|
||||||
|
assert.strictEqual(manager.canSendChat(), false, '没有验证要求时默认不放行');
|
||||||
|
manager._handleAuthPrompt('您需要绑定您的简幻通账号');
|
||||||
|
assert.strictEqual(manager.canSendChat(), true, '服务器要求验证后应放行');
|
||||||
|
|
||||||
|
manager.sendChat('123456', { internal: true });
|
||||||
|
manager._chatQueue.length = 0;
|
||||||
|
manager._writeChat('123456');
|
||||||
|
assert.deepStrictEqual(sent, ['123456']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allowChatBeforeLogin 配置可无条件放行登录前聊天', () => {
|
||||||
|
const { manager } = setup({ config: { server: { allowChatBeforeLogin: true } } });
|
||||||
|
manager.state = 'online';
|
||||||
|
manager.loggedIn = false;
|
||||||
|
manager.bot = { chat: () => {} };
|
||||||
|
assert.strictEqual(manager.canSendChat(), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('未连接时公屏聊天一律不放行', () => {
|
||||||
|
const { manager } = setup({ config: { server: { allowChatBeforeLogin: true } } });
|
||||||
|
manager.state = 'offline';
|
||||||
|
assert.strictEqual(manager.canSendChat(), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== 出站位置包的 onGround 纠正 ====================
|
||||||
|
|
||||||
|
/** 造一个站在实心方块上的假 bot */
|
||||||
|
function botStandingOnStone() {
|
||||||
|
return new FakeBot({
|
||||||
|
x: 0.5,
|
||||||
|
y: 72,
|
||||||
|
z: 0.5,
|
||||||
|
blockMap: new Map([['0,71,0', { name: 'stone', boundingBox: 'block' }]])
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('_isStandingOnGround 用脚下方块判断是否站在地面上', () => {
|
||||||
|
const { manager } = setup();
|
||||||
|
manager.bot = botStandingOnStone();
|
||||||
|
assert.strictEqual(manager._isStandingOnGround(), true);
|
||||||
|
|
||||||
|
manager.bot = new FakeBot({ x: 0.5, y: 72, z: 0.5 });
|
||||||
|
assert.strictEqual(manager._isStandingOnGround(), false, '脚下是空气时应为 false');
|
||||||
|
|
||||||
|
manager.bot = { entity: {} };
|
||||||
|
assert.strictEqual(manager._isStandingOnGround(), null, '拿不到世界数据时返回 null');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('出站位置包的 onGround 被世界数据纠正(修复「站地上却报空中」)', () => {
|
||||||
|
const { manager } = setup();
|
||||||
|
manager.bot = botStandingOnStone();
|
||||||
|
|
||||||
|
const params = { flags: { onGround: false } };
|
||||||
|
manager._fixOutboundOnGround(params);
|
||||||
|
assert.strictEqual(params.flags.onGround, true, '站在石头上必须上报 onGround=true');
|
||||||
|
assert.strictEqual(manager.packetStats.onGroundFixed, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('onGround 已一致时不改写、不计数', () => {
|
||||||
|
const { manager } = setup();
|
||||||
|
manager.bot = botStandingOnStone();
|
||||||
|
const params = { flags: { onGround: true } };
|
||||||
|
manager._fixOutboundOnGround(params);
|
||||||
|
assert.strictEqual(manager.packetStats.onGroundFixed, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('关闭 reportOnGroundFromWorld 后完全不干预出站包', () => {
|
||||||
|
const { manager } = setup({ config: { grim: { reportOnGroundFromWorld: false } } });
|
||||||
|
manager.bot = botStandingOnStone();
|
||||||
|
const params = { flags: { onGround: false } };
|
||||||
|
manager._fixOutboundOnGround(params);
|
||||||
|
assert.strictEqual(params.flags.onGround, false);
|
||||||
|
assert.strictEqual(manager.packetStats.onGroundFixed, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('出站包结构异常时不抛异常', () => {
|
||||||
|
const { manager } = setup();
|
||||||
|
manager.bot = botStandingOnStone();
|
||||||
|
assert.doesNotThrow(() => manager._fixOutboundOnGround(null));
|
||||||
|
assert.doesNotThrow(() => manager._fixOutboundOnGround({}));
|
||||||
|
assert.doesNotThrow(() => manager._fixOutboundOnGround({ flags: {} }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('位置包统计暴露在 snapshot 中,便于排查走不动的问题', () => {
|
||||||
|
const { manager } = setup();
|
||||||
|
const snap = manager.snapshot();
|
||||||
|
assert.ok(snap.packetStats);
|
||||||
|
assert.strictEqual(snap.packetStats.inboundPosition, 0);
|
||||||
|
assert.strictEqual(snap.packetStats.outboundPosition, 0);
|
||||||
|
assert.strictEqual(snap.onGround, false);
|
||||||
|
assert.strictEqual(snap.world, null, '未进入世界时没有世界诊断信息');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== 手动移动测试 ====================
|
||||||
|
|
||||||
|
test('walkTest 走控制键而不是寻路器,能测出真实位移', async () => {
|
||||||
|
const { manager, sent } = setup();
|
||||||
|
const bot = botStandingOnStone();
|
||||||
|
manager.bot = bot;
|
||||||
|
manager.state = 'online';
|
||||||
|
const controls = [];
|
||||||
|
manager.grim = { setControlState: (c, on) => controls.push([c, on]) };
|
||||||
|
// 模拟按住前进键后位置发生变化
|
||||||
|
setTimeout(() => { bot.entity.position.z -= 8; }, 30);
|
||||||
|
|
||||||
|
const result = await manager.walkTest('forward', 0.5);
|
||||||
|
assert.strictEqual(result.ok, true);
|
||||||
|
assert.ok(result.message.includes('位移'));
|
||||||
|
assert.deepStrictEqual(controls, [['forward', true], ['forward', false]], '按下后必须松开');
|
||||||
|
assert.ok(Math.abs(result.to.z - result.from.z) > 0.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('walkTest 在原地不动时返回失败并记警告', async () => {
|
||||||
|
const { manager, logger } = setup();
|
||||||
|
manager.bot = botStandingOnStone();
|
||||||
|
manager.state = 'online';
|
||||||
|
manager.grim = { setControlState: () => {} };
|
||||||
|
|
||||||
|
const result = await manager.walkTest('forward', 0.5);
|
||||||
|
assert.strictEqual(result.ok, false, '没有位移必须如实返回失败,而不是假装成功');
|
||||||
|
assert.ok(logger.list().some((e) => e.level === 'warn' && e.message.includes('几乎没有移动')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('walkTest 拒绝非法方向与未连接状态', async () => {
|
||||||
|
const { manager } = setup();
|
||||||
|
manager.state = 'online';
|
||||||
|
const bad = await manager.walkTest('teleport', 1);
|
||||||
|
assert.strictEqual(bad.ok, false);
|
||||||
|
assert.ok(bad.message.includes('方向'));
|
||||||
|
|
||||||
|
manager.state = 'offline';
|
||||||
|
const offline = await manager.walkTest('forward', 1);
|
||||||
|
assert.strictEqual(offline.ok, false);
|
||||||
|
assert.ok(offline.message.includes('未连接'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== 插件加载 ====================
|
||||||
|
test('connect 时显式加载 mineflayer-pathfinder 插件', () => {
|
||||||
|
const mineflayer = require('mineflayer');
|
||||||
|
const originalCreateBot = mineflayer.createBot;
|
||||||
|
const loaded = [];
|
||||||
|
mineflayer.createBot = () => {
|
||||||
|
const fake = new (require('events').EventEmitter)();
|
||||||
|
fake.loadPlugin = (plugin) => loaded.push(plugin);
|
||||||
|
return fake;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const { manager } = setup();
|
||||||
|
manager.bot = null; // setup 预置了 bot 桩,这里要真正走一次 connect 流程
|
||||||
|
manager.connect();
|
||||||
|
assert.strictEqual(loaded.length, 1, '必须调用 loadPlugin,否则 bot.pathfinder 永远为 undefined');
|
||||||
|
assert.ok(loaded[0], '加载的应是 pathfinder 插件');
|
||||||
|
// 插件加载后 bot.pathfinder 才会有值,isReady() 才可能为真
|
||||||
|
assert.strictEqual(manager.isReady(), false, '仅加载插件、未进入世界时仍不算就绪');
|
||||||
|
} finally {
|
||||||
|
mineflayer.createBot = originalCreateBot;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('加载插件失败时不抛异常,但会记错误日志', () => {
|
||||||
|
const mineflayer = require('mineflayer');
|
||||||
|
const originalCreateBot = mineflayer.createBot;
|
||||||
|
mineflayer.createBot = () => {
|
||||||
|
const fake = new (require('events').EventEmitter)();
|
||||||
|
fake.loadPlugin = () => { throw new Error('插件加载炸了'); };
|
||||||
|
return fake;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const { manager, logger } = setup();
|
||||||
|
manager.bot = null;
|
||||||
|
assert.doesNotThrow(() => manager.connect());
|
||||||
|
assert.ok(logger.list().some((e) => e.level === 'error' && e.message.includes('pathfinder')));
|
||||||
|
} finally {
|
||||||
|
mineflayer.createBot = originalCreateBot;
|
||||||
|
}
|
||||||
|
});
|
||||||
+415
@@ -0,0 +1,415 @@
|
|||||||
|
'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 { BotPool } = require('../src/bot/botPool');
|
||||||
|
const { BotStore, BotCredentialStore } = require('../src/config/botStore');
|
||||||
|
const { defaultConfig, normalizeConfig } = require('../src/config/expansionConfig');
|
||||||
|
const { Logger } = require('../src/util/logger');
|
||||||
|
const { TerritoryModel } = require('../src/core/territoryModel');
|
||||||
|
const chunkPos = require('../src/core/chunkPos');
|
||||||
|
|
||||||
|
/** 造一个只提供 getModel 的数据服务桩 */
|
||||||
|
function fakeDataService() {
|
||||||
|
const map = new Map([[
|
||||||
|
'A',
|
||||||
|
{ name: 'A', capital: { x: 0, z: 0 }, chunks: new Set([chunkPos.key(0, 0)]), color: 0x75bd20 }
|
||||||
|
]]);
|
||||||
|
const model = new TerritoryModel(map);
|
||||||
|
model.setSelfStateName('A');
|
||||||
|
return {
|
||||||
|
model,
|
||||||
|
getModel: () => model,
|
||||||
|
setSelectedStateName: (name) => model.setSelfStateName(name),
|
||||||
|
addSessionClaim: () => {},
|
||||||
|
refreshAsync: () => {},
|
||||||
|
detectStateByPlayer: async () => null,
|
||||||
|
snapshot: () => ({ stateCount: 1 })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function connection(username) {
|
||||||
|
return {
|
||||||
|
id: username,
|
||||||
|
username,
|
||||||
|
password: 'pw',
|
||||||
|
auth: 'offline',
|
||||||
|
host: 'mc.example.com',
|
||||||
|
port: 25565,
|
||||||
|
version: '1.21.4',
|
||||||
|
label: username
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 建一个含 2 个机器人、使用临时 bots.json 的池 */
|
||||||
|
function setup(options) {
|
||||||
|
const opts = options || {};
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-pool-'));
|
||||||
|
const storePath = path.join(dir, 'bots.json');
|
||||||
|
const credPath = path.join(dir, 'bots.credentials.json');
|
||||||
|
if (opts.settings) fs.writeFileSync(storePath, JSON.stringify({ bots: opts.settings }), 'utf8');
|
||||||
|
const config = normalizeConfig(Object.assign(defaultConfig(), opts.config || {}));
|
||||||
|
const pool = new BotPool({
|
||||||
|
connections: opts.connections || [connection('BotA'), connection('BotB')],
|
||||||
|
config,
|
||||||
|
logger: new Logger({ echo: false }),
|
||||||
|
dataService: fakeDataService(),
|
||||||
|
botStore: new BotStore(storePath),
|
||||||
|
credentialStore: opts.noCredentialStore ? null : new BotCredentialStore(credPath)
|
||||||
|
});
|
||||||
|
return { pool, storePath, credPath, config, dir };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('BotPool 为每个凭据建立一个机器人条目', () => {
|
||||||
|
const { pool } = setup();
|
||||||
|
assert.strictEqual(pool.size, 2);
|
||||||
|
assert.deepStrictEqual(pool.ids(), ['BotA', 'BotB']);
|
||||||
|
assert.strictEqual(pool.defaultId, 'BotA');
|
||||||
|
assert.strictEqual(pool.get('BotA').manager.connection.username, 'BotA');
|
||||||
|
assert.strictEqual(pool.get().id, 'BotA', '不传 id 时返回默认机器人');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('require 找不到机器人时抛出 404', () => {
|
||||||
|
const { pool } = setup();
|
||||||
|
assert.throws(() => pool.require('不存在'), (err) => err.statusCode === 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getConfigFor 未设置覆盖时等于全局配置', () => {
|
||||||
|
const { pool } = setup({ config: { strategy: 4, targetCount: 20 } });
|
||||||
|
const config = pool.getConfigFor('BotA');
|
||||||
|
assert.strictEqual(config.strategy, 4);
|
||||||
|
assert.strictEqual(config.targetCount, 20);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('每个机器人可以有独立的配置覆盖,互不影响', () => {
|
||||||
|
const { pool } = setup({ config: { strategy: 1, targetCount: 10 } });
|
||||||
|
pool.setBotSetting('BotB', { config: { strategy: 2, targetCount: 3, stateName: 'B国' } });
|
||||||
|
|
||||||
|
const a = pool.getConfigFor('BotA');
|
||||||
|
const b = pool.getConfigFor('BotB');
|
||||||
|
assert.strictEqual(a.strategy, 1);
|
||||||
|
assert.strictEqual(a.targetCount, 10);
|
||||||
|
assert.strictEqual(b.strategy, 2);
|
||||||
|
assert.strictEqual(b.targetCount, 3);
|
||||||
|
assert.strictEqual(b.stateName, 'B国');
|
||||||
|
// 未覆盖的字段回退到全局
|
||||||
|
assert.strictEqual(b.waitTimeoutSeconds, a.waitTimeoutSeconds);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setBotSetting 持久化到 bots.json,重建池后仍生效', () => {
|
||||||
|
const { pool, storePath } = setup();
|
||||||
|
pool.setBotSetting('BotB', { enabled: false, autoStart: true, label: '二号机', config: { targetCount: 7 } });
|
||||||
|
|
||||||
|
const saved = JSON.parse(fs.readFileSync(storePath, 'utf8'));
|
||||||
|
assert.strictEqual(saved.bots.BotB.enabled, false);
|
||||||
|
assert.strictEqual(saved.bots.BotB.autoStart, true);
|
||||||
|
assert.strictEqual(saved.bots.BotB.label, '二号机');
|
||||||
|
|
||||||
|
const rebuilt = new BotPool({
|
||||||
|
connections: [connection('BotA'), connection('BotB')],
|
||||||
|
config: defaultConfig(),
|
||||||
|
dataService: fakeDataService(),
|
||||||
|
botStore: new BotStore(storePath)
|
||||||
|
});
|
||||||
|
assert.strictEqual(rebuilt.get('BotB').manager.label, '二号机');
|
||||||
|
assert.strictEqual(rebuilt.getConfigFor('BotB').targetCount, 7);
|
||||||
|
assert.strictEqual(rebuilt.settingSnapshot('BotB').enabled, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('设置里绝不写入密码(密码只来自环境变量)', () => {
|
||||||
|
const { pool, storePath } = setup();
|
||||||
|
pool.setBotSetting('BotA', { label: '主号' });
|
||||||
|
const text = fs.readFileSync(storePath, 'utf8');
|
||||||
|
assert.ok(!text.includes('pw'), 'bots.json 中不得出现密码');
|
||||||
|
assert.ok(!/password/i.test(text), 'bots.json 中不得出现 password 字段');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('snapshot 汇总各机器人的连接状态与任务状态', () => {
|
||||||
|
const { pool } = setup();
|
||||||
|
const snap = pool.snapshot();
|
||||||
|
assert.strictEqual(snap.length, 2);
|
||||||
|
assert.strictEqual(snap[0].id, 'BotA');
|
||||||
|
assert.strictEqual(snap[0].state, 'idle');
|
||||||
|
assert.strictEqual(snap[0].enabled, true);
|
||||||
|
assert.strictEqual(snap[0].task.state, 'IDLE');
|
||||||
|
assert.strictEqual(snap[0].task.botId, 'BotA');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('positions 输出各机器人坐标供大地图打点', () => {
|
||||||
|
const { pool } = setup();
|
||||||
|
const positions = pool.positions();
|
||||||
|
assert.strictEqual(positions.length, 2);
|
||||||
|
assert.strictEqual(positions[0].id, 'BotA');
|
||||||
|
assert.strictEqual(positions[0].position, null, '未进入世界时没有坐标');
|
||||||
|
assert.strictEqual(positions[0].stateName, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('未连接时拒绝发送指令与聊天,并说明是「未连接」', () => {
|
||||||
|
const { pool } = setup();
|
||||||
|
const command = pool.sendCommand('BotA', 'u claimmode');
|
||||||
|
assert.strictEqual(command.ok, false);
|
||||||
|
assert.ok(command.message.includes('未连接'), '离线时应明确说明未连接,而不是含糊的失败');
|
||||||
|
|
||||||
|
const chat = pool.sendChat('BotA', '大家好');
|
||||||
|
assert.strictEqual(chat.ok, false);
|
||||||
|
assert.ok(chat.message.includes('未连接'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('已连接但未完成登录验证时拒绝发送指令与聊天', () => {
|
||||||
|
const { pool } = setup();
|
||||||
|
const entry = pool.get('BotA');
|
||||||
|
entry.manager.bot = { chat: () => {} };
|
||||||
|
entry.manager.state = 'online';
|
||||||
|
entry.manager.loggedIn = false;
|
||||||
|
|
||||||
|
const command = pool.sendCommand('BotA', 'u claimmode');
|
||||||
|
assert.strictEqual(command.ok, false);
|
||||||
|
assert.ok(command.message.includes('未完成登录验证'));
|
||||||
|
|
||||||
|
const chat = pool.sendChat('BotA', '大家好');
|
||||||
|
assert.strictEqual(chat.ok, false);
|
||||||
|
assert.ok(chat.message.includes('未完成登录验证'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('未登录时验证类指令仍可下发(否则无法完成登录)', () => {
|
||||||
|
const { pool } = setup();
|
||||||
|
const entry = pool.get('BotA');
|
||||||
|
const sent = [];
|
||||||
|
entry.manager.bot = { chat: (text) => sent.push(text) };
|
||||||
|
entry.manager.state = 'online';
|
||||||
|
entry.manager.loggedIn = false;
|
||||||
|
|
||||||
|
const result = pool.sendCommand('BotA', '/auth 12345 6789');
|
||||||
|
assert.strictEqual(result.ok, true);
|
||||||
|
// 指令有节流,这里直接驱动写入验证内容
|
||||||
|
entry.manager._commandQueue.length = 0;
|
||||||
|
entry.manager._writeCommand('auth 12345 6789');
|
||||||
|
assert.deepStrictEqual(sent, ['/auth 12345 6789']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('空指令被拒绝', () => {
|
||||||
|
const { pool } = setup();
|
||||||
|
const result = pool.sendCommand('BotA', ' ');
|
||||||
|
assert.strictEqual(result.ok, false);
|
||||||
|
assert.ok(result.message.includes('指令为空'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('空聊天消息被拒绝', () => {
|
||||||
|
const { pool } = setup();
|
||||||
|
const result = pool.sendChat('BotA', ' ');
|
||||||
|
assert.strictEqual(result.ok, false);
|
||||||
|
assert.ok(result.message.includes('内容为空'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('聊天与指令共用同一入口:以 / 开头按指令发送', () => {
|
||||||
|
const { pool } = setup();
|
||||||
|
const entry = pool.get('BotA');
|
||||||
|
entry.manager.bot = { chat: () => {} };
|
||||||
|
entry.manager.state = 'online';
|
||||||
|
entry.manager.loggedIn = true;
|
||||||
|
|
||||||
|
const chat = pool.sendChat('BotA', '大家好');
|
||||||
|
assert.strictEqual(chat.kind, 'chat');
|
||||||
|
assert.ok(chat.message.includes('消息已发送'));
|
||||||
|
|
||||||
|
const command = pool.sendChat('BotA', '/u restore confirm');
|
||||||
|
assert.strictEqual(command.kind, 'command', '以 / 开头应识别为指令');
|
||||||
|
assert.ok(command.message.includes('指令已发送'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('完成登录验证后允许发送指令与聊天', () => {
|
||||||
|
const { pool } = setup();
|
||||||
|
const entry = pool.get('BotA');
|
||||||
|
const sent = [];
|
||||||
|
entry.manager.bot = { chat: (text) => sent.push(text) };
|
||||||
|
entry.manager.state = 'online';
|
||||||
|
entry.manager.loggedIn = true;
|
||||||
|
|
||||||
|
assert.strictEqual(entry.manager.canOperate(), true);
|
||||||
|
assert.strictEqual(pool.sendChat('BotA', '大家好').ok, true);
|
||||||
|
assert.strictEqual(pool.sendCommand('BotA', '/spawn').ok, true);
|
||||||
|
|
||||||
|
// 指令队列有节流,这里直接驱动写入验证内容
|
||||||
|
entry.manager._commandQueue.length = 0;
|
||||||
|
entry.manager._chatQueue.length = 0;
|
||||||
|
entry.manager.sendChat('直接发送', { internal: true });
|
||||||
|
entry.manager._chatQueue.length = 0;
|
||||||
|
entry.manager._writeChat('直接发送');
|
||||||
|
assert.deepStrictEqual(sent, ['直接发送']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startTask 在机器人未就绪时返回失败且不抛异常', async () => {
|
||||||
|
const { pool } = setup();
|
||||||
|
const result = await pool.startTask('BotA');
|
||||||
|
assert.strictEqual(result.ok, false);
|
||||||
|
assert.ok(result.message.includes('尚未就绪'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startTask 带配置覆盖时写入该机器人的专属配置', async () => {
|
||||||
|
const { pool } = setup({ config: { strategy: 1 } });
|
||||||
|
await pool.startTask('BotB', { strategy: 3, targetCount: 5 });
|
||||||
|
const config = pool.getConfigFor('BotB');
|
||||||
|
assert.strictEqual(config.strategy, 3);
|
||||||
|
assert.strictEqual(config.targetCount, 5);
|
||||||
|
// 另一个机器人不受影响
|
||||||
|
assert.strictEqual(pool.getConfigFor('BotA').strategy, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('applyGlobalConfig 下发到全部机器人并保留各自的覆盖', () => {
|
||||||
|
const { pool } = setup({ config: { strategy: 1, targetCount: 10 } });
|
||||||
|
pool.setBotSetting('BotB', { config: { targetCount: 3 } });
|
||||||
|
|
||||||
|
const next = normalizeConfig({ strategy: 4, targetCount: 99 }, pool.config);
|
||||||
|
pool.applyGlobalConfig(next);
|
||||||
|
|
||||||
|
assert.strictEqual(pool.get('BotA').manager.config.strategy, 4);
|
||||||
|
assert.strictEqual(pool.getConfigFor('BotA').targetCount, 99);
|
||||||
|
assert.strictEqual(pool.getConfigFor('BotB').targetCount, 3, '机器人专属覆盖优先于全局');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stop 会停止全部机器人的任务并断开连接', () => {
|
||||||
|
const { pool } = setup();
|
||||||
|
let disconnected = 0;
|
||||||
|
for (const entry of pool.entries.values()) {
|
||||||
|
entry.manager.disconnect = () => { disconnected++; };
|
||||||
|
}
|
||||||
|
pool.stop();
|
||||||
|
assert.strictEqual(disconnected, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tick 驱动全部机器人且单个异常不影响其它', () => {
|
||||||
|
const { pool } = setup();
|
||||||
|
const ticks = [];
|
||||||
|
pool.get('BotA').controller.tick = () => { throw new Error('boom'); };
|
||||||
|
pool.get('BotB').controller.tick = () => { ticks.push('B'); };
|
||||||
|
assert.doesNotThrow(() => pool.tick());
|
||||||
|
assert.deepStrictEqual(ticks, ['B']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('未启用(enabled=false)的机器人不参与连接', () => {
|
||||||
|
const { pool } = setup({ settings: { BotB: { enabled: false } } });
|
||||||
|
const connected = [];
|
||||||
|
for (const entry of pool.entries.values()) {
|
||||||
|
entry.manager.connect = () => { connected.push(entry.id); };
|
||||||
|
}
|
||||||
|
pool.start();
|
||||||
|
assert.deepStrictEqual(connected, ['BotA']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== 控制台新增 / 移除玩家 ====================
|
||||||
|
|
||||||
|
test('addBot 新增玩家、落盘凭据,默认立即连接', () => {
|
||||||
|
const { pool, credPath } = setup();
|
||||||
|
const entry = pool.addBot({ username: 'NewBot', password: 'secret-pw', label: '新号' }, { connect: false });
|
||||||
|
|
||||||
|
assert.strictEqual(entry.id, 'NewBot');
|
||||||
|
assert.strictEqual(entry.source, 'store');
|
||||||
|
assert.strictEqual(pool.size, 3);
|
||||||
|
assert.strictEqual(entry.manager.label, '新号');
|
||||||
|
|
||||||
|
const saved = JSON.parse(fs.readFileSync(credPath, 'utf8'));
|
||||||
|
assert.strictEqual(saved.bots.NewBot.username, 'NewBot');
|
||||||
|
assert.strictEqual(saved.bots.NewBot.password, 'secret-pw');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addBot 在 enabled 时真的发起连接', () => {
|
||||||
|
const { pool } = setup();
|
||||||
|
const connected = [];
|
||||||
|
// addBot 内部会新建 BotManager,这里通过覆盖原型方法拦截真实网络连接
|
||||||
|
const originalConnect = Object.getPrototypeOf(pool.get('BotA').manager).connect;
|
||||||
|
Object.getPrototypeOf(pool.get('BotA').manager).connect = function stubConnect() {
|
||||||
|
connected.push(this.connection.username);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
pool.addBot({ username: 'NewBot', password: 'p1' });
|
||||||
|
assert.deepStrictEqual(connected, ['NewBot']);
|
||||||
|
} finally {
|
||||||
|
Object.getPrototypeOf(pool.get('BotA').manager).connect = originalConnect;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addBot 拒绝空用户名、非法用户名与重名', () => {
|
||||||
|
const { pool } = setup();
|
||||||
|
assert.throws(() => pool.addBot({ username: '' }), (err) => err.statusCode === 400);
|
||||||
|
assert.throws(() => pool.addBot({ username: '名字含中文且超长' }), (err) => err.statusCode === 400);
|
||||||
|
assert.throws(() => pool.addBot({ username: 'BotA' }), (err) => err.statusCode === 400);
|
||||||
|
assert.ok(pool.addBot({ username: 'ok_name' }, { connect: false }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('凭据文件里的账号在重启后自动恢复,且 .env 同名时以 .env 为准', () => {
|
||||||
|
const { pool, credPath, storePath, config } = setup();
|
||||||
|
pool.addBot({ username: 'NewBot', password: 'p1' }, { connect: false });
|
||||||
|
|
||||||
|
const rebuilt = new BotPool({
|
||||||
|
connections: [connection('BotA')],
|
||||||
|
config,
|
||||||
|
dataService: fakeDataService(),
|
||||||
|
botStore: new BotStore(storePath),
|
||||||
|
credentialStore: new BotCredentialStore(credPath)
|
||||||
|
});
|
||||||
|
assert.deepStrictEqual(rebuilt.ids(), ['BotA', 'NewBot']);
|
||||||
|
assert.strictEqual(rebuilt.get('NewBot').source, 'store');
|
||||||
|
|
||||||
|
// .env 里也有同名账号时,以 .env 为准(避免「改了 .env 不生效」)
|
||||||
|
const both = new BotPool({
|
||||||
|
connections: [connection('NewBot')],
|
||||||
|
config,
|
||||||
|
dataService: fakeDataService(),
|
||||||
|
credentialStore: new BotCredentialStore(credPath)
|
||||||
|
});
|
||||||
|
assert.strictEqual(both.size, 1);
|
||||||
|
assert.strictEqual(both.get('NewBot').source, 'env');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removeBot 移除控制台新增的玩家并删除凭据', () => {
|
||||||
|
const { pool, credPath } = setup();
|
||||||
|
pool.addBot({ username: 'NewBot', password: 'p1' }, { connect: false });
|
||||||
|
let disconnected = false;
|
||||||
|
pool.get('NewBot').manager.disconnect = () => { disconnected = true; };
|
||||||
|
|
||||||
|
pool.removeBot('NewBot');
|
||||||
|
assert.strictEqual(disconnected, true, '移除时应断开连接');
|
||||||
|
assert.strictEqual(pool.get('NewBot'), null);
|
||||||
|
assert.strictEqual(pool.size, 2);
|
||||||
|
assert.deepStrictEqual(JSON.parse(fs.readFileSync(credPath, 'utf8')).bots, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removeBot 拒绝移除 .env 里配置的账号并说明原因', () => {
|
||||||
|
const { pool } = setup();
|
||||||
|
assert.throws(() => pool.removeBot('BotA'), (err) => {
|
||||||
|
assert.strictEqual(err.statusCode, 400);
|
||||||
|
assert.ok(err.message.includes('.env'));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
assert.strictEqual(pool.size, 2, '拒绝后不应改变列表');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('未启用凭据存储时不允许在控制台新增玩家', () => {
|
||||||
|
const { pool } = setup({ noCredentialStore: true });
|
||||||
|
assert.throws(() => pool.addBot({ username: 'X' }, { connect: false }), (err) => err.statusCode === 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('snapshot 标注账号来源,供前端决定能否移除', () => {
|
||||||
|
const { pool } = setup();
|
||||||
|
pool.addBot({ username: 'NewBot', password: 'p1' }, { connect: false });
|
||||||
|
const snap = pool.snapshot();
|
||||||
|
assert.strictEqual(snap.find((b) => b.id === 'BotA').source, 'env');
|
||||||
|
assert.strictEqual(snap.find((b) => b.id === 'NewBot').source, 'store');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('凭据文件不含任何非密码字段之外的敏感内容,且权限被收紧', () => {
|
||||||
|
const { pool, credPath } = setup();
|
||||||
|
pool.addBot({ username: 'NewBot', password: 'p1' }, { connect: false });
|
||||||
|
const parsed = JSON.parse(fs.readFileSync(credPath, 'utf8'));
|
||||||
|
assert.deepStrictEqual(Object.keys(parsed), ['bots']);
|
||||||
|
assert.deepStrictEqual(Object.keys(parsed.bots.NewBot).sort(),
|
||||||
|
['auth', 'host', 'password', 'port', 'username', 'version']);
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
const mode = fs.statSync(credPath).mode & 0o777;
|
||||||
|
assert.strictEqual(mode, 0o600, '含密码的文件必须是 0600');
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
|
||||||
|
const { parseBotsFromEnv, sanitizeId, normalizePort } = require('../src/config/botsConfig');
|
||||||
|
|
||||||
|
test('MC_BOTS JSON 数组写法解析多个机器人', () => {
|
||||||
|
const bots = parseBotsFromEnv({
|
||||||
|
MC_HOST: 'mc.example.com',
|
||||||
|
MC_PORT: '25566',
|
||||||
|
MC_VERSION: '1.21.1',
|
||||||
|
MC_AUTH: 'offline',
|
||||||
|
MC_BOTS: JSON.stringify([
|
||||||
|
{ username: 'BotA', password: 'p1' },
|
||||||
|
{ username: 'BotB', password: 'p2', label: '二号机' }
|
||||||
|
])
|
||||||
|
});
|
||||||
|
assert.strictEqual(bots.length, 2);
|
||||||
|
assert.deepStrictEqual(bots.map((b) => b.username), ['BotA', 'BotB']);
|
||||||
|
assert.strictEqual(bots[0].host, 'mc.example.com');
|
||||||
|
assert.strictEqual(bots[0].port, 25566);
|
||||||
|
assert.strictEqual(bots[0].version, '1.21.1');
|
||||||
|
assert.strictEqual(bots[1].label, '二号机');
|
||||||
|
assert.strictEqual(bots[1].password, 'p2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('MC_BOTS 逗号分隔写法解析用户名与密码', () => {
|
||||||
|
const bots = parseBotsFromEnv({ MC_BOTS: 'BotA:p1, BotB:p2' });
|
||||||
|
assert.deepStrictEqual(bots.map((b) => [b.username, b.password]), [['BotA', 'p1'], ['BotB', 'p2']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('MC_BOTS 中没有冒号的项视为「只有用户名」', () => {
|
||||||
|
const bots = parseBotsFromEnv({ MC_BOTS: 'BotA' });
|
||||||
|
assert.strictEqual(bots.length, 1);
|
||||||
|
assert.strictEqual(bots[0].password, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('未配置 MC_BOTS 时回退到 MC_USERNAME / MC_PASSWORD', () => {
|
||||||
|
const bots = parseBotsFromEnv({ MC_USERNAME: 'SoloBot', MC_PASSWORD: 'pw' });
|
||||||
|
assert.strictEqual(bots.length, 1);
|
||||||
|
assert.strictEqual(bots[0].username, 'SoloBot');
|
||||||
|
assert.strictEqual(bots[0].password, 'pw');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('MC_BOTS 解析失败(非法 JSON)时回退到单机器人兜底', () => {
|
||||||
|
const bots = parseBotsFromEnv({ MC_BOTS: '[{bad json', MC_USERNAME: 'Fallback', MC_PASSWORD: 'p' });
|
||||||
|
assert.strictEqual(bots.length, 1);
|
||||||
|
assert.strictEqual(bots[0].username, 'Fallback');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('重名机器人被去重,空用户名被丢弃', () => {
|
||||||
|
const bots = parseBotsFromEnv({ MC_BOTS: 'BotA:p1,BotA:p2,, ,BotB:p3' });
|
||||||
|
assert.deepStrictEqual(bots.map((b) => b.username), ['BotA', 'BotB']);
|
||||||
|
assert.strictEqual(bots[0].password, 'p1', '应保留第一个同名条目');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('id 由用户名安全化生成,可用于 URL 与文件名', () => {
|
||||||
|
const bots = parseBotsFromEnv({ MC_BOTS: 'a/b:c:p1' });
|
||||||
|
assert.strictEqual(bots.length, 1);
|
||||||
|
assert.strictEqual(bots[0].username, 'a/b');
|
||||||
|
assert.strictEqual(bots[0].id, 'a_b');
|
||||||
|
assert.strictEqual(sanitizeId('正常名字_123'), '正常名字_123');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('端口非法时回退到 25565', () => {
|
||||||
|
assert.strictEqual(normalizePort('abc'), 25565);
|
||||||
|
assert.strictEqual(normalizePort('0'), 25565);
|
||||||
|
assert.strictEqual(normalizePort('70000'), 25565);
|
||||||
|
assert.strictEqual(normalizePort('25565'), 25565);
|
||||||
|
const bots = parseBotsFromEnv({ MC_BOTS: 'BotA:p', MC_PORT: 'x' });
|
||||||
|
assert.strictEqual(bots[0].port, 25565);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('没有任何配置时返回空列表', () => {
|
||||||
|
assert.deepStrictEqual(parseBotsFromEnv({}), []);
|
||||||
|
});
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
'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 { ChatLog, ChatKind, sanitize } = require('../src/core/chatLog');
|
||||||
|
|
||||||
|
/** 建一个使用临时目录的公屏日志 */
|
||||||
|
function setup(options) {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-chatlog-'));
|
||||||
|
return { log: new ChatLog(Object.assign({ dir }, options || {})), dir };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('append 记录公屏消息并保留发言人与类型', () => {
|
||||||
|
const { log } = setup();
|
||||||
|
const entry = log.append({
|
||||||
|
botId: 'BotA',
|
||||||
|
bot: 'BotA',
|
||||||
|
message: '大家好',
|
||||||
|
kind: ChatKind.CHAT,
|
||||||
|
username: 'Steve'
|
||||||
|
});
|
||||||
|
assert.strictEqual(entry.botId, 'BotA');
|
||||||
|
assert.strictEqual(entry.message, '大家好');
|
||||||
|
assert.strictEqual(entry.username, 'Steve');
|
||||||
|
assert.strictEqual(entry.kind, 'chat');
|
||||||
|
assert.ok(entry.t > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('空消息不入库,直接返回 null', () => {
|
||||||
|
const { log } = setup();
|
||||||
|
assert.strictEqual(log.append({ botId: 'A', message: '' }), null);
|
||||||
|
assert.strictEqual(log.append({ botId: 'A' }), null);
|
||||||
|
assert.strictEqual(log.snapshot().total, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('list 按机器人隔离,并支持关键字与类型过滤', () => {
|
||||||
|
const { log } = setup();
|
||||||
|
log.append({ botId: 'A', message: '服务器将在 5 分钟后重启', kind: ChatKind.SYSTEM });
|
||||||
|
log.append({ botId: 'A', message: '你好', kind: ChatKind.CHAT, username: 'Steve' });
|
||||||
|
log.append({ botId: 'B', message: '另一个机器人的消息' });
|
||||||
|
|
||||||
|
assert.strictEqual(log.list('A').length, 2);
|
||||||
|
assert.strictEqual(log.list('B').length, 1);
|
||||||
|
assert.strictEqual(log.list('A', { kind: ChatKind.SYSTEM }).length, 1);
|
||||||
|
assert.strictEqual(log.list('A', { keyword: '重启' }).length, 1);
|
||||||
|
assert.strictEqual(log.list('A', { keyword: '不存在' }).length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('list 限制条数时保留最新的若干条且保持时间升序', () => {
|
||||||
|
const { log } = setup();
|
||||||
|
for (let i = 0; i < 10; i++) log.append({ botId: 'A', message: 'm' + i, time: 1000 + i });
|
||||||
|
const rows = log.list('A', { limit: 3 });
|
||||||
|
assert.deepStrictEqual(rows.map((r) => r.message), ['m7', 'm8', 'm9']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('每个机器人的内存缓冲独立限长', () => {
|
||||||
|
const { log } = setup({ maxEntriesPerBot: 2 });
|
||||||
|
for (let i = 0; i < 5; i++) log.append({ botId: 'A', message: 'a' + i });
|
||||||
|
log.append({ botId: 'B', message: 'b0' });
|
||||||
|
assert.strictEqual(log.list('A').length, 2);
|
||||||
|
assert.strictEqual(log.list('B').length, 1);
|
||||||
|
const snap = log.snapshot();
|
||||||
|
const a = snap.bots.find((b) => b.botId === 'A');
|
||||||
|
assert.strictEqual(a.count, 5, '计数应统计全部写入量而非缓冲量');
|
||||||
|
assert.strictEqual(a.buffered, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('按机器人分文件落盘(JSONL),重启后可读回文件', () => {
|
||||||
|
const { log, dir } = setup();
|
||||||
|
log.append({ botId: 'BotA', bot: 'BotA', message: 'hello' });
|
||||||
|
log.append({ botId: 'BotA', bot: 'BotA', message: 'world' });
|
||||||
|
log.append({ botId: 'BotB', message: 'other' });
|
||||||
|
|
||||||
|
const fileA = path.join(dir, 'BotA.jsonl');
|
||||||
|
const fileB = path.join(dir, 'BotB.jsonl');
|
||||||
|
assert.strictEqual(fs.readFileSync(fileA, 'utf8').trim().split('\n').length, 2);
|
||||||
|
assert.strictEqual(fs.readFileSync(fileB, 'utf8').trim().split('\n').length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('机器人名含路径分隔符时落盘文件名被安全化', () => {
|
||||||
|
const { log, dir } = setup();
|
||||||
|
log.append({ botId: 'a/b:c', message: 'x' });
|
||||||
|
const files = fs.readdirSync(dir);
|
||||||
|
assert.strictEqual(files.length, 1);
|
||||||
|
assert.strictEqual(files[0], 'a_b_c.jsonl');
|
||||||
|
assert.strictEqual(sanitize('a/b:c'), 'a_b_c');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exportTo 导出纯文本,含类型标记与发言人', () => {
|
||||||
|
const { log, dir } = setup();
|
||||||
|
log.append({ botId: 'BotA', message: '你好', kind: ChatKind.CHAT, username: 'Steve' });
|
||||||
|
log.append({ botId: 'BotA', message: 'u claimmode', kind: ChatKind.COMMAND });
|
||||||
|
const file = path.join(dir, 'out.log');
|
||||||
|
assert.strictEqual(log.exportTo('BotA', file), file);
|
||||||
|
const text = fs.readFileSync(file, 'utf8');
|
||||||
|
assert.ok(text.includes('Steve: 你好'));
|
||||||
|
assert.ok(text.includes('【指令】u claimmode'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('onEntry 回调收到新消息,回调抛异常不影响写入', () => {
|
||||||
|
const { log } = setup();
|
||||||
|
const seen = [];
|
||||||
|
log.onEntry = (entry) => {
|
||||||
|
seen.push(entry.message);
|
||||||
|
throw new Error('订阅者异常');
|
||||||
|
};
|
||||||
|
assert.doesNotThrow(() => log.append({ botId: 'A', message: 'hi' }));
|
||||||
|
assert.deepStrictEqual(seen, ['hi']);
|
||||||
|
assert.strictEqual(log.list('A').length, 1);
|
||||||
|
});
|
||||||
@@ -428,3 +428,132 @@ test('规划队列为空时直接进入 COMPLETE', async () => {
|
|||||||
assert.strictEqual(controller.state, State.COMPLETE);
|
assert.strictEqual(controller.state, State.COMPLETE);
|
||||||
assert.strictEqual(controller.status, '没有可用的相邻无主区块');
|
assert.strictEqual(controller.status, '没有可用的相邻无主区块');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ==================== 多机器人:邦国归属与开拓日志 ====================
|
||||||
|
|
||||||
|
test('start 把识别到的邦国固化到 stateName,不依赖全局 selfStateName', async () => {
|
||||||
|
const { controller, config } = setup({ selfName: 'A' });
|
||||||
|
await controller.start(config);
|
||||||
|
assert.strictEqual(controller.stateName, 'A');
|
||||||
|
assert.strictEqual(controller.config.stateName, 'A', '应写回任务配置供规划器使用');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('两个机器人各自开拓不同邦国时互不覆盖', async () => {
|
||||||
|
const map = new Map([
|
||||||
|
['A', { name: 'A', capital: { x: 0, z: 0 }, chunks: new Set([chunkPos.key(0, 0)]), color: 0x75bd20 }],
|
||||||
|
['B', { name: 'B', capital: { x: 20, z: 20 }, chunks: new Set([chunkPos.key(20, 20)]), color: 0xff43de }]
|
||||||
|
]);
|
||||||
|
const model = new TerritoryModel(map);
|
||||||
|
const logger = new Logger({ echo: false });
|
||||||
|
|
||||||
|
const makeController = (name) => {
|
||||||
|
const bot = new FakeBot({ x: 0, y: 64, z: 0 });
|
||||||
|
const botManager = new FakeBotManager(bot, model);
|
||||||
|
botManager.connection = { username: name };
|
||||||
|
const dataService = new FakeDataService(model);
|
||||||
|
const controller = new ExpansionController({ botManager, dataService, logger, botId: name, botName: name });
|
||||||
|
const config = defaultConfig();
|
||||||
|
config.enforceClaimTime = false;
|
||||||
|
config.autoEat = false;
|
||||||
|
config.autoAttack = false;
|
||||||
|
config.durabilityGuard = false;
|
||||||
|
return { controller, config, model };
|
||||||
|
};
|
||||||
|
|
||||||
|
const first = makeController('BotA');
|
||||||
|
first.config.stateName = 'A';
|
||||||
|
const second = makeController('BotB');
|
||||||
|
second.config.stateName = 'B';
|
||||||
|
|
||||||
|
await first.controller.start(first.config);
|
||||||
|
await second.controller.start(second.config);
|
||||||
|
|
||||||
|
// 全局 selfStateName 会被最后启动的机器人改写,但各控制器仍按自己的邦国工作
|
||||||
|
assert.strictEqual(first.controller.stateName, 'A');
|
||||||
|
assert.strictEqual(second.controller.stateName, 'B');
|
||||||
|
|
||||||
|
first.controller.playerChunk = { x: 0, z: 0 };
|
||||||
|
second.controller.playerChunk = { x: 20, z: 20 };
|
||||||
|
first.controller.monitor.onChat('疆土(0,1) [8,24] 已为你的邦国开拓');
|
||||||
|
second.controller.monitor.onChat('疆土(20,21) [328,344] 已为你的邦国开拓');
|
||||||
|
|
||||||
|
assert.ok(model.state('A').chunks.has(chunkPos.key(0, 1)), 'A 的新占领应记到 A 名下');
|
||||||
|
assert.ok(!model.state('A').chunks.has(chunkPos.key(20, 21)));
|
||||||
|
assert.ok(model.state('B').chunks.has(chunkPos.key(20, 21)), 'B 的新占领应记到 B 名下');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('占领成功时写入开拓日志(含玩家、区块、时间与来源)', async () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-claimlog-'));
|
||||||
|
const { ExpansionLog } = require('../src/core/expansionLog');
|
||||||
|
const expansionLog = new ExpansionLog({ filePath: path.join(dir, 'expansion-log.jsonl') });
|
||||||
|
|
||||||
|
const { controller, config, bot } = setup();
|
||||||
|
controller.expansionLog = expansionLog;
|
||||||
|
controller.botId = 'BotA';
|
||||||
|
controller.botName = 'BotA';
|
||||||
|
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 before = Date.now();
|
||||||
|
controller.monitor.onChat(`疆土(${target.x},${target.z}) [${target.x * 16 + 1},${target.z * 16 + 2}] 已为你的邦国开拓`);
|
||||||
|
|
||||||
|
const rows = expansionLog.list();
|
||||||
|
assert.strictEqual(rows.length, 1);
|
||||||
|
const entry = rows[0];
|
||||||
|
assert.strictEqual(entry.bot, 'BotA');
|
||||||
|
assert.strictEqual(entry.botId, 'BotA');
|
||||||
|
assert.strictEqual(entry.state, 'A');
|
||||||
|
assert.strictEqual(entry.cx, target.x);
|
||||||
|
assert.strictEqual(entry.cz, target.z);
|
||||||
|
assert.strictEqual(entry.bx, target.x * 16 + 1);
|
||||||
|
assert.strictEqual(entry.bz, target.z * 16 + 2);
|
||||||
|
assert.strictEqual(entry.source, 'chat');
|
||||||
|
assert.ok(entry.t >= before && entry.t <= Date.now());
|
||||||
|
assert.strictEqual(entry.sessionCount, 1);
|
||||||
|
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('无坐标的通用成功消息记为 chat-generic 来源', async () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-claimlog2-'));
|
||||||
|
const { ExpansionLog } = require('../src/core/expansionLog');
|
||||||
|
const expansionLog = new ExpansionLog({ filePath: path.join(dir, 'expansion-log.jsonl') });
|
||||||
|
|
||||||
|
const { controller, config, bot } = setup();
|
||||||
|
controller.expansionLog = expansionLog;
|
||||||
|
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();
|
||||||
|
controller.monitor.onChat('你的疆土已开拓');
|
||||||
|
|
||||||
|
const rows = expansionLog.list();
|
||||||
|
assert.strictEqual(rows.length, 1);
|
||||||
|
assert.strictEqual(rows[0].source, 'chat-generic');
|
||||||
|
assert.strictEqual(rows[0].bx, null);
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('同一区块重复成功只记一条开拓日志', async () => {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-claimlog3-'));
|
||||||
|
const { ExpansionLog } = require('../src/core/expansionLog');
|
||||||
|
const expansionLog = new ExpansionLog({ filePath: path.join(dir, 'expansion-log.jsonl') });
|
||||||
|
|
||||||
|
const { controller, config, bot } = setup();
|
||||||
|
controller.expansionLog = expansionLog;
|
||||||
|
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(expansionLog.list().length, 1);
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
'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 { ExpansionLog } = require('../src/core/expansionLog');
|
||||||
|
|
||||||
|
/** 建一个使用临时目录的日志实例 */
|
||||||
|
function setup(options) {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-explog-'));
|
||||||
|
const filePath = path.join(dir, 'expansion-log.jsonl');
|
||||||
|
return { log: new ExpansionLog(Object.assign({ filePath }, options || {})), dir, filePath };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('record 记录开拓玩家、区块与时间', () => {
|
||||||
|
const { log } = setup();
|
||||||
|
const before = Date.now();
|
||||||
|
const entry = log.record({
|
||||||
|
botId: 'BotA',
|
||||||
|
bot: 'BotA',
|
||||||
|
state: '现代大陆',
|
||||||
|
chunk: { x: 12, z: -34 },
|
||||||
|
block: { x: 200, z: -540 },
|
||||||
|
source: 'chat',
|
||||||
|
sessionCount: 7
|
||||||
|
});
|
||||||
|
assert.strictEqual(entry.bot, 'BotA');
|
||||||
|
assert.strictEqual(entry.state, '现代大陆');
|
||||||
|
assert.strictEqual(entry.cx, 12);
|
||||||
|
assert.strictEqual(entry.cz, -34);
|
||||||
|
assert.strictEqual(entry.bx, 200);
|
||||||
|
assert.strictEqual(entry.bz, -540);
|
||||||
|
assert.strictEqual(entry.source, 'chat');
|
||||||
|
assert.strictEqual(entry.sessionCount, 7);
|
||||||
|
assert.ok(entry.t >= before && entry.t <= Date.now(), '时间戳应落在调用区间内');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('record 缺省字段被安全兜底(无方块坐标 / 无邦国)', () => {
|
||||||
|
const { log } = setup();
|
||||||
|
const entry = log.record({ botId: 'B', chunk: { x: 1, z: 2 } });
|
||||||
|
assert.strictEqual(entry.bx, null);
|
||||||
|
assert.strictEqual(entry.bz, null);
|
||||||
|
assert.strictEqual(entry.state, '');
|
||||||
|
assert.strictEqual(entry.source, 'chat');
|
||||||
|
assert.strictEqual(entry.sessionCount, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('list 支持按机器人 / 邦国 / 时间过滤并限制条数', () => {
|
||||||
|
const { log } = setup();
|
||||||
|
const base = Date.now();
|
||||||
|
log.record({ botId: 'A', chunk: { x: 0, z: 0 }, state: 'X' });
|
||||||
|
log.record({ botId: 'B', chunk: { x: 1, z: 0 }, state: 'Y' });
|
||||||
|
log.record({ botId: 'A', chunk: { x: 2, z: 0 }, state: 'Y' });
|
||||||
|
|
||||||
|
assert.strictEqual(log.list().length, 3);
|
||||||
|
assert.strictEqual(log.list({ botId: 'A' }).length, 2);
|
||||||
|
assert.strictEqual(log.list({ state: 'Y' }).length, 2);
|
||||||
|
assert.strictEqual(log.list({ since: base + 1e9 }).length, 0);
|
||||||
|
|
||||||
|
const limited = log.list({ limit: 2 });
|
||||||
|
assert.strictEqual(limited.length, 2);
|
||||||
|
// 取最新 2 条但保持时间升序
|
||||||
|
assert.deepStrictEqual(limited.map((e) => e.cx), [1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stats 汇总总数、按机器人分布与今日条数', () => {
|
||||||
|
const { log } = setup();
|
||||||
|
log.record({ botId: 'A', chunk: { x: 0, z: 0 } });
|
||||||
|
log.record({ botId: 'A', chunk: { x: 1, z: 0 } });
|
||||||
|
log.record({ botId: 'B', chunk: { x: 2, z: 0 } });
|
||||||
|
|
||||||
|
const stats = log.stats();
|
||||||
|
assert.strictEqual(stats.total, 3);
|
||||||
|
assert.strictEqual(stats.buffered, 3);
|
||||||
|
assert.strictEqual(stats.today, 3);
|
||||||
|
assert.strictEqual(stats.byBot[0].botId, 'A');
|
||||||
|
assert.strictEqual(stats.byBot[0].count, 2);
|
||||||
|
assert.ok(stats.firstAt > 0 && stats.lastAt >= stats.firstAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('落盘为 JSONL,重启后能从磁盘恢复历史', () => {
|
||||||
|
const { log, filePath } = setup();
|
||||||
|
log.record({ botId: 'A', bot: 'A', chunk: { x: 5, z: 6 }, state: 'S' });
|
||||||
|
log.record({ botId: 'B', bot: 'B', chunk: { x: 7, z: 8 } });
|
||||||
|
|
||||||
|
const lines = fs.readFileSync(filePath, 'utf8').trim().split('\n');
|
||||||
|
assert.strictEqual(lines.length, 2);
|
||||||
|
assert.strictEqual(JSON.parse(lines[0]).cx, 5);
|
||||||
|
|
||||||
|
const reloaded = new ExpansionLog({ filePath });
|
||||||
|
assert.strictEqual(reloaded.entries.length, 2);
|
||||||
|
assert.strictEqual(reloaded.totalRecorded, 2);
|
||||||
|
assert.strictEqual(reloaded.stats().byBot.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('磁盘上的损坏行(进程被杀留下的半行)被跳过而不是崩溃', () => {
|
||||||
|
const { filePath } = setup();
|
||||||
|
fs.writeFileSync(filePath, '{"t":1,"botId":"A","bot":"A","cx":1,"cz":1}\n{"t":2,"botId"', 'utf8');
|
||||||
|
const log = new ExpansionLog({ filePath });
|
||||||
|
assert.strictEqual(log.entries.length, 1);
|
||||||
|
assert.strictEqual(log.entries[0].cx, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('内存环形缓冲超过上限时丢弃最旧记录,但总数继续累计', () => {
|
||||||
|
const { log } = setup({ maxEntries: 3 });
|
||||||
|
for (let i = 0; i < 5; i++) log.record({ botId: 'A', chunk: { x: i, z: 0 } });
|
||||||
|
assert.strictEqual(log.entries.length, 3);
|
||||||
|
assert.strictEqual(log.entries[0].cx, 2);
|
||||||
|
assert.strictEqual(log.stats().total, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('onEntry 回调收到新记录,回调抛异常不影响写入', () => {
|
||||||
|
const { log } = setup();
|
||||||
|
const seen = [];
|
||||||
|
log.onEntry = (entry) => {
|
||||||
|
seen.push(entry.cx);
|
||||||
|
throw new Error('订阅者异常');
|
||||||
|
};
|
||||||
|
assert.doesNotThrow(() => log.record({ botId: 'A', chunk: { x: 9, z: 9 } }));
|
||||||
|
assert.deepStrictEqual(seen, [9]);
|
||||||
|
assert.strictEqual(log.entries.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exportCsv 输出带 BOM 的表格,含表头与全部记录', () => {
|
||||||
|
const { log, dir } = setup();
|
||||||
|
log.record({ botId: 'A', bot: 'A', state: 'X', chunk: { x: 1, z: 2 }, block: { x: 16, z: 32 } });
|
||||||
|
log.record({ botId: 'B', bot: 'B,带逗号', state: 'Y', chunk: { x: 3, z: 4 } });
|
||||||
|
const file = path.join(dir, 'out.csv');
|
||||||
|
assert.strictEqual(log.exportCsv(file), file);
|
||||||
|
|
||||||
|
const text = fs.readFileSync(file, 'utf8');
|
||||||
|
assert.ok(text.startsWith('\ufeff'), '应带 BOM 以便 Excel 正确识别中文');
|
||||||
|
const lines = text.replace(/^\ufeff/, '').split('\r\n');
|
||||||
|
assert.ok(lines[0].includes('机器人'));
|
||||||
|
assert.strictEqual(lines.length, 3);
|
||||||
|
assert.ok(lines[2].includes('"B,带逗号"'), '含逗号的字段应被引号包裹');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clearBuffer 只清内存,磁盘文件保留', () => {
|
||||||
|
const { log, filePath } = setup();
|
||||||
|
log.record({ botId: 'A', chunk: { x: 1, z: 1 } });
|
||||||
|
log.clearBuffer();
|
||||||
|
assert.strictEqual(log.entries.length, 0);
|
||||||
|
assert.ok(fs.readFileSync(filePath, 'utf8').trim().length > 0);
|
||||||
|
});
|
||||||
+78
-2
@@ -26,8 +26,84 @@ function setup(options) {
|
|||||||
return { bot, grim, config };
|
return { bot, grim, config };
|
||||||
}
|
}
|
||||||
|
|
||||||
test('shortestAngleDelta 走最短路径并处理环绕', () => {
|
// ==================== 协议补包(线上服务器「走不动」的根因)====================
|
||||||
assert.ok(Math.abs(shortestAngleDelta(0, 0.5) - 0.5) < 1e-9);
|
|
||||||
|
/** 等一轮宏任务,让 process.nextTick 里发出的 tick_end 落地 */
|
||||||
|
function flushTick() {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('每 tick 补发 player_input,内容反映当前控制键', async () => {
|
||||||
|
const { bot } = setup();
|
||||||
|
bot.setControlState('forward', true);
|
||||||
|
bot.setControlState('sprint', true);
|
||||||
|
bot.clearWritten();
|
||||||
|
|
||||||
|
bot.tick();
|
||||||
|
const packets = bot.writtenPackets('player_input');
|
||||||
|
assert.strictEqual(packets.length, 1, '每个 physicsTick 都应补发一次 player_input');
|
||||||
|
assert.strictEqual(packets[0].params.inputs.forward, true);
|
||||||
|
assert.strictEqual(packets[0].params.inputs.sprint, true);
|
||||||
|
assert.strictEqual(packets[0].params.inputs.jump, false);
|
||||||
|
assert.strictEqual(packets[0].params.inputs.backward, false);
|
||||||
|
assert.strictEqual(packets[0].params.inputs.shift, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('每 tick 补发 tick_end,且排在本帧位置包之后', async () => {
|
||||||
|
const { bot } = setup();
|
||||||
|
bot.clearWritten();
|
||||||
|
bot.tick();
|
||||||
|
assert.strictEqual(bot.writtenPackets('tick_end').length, 0, 'tick_end 应延后到下一轮微任务/宏任务');
|
||||||
|
await flushTick();
|
||||||
|
assert.strictEqual(bot.writtenPackets('tick_end').length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('版本不支持 player_input 时只补 tick_end,不报错', async () => {
|
||||||
|
const { bot } = setup({ bot: { features: { newPlayerInputPacket: false } } });
|
||||||
|
bot.clearWritten();
|
||||||
|
bot.tick();
|
||||||
|
await flushTick();
|
||||||
|
assert.strictEqual(bot.writtenPackets('player_input').length, 0);
|
||||||
|
assert.strictEqual(bot.writtenPackets('tick_end').length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('关闭开关后不再补发对应包', async () => {
|
||||||
|
const { bot } = setup({ config: { grim: { sendPlayerInput: false, sendTickEnd: false } } });
|
||||||
|
bot.clearWritten();
|
||||||
|
bot.tick();
|
||||||
|
await flushTick();
|
||||||
|
assert.strictEqual(bot._client.written.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('关掉 Grim 合规层后协议补包仍然生效(否则机器人根本走不动)', async () => {
|
||||||
|
const { bot } = setup({ config: { grim: { enabled: false } } });
|
||||||
|
bot.clearWritten();
|
||||||
|
bot.tick();
|
||||||
|
await flushTick();
|
||||||
|
assert.strictEqual(bot.writtenPackets('player_input').length, 1);
|
||||||
|
assert.strictEqual(bot.writtenPackets('tick_end').length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uninstall 后不再补包', async () => {
|
||||||
|
const { bot, grim } = setup();
|
||||||
|
grim.uninstall();
|
||||||
|
bot.clearWritten();
|
||||||
|
bot.tick();
|
||||||
|
await flushTick();
|
||||||
|
assert.strictEqual(bot._client.written.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('snapshot 暴露协议补包状态与计数', () => {
|
||||||
|
const { bot, grim } = setup();
|
||||||
|
bot.tick();
|
||||||
|
const snap = grim.snapshot();
|
||||||
|
assert.strictEqual(snap.protocolPackets.playerInputSupported, true);
|
||||||
|
assert.strictEqual(snap.protocolPackets.playerInputEnabled, true);
|
||||||
|
assert.strictEqual(snap.protocolPackets.playerInputSent, 1);
|
||||||
|
assert.strictEqual(snap.protocolPackets.tickEndEnabled, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shortestAngleDelta 走最短路径并处理环绕', () => { assert.ok(Math.abs(shortestAngleDelta(0, 0.5) - 0.5) < 1e-9);
|
||||||
// 从 170° 到 -170° 应走 +20° 而不是 -340°
|
// 从 170° 到 -170° 应走 +20° 而不是 -340°
|
||||||
const from = (170 * Math.PI) / 180;
|
const from = (170 * Math.PI) / 180;
|
||||||
const to = (-170 * Math.PI) / 180;
|
const to = (-170 * Math.PI) / 180;
|
||||||
|
|||||||
@@ -57,7 +57,37 @@ class FakeBot extends EventEmitter {
|
|||||||
this.registry = opts.registry || defaultRegistry();
|
this.registry = opts.registry || defaultRegistry();
|
||||||
this.pathfinder = null;
|
this.pathfinder = null;
|
||||||
this.blockMap = opts.blockMap || new Map();
|
this.blockMap = opts.blockMap || new Map();
|
||||||
|
/**
|
||||||
|
* 模拟「已加载区块范围」:设置后,超出该半径(方块)的位置 blockAt 返回 null,
|
||||||
|
* 用于测试寻路器只能规划已加载范围、需要分段寻路的行为。
|
||||||
|
* 圆心始终跟随实体当前位置,模拟「人走到哪、区块加载到哪」。
|
||||||
|
*/
|
||||||
|
this.loadedRadius = opts.loadedRadius === undefined ? null : opts.loadedRadius;
|
||||||
this.creative = { flying: false };
|
this.creative = { flying: false };
|
||||||
|
/** 版本能力探测:默认支持 1.21.3+ 的 player_input 包 */
|
||||||
|
this.features = Object.assign({ newPlayerInputPacket: true }, opts.features || {});
|
||||||
|
/** 假的协议客户端:记录写出的包,便于断言「有没有补发 player_input / tick_end」 */
|
||||||
|
this._client = opts.client || {
|
||||||
|
state: 'play',
|
||||||
|
written: [],
|
||||||
|
write(name, params) {
|
||||||
|
this.written.push({ name, params });
|
||||||
|
},
|
||||||
|
on() {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
supportFeature(name) {
|
||||||
|
return this.features[name] === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取出写出的指定包 */
|
||||||
|
writtenPackets(name) {
|
||||||
|
return this._client.written.filter((p) => p.name === name);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearWritten() {
|
||||||
|
this._client.written.length = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
getControlState(control) {
|
getControlState(control) {
|
||||||
@@ -72,6 +102,10 @@ class FakeBot extends EventEmitter {
|
|||||||
blockAt(pos) {
|
blockAt(pos) {
|
||||||
const key = `${Math.floor(pos.x)},${Math.floor(pos.y)},${Math.floor(pos.z)}`;
|
const key = `${Math.floor(pos.x)},${Math.floor(pos.y)},${Math.floor(pos.z)}`;
|
||||||
if (this.blockMap.has(key)) return this.blockMap.get(key);
|
if (this.blockMap.has(key)) return this.blockMap.get(key);
|
||||||
|
if (this.loadedRadius !== null) {
|
||||||
|
const center = this.entity.position;
|
||||||
|
if (Math.hypot(pos.x - center.x, pos.z - center.z) > this.loadedRadius) return null;
|
||||||
|
}
|
||||||
return { name: 'air', boundingBox: 'empty' };
|
return { name: 'air', boundingBox: 'empty' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +170,11 @@ class FakeBot extends EventEmitter {
|
|||||||
tickTimes(n) {
|
tickTimes(n) {
|
||||||
for (let i = 0; i < n; i++) this.emit('physicsTick');
|
for (let i = 0; i < n; i++) this.emit('physicsTick');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 接受资源包(记录调用,供测试断言) */
|
||||||
|
acceptResourcePack() {
|
||||||
|
this.resourcePackAccepted = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { FakeBot };
|
module.exports = { FakeBot };
|
||||||
|
|||||||
@@ -0,0 +1,559 @@
|
|||||||
|
'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 { WebConsole } = require('../src/web/server');
|
||||||
|
const { BotPool } = require('../src/bot/botPool');
|
||||||
|
const { BotStore, BotCredentialStore } = require('../src/config/botStore');
|
||||||
|
const { ExpansionLog } = require('../src/core/expansionLog');
|
||||||
|
const { ChatLog, ChatKind } = require('../src/core/chatLog');
|
||||||
|
const { BigMapService } = require('../src/core/bigMapService');
|
||||||
|
const { TerritoryModel } = require('../src/core/territoryModel');
|
||||||
|
const { defaultConfig, normalizeConfig } = require('../src/config/expansionConfig');
|
||||||
|
const { Logger } = require('../src/util/logger');
|
||||||
|
const chunkPos = require('../src/core/chunkPos');
|
||||||
|
|
||||||
|
/** 造一个含 2 个机器人、完整依赖的控制台 */
|
||||||
|
function setup(options) {
|
||||||
|
const opts = options || {};
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-webmulti-'));
|
||||||
|
|
||||||
|
const map = new Map([
|
||||||
|
['A', {
|
||||||
|
name: 'A',
|
||||||
|
capital: { x: 1, z: 2 },
|
||||||
|
chunks: new Set([chunkPos.key(0, 0), chunkPos.key(1, 0)]),
|
||||||
|
color: 0x2f81f7,
|
||||||
|
polygons: [[0, 0, 16, 0, 16, 16, 0, 16]]
|
||||||
|
}],
|
||||||
|
['B', {
|
||||||
|
name: 'B',
|
||||||
|
capital: { x: 9, z: 9 },
|
||||||
|
chunks: new Set([chunkPos.key(9, 9)]),
|
||||||
|
color: 0xff43de,
|
||||||
|
polygons: []
|
||||||
|
}]
|
||||||
|
]);
|
||||||
|
const model = new TerritoryModel(map);
|
||||||
|
model.setSelfStateName('A');
|
||||||
|
model.addSessionClaim({ x: 5, z: 5 }, 'A');
|
||||||
|
|
||||||
|
const dataService = {
|
||||||
|
model,
|
||||||
|
getModel: () => model,
|
||||||
|
snapshot: () => ({ stateCount: 2, claimedChunks: 4, markersUrl: 'http://x/markers.json' }),
|
||||||
|
refreshAsync: () => {},
|
||||||
|
fetchAllTerritoryOnce: async () => ({ ok: true, message: 'ok' }),
|
||||||
|
detectStateByPlayer: async () => null,
|
||||||
|
setSelectedStateName: (name) => model.setSelfStateName(name),
|
||||||
|
addSessionClaim: (chunk, name) => model.addSessionClaim(chunk, name),
|
||||||
|
applyApiConfig: () => {},
|
||||||
|
reschedule: () => {},
|
||||||
|
start: () => {},
|
||||||
|
stop: () => {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = normalizeConfig(Object.assign(defaultConfig(), opts.config || {}));
|
||||||
|
const logger = new Logger({ echo: false });
|
||||||
|
const expansionLog = new ExpansionLog({ filePath: path.join(dir, 'expansion-log.jsonl') });
|
||||||
|
const chatLog = new ChatLog({ dir: path.join(dir, 'chat') });
|
||||||
|
const bigMap = new BigMapService(config, logger, {
|
||||||
|
baseUrl: 'http://bgjq.simpfun.cn',
|
||||||
|
world: 'minecraft_overworld',
|
||||||
|
cacheDir: path.join(dir, 'mapcache'),
|
||||||
|
fetchImpl: async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
arrayBuffer: async () => new Uint8Array([0x89, 0x50, 0x4e, 0x47]).buffer
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const pool = new BotPool({
|
||||||
|
connections: [
|
||||||
|
{ id: 'BotA', username: 'BotA', password: 'pw', host: 'h', port: 25565, version: '1.21.4' },
|
||||||
|
{ id: 'BotB', username: 'BotB', password: 'pw', host: 'h', port: 25565, version: '1.21.4' }
|
||||||
|
],
|
||||||
|
config,
|
||||||
|
logger,
|
||||||
|
dataService,
|
||||||
|
expansionLog,
|
||||||
|
chatLog,
|
||||||
|
botStore: new BotStore(path.join(dir, 'bots.json')),
|
||||||
|
credentialStore: new BotCredentialStore(path.join(dir, 'bots.credentials.json'))
|
||||||
|
});
|
||||||
|
|
||||||
|
const webConsole = new WebConsole({
|
||||||
|
pool,
|
||||||
|
dataService,
|
||||||
|
logger,
|
||||||
|
expansionLog,
|
||||||
|
chatLog,
|
||||||
|
bigMap,
|
||||||
|
getConfig: () => config,
|
||||||
|
applyConfig: (next) => Object.assign(config, next)
|
||||||
|
}, { host: '127.0.0.1', port: 0, exportDir: dir, password: opts.password });
|
||||||
|
|
||||||
|
return { webConsole, pool, model, chatLog, expansionLog, bigMap, dir, config, logger };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('statusPayload 汇总全部机器人与新增日志 / 大地图状态', () => {
|
||||||
|
const { webConsole, pool } = setup();
|
||||||
|
const payload = webConsole.statusPayload();
|
||||||
|
assert.strictEqual(payload.bots.length, 2);
|
||||||
|
assert.strictEqual(payload.bot.id, 'BotA', '单机器人兼容字段指向默认机器人');
|
||||||
|
assert.strictEqual(payload.task.state, 'IDLE');
|
||||||
|
assert.strictEqual(payload.defaultBotId, 'BotA');
|
||||||
|
assert.ok(payload.expansionLog);
|
||||||
|
assert.ok(payload.chatLog);
|
||||||
|
assert.ok(payload.bigMap);
|
||||||
|
// 默认图幅 4096 格半径、层级 0 每张 4096 格 → 4 张瓦片(不再是把全世界拉下来)
|
||||||
|
assert.strictEqual(payload.bigMap.tiles.count, 4);
|
||||||
|
assert.strictEqual(payload.bigMap.followBot, true);
|
||||||
|
assert.strictEqual(pool.size, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HTTP:/api/bots 返回机器人列表,/api/bots/:id/status 返回单个机器人', async () => {
|
||||||
|
const { webConsole } = setup();
|
||||||
|
await webConsole.start();
|
||||||
|
const base = webConsole.baseUrl();
|
||||||
|
try {
|
||||||
|
const bots = await fetch(base + '/api/bots').then((r) => r.json());
|
||||||
|
assert.strictEqual(bots.ok, true);
|
||||||
|
assert.deepStrictEqual(bots.data.map((b) => b.id), ['BotA', 'BotB']);
|
||||||
|
|
||||||
|
const single = await fetch(base + '/api/bots/BotB/status').then((r) => r.json());
|
||||||
|
assert.strictEqual(single.data.id, 'BotB');
|
||||||
|
assert.strictEqual(single.data.task.botId, 'BotB');
|
||||||
|
|
||||||
|
const missing = await fetch(base + '/api/bots/没有这个/status');
|
||||||
|
assert.strictEqual(missing.status, 404);
|
||||||
|
} finally {
|
||||||
|
await webConsole.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HTTP:机器人设置可读可写,且不会泄露密码', async () => {
|
||||||
|
const { webConsole } = setup();
|
||||||
|
await webConsole.start();
|
||||||
|
const base = webConsole.baseUrl();
|
||||||
|
try {
|
||||||
|
const saved = await fetch(base + '/api/bots/BotB/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ label: '二号机', enabled: true, autoStart: false, config: { targetCount: 6 } })
|
||||||
|
}).then((r) => r.json());
|
||||||
|
assert.strictEqual(saved.ok, true);
|
||||||
|
assert.strictEqual(saved.data.label, '二号机');
|
||||||
|
assert.strictEqual(saved.data.config.targetCount, 6);
|
||||||
|
assert.ok(!JSON.stringify(saved.data).includes('pw'), '响应中不得含密码');
|
||||||
|
|
||||||
|
const read = await fetch(base + '/api/bots/BotB/settings').then((r) => r.json());
|
||||||
|
assert.strictEqual(read.data.label, '二号机');
|
||||||
|
} finally {
|
||||||
|
await webConsole.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HTTP:机器人未就绪时启动任务返回 ok=false 并给出原因', async () => {
|
||||||
|
const { webConsole } = setup();
|
||||||
|
await webConsole.start();
|
||||||
|
const base = webConsole.baseUrl();
|
||||||
|
try {
|
||||||
|
const res = await fetch(base + '/api/bots/BotA/task/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({})
|
||||||
|
}).then((r) => r.json());
|
||||||
|
assert.strictEqual(res.ok, false);
|
||||||
|
assert.ok(res.message.includes('尚未就绪'));
|
||||||
|
} finally {
|
||||||
|
await webConsole.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HTTP:聊天接口在未完成登录验证时返回 409,缺参数返回 400', async () => {
|
||||||
|
const { webConsole, pool } = setup();
|
||||||
|
await webConsole.start();
|
||||||
|
const base = webConsole.baseUrl();
|
||||||
|
try {
|
||||||
|
const missing = await fetch(base + '/api/bots/BotA/chat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({})
|
||||||
|
});
|
||||||
|
assert.strictEqual(missing.status, 400);
|
||||||
|
|
||||||
|
// 未连接:提示应指向「没连上」而不是含糊的失败
|
||||||
|
const offline = await fetch(base + '/api/bots/BotA/chat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ message: '大家好' })
|
||||||
|
});
|
||||||
|
assert.strictEqual(offline.status, 409);
|
||||||
|
const offlineBody = await offline.json();
|
||||||
|
assert.ok(offlineBody.message.includes('未连接'), '离线时应说明未连接');
|
||||||
|
|
||||||
|
// 已连接但未通过登录验证:提示应指向登录门禁
|
||||||
|
const manager = pool.get('BotA').manager;
|
||||||
|
manager.state = 'online';
|
||||||
|
manager.bot = { chat: () => {} };
|
||||||
|
manager.loggedIn = false;
|
||||||
|
const denied = await fetch(base + '/api/bots/BotA/chat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ message: '大家好' })
|
||||||
|
});
|
||||||
|
assert.strictEqual(denied.status, 409);
|
||||||
|
const body = await denied.json();
|
||||||
|
assert.ok(body.message.includes('未完成登录验证'));
|
||||||
|
} finally {
|
||||||
|
await webConsole.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HTTP:指令接口区分「未连接」与「未通过登录验证」,并放行验证类指令', async () => {
|
||||||
|
const { webConsole, pool } = setup();
|
||||||
|
await webConsole.start();
|
||||||
|
const base = webConsole.baseUrl();
|
||||||
|
const post = (command) => fetch(base + '/api/bots/BotA/command', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ command })
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const offline = await post('u claimmode');
|
||||||
|
assert.strictEqual(offline.status, 409);
|
||||||
|
assert.ok((await offline.json()).message.includes('未连接'));
|
||||||
|
|
||||||
|
const manager = pool.get('BotA').manager;
|
||||||
|
const sent = [];
|
||||||
|
manager.bot = { chat: (text) => sent.push(text) };
|
||||||
|
manager.state = 'online';
|
||||||
|
manager.loggedIn = false;
|
||||||
|
|
||||||
|
// 任务类指令仍被拦下
|
||||||
|
const blocked = await post('u claimmode');
|
||||||
|
assert.strictEqual(blocked.status, 409);
|
||||||
|
assert.ok((await blocked.json()).message.includes('未完成登录验证'));
|
||||||
|
|
||||||
|
// 验证类指令放行(否则无法完成登录,形成死锁)
|
||||||
|
const allowed = await post('/auth 12345 6789');
|
||||||
|
assert.strictEqual(allowed.status, 200);
|
||||||
|
assert.strictEqual((await allowed.json()).ok, true);
|
||||||
|
} finally {
|
||||||
|
await webConsole.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HTTP:公屏日志可按机器人查询与导出', async () => {
|
||||||
|
const { webConsole, chatLog } = setup();
|
||||||
|
chatLog.append({ botId: 'BotA', bot: 'BotA', message: '你好', kind: ChatKind.CHAT, username: 'Steve' });
|
||||||
|
chatLog.append({ botId: 'BotB', bot: 'BotB', message: '别的机器人的消息' });
|
||||||
|
await webConsole.start();
|
||||||
|
const base = webConsole.baseUrl();
|
||||||
|
try {
|
||||||
|
const rows = await fetch(base + '/api/bots/BotA/chat').then((r) => r.json());
|
||||||
|
assert.strictEqual(rows.data.length, 1);
|
||||||
|
assert.strictEqual(rows.data[0].message, '你好');
|
||||||
|
|
||||||
|
const exported = await fetch(base + '/api/bots/BotA/chat/export', { method: 'POST' }).then((r) => r.json());
|
||||||
|
assert.strictEqual(exported.ok, true);
|
||||||
|
assert.ok(fs.existsSync(exported.data.file));
|
||||||
|
} finally {
|
||||||
|
await webConsole.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HTTP:开拓日志查询与 CSV 导出', async () => {
|
||||||
|
const { webConsole, expansionLog } = setup();
|
||||||
|
expansionLog.record({
|
||||||
|
botId: 'BotA',
|
||||||
|
bot: 'BotA',
|
||||||
|
state: 'A',
|
||||||
|
chunk: { x: 3, z: 4 },
|
||||||
|
block: { x: 48, z: 64 },
|
||||||
|
source: 'chat'
|
||||||
|
});
|
||||||
|
expansionLog.record({ botId: 'BotB', bot: 'BotB', state: 'B', chunk: { x: 9, z: 9 } });
|
||||||
|
await webConsole.start();
|
||||||
|
const base = webConsole.baseUrl();
|
||||||
|
try {
|
||||||
|
const all = await fetch(base + '/api/expansion-log').then((r) => r.json());
|
||||||
|
assert.strictEqual(all.data.entries.length, 2);
|
||||||
|
assert.strictEqual(all.data.stats.total, 2);
|
||||||
|
assert.strictEqual(all.data.stats.byBot.length, 2);
|
||||||
|
|
||||||
|
const filtered = await fetch(base + '/api/expansion-log?botId=BotA').then((r) => r.json());
|
||||||
|
assert.strictEqual(filtered.data.entries.length, 1);
|
||||||
|
assert.strictEqual(filtered.data.entries[0].cx, 3);
|
||||||
|
|
||||||
|
const exported = await fetch(base + '/api/expansion-log/export', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({})
|
||||||
|
}).then((r) => r.json());
|
||||||
|
assert.strictEqual(exported.ok, true);
|
||||||
|
const text = fs.readFileSync(exported.data.file, 'utf8');
|
||||||
|
assert.ok(text.includes('机器人'), 'CSV 应含表头');
|
||||||
|
assert.ok(text.includes('BotA'));
|
||||||
|
} finally {
|
||||||
|
await webConsole.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HTTP:大地图元信息、领土标记与瓦片代理', async () => {
|
||||||
|
const { webConsole } = setup();
|
||||||
|
await webConsole.start();
|
||||||
|
const base = webConsole.baseUrl();
|
||||||
|
try {
|
||||||
|
const meta = await fetch(base + '/api/bigmap').then((r) => r.json());
|
||||||
|
assert.strictEqual(meta.data.world, 'minecraft_overworld');
|
||||||
|
assert.strictEqual(meta.data.tilePixels, 512);
|
||||||
|
assert.strictEqual(meta.data.tiles.count, 4);
|
||||||
|
assert.strictEqual(meta.data.rangeBlocks, 4096);
|
||||||
|
// 机器人还没进世界 → 中心回退到世界原点
|
||||||
|
assert.strictEqual(meta.data.center.source, 'origin');
|
||||||
|
assert.strictEqual(meta.data.bots.length, 2, '大地图应带上全部机器人坐标');
|
||||||
|
|
||||||
|
const markers = await fetch(base + '/api/bigmap/markers').then((r) => r.json());
|
||||||
|
const a = markers.data.states.find((s) => s.name === 'A');
|
||||||
|
assert.strictEqual(a.color, '#2f81f7');
|
||||||
|
assert.strictEqual(a.polygons.length, 1);
|
||||||
|
assert.deepStrictEqual(a.polygons[0], [0, 0, 16, 0, 16, 16, 0, 16]);
|
||||||
|
|
||||||
|
const tile = await fetch(base + '/api/bigmap/tile/0/0/0.png');
|
||||||
|
assert.strictEqual(tile.status, 200);
|
||||||
|
assert.strictEqual(tile.headers.get('content-type'), 'image/png');
|
||||||
|
|
||||||
|
const badTile = await fetch(base + '/api/bigmap/tile/x/0/0.png');
|
||||||
|
assert.strictEqual(badTile.status, 400);
|
||||||
|
} finally {
|
||||||
|
await webConsole.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HTTP:地图数据可按机器人视角返回各自的邦国与目标', async () => {
|
||||||
|
const { webConsole, pool } = setup();
|
||||||
|
pool.get('BotB').controller.stateName = 'B';
|
||||||
|
await webConsole.start();
|
||||||
|
const base = webConsole.baseUrl();
|
||||||
|
try {
|
||||||
|
const a = await fetch(base + '/api/map?bot=BotA').then((r) => r.json());
|
||||||
|
assert.strictEqual(a.data.selfState, 'A');
|
||||||
|
// 邦国 A 原有 2 个区块 + 1 个会话占领区块 = 3 个区块 → 6 个数字
|
||||||
|
assert.strictEqual(a.data.self.length, 6);
|
||||||
|
|
||||||
|
const b = await fetch(base + '/api/map?bot=BotB').then((r) => r.json());
|
||||||
|
assert.strictEqual(b.data.selfState, 'B');
|
||||||
|
assert.strictEqual(b.data.self.length, 2);
|
||||||
|
} finally {
|
||||||
|
await webConsole.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HTTP:配置了控制台密码时未登录一律 401,登录后可访问', async () => {
|
||||||
|
const { webConsole } = setup({ password: 'console-pw' });
|
||||||
|
await webConsole.start();
|
||||||
|
const base = webConsole.baseUrl();
|
||||||
|
try {
|
||||||
|
// 会话探测接口本身免鉴权,用于前端决定是否弹登录框
|
||||||
|
const session = await fetch(base + '/api/session').then((r) => r.json());
|
||||||
|
assert.strictEqual(session.data.required, true);
|
||||||
|
assert.strictEqual(session.data.authenticated, false);
|
||||||
|
|
||||||
|
const denied = await fetch(base + '/api/bots');
|
||||||
|
assert.strictEqual(denied.status, 401);
|
||||||
|
|
||||||
|
const wrong = await fetch(base + '/api/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password: '错的' })
|
||||||
|
});
|
||||||
|
assert.strictEqual(wrong.status, 401);
|
||||||
|
|
||||||
|
const login = await fetch(base + '/api/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password: 'console-pw' })
|
||||||
|
}).then((r) => r.json());
|
||||||
|
assert.strictEqual(login.ok, true);
|
||||||
|
assert.strictEqual(login.data.authenticated, true);
|
||||||
|
|
||||||
|
const allowed = await fetch(base + '/api/bots', { headers: { 'X-Auth-Token': 'console-pw' } });
|
||||||
|
assert.strictEqual(allowed.status, 200);
|
||||||
|
|
||||||
|
// 静态页面本身仍需可加载,否则用户看不到登录框
|
||||||
|
const html = await fetch(base + '/');
|
||||||
|
assert.strictEqual(html.status, 200);
|
||||||
|
} finally {
|
||||||
|
await webConsole.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HTTP:可在控制台新增与移除玩家,响应中不含密码', async () => {
|
||||||
|
const { webConsole, pool } = setup();
|
||||||
|
// 避免测试真的发起网络连接
|
||||||
|
const proto = Object.getPrototypeOf(pool.get('BotA').manager);
|
||||||
|
const originalConnect = proto.connect;
|
||||||
|
proto.connect = function stub() {};
|
||||||
|
await webConsole.start();
|
||||||
|
const base = webConsole.baseUrl();
|
||||||
|
try {
|
||||||
|
const added = await fetch(base + '/api/bots', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: 'NewBot', password: 'super-secret', label: '新号' })
|
||||||
|
}).then((r) => r.json());
|
||||||
|
assert.strictEqual(added.ok, true);
|
||||||
|
assert.strictEqual(added.data.id, 'NewBot');
|
||||||
|
assert.ok(!JSON.stringify(added).includes('super-secret'), '响应中不得回显密码');
|
||||||
|
assert.deepStrictEqual(added.data.bots.map((b) => b.id), ['BotA', 'BotB', 'NewBot']);
|
||||||
|
assert.strictEqual(added.data.bots[2].source, 'store');
|
||||||
|
|
||||||
|
const duplicate = await fetch(base + '/api/bots', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: 'NewBot' })
|
||||||
|
});
|
||||||
|
assert.strictEqual(duplicate.status, 400);
|
||||||
|
|
||||||
|
const invalid = await fetch(base + '/api/bots', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: '带中文的名字' })
|
||||||
|
});
|
||||||
|
assert.strictEqual(invalid.status, 400);
|
||||||
|
|
||||||
|
// .env 里配置的账号不允许从控制台移除
|
||||||
|
const envBot = await fetch(base + '/api/bots/BotA', { method: 'DELETE' });
|
||||||
|
assert.strictEqual(envBot.status, 400);
|
||||||
|
|
||||||
|
const removed = await fetch(base + '/api/bots/NewBot', { method: 'DELETE' }).then((r) => r.json());
|
||||||
|
assert.strictEqual(removed.ok, true);
|
||||||
|
assert.deepStrictEqual(removed.data.bots.map((b) => b.id), ['BotA', 'BotB']);
|
||||||
|
} finally {
|
||||||
|
proto.connect = originalConnect;
|
||||||
|
await webConsole.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HTTP:定时消息可读可写,且不影响其它设置', async () => {
|
||||||
|
const { webConsole, pool } = setup();
|
||||||
|
await webConsole.start();
|
||||||
|
const base = webConsole.baseUrl();
|
||||||
|
try {
|
||||||
|
const initial = await fetch(base + '/api/bots/BotA/schedules').then((r) => r.json());
|
||||||
|
assert.deepStrictEqual(initial.data, []);
|
||||||
|
|
||||||
|
const saved = await fetch(base + '/api/bots/BotA/schedules', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
schedules: [
|
||||||
|
{ id: 'restore', label: '恢复民心', content: '/u restore confirm', intervalSeconds: 300 },
|
||||||
|
{ content: '大家好', intervalSeconds: 600, enabled: false },
|
||||||
|
{ content: ' ' }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}).then((r) => r.json());
|
||||||
|
assert.strictEqual(saved.ok, true);
|
||||||
|
assert.strictEqual(saved.data.length, 2, '空内容应被丢弃');
|
||||||
|
assert.strictEqual(saved.data[0].kind, 'command', '以 / 开头识别为指令');
|
||||||
|
assert.strictEqual(saved.data[1].kind, 'chat');
|
||||||
|
assert.strictEqual(saved.data[1].enabled, false);
|
||||||
|
|
||||||
|
// 定时任务也应出现在状态负载里,前端据此渲染
|
||||||
|
const status = await fetch(base + '/api/status').then((r) => r.json());
|
||||||
|
const bot = status.data.bots.find((b) => b.id === 'BotA');
|
||||||
|
assert.strictEqual(bot.schedules.length, 2);
|
||||||
|
assert.ok(bot.schedules[0].nextDueAt > 0);
|
||||||
|
|
||||||
|
// 间隔过小会被钳制,防止刷屏
|
||||||
|
const clamped = await fetch(base + '/api/bots/BotA/schedules', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ schedules: [{ content: 'x', intervalSeconds: 1 }] })
|
||||||
|
}).then((r) => r.json());
|
||||||
|
assert.strictEqual(clamped.data[0].intervalSeconds, 5);
|
||||||
|
|
||||||
|
// 保存定时任务不应清掉别的设置
|
||||||
|
assert.strictEqual(pool.settingSnapshot('BotA').enabled, true);
|
||||||
|
} finally {
|
||||||
|
await webConsole.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HTTP:大地图图幅可按机器人位置与半径计算瓦片范围', async () => {
|
||||||
|
const { webConsole, pool } = setup();
|
||||||
|
// 给机器人一个真实坐标,验证中心会跟着它走
|
||||||
|
const manager = pool.get('BotB').manager;
|
||||||
|
manager.bot = { entity: { position: { x: 18432, y: 72, z: 18432 } } };
|
||||||
|
manager.state = 'online';
|
||||||
|
await webConsole.start();
|
||||||
|
const base = webConsole.baseUrl();
|
||||||
|
try {
|
||||||
|
const meta = await fetch(base + '/api/bigmap?rangeBlocks=1024&bot=BotB').then((r) => r.json());
|
||||||
|
assert.strictEqual(meta.data.center.source, 'bot');
|
||||||
|
assert.strictEqual(meta.data.center.x, 18432);
|
||||||
|
assert.strictEqual(meta.data.rangeBlocks, 1024);
|
||||||
|
assert.strictEqual(meta.data.tiles.count, 1, '1024 格半径且中心落在瓦片正中时只要 1 张瓦片');
|
||||||
|
assert.strictEqual(meta.data.tiles.minX, 4);
|
||||||
|
|
||||||
|
// 换成大图幅后瓦片数随之增加
|
||||||
|
const wide = await fetch(base + '/api/bigmap?rangeBlocks=8192&bot=BotB').then((r) => r.json());
|
||||||
|
assert.ok(wide.data.tiles.count > meta.data.tiles.count);
|
||||||
|
assert.strictEqual(wide.data.rangeBlocks, 8192);
|
||||||
|
} finally {
|
||||||
|
await webConsole.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HTTP:未配置密码时免登录(本机开发模式)', async () => {
|
||||||
|
const { webConsole } = setup();
|
||||||
|
await webConsole.start();
|
||||||
|
const base = webConsole.baseUrl();
|
||||||
|
try {
|
||||||
|
const session = await fetch(base + '/api/session').then((r) => r.json());
|
||||||
|
assert.strictEqual(session.data.required, false);
|
||||||
|
assert.strictEqual(session.data.authenticated, true);
|
||||||
|
const bots = await fetch(base + '/api/bots');
|
||||||
|
assert.strictEqual(bots.status, 200);
|
||||||
|
} finally {
|
||||||
|
await webConsole.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('WebSocket:握手鉴权失败时被拒绝,成功后可收到状态推送', async () => {
|
||||||
|
const { webConsole } = setup({ password: 'console-pw' });
|
||||||
|
await webConsole.start();
|
||||||
|
const base = webConsole.baseUrl();
|
||||||
|
const WebSocket = require('ws');
|
||||||
|
try {
|
||||||
|
const rejected = await new Promise((resolve) => {
|
||||||
|
const socket = new WebSocket(base.replace('http', 'ws') + '/ws');
|
||||||
|
socket.on('error', () => resolve('error'));
|
||||||
|
socket.on('open', () => { socket.close(); resolve('open'); });
|
||||||
|
});
|
||||||
|
assert.strictEqual(rejected, 'error');
|
||||||
|
|
||||||
|
const payload = await new Promise((resolve, reject) => {
|
||||||
|
const socket = new WebSocket(base.replace('http', 'ws') + '/ws?token=console-pw');
|
||||||
|
const timer = setTimeout(() => reject(new Error('未收到状态推送')), 3000);
|
||||||
|
socket.on('message', (data) => {
|
||||||
|
const parsed = JSON.parse(data.toString());
|
||||||
|
if (parsed.type === 'status') {
|
||||||
|
clearTimeout(timer);
|
||||||
|
socket.close();
|
||||||
|
resolve(parsed.data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('error', reject);
|
||||||
|
});
|
||||||
|
assert.strictEqual(payload.bots.length, 2);
|
||||||
|
} finally {
|
||||||
|
await webConsole.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
+137
-2
@@ -35,8 +35,143 @@ test('AltitudeGoal 使用 GoalNearXZ 时保留其到达范围', () => {
|
|||||||
assert.strictEqual(goal.isEnd({ x: 100, y: 70, z: 201 }), true, '范围内算到达');
|
assert.strictEqual(goal.isEnd({ x: 100, y: 70, z: 201 }), true, '范围内算到达');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('configure 施加 Grim 合规约束(默认禁止挖 / 放 / 跑酷 / 搭塔)', () => {
|
// ==================== 长距离分段寻路 ====================
|
||||||
const bot = new FakeBot();
|
//
|
||||||
|
// mineflayer-pathfinder 只能规划已加载区块范围(实测线上约 8 区块 ≈ 130 格),
|
||||||
|
// 而扩地目标常在几十个区块外,A* 会瞬间 noPath。分段寻路就是为这个场景写的。
|
||||||
|
|
||||||
|
/** 造一个「已加载范围」有限的 bot 与寻路服务 */
|
||||||
|
function setupSegment(options) {
|
||||||
|
const opts = options || {};
|
||||||
|
const bot = new FakeBot({ x: opts.x === undefined ? 0.5 : opts.x, y: 64, z: opts.z === undefined ? 0.5 : opts.z, loadedRadius: opts.loadedRadius === undefined ? 128 : opts.loadedRadius });
|
||||||
|
const goals = [];
|
||||||
|
bot.pathfinder = {
|
||||||
|
setMovements: () => {},
|
||||||
|
setGoal: (goal) => goals.push(goal),
|
||||||
|
thinkTimeout: 0,
|
||||||
|
tickTimeout: 0,
|
||||||
|
searchRadius: 0
|
||||||
|
};
|
||||||
|
const config = defaultConfig();
|
||||||
|
config.enforceClaimTime = false;
|
||||||
|
Object.assign(config.pathing, opts.config || {});
|
||||||
|
const service = new PathingService(bot, config, null);
|
||||||
|
service.configure();
|
||||||
|
return { bot, service, goals, config };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('_probeLoadedDistance 沿方向探测到已加载范围的边缘', () => {
|
||||||
|
const { service } = setupSegment({ loadedRadius: 128 });
|
||||||
|
assert.ok(Math.abs(service._probeLoadedDistance(1, 0, 512) - 128) < 2,
|
||||||
|
'向右探测应停在已加载半径处');
|
||||||
|
assert.ok(Math.abs(service._probeLoadedDistance(0, -1, 512) - 128) < 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('_segmentGoal 把远超已加载范围的目标裁剪到边缘附近', () => {
|
||||||
|
const { service } = setupSegment({
|
||||||
|
loadedRadius: 128,
|
||||||
|
config: { segmentMarginBlocks: 24, maxSegmentBlocks: 4096 }
|
||||||
|
});
|
||||||
|
const far = { x: 0.5 + 800, z: 0.5 };
|
||||||
|
const waypoint = service._segmentGoal(far);
|
||||||
|
assert.ok(waypoint.x < far.x, '必须裁剪,否则寻路器会瞬间 noPath');
|
||||||
|
assert.ok(waypoint.x > 0.5, '不能原地不动');
|
||||||
|
assert.ok(Math.abs(waypoint.x - (0.5 + 128 - 24)) < 2, `段长应约为 已加载半径 - 边距,实际 ${waypoint.x}`);
|
||||||
|
assert.ok(Math.abs(waypoint.z - 0.5) < 1, '方向应保持指向目标');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('_segmentGoal 的段长同时受「单段上限」约束', () => {
|
||||||
|
const { service } = setupSegment({
|
||||||
|
loadedRadius: 512,
|
||||||
|
config: { segmentMarginBlocks: 24, maxSegmentBlocks: 64 }
|
||||||
|
});
|
||||||
|
const waypoint = service._segmentGoal({ x: 0.5 + 800, z: 0.5 });
|
||||||
|
assert.ok(Math.abs(waypoint.x - (0.5 + 64)) < 3,
|
||||||
|
`即使已加载范围很大,单段也不该超过 maxSegmentBlocks,实际 ${waypoint.x}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('_segmentGoal 目标在已加载范围内时不做裁剪', () => {
|
||||||
|
const { service } = setupSegment({ loadedRadius: 128 });
|
||||||
|
const near = { x: 0.5 + 40, z: 0.5 };
|
||||||
|
assert.deepStrictEqual(service._segmentGoal(near), near);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pathTo 长距离目标时下发的是分段中途点而不是最终目标', () => {
|
||||||
|
const { service, goals } = setupSegment({ loadedRadius: 128 });
|
||||||
|
// 目标区块中心约在 (800, 0)
|
||||||
|
assert.strictEqual(service.pathTo({ x: 50, z: 0 }), true);
|
||||||
|
assert.strictEqual(goals.length, 1);
|
||||||
|
assert.strictEqual(service._waypointIsFinal, false, '这一段不是最终目标');
|
||||||
|
assert.ok(service.currentWaypoint.x < 800);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pathTo 近距离目标时直接下发最终目标', () => {
|
||||||
|
const { service, goals } = setupSegment({ loadedRadius: 512 });
|
||||||
|
assert.strictEqual(service.pathTo({ x: 5, z: 0 }), true);
|
||||||
|
assert.strictEqual(goals.length, 1);
|
||||||
|
assert.strictEqual(service._waypointIsFinal, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('走到中途点后自动规划下一段,而不是当作已到达', () => {
|
||||||
|
const { bot, service, goals } = setupSegment({ loadedRadius: 128 });
|
||||||
|
const events = [];
|
||||||
|
service.onPathEvent = (event) => events.push(event);
|
||||||
|
|
||||||
|
service.pathTo({ x: 50, z: 0 });
|
||||||
|
const before = goals.length;
|
||||||
|
bot.emit('goal_reached');
|
||||||
|
|
||||||
|
assert.strictEqual(goals.length, before + 1, '应继续规划下一段');
|
||||||
|
assert.strictEqual(service._segmentCount, 1);
|
||||||
|
assert.ok(events.includes(PathEvent.SEGMENT_REACHED), '应发出 SEGMENT_REACHED 供控制器重置超时');
|
||||||
|
assert.ok(!events.includes(PathEvent.AT_GOAL), '还没到最终目标,不能报到达');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('走到最终目标时发出 AT_GOAL 且不再规划', () => {
|
||||||
|
const { bot, service, goals } = setupSegment({ loadedRadius: 512 });
|
||||||
|
const events = [];
|
||||||
|
service.onPathEvent = (event) => events.push(event);
|
||||||
|
|
||||||
|
service.pathTo({ x: 5, z: 0 });
|
||||||
|
const before = goals.length;
|
||||||
|
bot.emit('goal_reached');
|
||||||
|
|
||||||
|
assert.strictEqual(goals.length, before, '已到最终目标,不应再规划');
|
||||||
|
assert.ok(events.includes(PathEvent.AT_GOAL));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('本段算出 noPath 时先缩短段长重试', () => {
|
||||||
|
const { service } = setupSegment({ loadedRadius: 128 });
|
||||||
|
service.pathTo({ x: 50, z: 0 });
|
||||||
|
const firstWaypoint = service.currentWaypoint.x;
|
||||||
|
|
||||||
|
// 模拟寻路器报告本段不可达
|
||||||
|
service.bot.emit('path_update', { status: 'noPath' });
|
||||||
|
assert.strictEqual(service.calcFailed, false, '第一次应重试而不是直接失败');
|
||||||
|
assert.ok(service.currentWaypoint.x < firstWaypoint, '段长应缩短');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('反复 noPath 最终会如实报失败,不会无限重试', () => {
|
||||||
|
const { service } = setupSegment({ loadedRadius: 128 });
|
||||||
|
const events = [];
|
||||||
|
service.onPathEvent = (event) => events.push(event);
|
||||||
|
service.pathTo({ x: 50, z: 0 });
|
||||||
|
for (let i = 0; i < 10; i++) service.bot.emit('path_update', { status: 'noPath' });
|
||||||
|
assert.strictEqual(service.calcFailed, true);
|
||||||
|
assert.ok(events.includes(PathEvent.CALC_FAILED));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancel 清空分段状态', () => {
|
||||||
|
const { service } = setupSegment({ loadedRadius: 128 });
|
||||||
|
service.pathTo({ x: 50, z: 0 });
|
||||||
|
service.cancel();
|
||||||
|
assert.strictEqual(service.finalTarget, null);
|
||||||
|
assert.strictEqual(service.currentWaypoint, null);
|
||||||
|
assert.strictEqual(service._segmentCount, 0);
|
||||||
|
assert.strictEqual(service.isPathing(), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('configure 施加 Grim 合规约束(默认禁止挖 / 放 / 跑酷 / 搭塔)', () => { const bot = new FakeBot();
|
||||||
const movements = {};
|
const movements = {};
|
||||||
bot.pathfinder = {
|
bot.pathfinder = {
|
||||||
setMovements: (m) => { movements.value = m; },
|
setMovements: (m) => { movements.value = m; },
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
|
||||||
|
const { ScheduledMessenger, normalizeTasks, clampInterval, MIN_INTERVAL_SECONDS, MAX_TASKS } = require('../src/bot/scheduledMessenger');
|
||||||
|
|
||||||
|
/** 造一个可控的假 BotManager */
|
||||||
|
function fakeManager(options) {
|
||||||
|
const opts = options || {};
|
||||||
|
const sent = [];
|
||||||
|
return {
|
||||||
|
connection: { username: 'BotA' },
|
||||||
|
canSendChat: () => opts.canSend !== false,
|
||||||
|
sendChat: (content) => {
|
||||||
|
sent.push(content);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
sent
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('normalizeTasks 丢弃空内容、补齐默认值、按 / 前缀判定类型', () => {
|
||||||
|
const tasks = normalizeTasks([
|
||||||
|
{ id: 'a', content: '/u restore confirm', intervalSeconds: 300, label: '恢复民心' },
|
||||||
|
{ content: '大家好', intervalSeconds: 60 },
|
||||||
|
{ content: ' ' },
|
||||||
|
null,
|
||||||
|
{ intervalSeconds: 10 }
|
||||||
|
]);
|
||||||
|
assert.strictEqual(tasks.length, 2);
|
||||||
|
assert.strictEqual(tasks[0].kind, 'command', '以 / 开头应识别为指令');
|
||||||
|
assert.strictEqual(tasks[0].enabled, true, '默认启用');
|
||||||
|
assert.strictEqual(tasks[1].kind, 'chat');
|
||||||
|
assert.ok(tasks[1].id, '缺 id 时应自动生成');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeTasks 钳制间隔到合理范围并限制条数', () => {
|
||||||
|
assert.strictEqual(clampInterval(1), MIN_INTERVAL_SECONDS, '间隔不能小于下限,否则等同刷屏');
|
||||||
|
assert.strictEqual(clampInterval(999999), 86400);
|
||||||
|
assert.strictEqual(clampInterval('abc'), 300, '非法值回退到 5 分钟');
|
||||||
|
const many = [];
|
||||||
|
for (let i = 0; i < MAX_TASKS + 10; i++) many.push({ content: 'm' + i, intervalSeconds: 60 });
|
||||||
|
assert.strictEqual(normalizeTasks(many).length, MAX_TASKS);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('id 重复时自动改名,保证每个任务独立计时', () => {
|
||||||
|
const tasks = normalizeTasks([
|
||||||
|
{ id: 'x', content: 'a', intervalSeconds: 60 },
|
||||||
|
{ id: 'x', content: 'b', intervalSeconds: 60 }
|
||||||
|
]);
|
||||||
|
assert.strictEqual(tasks.length, 2);
|
||||||
|
assert.notStrictEqual(tasks[0].id, tasks[1].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('到点才发送,且按间隔重复', () => {
|
||||||
|
const manager = fakeManager();
|
||||||
|
const messenger = new ScheduledMessenger(manager, null);
|
||||||
|
const t0 = 1_000_000;
|
||||||
|
messenger.setTasks([{ id: 'restore', content: '/u restore confirm', intervalSeconds: 300 }], t0);
|
||||||
|
|
||||||
|
assert.strictEqual(messenger.tick(t0), 0, '刚设置完不应立即发送');
|
||||||
|
assert.strictEqual(messenger.tick(t0 + 299_000), 0, '未到间隔不发');
|
||||||
|
assert.strictEqual(messenger.tick(t0 + 300_000), 1, '到点应发送');
|
||||||
|
assert.deepStrictEqual(manager.sent, ['/u restore confirm']);
|
||||||
|
assert.strictEqual(messenger.tick(t0 + 599_000), 0);
|
||||||
|
assert.strictEqual(messenger.tick(t0 + 600_000), 1, '第二个周期应再发一次');
|
||||||
|
assert.strictEqual(manager.sent.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('未连接 / 未通过登录验证时跳过本次并顺延,不补发', () => {
|
||||||
|
const manager = fakeManager({ canSend: false });
|
||||||
|
const messenger = new ScheduledMessenger(manager, null);
|
||||||
|
const t0 = 1_000_000;
|
||||||
|
messenger.setTasks([{ id: 'a', content: 'hi', intervalSeconds: 60 }], t0);
|
||||||
|
assert.strictEqual(messenger.tick(t0 + 60_000), 0, '未就绪时不发送');
|
||||||
|
assert.deepStrictEqual(manager.sent, []);
|
||||||
|
// 顺延一个周期:即使中间恢复也不会把漏掉的补发出来
|
||||||
|
manager.canSendChat = () => true;
|
||||||
|
assert.strictEqual(messenger.tick(t0 + 61_000), 0, '不能补发上一周期的内容');
|
||||||
|
assert.strictEqual(messenger.tick(t0 + 120_000), 1);
|
||||||
|
assert.deepStrictEqual(manager.sent, ['hi']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('禁用的任务不发送', () => {
|
||||||
|
const manager = fakeManager();
|
||||||
|
const messenger = new ScheduledMessenger(manager, null);
|
||||||
|
messenger.setTasks([{ id: 'a', content: 'hi', intervalSeconds: 60, enabled: false }], 1_000_000);
|
||||||
|
assert.strictEqual(messenger.tick(1_000_000 + 120_000), 0);
|
||||||
|
assert.deepStrictEqual(manager.sent, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('多个任务各自独立计时', () => {
|
||||||
|
const manager = fakeManager();
|
||||||
|
const messenger = new ScheduledMessenger(manager, null);
|
||||||
|
const t0 = 1_000_000;
|
||||||
|
messenger.setTasks([
|
||||||
|
{ id: 'fast', content: 'A', intervalSeconds: 30 },
|
||||||
|
{ id: 'slow', content: 'B', intervalSeconds: 300 }
|
||||||
|
], t0);
|
||||||
|
assert.strictEqual(messenger.tick(t0 + 30_000), 1);
|
||||||
|
assert.strictEqual(messenger.tick(t0 + 60_000), 1);
|
||||||
|
assert.strictEqual(messenger.tick(t0 + 300_000), 2, '两个任务在同一时刻都到点');
|
||||||
|
assert.deepStrictEqual(manager.sent, ['A', 'A', 'A', 'B']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('snapshot 暴露剩余秒数供控制台展示', () => {
|
||||||
|
const manager = fakeManager();
|
||||||
|
const messenger = new ScheduledMessenger(manager, null);
|
||||||
|
messenger.setTasks([{ id: 'a', label: '恢复', content: '/u restore confirm', intervalSeconds: 300 }], Date.now());
|
||||||
|
const snap = messenger.snapshot();
|
||||||
|
assert.strictEqual(snap.length, 1);
|
||||||
|
assert.strictEqual(snap[0].label, '恢复');
|
||||||
|
assert.strictEqual(snap[0].kind, 'command');
|
||||||
|
assert.ok(snap[0].nextDueAt > 0);
|
||||||
|
assert.ok(snap[0].nextInSeconds <= 300 && snap[0].nextInSeconds >= 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setTasks 整体替换并重置计时', () => {
|
||||||
|
const manager = fakeManager();
|
||||||
|
const messenger = new ScheduledMessenger(manager, null);
|
||||||
|
const t0 = 1_000_000;
|
||||||
|
messenger.setTasks([{ id: 'a', content: 'A', intervalSeconds: 60 }], t0);
|
||||||
|
messenger.tick(t0 + 60_000);
|
||||||
|
assert.strictEqual(manager.sent.length, 1);
|
||||||
|
|
||||||
|
messenger.setTasks([{ id: 'b', content: 'B', intervalSeconds: 60 }], t0 + 60_000);
|
||||||
|
assert.strictEqual(messenger.snapshot().length, 1);
|
||||||
|
assert.strictEqual(messenger.tick(t0 + 61_000), 0, '替换后重新计时,不应立刻发送');
|
||||||
|
assert.strictEqual(messenger.tick(t0 + 200_000), 1);
|
||||||
|
assert.deepStrictEqual(manager.sent, ['A', 'B']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('start / stop 控制心跳定时器', () => {
|
||||||
|
const messenger = new ScheduledMessenger(fakeManager(), null);
|
||||||
|
messenger.start();
|
||||||
|
assert.ok(messenger._timer, 'start 后应有定时器');
|
||||||
|
const timer = messenger._timer;
|
||||||
|
messenger.start();
|
||||||
|
assert.strictEqual(messenger._timer, timer, '重复 start 不应叠加定时器');
|
||||||
|
messenger.stop();
|
||||||
|
assert.strictEqual(messenger._timer, null);
|
||||||
|
});
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
|
||||||
|
const {
|
||||||
|
SimpApiClient,
|
||||||
|
SimpApiV2Client,
|
||||||
|
BASE_URL,
|
||||||
|
BASE_URL_V2,
|
||||||
|
componentToText,
|
||||||
|
normalizeLimit,
|
||||||
|
parseUnionSummary,
|
||||||
|
parseTerritoryChunk
|
||||||
|
} = require('../src/core/simpApiClient');
|
||||||
|
|
||||||
|
test('componentToText 把公告的组件 JSON 压平成纯文本', () => {
|
||||||
|
const raw = '{"color":"gold","extra":[{"color":"aqua","text":"wans7891"},'
|
||||||
|
+ '{"color":"yellow","text":" 在坐标 (-8109, -8976)"},'
|
||||||
|
+ '{"color":"gold","text":" 创立了新的邦国 "},'
|
||||||
|
+ '{"color":"aqua","text":"「雅典维亚城邦」"}]}';
|
||||||
|
assert.strictEqual(componentToText(raw), 'wans7891 在坐标 (-8109, -8976) 创立了新的邦国 「雅典维亚城邦」');
|
||||||
|
// 嵌套 extra
|
||||||
|
const nested = '{"text":"公告: ","extra":[{"text":"外层","extra":[{"text":"内层"}]}]}';
|
||||||
|
assert.strictEqual(componentToText(nested), '公告: 外层内层');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('componentToText 对非 JSON 文本原样返回', () => {
|
||||||
|
assert.strictEqual(componentToText('普通公告'), '普通公告');
|
||||||
|
assert.strictEqual(componentToText(''), '');
|
||||||
|
assert.strictEqual(componentToText(null), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeLimit 钳制到 1..1000', () => {
|
||||||
|
assert.strictEqual(normalizeLimit(0), 1);
|
||||||
|
assert.strictEqual(normalizeLimit(50), 50);
|
||||||
|
assert.strictEqual(normalizeLimit(99999), 1000);
|
||||||
|
assert.strictEqual(normalizeLimit('abc'), 100, '非法值回退到文档默认值 100');
|
||||||
|
assert.strictEqual(normalizeLimit(undefined), 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseUnionSummary 解析邦国摘要并按方块坐标换算王城区块', () => {
|
||||||
|
const union = parseUnionSummary({
|
||||||
|
id: 12,
|
||||||
|
name: '养老院',
|
||||||
|
leader_name: 'LouieMAIN',
|
||||||
|
member_count: 6,
|
||||||
|
territory_chunk_count: 28853,
|
||||||
|
territory_block_area: 7386368,
|
||||||
|
special_output_material: 'TERRACOTTA',
|
||||||
|
capital_x: 6763.4,
|
||||||
|
capital_z: 11929.2
|
||||||
|
});
|
||||||
|
assert.strictEqual(union.name, '养老院');
|
||||||
|
assert.strictEqual(union.leader, 'LouieMAIN');
|
||||||
|
assert.strictEqual(union.memberCount, 6);
|
||||||
|
assert.strictEqual(union.chunkCount, 28853);
|
||||||
|
assert.strictEqual(union.specialOutput, 'TERRACOTTA');
|
||||||
|
// 方块坐标 ÷ 16 才是区块坐标
|
||||||
|
assert.deepStrictEqual(union.capital, { x: 422, z: 745 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseUnionSummary 缺王城坐标时返回 null 而不是 NaN', () => {
|
||||||
|
const union = parseUnionSummary({ name: '没有王城的邦国', capital_x: null, capital_z: null });
|
||||||
|
assert.strictEqual(union.capital, null);
|
||||||
|
assert.strictEqual(parseUnionSummary({}), null, '没有邦国名应视为无效');
|
||||||
|
assert.strictEqual(parseUnionSummary(null), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseTerritoryChunk 解析区块并拒绝坐标缺失的行', () => {
|
||||||
|
const chunk = parseTerritoryChunk({
|
||||||
|
x: 1,
|
||||||
|
z: -2,
|
||||||
|
union_id: 12,
|
||||||
|
union_name: '养老院',
|
||||||
|
loyalty: 100,
|
||||||
|
last_claimed_at: '2025-08-08T13:43:21.323+08:00'
|
||||||
|
});
|
||||||
|
assert.deepStrictEqual(chunk, {
|
||||||
|
x: 1,
|
||||||
|
z: -2,
|
||||||
|
unionId: 12,
|
||||||
|
unionName: '养老院',
|
||||||
|
loyalty: 100,
|
||||||
|
lastClaimedAt: '2025-08-08T13:43:21.323+08:00'
|
||||||
|
});
|
||||||
|
assert.strictEqual(parseTerritoryChunk({ x: null, z: 1 }), null);
|
||||||
|
assert.strictEqual(parseTerritoryChunk({}), null);
|
||||||
|
assert.strictEqual(parseTerritoryChunk(null), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('客户端在未配置 Key 时把错误写清楚,而不是静默失败', async () => {
|
||||||
|
const client = new SimpApiClient(null);
|
||||||
|
assert.strictEqual(client.hasApiKey(), false);
|
||||||
|
assert.strictEqual(await client.getUnionTerritoryStats('x'), null);
|
||||||
|
assert.strictEqual(client.lastError(), '未配置 API Key');
|
||||||
|
|
||||||
|
const v2 = new SimpApiV2Client(null);
|
||||||
|
// 在线列表在无 Key 时返回空结构,但 lastError 必须说明原因
|
||||||
|
assert.deepStrictEqual(await v2.getOnlinePlayers(), { online: 0, max: 0, players: [] });
|
||||||
|
assert.strictEqual(v2.lastError(), '未配置 API-V2 Key');
|
||||||
|
assert.strictEqual(await v2.getDragon(), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== API-V2(字段名以实测响应为准)====================
|
||||||
|
|
||||||
|
/** 造一个返回固定响应的假 fetch */
|
||||||
|
function stubV2(responses) {
|
||||||
|
const calls = [];
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
fetchImpl: async (url) => {
|
||||||
|
calls.push(url);
|
||||||
|
const path = url.split('/api-v2/')[1] || '';
|
||||||
|
const body = responses[path];
|
||||||
|
if (body === undefined) return { status: 404, text: async () => '{"error":"not found"}' };
|
||||||
|
if (typeof body === 'object' && body.__status) {
|
||||||
|
return { status: body.__status, text: async () => JSON.stringify(body.body) };
|
||||||
|
}
|
||||||
|
return { status: 200, text: async () => JSON.stringify(body) };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用桩 fetch 建一个 V2 客户端 */
|
||||||
|
function v2With(responses) {
|
||||||
|
const stub = stubV2(responses);
|
||||||
|
const client = new SimpApiV2Client(null);
|
||||||
|
client.setApiKey('test-key');
|
||||||
|
client.fetchImpl = stub.fetchImpl;
|
||||||
|
return { client, stub };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('getOnlinePlayers 解析 {online,max,players} 结构而不是裸数组', async () => {
|
||||||
|
const { client } = v2With({
|
||||||
|
players: {
|
||||||
|
online: 2,
|
||||||
|
max: 5000,
|
||||||
|
players: [
|
||||||
|
{ name: 'wans2023', displayName: 'wans2023', uuid: 'u1', world: 'world' },
|
||||||
|
{ name: 'xiaofan', displayName: 'xiaofan', uuid: 'u2', world: 'world' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const result = await client.getOnlinePlayers();
|
||||||
|
assert.strictEqual(result.online, 2);
|
||||||
|
assert.strictEqual(result.max, 5000);
|
||||||
|
assert.strictEqual(result.players.length, 2);
|
||||||
|
assert.strictEqual(result.players[0].name, 'wans2023');
|
||||||
|
assert.strictEqual(result.players[0].world, 'world');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getGameTime 直接使用服务端算好的时刻,不再用 tick 换算', async () => {
|
||||||
|
const { client } = v2With({
|
||||||
|
time: { world: 'world', tick: 10085, phase: 'DAY', time12: '04:05 PM', time24: '16:05' }
|
||||||
|
});
|
||||||
|
const time = await client.getGameTime();
|
||||||
|
assert.strictEqual(time.time24, '16:05');
|
||||||
|
assert.strictEqual(time.time12, '04:05 PM');
|
||||||
|
assert.strictEqual(time.phase, 'DAY');
|
||||||
|
assert.strictEqual(time.tick, 10085);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getDragon 读的是 dragonAlive 而不是 alive', async () => {
|
||||||
|
const { client } = v2With({ dragon: { dragonAlive: false, world: 'world_the_end' } });
|
||||||
|
const dragon = await client.getDragon();
|
||||||
|
assert.strictEqual(dragon.alive, false);
|
||||||
|
assert.strictEqual(dragon.world, 'world_the_end');
|
||||||
|
|
||||||
|
const alive = v2With({ dragon: { dragonAlive: true, world: 'world_the_end' } });
|
||||||
|
assert.strictEqual((await alive.client.getDragon()).alive, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getPlayerEquipment 解析 equipment 子对象,并把 damage 换算成剩余耐久', async () => {
|
||||||
|
const { client } = v2With({
|
||||||
|
'players/wans2023/equipment': {
|
||||||
|
name: 'wans2023',
|
||||||
|
displayName: 'wans2023',
|
||||||
|
uuid: 'u1',
|
||||||
|
equipment: {
|
||||||
|
mainHand: { type: 'BAMBOO_PLANKS', key: 'minecraft:bamboo_planks', amount: 9, damage: 0, maxDurability: 0, unbreakable: false },
|
||||||
|
offHand: null,
|
||||||
|
helmet: { type: 'IRON_HELMET', key: 'minecraft:iron_helmet', amount: 1, damage: 157, maxDurability: 165, unbreakable: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const equipment = await client.getPlayerEquipment('wans2023');
|
||||||
|
assert.strictEqual(equipment.player, 'wans2023');
|
||||||
|
assert.strictEqual(equipment.mainHand.key, 'minecraft:bamboo_planks');
|
||||||
|
assert.strictEqual(equipment.mainHand.amount, 9);
|
||||||
|
assert.strictEqual(equipment.mainHand.maxDurability, 0, '方块类物品没有耐久');
|
||||||
|
assert.strictEqual(equipment.offHand, null);
|
||||||
|
// damage=157 / max=165 -> 剩余 8,而不是把 157 当成剩余耐久
|
||||||
|
assert.strictEqual(equipment.helmet.damage, 157);
|
||||||
|
assert.strictEqual(equipment.helmet.maxDurability, 165);
|
||||||
|
assert.strictEqual(equipment.helmet.remainingDurability, 8);
|
||||||
|
assert.strictEqual(equipment.chestplate, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('V2 触发限流时把 retryAfterSeconds 写进错误信息', async () => {
|
||||||
|
const { client } = v2With({
|
||||||
|
dragon: { __status: 429, body: { error: '请求过于频繁,已超出限速', limit: 1, retryAfterSeconds: 16 } }
|
||||||
|
});
|
||||||
|
assert.strictEqual(await client.getDragon(), null);
|
||||||
|
assert.ok(client.lastError().includes('还需等待 16 秒'), '实际:' + client.lastError());
|
||||||
|
assert.strictEqual(client.lastHttpStatus(), 429);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('V2 的 401 提示要点明「需要单独申请密钥」', async () => {
|
||||||
|
const { client } = v2With({
|
||||||
|
time: { __status: 401, body: { error: 'missing or invalid api key' } }
|
||||||
|
});
|
||||||
|
assert.strictEqual(await client.getGameTime(), null);
|
||||||
|
assert.ok(client.lastError().includes('单独申请'), '实际:' + client.lastError());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('V2 响应走本地缓存,不会因为反复点击把配额打满', async () => {
|
||||||
|
const { client, stub } = v2With({
|
||||||
|
time: { world: 'world', tick: 1, phase: 'DAY', time12: 'x', time24: '06:00' }
|
||||||
|
});
|
||||||
|
await client.getGameTime();
|
||||||
|
await client.getGameTime();
|
||||||
|
await client.getGameTime();
|
||||||
|
assert.strictEqual(stub.calls.length, 1, '三次查询只应打一次接口');
|
||||||
|
assert.strictEqual(client.snapshot().cacheHits, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setApiKey 变更时清空缓存,避免旧 Key 的响应串味', () => {
|
||||||
|
const client = new SimpApiClient(null);
|
||||||
|
client.cache.set('info/unions', { body: '[]', expiresAt: Date.now() + 10000 });
|
||||||
|
client.setApiKey('first');
|
||||||
|
assert.strictEqual(client.cache.size, 0, '第一次设置也应从空 Key 状态切换');
|
||||||
|
client.cache.set('info/unions', { body: '[]', expiresAt: Date.now() + 10000 });
|
||||||
|
client.setApiKey('second');
|
||||||
|
assert.strictEqual(client.cache.size, 0, '换 Key 必须清缓存');
|
||||||
|
// 相同 Key 重复设置不应清缓存(否则每次配置下发都会白打接口)
|
||||||
|
client.cache.set('info/unions', { body: '[]', expiresAt: Date.now() + 10000 });
|
||||||
|
client.setApiKey('second');
|
||||||
|
assert.strictEqual(client.cache.size, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('API 与 API-V2 是两个独立的基础地址与密钥', () => {
|
||||||
|
const v1 = new SimpApiClient(null, {});
|
||||||
|
const v2 = new SimpApiV2Client(null, {});
|
||||||
|
assert.strictEqual(v1.baseUrl, BASE_URL);
|
||||||
|
assert.strictEqual(v2.baseUrl, BASE_URL_V2);
|
||||||
|
assert.notStrictEqual(v1.baseUrl, v2.baseUrl);
|
||||||
|
v1.setApiKey('key-v1');
|
||||||
|
assert.strictEqual(v1.hasApiKey(), true);
|
||||||
|
assert.strictEqual(v2.hasApiKey(), false, 'V1 的 Key 不应影响 V2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('snapshot 暴露用量与缓存统计供控制台展示', () => {
|
||||||
|
const client = new SimpApiClient(null);
|
||||||
|
const snap = client.snapshot();
|
||||||
|
assert.strictEqual(snap.hasKey, false);
|
||||||
|
assert.strictEqual(snap.requestCount, 0);
|
||||||
|
assert.strictEqual(snap.cacheHits, 0);
|
||||||
|
assert.strictEqual(snap.cachedPaths, 0);
|
||||||
|
assert.strictEqual(snap.baseUrl, BASE_URL);
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户