feat: 支持多机器人挂机、开拓日志、公屏完整日志与大地图
多机器人(src/bot/botPool.js、src/config/botsConfig.js) - .env 用 MC_BOTS 一次配置多个账号(JSON 数组或 用户名:密码 逗号分隔),未配置时回退单机器人 - 每个机器人独立连接 / 登录 / 扩地状态机,共享一份领土数据服务与运行日志 - 密码只从 .env 读取;data/bots.json 只存启用状态、备注名、自动开始与各自的配置覆盖 - 每个机器人可覆盖全局配置(策略 / 目标邦国 / 目标数量等),未覆盖字段回退全局默认 开拓日志(src/core/expansionLog.js) - 逐条记录开拓玩家、邦国、区块、方块坐标、时间与判定来源,追加写入 data/expansion-log.jsonl - 支持按机器人 / 邦国 / 时间筛选、统计、导出带 BOM 的 CSV,超过 16MB 自动轮转 游戏公屏完整日志与聊天框(src/core/chatLog.js) - 每个机器人的全部公屏 / 系统 / 动作栏消息按机器人分文件留档,自身发出的聊天与指令同样留档 - 控制台新增公屏聊天页签与消息发送框 登录验证门禁 - 游戏侧:未完成登录验证时拒绝发送指令 / 聊天,isReady() 也不放行扩地任务; 新增 server.loginConfirmTimeoutMs 兜底,避免服务器不回提示导致永远卡在未登录 - 控制台侧:新增 WEB_PASSWORD 与 /api/login、/api/session,页面登录框验证通过才允许操作 大地图(src/core/bigMapService.js) - 爬取服务器地图站点(squaremap)的瓦片图片与领土标记,一张图上看全部机器人坐标 - 坐标系对源站实测标定并用 27 个王城交叉验证;瓦片磁盘缓存,层级 0 启动预爬取、更高层级按需穿透 - 领土按 markers.json 原始多边形绘制(预编译 Path2D,even-odd 保留孔洞),叠加王城与机器人标签 配套改造 - TerritoryModel 新增 flyingChunksOf / ownChunksOf,邦国归属改为按名称显式传入, 不再依赖全局 selfStateName(多机器人会互相覆盖) - Web 接口以 /api/bots/:id/... 为主,原单机器人接口保留为默认机器人的等价别名 - 新增 scripts/check-web-ids.js 校验前端元素引用一致性 测试从 154 增至 242,全部通过。
这个提交包含在:
+19
-8
@@ -5,24 +5,35 @@
|
||||
MC_HOST=bgjq.simpfun.cn
|
||||
MC_PORT=25565
|
||||
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_PASSWORD=<your-server-password>
|
||||
|
||||
# offline=离线服(服务器用 /login 密码登录);microsoft=正版
|
||||
MC_AUTH=offline
|
||||
|
||||
# 服务器登录/注册密码(离线服必填;正版服留空)
|
||||
MC_PASSWORD=<your-server-password>
|
||||
|
||||
# ==================== Web 控制台 ====================
|
||||
WEB_HOST=127.0.0.1
|
||||
WEB_PORT=3081
|
||||
# 控制台访问令牌(留空则仅监听 127.0.0.1 且不校验;非本机访问必须设置)
|
||||
# 控制台令牌(可写进 URL 的 ?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
|
||||
# markers.json 所属世界名(对应 Dynmap 配置里的 world 目录名)
|
||||
# 世界目录名(对应地图站点上的世界名)
|
||||
DYNMAP_WORLD=minecraft_overworld
|
||||
|
||||
# ==================== SimpCore 开放 API(可选) ====================
|
||||
|
||||
+60
@@ -2,6 +2,66 @@
|
||||
|
||||
本项目遵循 [约定式提交](https://www.conventionalcommits.org/zh-hans/) 与 [语义化版本](https://semver.org/lang/zh-CN/)。
|
||||
|
||||
## [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`)
|
||||
|
||||
### 已知限制
|
||||
|
||||
- **多个机器人共享一份领土模型**:`markers.json` 是全服数据,没必要每个机器人各拉一遍;
|
||||
因此「会话新占领」区块是全局共享的,控制台地图的己方邦国显示的是最近启动的那个机器人的邦国
|
||||
- **大地图层级 3 不预爬取**:全图 80×80=6400 张瓦片约 200MB,改为按需拉取 + 永久缓存
|
||||
|
||||
## [1.0.0] - 2026-02-14
|
||||
|
||||
由 Fabric 客户端模组 AutoStore v1.3.1 重构为 Node.js + mineflayer 机器人,UI 改为网页控制台,
|
||||
|
||||
@@ -4,10 +4,11 @@ Minecraft 自动扩地机器人 —— 由原 Fabric 客户端模组 [AutoStore]
|
||||
**Node.js + mineflayer 实现,UI 从游戏内 GUI 改为网页控制台,并针对装有 Grim 反作弊的服务器做合规适配。**
|
||||
|
||||
不需要开游戏客户端,一条命令启动,浏览器打开控制台即可开始自动扩地。
|
||||
支持**同时挂多个玩家(机器人)**、**开拓日志**、**游戏公屏完整日志**与**爬取服务器地图的大地图**。
|
||||
|
||||
```
|
||||
npm install
|
||||
cp .env.example .env # 填入服务器地址与账号密码
|
||||
cp .env.example .env # 填入服务器地址与账号密码(支持多个账号)
|
||||
npm start # 打开 http://127.0.0.1:3081
|
||||
```
|
||||
|
||||
@@ -35,6 +36,143 @@ npm start # 打开 http://127.0.0.1:3081
|
||||
| 日志导出 | ✅ | 网页按钮导出到 `data/` |
|
||||
| SimpCore 开放 API 第四数据源 | ✅ | 低频轮询 + 手动全服拉取 |
|
||||
| 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%,
|
||||
即只有正确公式能让王城稳定落在陆地上。
|
||||
|
||||
### 缓存与爬取
|
||||
|
||||
| 层级 | 每瓦片方块数 | 全世界瓦片数 | 策略 |
|
||||
| --- | --- | --- | --- |
|
||||
| 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 填充保留国中孔洞),
|
||||
叠加本次会话新占领区块、王城与**全部机器人坐标(含名字标签)**
|
||||
- 操作:滚轮缩放、拖拽平移、「全世界」/「回到机器人」一键定位、「爬取地图」手动触发预爬取
|
||||
|
||||
---
|
||||
|
||||
## 登录验证门禁
|
||||
|
||||
「登录验证后才允许操作」在两侧都做了强制:
|
||||
|
||||
**游戏侧**(`src/bot/botManager.js`)
|
||||
|
||||
- `canOperate()` = 已连接 + 已进入世界 + 已完成登录验证
|
||||
- 未通过时 `sendCommand` / `sendChat` 直接拒绝并记警告,`isReady()` 也不放行扩地任务
|
||||
- 登录 / 注册流程自身用 `{ internal: true }` 绕过门禁
|
||||
- **登录确认兜底**:部分离线服登录成功后不回明确提示,`server.loginConfirmTimeoutMs`
|
||||
(默认 10 秒)内既没收到成功也没收到失败提示时按「已登录」放行,避免机器人永远卡死;
|
||||
收到「密码错误 / 登录失败」则标记 `loginFailed` 且不放行。设为 `0` 可禁用兜底
|
||||
|
||||
**控制台侧**(`src/web/server.js`)
|
||||
|
||||
- 配置 `WEB_PASSWORD`(或 `WEB_TOKEN`)后,页面弹出登录框,`POST /api/login` 校验通过才解锁
|
||||
- 所有 `/api` 与 WebSocket 握手都要求凭证(`X-Auth-Token` 头 / `?token=` / Cookie)
|
||||
- `/api/session` 与 `/api/login` 本身免鉴权,否则用户无法登录
|
||||
- 控制台顶栏显示登录状态,可随时退出登录
|
||||
|
||||
---
|
||||
|
||||
@@ -161,12 +299,13 @@ npm start
|
||||
| --- | --- |
|
||||
| `MC_HOST` / `MC_PORT` | 服务器地址与端口 |
|
||||
| `MC_VERSION` | 协议版本,默认 `1.21.4` |
|
||||
| `MC_USERNAME` | 机器人游戏名 |
|
||||
| `MC_BOTS` | **多机器人**账号列表:`用户名:密码` 逗号分隔,或 JSON 数组写法 |
|
||||
| `MC_USERNAME` / `MC_PASSWORD` | 单机器人兜底(未设置 `MC_BOTS` 时生效) |
|
||||
| `MC_AUTH` | `offline`(离线服)或 `microsoft`(正版) |
|
||||
| `MC_PASSWORD` | 服务器登录 / 注册密码,供 `{password}` 占位符使用 |
|
||||
| `WEB_HOST` / `WEB_PORT` | 控制台监听地址与端口 |
|
||||
| `WEB_TOKEN` | 控制台访问令牌;非本机访问**必须**设置(页面静态资源不校验,`/api` 与 WebSocket 握手校验) |
|
||||
| `DYNMAP_BASE` / `DYNMAP_WORLD` | 地图数据源根地址与世界目录名 |
|
||||
| `WEB_TOKEN` | 控制台令牌;非本机访问**必须**设置(页面静态资源不校验,`/api` 与 WebSocket 握手校验) |
|
||||
| `WEB_PASSWORD` | 控制台登录密码:配置后页面弹出登录框,验证通过才允许操作机器人的控制台 / 聊天框 |
|
||||
| `DYNMAP_BASE` / `DYNMAP_WORLD` | 地图站点根地址与世界目录名(领土数据 + 大地图瓦片) |
|
||||
| `SIMP_API_KEY` | SimpCore 开放 API Key(可选) |
|
||||
|
||||
---
|
||||
@@ -175,37 +314,57 @@ npm start
|
||||
|
||||
打开控制台后:
|
||||
|
||||
- **左栏**:连接状态、任务状态机、领土数据源、Grim 合规面板
|
||||
- **中栏**:领土地图(Canvas,滚轮缩放 / 拖拽平移 / 图层开关)+ 实时运行日志
|
||||
- **右栏**:分 7 个标签页的配置表单 + 手动指令输入
|
||||
- **顶栏**:机器人下拉选择、连接 / 任务徽标、控制台登录状态、开始扩地 / 停止 / 重连
|
||||
- **左栏**:机器人列表(状态 / 坐标 / 任务 / 已开拓数)、连接、任务、领土数据源、Grim 合规
|
||||
- **中栏**:
|
||||
- 地图页签 —— **大地图**(瓦片 + 领土多边形 + 全部机器人坐标)与 **领土地图**(区块级)
|
||||
- 日志页签 —— **运行日志** / **公屏聊天** / **开拓日志**
|
||||
- **右栏**:8 个标签页的配置表单(可切换「仅对当前机器人生效」)+ 控制台 / 聊天框
|
||||
|
||||
顶栏按钮:开始扩地 / 停止 / 重连。
|
||||
|
||||
地图图层:己方领土(蓝)、本次占领(绿)、飞地疏漏(红)、当前目标(黄)、机器人(白点)、王城(紫)。
|
||||
地图图层:己方领土(蓝)、本次占领(绿)、飞地疏漏(红)、当前目标(黄)、机器人(白点 / 黄点)、王城(紫)。
|
||||
|
||||
### REST / WebSocket 接口
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/status` | 机器人 / 任务 / 数据源完整状态 |
|
||||
| GET | `/api/config` | 当前配置 |
|
||||
| POST | `/api/config` | 保存并立即应用配置 |
|
||||
| GET | `/api/map?full=1` | 地图数据(`full=1` 附带全部邦国区块) |
|
||||
| GET | `/api/session` | 控制台登录状态(免鉴权,用于决定是否弹登录框) |
|
||||
| POST | `/api/login` | 控制台登录(免鉴权) |
|
||||
| GET | `/api/status` | 机器人池 / 任务 / 数据源 / 日志 / 大地图完整状态 |
|
||||
| GET | `/api/bots` | 全部机器人状态列表 |
|
||||
| GET | `/api/bots/:id/status` | 单个机器人状态 |
|
||||
| GET | `/api/bots/:id/settings` | 单个机器人的设置与生效配置 |
|
||||
| POST | `/api/bots/:id/settings` | 保存备注名 / 启用 / 自动开始 / 配置覆盖 |
|
||||
| POST | `/api/bots/:id/task/start` | 启动该机器人的扩地(body 为配置覆盖) |
|
||||
| POST | `/api/bots/:id/task/stop` | 停止该机器人的扩地 |
|
||||
| POST | `/api/bots/:id/reconnect` | 重连该机器人 |
|
||||
| POST | `/api/bots/:id/command` | 向该机器人发送游戏指令(要求已登录验证) |
|
||||
| POST | `/api/bots/:id/chat` | 向该机器人发送公屏消息(要求已登录验证) |
|
||||
| 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` | 大地图元信息(坐标系、瓦片范围、缓存进度、机器人坐标、领土标记) |
|
||||
| GET | `/api/bigmap/markers` | 大地图领土标记(多边形 + 王城) |
|
||||
| GET | `/api/bigmap/tile/:z/:x/:y` | 瓦片代理(缓存优先,避免跨域) |
|
||||
| POST | `/api/bigmap/crawl` | 手动触发批量爬取瓦片 |
|
||||
| POST | `/api/bigmap/cache/clear` | 清空瓦片缓存 |
|
||||
| GET | `/api/config` | 全局配置 |
|
||||
| POST | `/api/config` | 保存并立即应用全局配置 |
|
||||
| GET | `/api/map?full=1&bot=<id>` | 领土地图数据(`full=1` 附带全部邦国区块,`bot` 指定视角机器人) |
|
||||
| GET | `/api/logs` | 运行日志 |
|
||||
| POST | `/api/logs/export` | 导出日志到 `data/` |
|
||||
| POST | `/api/task/start` | 启动扩地(可带配置覆盖) |
|
||||
| POST | `/api/task/stop` | 停止扩地 |
|
||||
| POST | `/api/logs/export` | 导出运行日志到 `data/` |
|
||||
| POST | `/api/map/refresh` | 手动刷新地图数据 |
|
||||
| POST | `/api/api/fetch-all` | 手动拉取全服领土(重接口) |
|
||||
| POST | `/api/command` | 发送游戏指令 |
|
||||
| POST | `/api/bot/reconnect` | 重连服务器 |
|
||||
| WS | `/ws` | 实时状态推送(1s)与日志流 |
|
||||
| POST | `/api/task/start`、`/api/task/stop`、`/api/command`、`/api/bot/reconnect` | 默认机器人的等价别名(兼容单机器人用法) |
|
||||
| WS | `/ws` | 实时推送:`status`(1s)、`log`、`chat`、`expansion` |
|
||||
|
||||
---
|
||||
|
||||
## 配置项
|
||||
|
||||
配置保存在 `data/autostore.json`,可通过控制台表单修改。完整字段见
|
||||
配置保存在 `data/autostore.json`(全局默认),可通过控制台表单修改;勾选「仅对当前机器人生效」时
|
||||
写入 `data/bots.json` 作为该机器人的配置覆盖。完整字段见
|
||||
[`src/config/expansionConfig.js`](src/config/expansionConfig.js),分组如下:
|
||||
|
||||
| 分组 | 内容 |
|
||||
@@ -215,8 +374,9 @@ npm start
|
||||
| 寻路 | 疾跑 / 游泳 / 跳跃 / 挖方块 / 放方块、高度偏好、跑酷、搭塔、下落高度、液体代价 |
|
||||
| 生存 | 自动进食、自动攻击、耐久保护、战斗筛选、水域策略、自动用船 |
|
||||
| 数据 | 地图刷新间隔、SimpCore API |
|
||||
| 大地图 | 开关、世界名、预爬取层级、最大层级、覆盖半径、启动预爬取、下载并发数 |
|
||||
| Grim | 合规层全部阈值(见上) |
|
||||
| 服务器 | 开垦模式指令、登录 / 注册模板、指令节流、重连 |
|
||||
| 服务器 | 开垦模式指令、登录 / 注册模板、登录确认超时、指令节流、重连 |
|
||||
|
||||
---
|
||||
|
||||
@@ -230,36 +390,52 @@ npm run dev # 文件变更自动重启
|
||||
|
||||
### 测试覆盖
|
||||
|
||||
154 个测试用例,覆盖:
|
||||
242 个测试用例,覆盖:
|
||||
|
||||
- 区块坐标编解码(含负数与边界)
|
||||
- 领土模型:飞地识别、会话占领、API 数据合并
|
||||
- 规划器:四种策略、S 形顺序、**接壤只算己方领土**(历史 bug 回归)、他国区块切断连通
|
||||
- markers.json 解析:奇偶填充孔洞、王城换算、颜色解析
|
||||
- 领土模型:飞地识别、会话占领、按邦国名取飞地与己方区块、API 数据合并、多边形保留
|
||||
- 规划器:四种策略、S 形顺序、**接壤只算己方领土**(历史 bug 回归)、他国区块切断连通、
|
||||
按 `config.stateName` 解析己方邦国
|
||||
- markers.json 解析:奇偶填充孔洞、王城换算、颜色解析、多边形扁平化
|
||||
- 字幕 / 聊天解析:进度、带坐标成功、通用成功、模糊匹配
|
||||
- Grim 合规层:旋转限速、动作互斥、攻击冷却表、距离 / 视线校验、物理自检
|
||||
- 战斗筛选:中立生物豁免、条件敌对、猪灵金甲判定、耐久保护
|
||||
- 寻路服务:Grim 约束是否真正施加到 Movements、高度偏好目标
|
||||
- 控制器状态机:完整扩地流程、超时重试、走两步、回城、耐久保护停止、完成
|
||||
- Web 控制台:地图负载、REST 接口、令牌鉴权
|
||||
- 控制器状态机:完整扩地流程、超时重试、走两步、回城、耐久保护停止、完成、
|
||||
多机器人邦国归属隔离、开拓日志写入与去重
|
||||
- 多机器人:凭据解析(JSON / 逗号分隔 / 兜底)、机器人池组装、配置覆盖与持久化、
|
||||
密码不落配置文件、未登录时拒绝发送、全局配置下发保留各自覆盖
|
||||
- 机器人登录门禁:就绪判定、登录成功 / 失败 / 已登录提示、注册与登录自动补发、
|
||||
确认兜底超时、公屏消息类型映射
|
||||
- 开拓日志:字段记录、筛选、统计、JSONL 落盘与重启恢复、损坏行容错、环形缓冲、CSV 导出
|
||||
- 公屏日志:按机器人隔离、类型 / 关键字过滤、分文件落盘、文件名安全化、文本导出
|
||||
- 大地图:坐标系换算(含对源站实测标定的王城用例)、瓦片范围、缓存命中、批量爬取、缓存清理
|
||||
- Web 控制台:地图负载、单 / 多机器人接口、聊天与开拓日志接口、瓦片代理、
|
||||
令牌与登录鉴权、WebSocket 握手鉴权
|
||||
|
||||
### 目录结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── index.js 入口:装配模块、启动 Web 控制台、驱动 tick
|
||||
├── index.js 入口:装配模块、启动 Web 控制台、驱动多机器人 tick
|
||||
├── config/
|
||||
│ ├── expansionConfig.js 配置默认值 / 校验 / 归一化
|
||||
│ └── configStore.js 配置 JSON 持久化
|
||||
│ ├── configStore.js 全局配置 JSON 持久化
|
||||
│ ├── botsConfig.js 从 .env 解析多机器人凭据(密码只在这里读)
|
||||
│ └── botStore.js 机器人设置持久化(不含密码)
|
||||
├── core/
|
||||
│ ├── chunkPos.js 区块坐标编解码(整数键,零临时对象)
|
||||
│ ├── territoryModel.js 领土模型:区块集合、飞地、会话占领、API 合并
|
||||
│ ├── territoryDataService.js Dynmap markers.json 拉取与解析
|
||||
│ ├── territoryModel.js 领土模型:区块集合、飞地、会话占领、API 合并、多边形
|
||||
│ ├── territoryDataService.js markers.json 拉取与解析
|
||||
│ ├── simpApiClient.js SimpCore 开放 API 客户端
|
||||
│ ├── expansionPlanner.js 四种扩地策略
|
||||
│ └── subtitleChatMonitor.js 字幕 / 聊天解析与模糊匹配
|
||||
│ ├── subtitleChatMonitor.js 字幕 / 聊天解析与模糊匹配
|
||||
│ ├── expansionLog.js ★ 开拓日志(玩家 / 邦国 / 区块 / 时间 / 来源)
|
||||
│ ├── chatLog.js ★ 游戏公屏完整日志(按机器人分文件)
|
||||
│ └── bigMapService.js ★ 大地图瓦片爬取与缓存
|
||||
├── bot/
|
||||
│ ├── botManager.js 连接、登录、重连、事件归一化
|
||||
│ ├── botPool.js ★ 机器人池:同时挂多个玩家
|
||||
│ ├── botManager.js 连接、登录验证、公屏留档、指令 / 聊天发送
|
||||
│ ├── grimCompat.js ★ Grim 反作弊合规层
|
||||
│ ├── pathingService.js 寻路服务(替代 Baritone)
|
||||
│ ├── autoCombat.js 战斗筛选与耐久检查
|
||||
@@ -267,12 +443,19 @@ src/
|
||||
│ ├── autoBoat.js 自动用船(默认关闭)
|
||||
│ └── durabilityGuard.js 耐久保护
|
||||
├── controller/
|
||||
│ └── expansionController.js 扩地任务状态机
|
||||
│ └── expansionController.js 扩地任务状态机(按机器人实例化)
|
||||
├── web/
|
||||
│ ├── server.js Express + WebSocket
|
||||
│ ├── server.js Express + WebSocket + 登录鉴权
|
||||
│ └── public/ 控制台前端(原生 HTML/CSS/JS,无构建步骤)
|
||||
└── util/
|
||||
└── 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",
|
||||
"version": "1.0.0",
|
||||
"description": "AutoStore Bot —— Minecraft 自动扩地机器人(mineflayer 重写版,Web 控制台,Grim 反作弊适配)",
|
||||
"version": "1.1.0",
|
||||
"description": "AutoStore Bot —— Minecraft 自动扩地机器人(mineflayer 重写版,多机器人挂机,Web 控制台,开拓日志,大地图,Grim 反作弊适配)",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "node --watch src/index.js",
|
||||
"test": "node --test",
|
||||
"check": "node scripts/check-syntax.js"
|
||||
"check": "node scripts/check-syntax.js && node scripts/check-web-ids.js"
|
||||
},
|
||||
"keywords": [
|
||||
"minecraft",
|
||||
|
||||
@@ -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('前端元素引用自检通过');
|
||||
+194
-18
@@ -9,12 +9,15 @@ const { PathingService } = require('./pathingService');
|
||||
const { AutoEater } = require('./autoEat');
|
||||
const { AutoBoatController } = require('./autoBoat');
|
||||
const { DurabilityGuard } = require('./durabilityGuard');
|
||||
const { ChatKind } = require('../core/chatLog');
|
||||
|
||||
/** 服务器要求登录 / 注册时的提示关键词 */
|
||||
const LOGIN_PROMPT_PATTERN = /请(先)?(输入)?登录|请登录|请输入密码|login|登入/i;
|
||||
const REGISTER_PROMPT_PATTERN = /请(先)?(输入)?注册|请注册|register/i;
|
||||
const LOGIN_OK_PATTERN = /登录成功|登陆成功|注册成功|欢迎回来|成功登录/;
|
||||
const ALREADY_LOGGED_IN_PATTERN = /已登录|无需登录|你已经登录/;
|
||||
/** 登录 / 注册明确失败的关键词(命中后不再自动重试,也不放行操作) */
|
||||
const LOGIN_FAIL_PATTERN = /密码(错误|不正确|有误)|登录失败|注册失败|wrong password|invalid password/i;
|
||||
|
||||
/**
|
||||
* 机器人管理器:负责连接、登录、断线重连,并把底层 mineflayer 事件
|
||||
@@ -22,6 +25,9 @@ const ALREADY_LOGGED_IN_PATTERN = /已登录|无需登录|你已经登录/;
|
||||
*
|
||||
* 同时在这里装配 Grim 合规层、寻路服务、自动进食、自动用船、耐久保护等子模块,
|
||||
* 使它们与 bot 实例的生命周期保持一致。
|
||||
*
|
||||
* 一个 BotManager 实例对应**一个**游戏账号;同时挂多个玩家时由
|
||||
* {@link module:bot/botPool.BotPool} 创建并持有多个实例。
|
||||
*/
|
||||
class BotManager extends EventEmitter {
|
||||
/**
|
||||
@@ -30,16 +36,26 @@ class BotManager extends EventEmitter {
|
||||
* @param {number} connection.port
|
||||
* @param {string} connection.username
|
||||
* @param {string} connection.version
|
||||
* @param {string} [connection.id] 机器人 id(缺省取游戏名)
|
||||
* @param {string} [connection.label] 备注名
|
||||
* @param {string} [connection.auth]
|
||||
* @param {string} [connection.password]
|
||||
* @param {object} config 完整配置对象
|
||||
* @param {import('../util/logger').Logger} [logger]
|
||||
* @param {{chatLog?: import('../core/chatLog').ChatLog}} [options]
|
||||
*/
|
||||
constructor(connection, config, logger) {
|
||||
constructor(connection, config, logger, options) {
|
||||
super();
|
||||
const opts = options || {};
|
||||
this.connection = connection;
|
||||
this.config = config;
|
||||
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} */
|
||||
this.bot = null;
|
||||
@@ -58,6 +74,10 @@ class BotManager extends EventEmitter {
|
||||
this.state = 'idle';
|
||||
/** 是否已完成登录(离线服密码登录) */
|
||||
this.loggedIn = false;
|
||||
/** 登录 / 注册是否被服务器明确拒绝 */
|
||||
this.loginFailed = false;
|
||||
/** 登录确认兜底定时器(部分服务器不回成功提示) */
|
||||
this._loginConfirmTimer = null;
|
||||
/** 断线重连计数 */
|
||||
this.reconnectAttempts = 0;
|
||||
/** 是否由用户主动断开(主动断开不重连) */
|
||||
@@ -66,6 +86,8 @@ class BotManager extends EventEmitter {
|
||||
this._lastCommandAt = 0;
|
||||
/** 待发送的指令队列(保证最小间隔) */
|
||||
this._commandQueue = [];
|
||||
/** 待发送的聊天队列(与指令共用同一个节流器,避免刷屏) */
|
||||
this._chatQueue = [];
|
||||
this._commandTimer = null;
|
||||
/** 最近一次收到过的聊天消息(供 Web 控制台展示) */
|
||||
this.lastMessages = [];
|
||||
@@ -108,7 +130,9 @@ class BotManager extends EventEmitter {
|
||||
clearTimeout(this._reconnectTimer);
|
||||
this._reconnectTimer = null;
|
||||
}
|
||||
this._clearLoginConfirmTimer();
|
||||
this._commandQueue.length = 0;
|
||||
this._chatQueue.length = 0;
|
||||
if (this.bot) {
|
||||
try {
|
||||
this.bot.quit('AutoStore 主动断开');
|
||||
@@ -121,7 +145,8 @@ class BotManager extends EventEmitter {
|
||||
|
||||
/** @returns {boolean} 是否已进入世界并完成登录,可以执行任务 */
|
||||
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} 是否已进入世界(未强制要求登录完成) */
|
||||
@@ -162,6 +187,7 @@ class BotManager extends EventEmitter {
|
||||
bot.on('end', (reason) => {
|
||||
this.state = 'offline';
|
||||
this.loggedIn = false;
|
||||
this._clearLoginConfirmTimer();
|
||||
this._log('连接已断开:' + (reason || '未知原因'));
|
||||
this.emit('end', reason);
|
||||
this._scheduleReconnect();
|
||||
@@ -184,11 +210,14 @@ class BotManager extends EventEmitter {
|
||||
});
|
||||
|
||||
// ===== 聊天 / 字幕 / 动作栏:占领判定与登录提示的数据源 =====
|
||||
bot.on('messagestr', (message) => {
|
||||
// messagestr 的第二参为消息通道(chat / system / game_info),第四参为发言者(正版签名聊天才有)。
|
||||
bot.on('messagestr', (message, position, _jsonMsg, sender) => {
|
||||
if (!message) return;
|
||||
this._pushMessage(message);
|
||||
// 游戏公屏完整日志:所有消息(含系统公告)都留档,不只留开垦相关
|
||||
this._recordChat(message, position, sender);
|
||||
this._handleAuthPrompt(message);
|
||||
this.emit('chat', message);
|
||||
this.emit('chat', message, position);
|
||||
});
|
||||
|
||||
bot.on('title', (text, type) => {
|
||||
@@ -254,55 +283,137 @@ class BotManager extends EventEmitter {
|
||||
const server = this.config.server;
|
||||
const password = this.connection.password;
|
||||
this.loggedIn = !server.loginCommand;
|
||||
this.loginFailed = false;
|
||||
if (!server.loginCommand || !password) return;
|
||||
// 稍等片刻再发,避免与服务器的欢迎信息 / 传送抢时序
|
||||
setTimeout(() => {
|
||||
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?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录确认兜底:部分离线服登录成功后只发一句普通提示,甚至什么都不发。
|
||||
* 若在超时内既没收到成功提示也没收到失败提示,就按「已登录」放行,
|
||||
* 否则机器人会永远卡在「未登录」而无法接受控制台操作。
|
||||
*/
|
||||
_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;
|
||||
}
|
||||
}
|
||||
|
||||
/** 从聊天消息里识别服务器的登录 / 注册提示 */
|
||||
_handleAuthPrompt(message) {
|
||||
const server = this.config.server;
|
||||
const password = this.connection.password;
|
||||
if (LOGIN_FAIL_PATTERN.test(message)) {
|
||||
this.loginFailed = true;
|
||||
this.loggedIn = false;
|
||||
this._clearLoginConfirmTimer();
|
||||
this._log('登录 / 注册被服务器拒绝:' + message, 'error');
|
||||
return;
|
||||
}
|
||||
if (!password) return;
|
||||
if (LOGIN_OK_PATTERN.test(message)) {
|
||||
if (!this.loggedIn) this._log('登录成功');
|
||||
if (!this.loggedIn) this._log('登录成功', 'success');
|
||||
this.loggedIn = true;
|
||||
this.loginFailed = false;
|
||||
this._clearLoginConfirmTimer();
|
||||
return;
|
||||
}
|
||||
if (ALREADY_LOGGED_IN_PATTERN.test(message)) {
|
||||
this.loggedIn = true;
|
||||
this._clearLoginConfirmTimer();
|
||||
return;
|
||||
}
|
||||
if (server.registerCommand && REGISTER_PROMPT_PATTERN.test(message)) {
|
||||
this._log('服务器要求注册,正在注册');
|
||||
this.sendCommand(fillTemplate(server.registerCommand, password));
|
||||
this.sendCommand(fillTemplate(server.registerCommand, password), { internal: true });
|
||||
this._armLoginConfirmFallback();
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送一条聊天指令(带最小间隔节流,防刷屏被服务器或 Grim 判定异常)。
|
||||
*
|
||||
* 未完成登录验证时拒绝发送:离线服的 /login 是硬门槛,
|
||||
* 未登录就发 /u claimmode 只会被服务器静默丢弃。
|
||||
*
|
||||
* @param {string} command 不带斜杠的指令,例如 `u claimmode`
|
||||
* @param {{immediate?: boolean}} [options]
|
||||
* @param {{immediate?: boolean, internal?: boolean}} [options] internal 为登录 / 注册流程自身使用,绕过门禁
|
||||
* @returns {boolean} 是否已入队
|
||||
*/
|
||||
sendCommand(command, options) {
|
||||
if (!command) return;
|
||||
if (!command) return false;
|
||||
const opts = options || {};
|
||||
if (!opts.internal && !this.canOperate()) {
|
||||
this._log('未完成登录验证,已拒绝发送指令:/' + command, 'warn');
|
||||
return false;
|
||||
}
|
||||
if (opts.immediate) {
|
||||
this._writeCommand(command);
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
this._commandQueue.push(command);
|
||||
this._drainCommandQueue();
|
||||
return 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.canOperate()) {
|
||||
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() {
|
||||
@@ -311,10 +422,14 @@ class BotManager extends EventEmitter {
|
||||
const wait = Math.max(0, this.config.server.commandCooldownMs - (now - this._lastCommandAt));
|
||||
this._commandTimer = setTimeout(() => {
|
||||
this._commandTimer = null;
|
||||
const next = this._commandQueue.shift();
|
||||
if (next === undefined) return;
|
||||
this._writeCommand(next);
|
||||
if (this._commandQueue.length > 0) this._drainCommandQueue();
|
||||
const nextCommand = this._commandQueue.shift();
|
||||
if (nextCommand !== undefined) {
|
||||
this._writeCommand(nextCommand);
|
||||
} else {
|
||||
const nextChat = this._chatQueue.shift();
|
||||
if (nextChat !== undefined) this._writeChat(nextChat);
|
||||
}
|
||||
if (this._commandQueue.length > 0 || this._chatQueue.length > 0) this._drainCommandQueue();
|
||||
}, wait);
|
||||
if (this._commandTimer.unref) this._commandTimer.unref();
|
||||
}
|
||||
@@ -324,6 +439,8 @@ class BotManager extends EventEmitter {
|
||||
this._lastCommandAt = Date.now();
|
||||
const text = command.startsWith('/') ? command : '/' + command;
|
||||
this._log('发送指令:' + text);
|
||||
// 指令也留档:出问题时能对照「指令发出后服务器回了什么」
|
||||
this._recordOutgoing(text, ChatKind.COMMAND);
|
||||
try {
|
||||
this.bot.chat(text);
|
||||
} catch (err) {
|
||||
@@ -331,13 +448,30 @@ 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)));
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {object} 连接状态快照(供 Web 控制台展示) */
|
||||
snapshot() {
|
||||
const bot = this.bot;
|
||||
const entity = bot ? bot.entity : null;
|
||||
return {
|
||||
id: this.id,
|
||||
label: this.label,
|
||||
state: this.state,
|
||||
loggedIn: this.loggedIn,
|
||||
loginFailed: this.loginFailed,
|
||||
canOperate: this.canOperate(),
|
||||
username: this.connection.username,
|
||||
host: `${this.connection.host}:${this.connection.port}`,
|
||||
version: this.connection.version,
|
||||
@@ -366,9 +500,51 @@ class BotManager extends EventEmitter {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 +557,4 @@ function fillTemplate(template, password) {
|
||||
return String(template).replace(/\{password\}/g, password);
|
||||
}
|
||||
|
||||
module.exports = { BotManager, fillTemplate };
|
||||
module.exports = { BotManager, fillTemplate, kindOfPosition };
|
||||
|
||||
+380
@@ -0,0 +1,380 @@
|
||||
'use strict';
|
||||
|
||||
const { EventEmitter } = require('events');
|
||||
|
||||
const { BotManager } = require('./botManager');
|
||||
const { ExpansionController } = require('../controller/expansionController');
|
||||
const { normalizeConfig } = require('../config/expansionConfig');
|
||||
|
||||
/**
|
||||
* 机器人池:同时挂多个玩家(机器人)。
|
||||
*
|
||||
* 每个机器人由一对 `BotManager + ExpansionController` 组成,各自独立连接、独立登录、
|
||||
* 独立跑扩地状态机;但**共享**同一份领土数据服务(markers.json 是全局的,
|
||||
* 没必要每个机器人各拉一遍)与同一份运行日志。
|
||||
*
|
||||
* 每个机器人可以有自己的一套扩地配置覆盖(存在 `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] 机器人设置持久化
|
||||
*/
|
||||
constructor(deps) {
|
||||
super();
|
||||
this.connections = deps.connections || [];
|
||||
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;
|
||||
|
||||
/** @type {Map<string, object>} 机器人 id → 运行时条目 */
|
||||
this.entries = new Map();
|
||||
/** 已执行过「启动后自动开始扩地」的机器人(避免重连后把手动停止的任务又拉起来) */
|
||||
this._autoStarted = new Set();
|
||||
/** 机器人设置(非敏感部分) */
|
||||
this.settings = this.botStore ? this.botStore.load().bots : {};
|
||||
/** 启动时间戳 */
|
||||
this.startedAt = 0;
|
||||
|
||||
for (const connection of this.connections) {
|
||||
this._addBot(connection);
|
||||
}
|
||||
this.defaultId = this.entries.size > 0 ? this.entries.keys().next().value : '';
|
||||
}
|
||||
|
||||
// ==================== 组装 ====================
|
||||
|
||||
/**
|
||||
* 创建一个机器人条目:管理器 + 控制器,并接好事件。
|
||||
* @param {object} connection
|
||||
*/
|
||||
_addBot(connection) {
|
||||
const id = connection.id || connection.username;
|
||||
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));
|
||||
|
||||
this.entries.set(id, {
|
||||
id,
|
||||
connection,
|
||||
manager,
|
||||
controller,
|
||||
/** 该机器人实际生效的配置(全局 + 覆盖) */
|
||||
config: manager.config
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 生命周期 ====================
|
||||
|
||||
/** 连接所有已启用的机器人 */
|
||||
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.manager.connect();
|
||||
}
|
||||
}
|
||||
|
||||
/** 断开所有机器人 */
|
||||
stop() {
|
||||
for (const entry of this.entries.values()) {
|
||||
try {
|
||||
entry.controller.stop();
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
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 || '',
|
||||
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;
|
||||
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)
|
||||
};
|
||||
}
|
||||
|
||||
_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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 向某机器人发送游戏指令。
|
||||
* @param {string} id
|
||||
* @param {string} command 不带斜杠的指令
|
||||
* @returns {{ok: boolean, message: string}}
|
||||
*/
|
||||
sendCommand(id, command) {
|
||||
const entry = this.require(id);
|
||||
if (!entry.manager.canOperate()) {
|
||||
return { ok: false, message: `${entry.connection.username} 未完成登录验证,已拒绝发送指令` };
|
||||
}
|
||||
const ok = entry.manager.sendCommand(command.replace(/^\//, ''));
|
||||
return { ok, message: ok ? '已发送:' + command : '发送失败' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 向某机器人发送公屏聊天消息。
|
||||
* @param {string} id
|
||||
* @param {string} message 聊天正文(以 `/` 开头时按指令发送)
|
||||
* @returns {{ok: boolean, message: string}}
|
||||
*/
|
||||
sendChat(id, message) {
|
||||
const entry = this.require(id);
|
||||
if (!entry.manager.canOperate()) {
|
||||
return { ok: false, message: `${entry.connection.username} 未完成登录验证,已拒绝发送消息` };
|
||||
}
|
||||
const text = String(message || '').trim();
|
||||
if (!text) return { ok: false, message: '消息为空' };
|
||||
const ok = entry.manager.sendChat(text);
|
||||
return { ok, message: ok ? '已发送' : '发送失败' };
|
||||
}
|
||||
|
||||
// ==================== 状态快照 ====================
|
||||
|
||||
/**
|
||||
* @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,
|
||||
task: entry.controller.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);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { BotPool };
|
||||
@@ -0,0 +1,54 @@
|
||||
'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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { BotStore };
|
||||
@@ -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 };
|
||||
@@ -173,6 +173,24 @@ function defaultConfig() {
|
||||
blockActionsWhileRiding: true
|
||||
},
|
||||
|
||||
// ==================== 大地图(新增) ====================
|
||||
bigMap: {
|
||||
/** 是否启用大地图(爬取服务器地图站点的瓦片图片与领土标记) */
|
||||
enabled: true,
|
||||
/** 地图站点上的世界目录名;留空则回退环境变量 DYNMAP_WORLD */
|
||||
world: '',
|
||||
/** 启动时预爬取的层级(0 = 全世界一张大图,本站为 10×10=100 张瓦片) */
|
||||
zoom: 0,
|
||||
/** 允许浏览的最大层级(本站 squaremap 的 zoom.max 为 3) */
|
||||
maxZoom: 3,
|
||||
/** 以原点为中心的覆盖半径(方块),默认与服务器世界边界 ±20480 一致 */
|
||||
rangeBlocks: 20480,
|
||||
/** 启动时是否自动预爬取 */
|
||||
crawlOnStart: true,
|
||||
/** 瓦片下载并发数(对源站友好,默认 4) */
|
||||
concurrency: 4
|
||||
},
|
||||
|
||||
// ==================== 服务器交互(新增) ====================
|
||||
server: {
|
||||
/** 启用开垦模式的指令(不带斜杠),留空表示服务器自动开启 */
|
||||
@@ -181,6 +199,12 @@ function defaultConfig() {
|
||||
loginCommand: 'login {password}',
|
||||
/** 注册指令模板,`{password}` 会被替换;留空则不发送 */
|
||||
registerCommand: '',
|
||||
/**
|
||||
* 登录结果确认超时(毫秒)。
|
||||
* 部分离线服登录成功后不回明确提示,超时后按「已登录」放行,
|
||||
* 否则机器人会永远卡在未登录状态而无法接受控制台操作;设为 0 表示禁用兜底。
|
||||
*/
|
||||
loginConfirmTimeoutMs: 10000,
|
||||
/** 聊天指令最小间隔(毫秒),防刷屏 */
|
||||
commandCooldownMs: 1200,
|
||||
/** 断线后是否自动重连 */
|
||||
@@ -277,8 +301,17 @@ function validateConfig(config) {
|
||||
config.grim.maxRotationPerTickDeg = clampNumber(config.grim.maxRotationPerTickDeg, 1, 180, 8.6);
|
||||
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, 20480);
|
||||
config.bigMap.concurrency = clampInt(config.bigMap.concurrency, 1, 16, 4);
|
||||
if (typeof config.bigMap.world !== 'string') config.bigMap.world = '';
|
||||
|
||||
config.server.commandCooldownMs = clampInt(config.server.commandCooldownMs, 0, 60000, 1200);
|
||||
config.server.reconnectDelayMs = clampInt(config.server.reconnectDelayMs, 1000, 600000, 10000);
|
||||
config.server.loginConfirmTimeoutMs = clampInt(config.server.loginConfirmTimeoutMs, 0, 300000, 10000);
|
||||
|
||||
if (config.waterMode !== WATER_MODE.SWIM && config.waterMode !== WATER_MODE.BOAT) {
|
||||
config.waterMode = WATER_MODE.AVOID;
|
||||
|
||||
@@ -50,11 +50,22 @@ class ExpansionController {
|
||||
* @param {import('../bot/botManager').BotManager} deps.botManager
|
||||
* @param {import('../core/territoryDataService').TerritoryDataService} deps.dataService
|
||||
* @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) {
|
||||
this.botManager = deps.botManager;
|
||||
this.dataService = deps.dataService;
|
||||
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();
|
||||
/** @type {import('../core/subtitleChatMonitor').SubtitleChatMonitor} */
|
||||
this.monitor = new SubtitleChatMonitor({
|
||||
@@ -111,6 +122,13 @@ class ExpansionController {
|
||||
this.combatTargetEntityId = -1;
|
||||
/** 当前是否正持续按住跳跃键(自动游泳用) */
|
||||
this.holdingJump = false;
|
||||
/**
|
||||
* 本机器人实际开拓的邦国名(start() 时解析并固化)。
|
||||
*
|
||||
* 多机器人场景下每个机器人可能属于不同邦国,绝不能依赖领土模型的全局
|
||||
* selfStateName(那会被最后启动的机器人覆盖)。
|
||||
*/
|
||||
this.stateName = '';
|
||||
/** 是否已经发送过开垦模式指令 */
|
||||
this._claimModeSent = false;
|
||||
/** @type {((status: object) => void)|null} 状态变化回调(Web 控制台用) */
|
||||
@@ -177,34 +195,36 @@ class ExpansionController {
|
||||
this.playerChunk = this._playerChunk();
|
||||
|
||||
// ===== 识别己方邦国 =====
|
||||
// 解析结果固化到 this.stateName,并写回本次任务配置,
|
||||
// 使规划器不再依赖领土模型的全局 selfStateName(多机器人会互相覆盖)。
|
||||
const model = this.dataService.getModel();
|
||||
let resolvedName = '';
|
||||
if (!this.config.stateName) {
|
||||
const auto = model.findContainingState(this.playerChunk);
|
||||
if (auto) {
|
||||
model.setSelfStateName(auto.name);
|
||||
this.dataService.setSelectedStateName(auto.name);
|
||||
resolvedName = auto.name;
|
||||
} else {
|
||||
// 兜底:通过 SimpCore API 按玩家名识别
|
||||
const detected = await this.dataService.detectStateByPlayer(bm.connection.username);
|
||||
if (detected) {
|
||||
this.dataService.getModel().setSelfStateName(detected);
|
||||
this.dataService.setSelectedStateName(detected);
|
||||
resolvedName = detected;
|
||||
} else {
|
||||
this.status = '无法自动识别邦国,请在控制台手动选择';
|
||||
this.state = State.IDLE;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else if (!model.state(this.config.stateName)) {
|
||||
this.status = '所选邦国不存在:' + this.config.stateName;
|
||||
this.state = State.IDLE;
|
||||
return false;
|
||||
} else {
|
||||
const model2 = this.dataService.getModel();
|
||||
if (!model2.state(this.config.stateName)) {
|
||||
this.status = '所选邦国不存在:' + this.config.stateName;
|
||||
this.state = State.IDLE;
|
||||
return false;
|
||||
}
|
||||
model2.setSelfStateName(this.config.stateName);
|
||||
this.dataService.setSelectedStateName(this.config.stateName);
|
||||
resolvedName = this.config.stateName;
|
||||
}
|
||||
this.stateName = resolvedName;
|
||||
this.config = Object.assign({}, requestedConfig, { stateName: resolvedName });
|
||||
// 全局选中邦国跟随最近启动的机器人,供控制台地图与数据源展示使用
|
||||
this.dataService.setSelectedStateName(resolvedName);
|
||||
|
||||
// ===== 复位任务状态 =====
|
||||
this.completedClaims = 0;
|
||||
@@ -231,7 +251,7 @@ class ExpansionController {
|
||||
}
|
||||
|
||||
this.setState(State.PLANNING, '正在生成扩张计划');
|
||||
this._log('开始自动扩地,邦国:' + this.dataService.getModel().selfStateNameValue());
|
||||
this._log(`开始自动扩地,邦国:` + this.stateName);
|
||||
await this.plan();
|
||||
return true;
|
||||
}
|
||||
@@ -669,7 +689,7 @@ class ExpansionController {
|
||||
*/
|
||||
_isAdjacentToOwn(chunk) {
|
||||
const model = this.dataService.getModel();
|
||||
const self = model.selfState();
|
||||
const self = model.state(this.stateName);
|
||||
const ownChunks = self ? self.chunks : null;
|
||||
const sessionClaims = model.sessionClaimsSet();
|
||||
const x = chunk.x;
|
||||
@@ -698,7 +718,7 @@ class ExpansionController {
|
||||
*/
|
||||
_ringDistance(chunk, model) {
|
||||
let min = Infinity;
|
||||
const self = model.selfState();
|
||||
const self = model.state(this.stateName);
|
||||
if (self) {
|
||||
for (const k of self.chunks) {
|
||||
const pos = chunkPos.decode(k);
|
||||
@@ -740,8 +760,10 @@ class ExpansionController {
|
||||
/**
|
||||
* 开垦成功(带具体坐标):把新占领区块加入模型与数据服务,累计完成数并进入 SUCCESS 状态。
|
||||
* @param {{x: number, z: number}} chunk
|
||||
* @param {number} [blockX] 方块坐标 X(服务器回报值)
|
||||
* @param {number} [blockZ] 方块坐标 Z(服务器回报值)
|
||||
*/
|
||||
onSuccess(chunk) {
|
||||
onSuccess(chunk, blockX, blockZ) {
|
||||
if (!this.isRunning()) return;
|
||||
const target = chunk || this.target;
|
||||
if (!target) return;
|
||||
@@ -749,8 +771,8 @@ class ExpansionController {
|
||||
// 去重:同一区块只计一次
|
||||
if (this.claimedSet.has(key)) return;
|
||||
this.claimedSet.add(key);
|
||||
this.dataService.getModel().addSessionClaim(target);
|
||||
this.dataService.addSessionClaim(target);
|
||||
this.dataService.getModel().addSessionClaim(target, this.stateName);
|
||||
this.dataService.addSessionClaim(target, this.stateName);
|
||||
// 占领成功后立即触发一次地图刷新(请求侧已带缓存击穿),缩短「会话占领等待确认」窗口
|
||||
this.dataService.refreshAsync();
|
||||
this.completedClaims++;
|
||||
@@ -758,6 +780,7 @@ class ExpansionController {
|
||||
this.target = target;
|
||||
this.lastProgress = 100;
|
||||
this._log(`占领成功 ${target.x},${target.z}`);
|
||||
this._recordClaim(target, blockX, blockZ, 'chat');
|
||||
this.setState(State.SUCCESS, `疆土已开拓 ${target.x},${target.z}`);
|
||||
}
|
||||
|
||||
@@ -774,16 +797,40 @@ class ExpansionController {
|
||||
// 去重:同一区块只计一次
|
||||
if (this.claimedSet.has(key)) return;
|
||||
this.claimedSet.add(key);
|
||||
this.dataService.getModel().addSessionClaim(this.target);
|
||||
this.dataService.addSessionClaim(this.target);
|
||||
this.dataService.getModel().addSessionClaim(this.target, this.stateName);
|
||||
this.dataService.addSessionClaim(this.target, this.stateName);
|
||||
this.dataService.refreshAsync();
|
||||
this.completedClaims++;
|
||||
this.consecutiveTimeoutMillis = 0;
|
||||
this.lastProgress = 100;
|
||||
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}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入开拓日志:详细记录开拓玩家、区块、时间与来源。
|
||||
*
|
||||
* @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 +990,9 @@ class ExpansionController {
|
||||
}
|
||||
|
||||
_log(message, level) {
|
||||
if (this.logger) this.logger.log(message, level);
|
||||
if (!this.logger) return;
|
||||
// 多机器人共用一份运行日志:统一加机器人名前缀,便于区分来源
|
||||
this.logger.log(`[${this.botName}] ${message}`, level);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -951,6 +1000,9 @@ class ExpansionController {
|
||||
*/
|
||||
snapshot() {
|
||||
return {
|
||||
botId: this.botId,
|
||||
botName: this.botName,
|
||||
stateName: this.stateName,
|
||||
state: this.state,
|
||||
status: this.status,
|
||||
running: this.isRunning(),
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
'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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算覆盖 `[-rangeBlocks, rangeBlocks)` 区间所需的瓦片下标范围(闭区间)。
|
||||
*
|
||||
* @param {number} zoom 层级
|
||||
* @param {number} [rangeBlocks] 覆盖半径(方块),缺省取配置
|
||||
* @returns {{minX: number, maxX: number, minZ: number, maxZ: number, count: number}}
|
||||
*/
|
||||
tileRange(zoom, rangeBlocks) {
|
||||
const radius = Number.isFinite(rangeBlocks)
|
||||
? Math.abs(rangeBlocks)
|
||||
: (this.config && this.config.bigMap ? this.config.bigMap.rangeBlocks : DEFAULT_RANGE_BLOCKS);
|
||||
const perTile = this.blocksPerTile(zoom);
|
||||
const minX = Math.floor(-radius / perTile);
|
||||
const maxX = Math.ceil(radius / perTile) - 1;
|
||||
const minZ = minX;
|
||||
const maxZ = maxX;
|
||||
return { minX, maxX, minZ, maxZ, count: (maxX - minX + 1) * (maxZ - minZ + 1) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 瓦片覆盖的方块区间(闭开区间 `[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}} [options] force=true 时忽略磁盘缓存重新下载
|
||||
* @returns {Promise<object>} 爬取结果统计
|
||||
*/
|
||||
async crawlAll(zoom, options) {
|
||||
if (this.crawl.running) return this.snapshot().crawl;
|
||||
const z = clampZoom(Number.isFinite(zoom) ? zoom : (this.config.bigMap.zoom || 0), this.maxZoom);
|
||||
const opts = options || {};
|
||||
const range = this.tileRange(z);
|
||||
this._crawlAbort = false;
|
||||
this.crawl = {
|
||||
running: true,
|
||||
zoom: z,
|
||||
total: range.count,
|
||||
done: 0,
|
||||
failed: 0,
|
||||
startedAt: Date.now(),
|
||||
finishedAt: 0,
|
||||
lastError: ''
|
||||
};
|
||||
if (this.logger) {
|
||||
this.logger.log(`开始爬取大地图瓦片:层级 ${z},范围 ${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;
|
||||
}
|
||||
|
||||
// ==================== 状态 ====================
|
||||
|
||||
/**
|
||||
* @returns {object} 大地图状态快照(供控制台与前端渲染使用)
|
||||
*/
|
||||
snapshot() {
|
||||
const zoom = this.config && this.config.bigMap ? this.config.bigMap.zoom : 0;
|
||||
const range = this.tileRange(zoom);
|
||||
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: this.config && this.config.bigMap ? this.config.bigMap.rangeBlocks : DEFAULT_RANGE_BLOCKS,
|
||||
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 {object} config 扩地配置
|
||||
* @param {{x: number, z: number}} playerChunk 玩家当前所在区块
|
||||
* @returns {Array<{x: number, z: number}>} 建议占领的区块列表(按优先级排序)
|
||||
*/
|
||||
generate(model, config, playerChunk) {
|
||||
const stateName = resolveStateName(model, config);
|
||||
let result;
|
||||
if (config.strategy === 1) {
|
||||
result = this.generateHolePriority(model, config, playerChunk);
|
||||
result = this.generateHolePriority(model, config, playerChunk, stateName);
|
||||
} else if (config.strategy === 4) {
|
||||
result = this.generatePerimeter(model, config, playerChunk);
|
||||
result = this.generatePerimeter(model, config, playerChunk, stateName);
|
||||
} 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) {
|
||||
result = this.generateRectangle(model, config, playerChunk);
|
||||
result = this.generateRectangle(model, config, playerChunk, stateName);
|
||||
} 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}>}
|
||||
*/
|
||||
prefixFlyingChunks(model, config, playerChunk, planned) {
|
||||
const flying = model.selfFlyingChunks();
|
||||
prefixFlyingChunks(model, config, playerChunk, planned, stateName) {
|
||||
const flying = model.flyingChunksOf(stateName || model.selfStateNameValue());
|
||||
if (!flying || flying.size === 0) return planned;
|
||||
const limit = config.targetCount > 0
|
||||
? config.targetCount
|
||||
@@ -79,8 +83,8 @@ class ExpansionPlanner {
|
||||
* 候选必须未被占领、与己方领土相邻、且在配置区域内。
|
||||
* 先排矩形内部的空洞,再排外部边界,最后按「离玩家近、己方邻居多」排序。
|
||||
*/
|
||||
generateHolePriority(model, config, playerChunk) {
|
||||
const actualOwn = this.ownChunks(model);
|
||||
generateHolePriority(model, config, playerChunk, stateName) {
|
||||
const actualOwn = this.ownChunks(model, stateName);
|
||||
const allClaimed = model.allClaimedChunks();
|
||||
if (actualOwn.size === 0) return [];
|
||||
const limit = config.targetCount > 0 ? config.targetCount : 256;
|
||||
@@ -135,12 +139,12 @@ class ExpansionPlanner {
|
||||
* 收集所有与己方领土相邻的合法区块,以领土质心(或王城)为中心按方位角排序,
|
||||
* 使扩张点均匀分布在领土四周。支持多圈扩张。
|
||||
*/
|
||||
generatePerimeter(model, config, playerChunk) {
|
||||
const actualOwn = this.ownChunks(model);
|
||||
generatePerimeter(model, config, playerChunk, stateName) {
|
||||
const actualOwn = this.ownChunks(model, stateName);
|
||||
const allClaimed = model.allClaimedChunks();
|
||||
const plannedOwn = new Set(actualOwn);
|
||||
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 rings = config.maxRings > 0 ? config.maxRings : 1;
|
||||
const result = [];
|
||||
@@ -169,8 +173,8 @@ class ExpansionPlanner {
|
||||
* 收集所有与己方领土相邻的合法区块,以玩家当前位置为中心,
|
||||
* 按与玩家的曼哈顿距离由近到远排序(距离相同者优先己方邻居多的),围绕玩家一圈圈向外铺开。
|
||||
*/
|
||||
generateAroundPlayer(model, config, playerChunk) {
|
||||
const actualOwn = this.ownChunks(model);
|
||||
generateAroundPlayer(model, config, playerChunk, stateName) {
|
||||
const actualOwn = this.ownChunks(model, stateName);
|
||||
const allClaimed = model.allClaimedChunks();
|
||||
if (actualOwn.size === 0) return [];
|
||||
const limit = config.targetCount > 0 ? config.targetCount : 64;
|
||||
@@ -195,8 +199,8 @@ class ExpansionPlanner {
|
||||
* 每步挑选评分最优的区块:距离近、方向一致、邻居多、形状规整者优先。
|
||||
* 当扩张形状长宽比超过 2:1 时切换方向补短边,避免领土过于细长。
|
||||
*/
|
||||
generateLine(model, config, playerChunk) {
|
||||
const actualOwn = this.ownChunks(model);
|
||||
generateLine(model, config, playerChunk, stateName) {
|
||||
const actualOwn = this.ownChunks(model, stateName);
|
||||
const allClaimed = model.allClaimedChunks();
|
||||
const plannedOwn = new Set(actualOwn);
|
||||
const candidates = new Set();
|
||||
@@ -247,8 +251,8 @@ class ExpansionPlanner {
|
||||
* - 首条扫描线的行内起始端同样选取离王城更近的一端;
|
||||
* - 每个入选区块必须与「己方领土(含本次已选)」四正方向接壤,保证服务器接受开拓。
|
||||
*/
|
||||
generateRectangle(model, config, playerChunk) {
|
||||
const actualOwn = this.ownChunks(model);
|
||||
generateRectangle(model, config, playerChunk, stateName) {
|
||||
const actualOwn = this.ownChunks(model, stateName);
|
||||
const allClaimed = model.allClaimedChunks();
|
||||
if (actualOwn.size === 0) return [];
|
||||
const minX = config.regionMinX;
|
||||
@@ -256,7 +260,7 @@ class ExpansionPlanner {
|
||||
const minZ = config.regionMinZ;
|
||||
const maxZ = config.regionMaxZ;
|
||||
const limit = config.targetCount > 0 ? config.targetCount : Infinity;
|
||||
const capital = this.capital(model, playerChunk);
|
||||
const capital = this.capital(model, stateName, playerChunk);
|
||||
|
||||
// ===== 1. 四条边到王城的距离(点到线段距离的平方),选最近者为起始边 =====
|
||||
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) };
|
||||
}
|
||||
|
||||
/** 汇总「己方」区块:本次会话新占领的区块 + 己方邦国已拥有的区块 */
|
||||
ownChunks(model) {
|
||||
const result = new Set(model.sessionClaimsSet());
|
||||
const self = model.selfState();
|
||||
if (self) {
|
||||
for (const k of self.chunks) result.add(k);
|
||||
}
|
||||
return result;
|
||||
/** 汇总「己方」区块:本次会话新占领的区块 + 指定邦国已拥有的区块 */
|
||||
ownChunks(model, stateName) {
|
||||
return model.ownChunksOf(stateName || model.selfStateNameValue());
|
||||
}
|
||||
|
||||
/** 获取己方邦国的王城位置;未识别邦国时回退到 fallback */
|
||||
capital(model, fallback) {
|
||||
const self = model.selfState();
|
||||
/** 获取指定邦国的王城位置;未识别邦国时回退到 fallback */
|
||||
capital(model, stateName, fallback) {
|
||||
const self = model.state(stateName || model.selfStateNameValue());
|
||||
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 };
|
||||
|
||||
@@ -367,10 +367,11 @@ class TerritoryDataService {
|
||||
/**
|
||||
* 记录一次会话内新占领的区块。
|
||||
* @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.model.addSessionClaim(chunk);
|
||||
this.model.addSessionClaim(chunk, stateName);
|
||||
}
|
||||
|
||||
/** @returns {object} 数据源状态快照(供 Web 控制台展示) */
|
||||
@@ -428,13 +429,15 @@ function parseMarkers(json) {
|
||||
const claimColors = new Map();
|
||||
/** @type {Map<string, {x: number, z: number}>} */
|
||||
const capitals = new Map();
|
||||
/** @type {Map<string, number[][]>} 邦国名 → 扁平多边形列表(大地图直接绘制用) */
|
||||
const claimPolygons = new Map();
|
||||
|
||||
for (const layer of root) {
|
||||
if (!layer || typeof layer !== 'object') continue;
|
||||
const markers = layer.markers;
|
||||
if (!Array.isArray(markers)) continue;
|
||||
if (layer.id === 'union_claims') {
|
||||
for (const marker of markers) parseClaimMarker(marker, claims, claimColors);
|
||||
for (const marker of markers) parseClaimMarker(marker, claims, claimColors, claimPolygons);
|
||||
} else if (layer.id === 'union_capitals') {
|
||||
for (const marker of markers) parseCapitalMarker(marker, capitals);
|
||||
}
|
||||
@@ -448,7 +451,13 @@ function parseMarkers(json) {
|
||||
capital = first.done ? { x: 0, z: 0 } : chunkPos.decode(first.value);
|
||||
}
|
||||
const color = claimColors.has(name) ? claimColors.get(name) : DEFAULT_COLOR;
|
||||
states.set(name, { name, capital, chunks: chunkKeys, color });
|
||||
states.set(name, {
|
||||
name,
|
||||
capital,
|
||||
chunks: chunkKeys,
|
||||
color,
|
||||
polygons: claimPolygons.get(name) || []
|
||||
});
|
||||
}
|
||||
return new TerritoryModel(states);
|
||||
}
|
||||
@@ -456,8 +465,11 @@ function parseMarkers(json) {
|
||||
/**
|
||||
* 解析一个领土多边形 marker:从弹窗文本提取邦国名,
|
||||
* 将多边形覆盖的区块按 even-odd 规则填充进该邦国的区块集合,并记录颜色。
|
||||
*
|
||||
* 同时把原始多边形(方块坐标,扁平为 `[x1, z1, x2, z2, ...]`)保留下来,
|
||||
* 供大地图按几何直接绘制——低缩放级别下逐个区块画矩形既慢又糊。
|
||||
*/
|
||||
function parseClaimMarker(marker, claims, colors) {
|
||||
function parseClaimMarker(marker, claims, colors, polygons) {
|
||||
if (!marker || typeof marker !== 'object') return;
|
||||
const popup = typeof marker.popup === 'string' ? marker.popup : '';
|
||||
const matcher = CLAIM_PATTERN.exec(popup);
|
||||
@@ -471,12 +483,36 @@ function parseClaimMarker(marker, claims, colors) {
|
||||
}
|
||||
// 一个 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)) {
|
||||
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:从弹窗文本提取邦国名,记录王城的区块坐标 */
|
||||
function parseCapitalMarker(marker, capitals) {
|
||||
if (!marker || typeof marker !== 'object') return;
|
||||
@@ -613,6 +649,7 @@ module.exports = {
|
||||
fillPolygonEvenOdd,
|
||||
pointInPolygon,
|
||||
extractPolygons,
|
||||
flattenPolygon,
|
||||
parseColor,
|
||||
buildMarkersUrl,
|
||||
CLAIM_CONFIRM_GRACE_MS,
|
||||
|
||||
+41
-7
@@ -19,10 +19,12 @@ const API_DEFAULT_COLOR = 0xff43de;
|
||||
|
||||
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) {
|
||||
/** @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 || []);
|
||||
/** @type {Set<number>} 所有邦国已占领区块的并集 */
|
||||
this.allClaimed = new Set();
|
||||
@@ -126,7 +128,36 @@ class TerritoryModel {
|
||||
|
||||
/** @returns {Set<number>} 己方邦国的飞地(疏漏区块);未选择邦国时为空集 */
|
||||
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>>} 各邦国飞地区块(懒计算并缓存) */
|
||||
@@ -176,14 +207,16 @@ class TerritoryModel {
|
||||
/**
|
||||
* 记录一次会话内新占领的区块:加入会话集合与全局已占领集合,
|
||||
* 并同步更新己方邦国的区块集合。
|
||||
*
|
||||
* @param {{x: number, z: number}} chunk
|
||||
* @param {string} [stateName] 归属邦国名;缺省时使用全局 selfStateName
|
||||
*/
|
||||
addSessionClaim(chunk) {
|
||||
addSessionClaim(chunk, stateName) {
|
||||
const k = chunkPos.key(chunk.x, chunk.z);
|
||||
this.sessionClaims.add(k);
|
||||
this.allClaimed.add(k);
|
||||
this._invalidateFlying();
|
||||
const self = this.states.get(this.selfStateName);
|
||||
const self = this.states.get(stateName || this.selfStateName);
|
||||
if (self) {
|
||||
self.chunks.add(k);
|
||||
}
|
||||
@@ -224,7 +257,7 @@ class TerritoryModel {
|
||||
const first = chunks.values().next();
|
||||
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 {
|
||||
const chunks = new Set(existing.chunks);
|
||||
for (const k of chunkKeys) chunks.add(k);
|
||||
@@ -232,7 +265,8 @@ class TerritoryModel {
|
||||
if (apiCapital && (!capital || (capital.x === 0 && capital.z === 0))) {
|
||||
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 覆盖的邦国原样保留
|
||||
|
||||
+54
-30
@@ -7,11 +7,15 @@ require('dotenv').config({ path: path.join(__dirname, '..', '.env') });
|
||||
const chalk = require('chalk');
|
||||
|
||||
const { ConfigStore } = require('./config/configStore');
|
||||
const { BotStore } = require('./config/botStore');
|
||||
const { parseBotsFromEnv } = require('./config/botsConfig');
|
||||
const { normalizeConfig } = require('./config/expansionConfig');
|
||||
const { Logger } = require('./util/logger');
|
||||
const { BotManager } = require('./bot/botManager');
|
||||
const { BotPool } = require('./bot/botPool');
|
||||
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');
|
||||
|
||||
/** 控制器 tick 间隔(毫秒),50ms ≈ 20 TPS */
|
||||
@@ -21,16 +25,13 @@ async function main() {
|
||||
const logger = new Logger({ echo: true });
|
||||
logger.log(chalk.bold('==== AutoStore Bot 启动 ===='));
|
||||
|
||||
// ==================== 连接参数(全部来自环境变量,密钥不落代码) ====================
|
||||
const connection = {
|
||||
host: process.env.MC_HOST || 'bgjq.simpfun.cn',
|
||||
port: Number.parseInt(process.env.MC_PORT || '25565', 10),
|
||||
username: process.env.MC_USERNAME || 'AutoStoreBot',
|
||||
version: process.env.MC_VERSION || '1.21.4',
|
||||
auth: process.env.MC_AUTH || 'offline',
|
||||
password: process.env.MC_PASSWORD || ''
|
||||
};
|
||||
if (!Number.isFinite(connection.port) || connection.port <= 0) connection.port = 25565;
|
||||
const dataDir = path.join(__dirname, '..', 'data');
|
||||
|
||||
// ==================== 机器人凭据(全部来自环境变量,密钥不落代码 / 不落配置文件) ====================
|
||||
const connections = parseBotsFromEnv(process.env);
|
||||
if (connections.length === 0) {
|
||||
logger.log('未配置任何机器人:请在 .env 里设置 MC_BOTS 或 MC_USERNAME / MC_PASSWORD', 'error');
|
||||
}
|
||||
|
||||
// ==================== 配置 ====================
|
||||
const configStore = new ConfigStore();
|
||||
@@ -40,27 +41,38 @@ async function main() {
|
||||
if (!config.apiKey && process.env.SIMP_API_KEY) config.apiKey = process.env.SIMP_API_KEY.trim();
|
||||
|
||||
logger.log(`配置文件:${configStore.filePath}`);
|
||||
logger.log(`服务器:${connection.host}:${connection.port} / ${connection.username} / ${connection.version} / ${connection.auth}`);
|
||||
logger.log(`机器人数量:${connections.length}`
|
||||
+ (connections.length > 0 ? '(' + connections.map((c) => c.username).join('、') + ')' : ''));
|
||||
|
||||
// ==================== 各模块 ====================
|
||||
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 controller = new ExpansionController({ botManager, dataService, logger });
|
||||
|
||||
// 寻路事件转发给控制器(与 Baritone 的 PathEvent 语义一致)
|
||||
botManager.on('modulesReady', () => {
|
||||
if (botManager.pathing) {
|
||||
botManager.pathing.onPathEvent = (event, detail) => controller.onPathEvent(event, detail);
|
||||
}
|
||||
const bigMap = new BigMapService(config, logger, {
|
||||
baseUrl: process.env.DYNMAP_BASE,
|
||||
world: config.bigMap.world || process.env.DYNMAP_WORLD,
|
||||
cacheDir: path.join(dataDir, 'mapcache')
|
||||
});
|
||||
const botStore = new BotStore();
|
||||
const pool = new BotPool({
|
||||
connections,
|
||||
config,
|
||||
logger,
|
||||
dataService,
|
||||
expansionLog,
|
||||
chatLog,
|
||||
botStore
|
||||
});
|
||||
|
||||
// ==================== Web 控制台 ====================
|
||||
const webConsole = new WebConsole(
|
||||
{
|
||||
botManager,
|
||||
controller,
|
||||
pool,
|
||||
dataService,
|
||||
logger,
|
||||
expansionLog,
|
||||
chatLog,
|
||||
bigMap,
|
||||
getConfig: () => config,
|
||||
applyConfig: (next) => applyConfig(next)
|
||||
},
|
||||
@@ -68,7 +80,8 @@ async function main() {
|
||||
host: process.env.WEB_HOST || '127.0.0.1',
|
||||
port: Number.parseInt(process.env.WEB_PORT || '3081', 10),
|
||||
token: process.env.WEB_TOKEN || '',
|
||||
exportDir: path.join(__dirname, '..', 'data')
|
||||
password: process.env.WEB_PASSWORD || '',
|
||||
exportDir: dataDir
|
||||
}
|
||||
);
|
||||
|
||||
@@ -80,11 +93,14 @@ async function main() {
|
||||
function applyConfig(next) {
|
||||
config = normalizeConfig(next, config);
|
||||
configStore.save(config);
|
||||
botManager.applyConfig(config);
|
||||
controller.config = config;
|
||||
pool.applyGlobalConfig(config);
|
||||
dataService.config = config;
|
||||
dataService.applyApiConfig(config.apiEnabled, config.apiKey, config.apiRefreshIntervalSeconds);
|
||||
dataService.reschedule(config.refreshIntervalSeconds);
|
||||
if (bigMap) {
|
||||
bigMap.config = config;
|
||||
bigMap.world = config.bigMap.world || process.env.DYNMAP_WORLD || bigMap.world;
|
||||
}
|
||||
logger.log('配置已更新并应用');
|
||||
return config;
|
||||
}
|
||||
@@ -92,11 +108,20 @@ async function main() {
|
||||
// ==================== 启动 ====================
|
||||
dataService.applyApiConfig(config.apiEnabled, config.apiKey, config.apiRefreshIntervalSeconds);
|
||||
dataService.start(config.refreshIntervalSeconds);
|
||||
botManager.connect();
|
||||
pool.start();
|
||||
|
||||
// 大地图:启动时预爬取低层级瓦片(全世界一张大图),保证控制台打开就能看到
|
||||
if (config.bigMap.enabled && config.bigMap.crawlOnStart) {
|
||||
setTimeout(() => {
|
||||
bigMap.crawlAll(config.bigMap.zoom).catch((err) => {
|
||||
logger.log('大地图预爬取失败:' + (err && err.message ? err.message : String(err)), 'warn');
|
||||
});
|
||||
}, 3000).unref?.();
|
||||
}
|
||||
|
||||
const tickTimer = setInterval(() => {
|
||||
try {
|
||||
controller.tick();
|
||||
pool.tick();
|
||||
} catch (err) {
|
||||
logger.error('tick 异常:' + (err && err.stack ? err.stack : err));
|
||||
}
|
||||
@@ -114,8 +139,7 @@ async function main() {
|
||||
shuttingDown = true;
|
||||
logger.log(`收到 ${signal},正在退出…`);
|
||||
clearInterval(tickTimer);
|
||||
controller.stop();
|
||||
botManager.disconnect();
|
||||
pool.stop();
|
||||
dataService.stop();
|
||||
try {
|
||||
await webConsole.stop();
|
||||
|
||||
+1039
-127
文件差异内容过多而无法显示
加载差异
+114
-15
@@ -12,12 +12,17 @@
|
||||
<span class="logo">AS</span>
|
||||
<div>
|
||||
<h1>AutoStore Bot</h1>
|
||||
<p>Minecraft 自动扩地机器人 · mineflayer · Grim 合规</p>
|
||||
<p>多机器人自动扩地 · mineflayer · Grim 合规 · 大地图</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<label class="inline-field">
|
||||
当前机器人
|
||||
<select id="botSelect" class="input input-sm"></select>
|
||||
</label>
|
||||
<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>
|
||||
<button id="btnStart" class="btn btn-primary">开始扩地</button>
|
||||
<button id="btnStop" class="btn btn-danger">停止</button>
|
||||
<button id="btnReconnect" class="btn">重连</button>
|
||||
@@ -27,6 +32,17 @@
|
||||
<main class="layout">
|
||||
<!-- ==================== 左栏:状态面板 ==================== -->
|
||||
<section class="column col-left">
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h2>机器人(<span id="botCount">0</span>)</h2>
|
||||
<div class="row">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>连接</h2>
|
||||
<dl class="kv">
|
||||
@@ -37,8 +53,14 @@
|
||||
<dt>所在区块</dt><dd id="kChunk">-</dd>
|
||||
<dt>生命 / 饥饿</dt><dd id="kHealth">-</dd>
|
||||
<dt>状态</dt><dd id="kBotState">-</dd>
|
||||
<dt>登录验证</dt><dd id="kLogin">-</dd>
|
||||
<dt>重连次数</dt><dd id="kReconnect">0</dd>
|
||||
</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 class="card">
|
||||
@@ -46,6 +68,7 @@
|
||||
<dl class="kv">
|
||||
<dt>状态机</dt><dd id="kTaskState">IDLE</dd>
|
||||
<dt>描述</dt><dd id="kTaskStatus">未启动</dd>
|
||||
<dt>开拓邦国</dt><dd id="kTaskStateName">-</dd>
|
||||
<dt>当前目标</dt><dd id="kTarget">-</dd>
|
||||
<dt>队列</dt><dd id="kQueue">0</dd>
|
||||
<dt>已完成</dt><dd id="kCompleted">0</dd>
|
||||
@@ -90,12 +113,22 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ==================== 中栏:地图 ==================== -->
|
||||
<!-- ==================== 中栏:地图与日志 ==================== -->
|
||||
<section class="column col-center">
|
||||
<div class="card card-map">
|
||||
<div class="card-head">
|
||||
<h2>领土地图</h2>
|
||||
<div class="row">
|
||||
<div class="tabs tabs-inline" id="mapTabs">
|
||||
<button class="tab active" data-view="big">大地图</button>
|
||||
<button class="tab" data-view="territory">领土地图</button>
|
||||
</div>
|
||||
<div class="row" id="bigMapControls">
|
||||
<label class="chk"><input type="checkbox" id="chkTerritory" checked> 领土标记</label>
|
||||
<label class="chk"><input type="checkbox" id="chkBotLabels" checked> 机器人名字</label>
|
||||
<button id="btnMapFitWorld" class="btn btn-sm">全世界</button>
|
||||
<button id="btnMapCenterBots" class="btn btn-sm">回到机器人</button>
|
||||
<button id="btnCrawlMap" class="btn btn-sm">爬取地图</button>
|
||||
</div>
|
||||
<div class="row 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>
|
||||
@@ -105,30 +138,65 @@
|
||||
</div>
|
||||
<canvas id="mapCanvas"></canvas>
|
||||
<div class="map-legend">
|
||||
<span><i class="sw sw-self"></i>己方领土</span>
|
||||
<span><i class="sw sw-session"></i>本次占领</span>
|
||||
<span><i class="sw sw-flying"></i>飞地(疏漏)</span>
|
||||
<span><i class="sw sw-target"></i>当前目标</span>
|
||||
<span><i class="sw sw-player"></i>机器人</span>
|
||||
<span><i class="sw sw-capital"></i>王城</span>
|
||||
<span id="legendBig">
|
||||
<i class="sw sw-self"></i>己方领土
|
||||
<i class="sw sw-session"></i>本次占领
|
||||
<i class="sw sw-capital"></i>王城
|
||||
<i class="sw sw-bot"></i>机器人
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-log">
|
||||
<div class="card-head">
|
||||
<h2>运行日志</h2>
|
||||
<div class="row">
|
||||
<div class="tabs tabs-inline" id="logTabs">
|
||||
<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" id="logControls">
|
||||
<label class="chk"><input type="checkbox" id="chkAutoScroll" checked> 自动滚动</label>
|
||||
<button id="btnClearLog" class="btn btn-sm">清屏</button>
|
||||
<button id="btnExportLog" class="btn btn-sm">导出日志</button>
|
||||
</div>
|
||||
<div class="row 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 hidden" id="expControls">
|
||||
<label class="chk">机器人
|
||||
<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 id="logView" class="log-view"></div>
|
||||
<div id="chatView" class="log-view hidden"></div>
|
||||
<div id="expView" class="log-view 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>
|
||||
</section>
|
||||
|
||||
<!-- ==================== 右栏:配置 ==================== -->
|
||||
<!-- ==================== 右栏:配置与控制 ==================== -->
|
||||
<section class="column col-right">
|
||||
<div class="card card-config">
|
||||
<div class="card-head">
|
||||
@@ -138,12 +206,17 @@
|
||||
<button id="btnReloadConfig" class="btn btn-sm">重新载入</button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="chk config-scope">
|
||||
<input type="checkbox" id="chkBotOverride">
|
||||
仅对当前机器人生效(不勾选则写入全局默认配置)
|
||||
</label>
|
||||
<div class="tabs" id="configTabs">
|
||||
<button class="tab active" data-tab="strategy">策略</button>
|
||||
<button class="tab" data-tab="timing">超时</button>
|
||||
<button class="tab" data-tab="pathing">寻路</button>
|
||||
<button class="tab" data-tab="survival">生存</button>
|
||||
<button class="tab" data-tab="data">数据</button>
|
||||
<button class="tab" data-tab="bigmap">大地图</button>
|
||||
<button class="tab" data-tab="grim">Grim</button>
|
||||
<button class="tab" data-tab="server">服务器</button>
|
||||
</div>
|
||||
@@ -151,9 +224,17 @@
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>手动指令</h2>
|
||||
<div class="card-head">
|
||||
<h2>控制台 / 聊天框</h2>
|
||||
<span class="mono small" id="consoleTarget">-</span>
|
||||
</div>
|
||||
<p class="hint" id="consoleHint">登录验证通过后可向该机器人发送指令或公屏消息。</p>
|
||||
<div class="row">
|
||||
<input id="cmdInput" class="input" type="text" placeholder="u claimmode(不带斜杠)">
|
||||
<select id="sendMode" class="input input-sm">
|
||||
<option value="chat">公屏聊天</option>
|
||||
<option value="command">游戏指令</option>
|
||||
</select>
|
||||
<input id="cmdInput" class="input" type="text" placeholder="输入内容后回车发送">
|
||||
<button id="btnSendCmd" class="btn btn-sm">发送</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
@@ -162,10 +243,28 @@
|
||||
<button class="btn btn-sm" data-cmd="spawn">/spawn</button>
|
||||
<button class="btn btn-sm" data-cmd="list">/list</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<input id="quickChatInput" class="input" type="text" placeholder="快捷公屏消息(如:大家好)">
|
||||
<button id="btnQuickChat" class="btn btn-sm">公屏发送</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- 登录遮罩:控制台配置了 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">登录</button>
|
||||
<button id="btnLogout" class="btn btn-sm">退出登录</button>
|
||||
</div>
|
||||
<p id="loginError" class="hint error-text"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast"></div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
|
||||
+76
-3
@@ -85,7 +85,7 @@ html, body {
|
||||
/* ==================== 布局 ==================== */
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 300px minmax(420px, 1fr) 360px;
|
||||
grid-template-columns: 320px minmax(460px, 1fr) 380px;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
align-items: start;
|
||||
@@ -140,7 +140,7 @@ html, body {
|
||||
.card-map { display: flex; flex-direction: column; }
|
||||
#mapCanvas {
|
||||
width: 100%;
|
||||
height: 380px;
|
||||
height: 440px;
|
||||
background: #05070a;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
@@ -163,13 +163,86 @@ html, body {
|
||||
.sw-target { background: #d29922; }
|
||||
.sw-player { background: #ffffff; }
|
||||
.sw-capital { background: #bc8cff; }
|
||||
.sw-bot { background: #ffd33d; }
|
||||
|
||||
.chk { display: flex; align-items: center; gap: 4px; font-size: 12px; color: var(--muted); cursor: pointer; }
|
||||
.hidden { display: none !important; }
|
||||
.inline-field { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
|
||||
.input-sm { width: auto; min-width: 110px; padding: 4px 8px; font-size: 12px; }
|
||||
.tabs-inline { margin-bottom: 0; }
|
||||
.error-text { color: var(--red); }
|
||||
|
||||
/* ==================== 机器人列表 ==================== */
|
||||
.bot-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.bot-item {
|
||||
display: grid;
|
||||
grid-template-columns: 10px 1fr auto;
|
||||
gap: 8px; align-items: center;
|
||||
padding: 7px 9px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--panel-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.bot-item:hover { border-color: #48505e; }
|
||||
.bot-item.active { border-color: var(--accent-2); box-shadow: 0 0 0 1px rgba(47,129,247,.35); }
|
||||
.bot-item .dot {
|
||||
width: 9px; height: 9px; border-radius: 50%; background: var(--muted);
|
||||
}
|
||||
.bot-item .dot.online { background: var(--green); }
|
||||
.bot-item .dot.connecting { background: var(--yellow); }
|
||||
.bot-item .dot.offline { background: var(--red); }
|
||||
.bot-item .dot.disabled { background: #484f58; }
|
||||
.bot-item .name { font-size: 13px; }
|
||||
.bot-item .sub { font-size: 11px; color: var(--muted); }
|
||||
.bot-item .meta { font-size: 11px; color: var(--muted); text-align: right; white-space: nowrap; }
|
||||
|
||||
/* ==================== 公屏聊天 ==================== */
|
||||
.chat-line { display: block; }
|
||||
.chat-time { color: #586069; 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(--muted); }
|
||||
.chat-action { color: #6e7681; }
|
||||
|
||||
/* ==================== 开拓日志 ==================== */
|
||||
#expView { white-space: normal; padding: 0; }
|
||||
.exp-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.exp-table th {
|
||||
position: sticky; top: 0;
|
||||
text-align: left; color: var(--muted); font-weight: 500;
|
||||
background: #05070a; padding: 4px 6px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.exp-table td { padding: 3px 6px; border-bottom: 1px solid #12161d; white-space: nowrap; }
|
||||
.exp-table tbody tr:hover { background: #0e131a; }
|
||||
.exp-empty { color: var(--muted); }
|
||||
|
||||
.config-scope { margin-bottom: 8px; }
|
||||
|
||||
/* ==================== 登录遮罩 ==================== */
|
||||
.overlay {
|
||||
position: fixed; inset: 0; z-index: 200;
|
||||
display: grid; place-items: center;
|
||||
background: rgba(5, 7, 10, .78);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
.overlay-box {
|
||||
width: min(360px, 92vw);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 18px;
|
||||
}
|
||||
.overlay-box h2 { font-size: 15px; margin: 0 0 6px; }
|
||||
.overlay-box .input { margin: 10px 0; }
|
||||
|
||||
/* ==================== 日志 ==================== */
|
||||
.card-log { display: flex; flex-direction: column; }
|
||||
.log-view {
|
||||
height: 260px;
|
||||
height: 300px;
|
||||
overflow-y: auto;
|
||||
background: #05070a;
|
||||
border: 1px solid var(--border);
|
||||
|
||||
+590
-55
@@ -7,42 +7,60 @@ const express = require('express');
|
||||
const { WebSocketServer } = require('ws');
|
||||
|
||||
const chunkPos = require('../core/chunkPos');
|
||||
const { defaultConfig, normalizeConfig, copyConfig } = require('../config/expansionConfig');
|
||||
const { defaultConfig, normalizeConfig } = require('../config/expansionConfig');
|
||||
|
||||
/** 状态推送间隔(毫秒) */
|
||||
const STATUS_PUSH_INTERVAL_MS = 1000;
|
||||
/** 登录接口路径(免令牌,否则无法登录) */
|
||||
const LOGIN_PATH = '/api/login';
|
||||
/** 会话探测接口路径(免令牌) */
|
||||
const SESSION_PATH = '/api/session';
|
||||
|
||||
/**
|
||||
* Web 控制台服务:Express 提供静态页面与 REST 接口,WebSocket 推送实时状态与日志。
|
||||
*
|
||||
* 安全约定:
|
||||
* - 默认只监听 127.0.0.1;
|
||||
* - 配置 `WEB_TOKEN` 后所有 `/api` 请求与 WebSocket 握手都必须带正确令牌;
|
||||
* - 配置 `WEB_TOKEN` / `WEB_PASSWORD` 后所有 `/api` 请求与 WebSocket 握手都必须带正确凭证;
|
||||
* - 控制台页面提供登录框:验证通过后把凭证存在浏览器本地,用于操作各机器人的控制台 / 聊天框;
|
||||
* - 令牌比较使用定时安全比较,避免时序侧信道。
|
||||
*
|
||||
* 多机器人:依赖注入的 `pool`({@link module:bot/botPool.BotPool})负责管理多个机器人,
|
||||
* 每个机器人的控制台 / 聊天框 / 任务接口都以 `/api/bots/:id/...` 暴露。
|
||||
* 为兼容既有单机器人用法,未传 `pool` 时会用 `botManager` + `controller` 包装出一个单机器人池。
|
||||
*/
|
||||
class WebConsole {
|
||||
/**
|
||||
* @param {object} deps
|
||||
* @param {import('../bot/botManager').BotManager} deps.botManager
|
||||
* @param {import('../controller/expansionController').ExpansionController} deps.controller
|
||||
* @param {import('../bot/botPool').BotPool} [deps.pool] 机器人池
|
||||
* @param {import('../bot/botManager').BotManager} [deps.botManager] 单机器人模式
|
||||
* @param {import('../controller/expansionController').ExpansionController} [deps.controller] 单机器人模式
|
||||
* @param {import('../core/territoryDataService').TerritoryDataService} deps.dataService
|
||||
* @param {import('../util/logger').Logger} deps.logger
|
||||
* @param {() => object} deps.getConfig
|
||||
* @param {(config: object) => object} deps.applyConfig
|
||||
* @param {{host?: string, port?: number, token?: string, exportDir?: string}} [options]
|
||||
* @param {import('../core/expansionLog').ExpansionLog} [deps.expansionLog]
|
||||
* @param {import('../core/chatLog').ChatLog} [deps.chatLog]
|
||||
* @param {import('../core/bigMapService').BigMapService} [deps.bigMap]
|
||||
* @param {{host?: string, port?: number, token?: string, password?: string, exportDir?: string}} [options]
|
||||
*/
|
||||
constructor(deps, options) {
|
||||
const opts = options || {};
|
||||
this.botManager = deps.botManager;
|
||||
this.controller = deps.controller;
|
||||
this.pool = deps.pool || new SingleBotPool(deps.botManager, deps.controller);
|
||||
this.dataService = deps.dataService;
|
||||
this.logger = deps.logger;
|
||||
this.expansionLog = deps.expansionLog || null;
|
||||
this.chatLog = deps.chatLog || null;
|
||||
this.bigMap = deps.bigMap || null;
|
||||
this.getConfig = deps.getConfig;
|
||||
this.applyConfig = deps.applyConfig;
|
||||
this.exportDir = opts.exportDir || path.join(__dirname, '..', '..', 'data');
|
||||
this.host = opts.host || '127.0.0.1';
|
||||
this.port = opts.port === undefined ? 3081 : opts.port;
|
||||
/** 可写进 URL 的令牌(`?token=`) */
|
||||
this.token = opts.token || '';
|
||||
/** 控制台登录密码(`WEB_PASSWORD`);与 token 二选一或同时配置 */
|
||||
this.password = opts.password || '';
|
||||
|
||||
this.app = express();
|
||||
this.server = null;
|
||||
@@ -54,6 +72,23 @@ class WebConsole {
|
||||
this._setupRoutes();
|
||||
}
|
||||
|
||||
/** 默认机器人条目(`pool` 里的第一个) */
|
||||
get defaultEntry() {
|
||||
return this.pool.get(this.pool.defaultId);
|
||||
}
|
||||
|
||||
/** 兼容既有调用:默认机器人的管理器 */
|
||||
get botManager() {
|
||||
const entry = this.defaultEntry;
|
||||
return entry ? entry.manager : null;
|
||||
}
|
||||
|
||||
/** 兼容既有调用:默认机器人的控制器 */
|
||||
get controller() {
|
||||
const entry = this.defaultEntry;
|
||||
return entry ? entry.controller : null;
|
||||
}
|
||||
|
||||
/** 启动 HTTP + WebSocket 服务 */
|
||||
start() {
|
||||
this.server = http.createServer(this.app);
|
||||
@@ -85,13 +120,10 @@ class WebConsole {
|
||||
}, STATUS_PUSH_INTERVAL_MS);
|
||||
if (this._pushTimer.unref) this._pushTimer.unref();
|
||||
|
||||
// 新日志实时推送
|
||||
this.logger.onEntry = (entry) => {
|
||||
const payload = JSON.stringify({ type: 'log', data: entry });
|
||||
for (const ws of this.clients) {
|
||||
if (ws.readyState === 1) ws.send(payload);
|
||||
}
|
||||
};
|
||||
// 新日志 / 新公屏消息 / 新开拓记录实时推送
|
||||
this.logger.onEntry = (entry) => this._broadcast('log', entry);
|
||||
if (this.chatLog) this.chatLog.onEntry = (entry) => this._broadcast('chat', entry);
|
||||
if (this.expansionLog) this.expansionLog.onEntry = (entry) => this._broadcast('expansion', entry);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.server.listen(this.port, this.host, () => {
|
||||
@@ -117,6 +149,8 @@ class WebConsole {
|
||||
async stop() {
|
||||
if (this._pushTimer) clearInterval(this._pushTimer);
|
||||
this.logger.onEntry = null;
|
||||
if (this.chatLog) this.chatLog.onEntry = null;
|
||||
if (this.expansionLog) this.expansionLog.onEntry = null;
|
||||
for (const ws of this.clients) {
|
||||
try {
|
||||
ws.close();
|
||||
@@ -134,19 +168,46 @@ class WebConsole {
|
||||
_setupRoutes() {
|
||||
const app = this.app;
|
||||
app.disable('x-powered-by');
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
app.use(express.json({ limit: '8mb' }));
|
||||
// 静态资源(控制台页面本身)不需要令牌:浏览器加载 index.html 时带的 ?token= 不会
|
||||
// 附加到后续 style.css / app.js 的子请求上,若一并拦截会导致页面样式与脚本 404/401。
|
||||
// 页面本身不含任何敏感信息,真正需要保护的是下面的 /api 接口。
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
// ===== 免鉴权:登录与会话探测(否则用户无法登录)=====
|
||||
app.post(LOGIN_PATH, (req, res) => {
|
||||
const provided = String((req.body && req.body.password) || req.body && req.body.token || '');
|
||||
if (!this.authRequired()) {
|
||||
res.json({ ok: true, data: { authenticated: true, required: false, token: '' } });
|
||||
return;
|
||||
}
|
||||
if (!this._matchesSecret(provided)) {
|
||||
res.status(401).json({ ok: false, message: '密码错误' });
|
||||
return;
|
||||
}
|
||||
res.json({ ok: true, data: { authenticated: true, required: true, token: provided } });
|
||||
});
|
||||
|
||||
app.get(SESSION_PATH, (req, res) => {
|
||||
res.json({
|
||||
ok: true,
|
||||
data: {
|
||||
required: this.authRequired(),
|
||||
authenticated: !this.authRequired() || this._authorized(req)
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 其余 /api 一律要求凭证 =====
|
||||
app.use((req, res, next) => {
|
||||
if (!this._authorized(req)) {
|
||||
res.status(401).json({ ok: false, message: '未授权:请在 URL 上带 ?token= 或提供 X-Auth-Token 头' });
|
||||
res.status(401).json({ ok: false, message: '未授权:请先登录控制台或提供 X-Auth-Token 头' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// -------- 全局状态 --------
|
||||
app.get('/api/status', (req, res) => {
|
||||
res.json({ ok: true, data: this.statusPayload() });
|
||||
});
|
||||
@@ -175,36 +236,240 @@ class WebConsole {
|
||||
|
||||
app.post('/api/logs/export', (req, res) => {
|
||||
const file = path.join(this.exportDir, `autostore-debug-${Date.now()}.log`);
|
||||
const result = this.controller.exportLogs(file);
|
||||
const controller = this.controller;
|
||||
const result = controller ? controller.exportLogs(file) : null;
|
||||
if (result) res.json({ ok: true, data: { file: result } });
|
||||
else res.status(500).json({ ok: false, message: '导出失败' });
|
||||
});
|
||||
|
||||
// -------- 机器人池 --------
|
||||
app.get('/api/bots', (req, res) => {
|
||||
res.json({ ok: true, data: this.botsPayload() });
|
||||
});
|
||||
|
||||
app.get('/api/bots/:id/status', (req, res) => {
|
||||
this._withEntry(req, res, (entry) => ({
|
||||
ok: true,
|
||||
data: Object.assign({}, entry.manager.snapshot(), { task: entry.controller.snapshot() })
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/api/bots/:id/settings', (req, res) => {
|
||||
this._withEntry(req, res, (entry) => ({
|
||||
ok: true,
|
||||
data: this._settingSnapshot(entry)
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/api/bots/:id/settings', (req, res) => {
|
||||
this._withEntry(req, res, (entry) => {
|
||||
const body = req.body || {};
|
||||
const patch = {};
|
||||
if (body.label !== undefined) patch.label = String(body.label);
|
||||
if (body.enabled !== undefined) patch.enabled = body.enabled === true;
|
||||
if (body.autoStart !== undefined) patch.autoStart = body.autoStart === true;
|
||||
if (body.config !== undefined) patch.config = body.config;
|
||||
const saved = this._setBotSetting(entry, patch);
|
||||
res.json({ ok: true, data: saved });
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/bots/:id/task/start', async (req, res) => {
|
||||
try {
|
||||
const entry = this.pool.require(req.params.id);
|
||||
const body = req.body && Object.keys(req.body).length > 0 ? req.body : null;
|
||||
const result = await this._startTask(entry, body);
|
||||
res.json({ ok: result.ok, data: this.statusPayload(), message: result.message });
|
||||
} catch (err) {
|
||||
this._error(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/bots/:id/task/stop', (req, res) => {
|
||||
this._withEntry(req, res, (entry) => {
|
||||
this.pool.stopTask(entry.id);
|
||||
res.json({ ok: true, data: this.statusPayload(), message: entry.connection.username + ' 已停止' });
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/bots/:id/reconnect', (req, res) => {
|
||||
this._withEntry(req, res, (entry) => {
|
||||
this.pool.reconnect(entry.id);
|
||||
res.json({ ok: true, message: entry.connection.username + ' 正在重连' });
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
// 控制台 / 聊天框操作:都要求该机器人已完成游戏内登录验证
|
||||
app.post('/api/bots/:id/command', (req, res) => {
|
||||
const command = req.body && req.body.command;
|
||||
if (!command || typeof command !== 'string') {
|
||||
res.status(400).json({ ok: false, message: '缺少 command 字段' });
|
||||
return;
|
||||
}
|
||||
this._withEntry(req, res, (entry) => {
|
||||
const result = this.pool.sendCommand(entry.id, command);
|
||||
res.status(result.ok ? 200 : 409).json(result);
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/bots/:id/chat', (req, res) => {
|
||||
const message = req.body && req.body.message;
|
||||
if (!message || typeof message !== 'string') {
|
||||
res.status(400).json({ ok: false, message: '缺少 message 字段' });
|
||||
return;
|
||||
}
|
||||
this._withEntry(req, res, (entry) => {
|
||||
const result = this.pool.sendChat(entry.id, message);
|
||||
res.status(result.ok ? 200 : 409).json(result);
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/bots/:id/chat', (req, res) => {
|
||||
if (!this.chatLog) {
|
||||
res.json({ ok: true, data: [] });
|
||||
return;
|
||||
}
|
||||
const query = req.query || {};
|
||||
res.json({
|
||||
ok: true,
|
||||
data: this.chatLog.list(req.params.id, {
|
||||
limit: toPositiveInt(query.limit, 300),
|
||||
since: toOptionalInt(query.since),
|
||||
kind: typeof query.kind === 'string' && query.kind ? query.kind : undefined,
|
||||
keyword: typeof query.keyword === 'string' && query.keyword ? query.keyword : undefined
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/bots/:id/chat/export', (req, res) => {
|
||||
if (!this.chatLog) {
|
||||
res.status(500).json({ ok: false, message: '未启用公屏日志' });
|
||||
return;
|
||||
}
|
||||
const file = path.join(this.exportDir, `chat-${sanitizeFile(req.params.id)}-${Date.now()}.log`);
|
||||
const result = this.chatLog.exportTo(req.params.id, file);
|
||||
if (result) res.json({ ok: true, data: { file: result } });
|
||||
else res.status(500).json({ ok: false, message: '导出失败' });
|
||||
});
|
||||
|
||||
// -------- 开拓日志 --------
|
||||
app.get('/api/expansion-log', (req, res) => {
|
||||
if (!this.expansionLog) {
|
||||
res.json({ ok: true, data: { entries: [], stats: null } });
|
||||
return;
|
||||
}
|
||||
const query = req.query || {};
|
||||
res.json({
|
||||
ok: true,
|
||||
data: {
|
||||
entries: this.expansionLog.list({
|
||||
limit: toPositiveInt(query.limit, 200),
|
||||
botId: typeof query.botId === 'string' && query.botId ? query.botId : undefined,
|
||||
state: typeof query.state === 'string' && query.state ? query.state : undefined,
|
||||
since: toOptionalInt(query.since)
|
||||
}),
|
||||
stats: this.expansionLog.stats()
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/expansion-log/export', (req, res) => {
|
||||
if (!this.expansionLog) {
|
||||
res.status(500).json({ ok: false, message: '未启用开拓日志' });
|
||||
return;
|
||||
}
|
||||
const body = req.body || {};
|
||||
const file = path.join(this.exportDir, `expansion-log-${Date.now()}.csv`);
|
||||
const result = this.expansionLog.exportCsv(file, {
|
||||
botId: body.botId || undefined,
|
||||
since: toOptionalInt(body.since)
|
||||
});
|
||||
if (result) res.json({ ok: true, data: { file: result } });
|
||||
else res.status(500).json({ ok: false, message: '导出失败' });
|
||||
});
|
||||
|
||||
app.post('/api/expansion-log/clear', (req, res) => {
|
||||
if (this.expansionLog) this.expansionLog.clearBuffer();
|
||||
res.json({ ok: true, message: '已清空内存中的开拓日志(磁盘文件保留)' });
|
||||
});
|
||||
|
||||
// -------- 大地图 --------
|
||||
app.get('/api/bigmap', (req, res) => {
|
||||
if (!this.bigMap) {
|
||||
res.json({ ok: true, data: { enabled: false } });
|
||||
return;
|
||||
}
|
||||
res.json({ ok: true, data: this.bigMapPayload() });
|
||||
});
|
||||
|
||||
app.get('/api/bigmap/markers', (req, res) => {
|
||||
res.json({ ok: true, data: this.bigMapMarkersPayload() });
|
||||
});
|
||||
|
||||
app.get('/api/bigmap/tile/:z/:x/:y', async (req, res) => {
|
||||
if (!this.bigMap) {
|
||||
res.status(404).end();
|
||||
return;
|
||||
}
|
||||
const zoom = Number.parseInt(req.params.z, 10);
|
||||
const tileX = Number.parseInt(req.params.x, 10);
|
||||
const tileZ = Number.parseInt(String(req.params.y).replace(/\.png$/i, ''), 10);
|
||||
if (![zoom, tileX, tileZ].every(Number.isFinite)) {
|
||||
res.status(400).json({ ok: false, message: '瓦片坐标非法' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await this.bigMap.getTile(zoom, tileX, tileZ);
|
||||
if (!data) {
|
||||
res.status(404).end();
|
||||
return;
|
||||
}
|
||||
res.setHeader('Content-Type', 'image/png');
|
||||
// 瓦片内容随服务器地图更新,但变更频率很低;缓存 10 分钟足以兼顾新鲜度与带宽
|
||||
res.setHeader('Cache-Control', 'public, max-age=600');
|
||||
res.end(data);
|
||||
} catch (err) {
|
||||
this._error(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/bigmap/crawl', async (req, res) => {
|
||||
if (!this.bigMap) {
|
||||
res.status(500).json({ ok: false, message: '未启用大地图' });
|
||||
return;
|
||||
}
|
||||
const body = req.body || {};
|
||||
const zoom = Number.isFinite(Number(body.zoom)) ? Number(body.zoom) : undefined;
|
||||
// 爬取可能持续数十秒:立刻返回,进度通过 /api/bigmap 轮询
|
||||
this.bigMap.crawlAll(zoom, { force: body.force === true }).catch((err) => {
|
||||
this.logger.log('大地图爬取失败:' + (err && err.message ? err.message : String(err)), 'error');
|
||||
});
|
||||
res.json({ ok: true, message: '已开始爬取地图瓦片', data: this.bigMap.snapshot() });
|
||||
});
|
||||
|
||||
app.post('/api/bigmap/cache/clear', (req, res) => {
|
||||
if (!this.bigMap) {
|
||||
res.status(500).json({ ok: false, message: '未启用大地图' });
|
||||
return;
|
||||
}
|
||||
const removed = this.bigMap.clearCache();
|
||||
res.json({ ok: true, message: `已清空瓦片缓存(${removed} 个文件)` });
|
||||
});
|
||||
|
||||
// -------- 地图数据(领土模型) --------
|
||||
app.get('/api/map', (req, res) => {
|
||||
try {
|
||||
res.json({ ok: true, data: this.mapPayload(req.query.full === '1') });
|
||||
res.json({ ok: true, data: this.mapPayload(req.query.full === '1', req.query.bot) });
|
||||
} catch (err) {
|
||||
res.status(500).json({ ok: false, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/task/start', async (req, res) => {
|
||||
try {
|
||||
const config = req.body && Object.keys(req.body).length > 0
|
||||
? normalizeConfig(req.body, this.getConfig())
|
||||
: this.getConfig();
|
||||
const ok = await this.controller.start(config);
|
||||
res.json({ ok, data: this.statusPayload(), message: ok ? '任务已启动' : this.controller.status });
|
||||
} catch (err) {
|
||||
res.status(500).json({ ok: false, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/task/stop', (req, res) => {
|
||||
this.controller.stop();
|
||||
res.json({ ok: true, data: this.statusPayload() });
|
||||
});
|
||||
|
||||
app.post('/api/map/refresh', (req, res) => {
|
||||
this.dataService.refreshAsync();
|
||||
res.json({ ok: true, message: '已触发地图刷新' });
|
||||
@@ -215,49 +480,154 @@ class WebConsole {
|
||||
res.json({ ok: result.ok, message: result.message });
|
||||
});
|
||||
|
||||
// -------- 单机器人兼容接口(等价于默认机器人的 /api/bots/:id/...)--------
|
||||
app.post('/api/task/start', async (req, res) => {
|
||||
try {
|
||||
const entry = this.defaultEntry;
|
||||
if (!entry) {
|
||||
res.status(500).json({ ok: false, message: '没有可用的机器人' });
|
||||
return;
|
||||
}
|
||||
const body = req.body && Object.keys(req.body).length > 0 ? req.body : null;
|
||||
const result = await this._startTask(entry, body);
|
||||
res.json({ ok: result.ok, data: this.statusPayload(), message: result.message });
|
||||
} catch (err) {
|
||||
this._error(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/task/stop', (req, res) => {
|
||||
const entry = this.defaultEntry;
|
||||
if (entry) this.pool.stopTask(entry.id);
|
||||
res.json({ ok: true, data: this.statusPayload() });
|
||||
});
|
||||
|
||||
app.post('/api/command', (req, res) => {
|
||||
const command = req.body && req.body.command;
|
||||
if (!command || typeof command !== 'string') {
|
||||
res.status(400).json({ ok: false, message: '缺少 command 字段' });
|
||||
return;
|
||||
}
|
||||
this.botManager.sendCommand(command.replace(/^\//, ''));
|
||||
res.json({ ok: true, message: '已发送:' + command });
|
||||
const entry = this.defaultEntry;
|
||||
if (!entry) {
|
||||
res.status(500).json({ ok: false, message: '没有可用的机器人' });
|
||||
return;
|
||||
}
|
||||
const result = this.pool.sendCommand(entry.id, command);
|
||||
res.status(result.ok ? 200 : 409).json(result);
|
||||
});
|
||||
|
||||
app.post('/api/bot/reconnect', (req, res) => {
|
||||
this.botManager.disconnect();
|
||||
setTimeout(() => this.botManager.connect(), 500);
|
||||
const entry = this.defaultEntry;
|
||||
if (entry) this.pool.reconnect(entry.id);
|
||||
res.json({ ok: true, message: '正在重连' });
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 路由辅助 ====================
|
||||
|
||||
/**
|
||||
* 校验请求授权。
|
||||
* 解析机器人条目并把 handler 的返回值作为 JSON 响应;抛错统一转成 HTTP 状态码。
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
* @param {(entry: object) => object|null} handler 返回 null 表示已自行响应
|
||||
*/
|
||||
_withEntry(req, res, handler) {
|
||||
try {
|
||||
const entry = this.pool.require(req.params.id);
|
||||
const payload = handler(entry);
|
||||
if (payload !== null && payload !== undefined) res.json(payload);
|
||||
} catch (err) {
|
||||
this._error(res, err);
|
||||
}
|
||||
}
|
||||
|
||||
_error(res, err) {
|
||||
const status = err && err.statusCode ? err.statusCode : 500;
|
||||
res.status(status).json({ ok: false, message: err && err.message ? err.message : String(err) });
|
||||
}
|
||||
|
||||
/** 兼容不同形态的机器人池:取设置快照 */
|
||||
_settingSnapshot(entry) {
|
||||
if (typeof this.pool.settingSnapshot === 'function') return this.pool.settingSnapshot(entry.id);
|
||||
return { id: entry.id, label: entry.manager.label, enabled: true, autoStart: false, config: entry.config };
|
||||
}
|
||||
|
||||
/** 兼容不同形态的机器人池:保存设置 */
|
||||
_setBotSetting(entry, patch) {
|
||||
if (typeof this.pool.setBotSetting === 'function') return this.pool.setBotSetting(entry.id, patch);
|
||||
return this._settingSnapshot(entry);
|
||||
}
|
||||
|
||||
/** 兼容不同形态的机器人池:启动任务 */
|
||||
async _startTask(entry, body) {
|
||||
if (typeof this.pool.startTask === 'function') return this.pool.startTask(entry.id, body);
|
||||
const config = body ? normalizeConfig(body, this.getConfig()) : this.getConfig();
|
||||
entry.controller.config = config;
|
||||
const ok = await entry.controller.start(config);
|
||||
return { ok, message: ok ? '任务已启动' : entry.controller.status, entry };
|
||||
}
|
||||
|
||||
// ==================== 鉴权 ====================
|
||||
|
||||
/** @returns {boolean} 是否配置了访问凭证 */
|
||||
authRequired() {
|
||||
return !!(this.token || this.password);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验请求授权:支持 `X-Auth-Token` 头、`?token=` 查询参数与 Cookie。
|
||||
* @param {import('http').IncomingMessage} req
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_authorized(req) {
|
||||
if (!this.token) return true;
|
||||
let provided = '';
|
||||
if (!this.authRequired()) return true;
|
||||
return this._matchesSecret(this._providedSecret(req));
|
||||
}
|
||||
|
||||
/** @returns {boolean} 给定凭证是否匹配 token 或密码 */
|
||||
_matchesSecret(provided) {
|
||||
if (!provided) return false;
|
||||
if (this.token && timingSafeEqual(provided, this.token)) return true;
|
||||
if (this.password && timingSafeEqual(provided, this.password)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 从请求里提取凭证(头 → 查询参数 → Cookie) */
|
||||
_providedSecret(req) {
|
||||
const header = req.headers['x-auth-token'];
|
||||
if (typeof header === 'string') provided = header;
|
||||
if (!provided && req.url) {
|
||||
if (typeof header === 'string' && header) return header;
|
||||
if (req.url) {
|
||||
const query = req.url.includes('?') ? req.url.slice(req.url.indexOf('?') + 1) : '';
|
||||
const params = new URLSearchParams(query);
|
||||
provided = params.get('token') || '';
|
||||
const fromQuery = params.get('token');
|
||||
if (fromQuery) return fromQuery;
|
||||
}
|
||||
return timingSafeEqual(provided, this.token);
|
||||
const cookie = req.headers.cookie;
|
||||
if (typeof cookie === 'string') {
|
||||
const match = /(?:^|;\s*)autostore_token=([^;]*)/.exec(cookie);
|
||||
if (match) return decodeURIComponent(match[1]);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// ==================== 数据组装 ====================
|
||||
|
||||
/** @returns {object} 完整状态负载 */
|
||||
statusPayload() {
|
||||
const entry = this.defaultEntry;
|
||||
const bots = this.pool.snapshot();
|
||||
const primary = bots.length > 0 ? bots[0] : null;
|
||||
return {
|
||||
bot: this.botManager.snapshot(),
|
||||
task: this.controller.snapshot(),
|
||||
// 单机器人字段保留,避免既有前端 / 调用方失效
|
||||
bot: primary || (entry ? entry.manager.snapshot() : null),
|
||||
task: entry ? entry.controller.snapshot() : null,
|
||||
data: this.dataService.snapshot(),
|
||||
bots,
|
||||
defaultBotId: this.pool.defaultId || '',
|
||||
expansionLog: this.expansionLog ? this.expansionLog.stats() : null,
|
||||
chatLog: this.chatLog ? this.chatLog.snapshot() : null,
|
||||
bigMap: this.bigMap ? this.bigMap.snapshot() : null,
|
||||
serverTime: Date.now(),
|
||||
claimTime: {
|
||||
start: this.getConfig().claimStartHour,
|
||||
@@ -267,25 +637,33 @@ class WebConsole {
|
||||
};
|
||||
}
|
||||
|
||||
/** @returns {Array<object>} 机器人列表快照 */
|
||||
botsPayload() {
|
||||
return this.pool.snapshot();
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装地图数据。
|
||||
*
|
||||
* 区块一律编码为扁平的 `[x1, z1, x2, z2, ...]` 数组,减少 JSON 体积。
|
||||
*
|
||||
* @param {boolean} full 是否包含全部邦国的区块(体积大,按需请求)
|
||||
* @param {string} [botId] 以哪个机器人为视角(缺省取默认机器人)
|
||||
* @returns {object}
|
||||
*/
|
||||
mapPayload(full) {
|
||||
mapPayload(full, botId) {
|
||||
const model = this.dataService.getModel();
|
||||
const selfName = model.selfStateNameValue();
|
||||
const self = model.selfState();
|
||||
const entry = this.pool.get(botId) || this.defaultEntry;
|
||||
const controller = entry ? entry.controller : null;
|
||||
const selfName = (controller && controller.stateName) || model.selfStateNameValue();
|
||||
const self = model.state(selfName);
|
||||
const result = {
|
||||
selfState: selfName,
|
||||
self: self ? flattenChunks(self.chunks) : [],
|
||||
session: flattenChunks(model.sessionClaimsSet()),
|
||||
flying: flattenChunks(model.selfFlyingChunks()),
|
||||
target: this.controller.target,
|
||||
playerChunk: this.controller.playerChunk,
|
||||
flying: self ? flattenChunks(model.flyingChunksOf(selfName)) : [],
|
||||
target: controller ? controller.target : null,
|
||||
playerChunk: controller ? controller.playerChunk : null,
|
||||
states: [],
|
||||
full
|
||||
};
|
||||
@@ -311,11 +689,141 @@ class WebConsole {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** @returns {object} 大地图元信息(坐标系、瓦片范围、缓存进度、机器人与领土) */
|
||||
bigMapPayload() {
|
||||
const snapshot = this.bigMap.snapshot();
|
||||
return Object.assign(snapshot, {
|
||||
bots: this.pool.positions(),
|
||||
markers: this.bigMapMarkersPayload()
|
||||
});
|
||||
}
|
||||
|
||||
/** @returns {object} 大地图上的领土标记(多边形 + 王城) */
|
||||
bigMapMarkersPayload() {
|
||||
const model = this.dataService.getModel();
|
||||
const states = [];
|
||||
for (const state of model.statesIter()) {
|
||||
states.push({
|
||||
name: state.name,
|
||||
color: '#' + (state.color & 0xffffff).toString(16).padStart(6, '0'),
|
||||
capital: state.capital,
|
||||
chunkCount: state.chunks.size,
|
||||
polygons: state.polygons || []
|
||||
});
|
||||
}
|
||||
states.sort((a, b) => b.chunkCount - a.chunkCount);
|
||||
return {
|
||||
world: this.bigMap ? this.bigMap.world : '',
|
||||
updatedAt: model.updatedAt || 0,
|
||||
states
|
||||
};
|
||||
}
|
||||
|
||||
_broadcast(type, data) {
|
||||
if (this.clients.size === 0) return;
|
||||
const payload = JSON.stringify({ type, data });
|
||||
for (const ws of this.clients) {
|
||||
if (ws.readyState === 1) ws.send(payload);
|
||||
}
|
||||
}
|
||||
|
||||
_send(ws, payload) {
|
||||
if (ws.readyState === 1) ws.send(JSON.stringify(payload));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 单机器人池适配器:把 `{botManager, controller}` 包装成与
|
||||
* {@link module:bot/botPool.BotPool} 相同的接口,使 WebConsole 只有一条代码路径。
|
||||
*
|
||||
* 这是为了向后兼容既有的单机器人用法与测试,不承担多机器人职责。
|
||||
*/
|
||||
class SingleBotPool {
|
||||
/**
|
||||
* @param {import('../bot/botManager').BotManager} botManager
|
||||
* @param {import('../controller/expansionController').ExpansionController} controller
|
||||
*/
|
||||
constructor(botManager, controller) {
|
||||
const id = (botManager && botManager.id)
|
||||
|| (botManager && botManager.connection && botManager.connection.username)
|
||||
|| 'bot';
|
||||
this.defaultId = id;
|
||||
this.entries = new Map([[id, { id, manager: botManager, controller, config: null, connection: { username: id } }]]);
|
||||
}
|
||||
|
||||
/** @param {string} [id] */
|
||||
get(id) {
|
||||
if (!id) return this.entries.get(this.defaultId) || null;
|
||||
return this.entries.get(id) || null;
|
||||
}
|
||||
|
||||
/** @param {string} id */
|
||||
require(id) {
|
||||
const entry = this.get(id);
|
||||
if (!entry) {
|
||||
const err = new Error('机器人不存在:' + id);
|
||||
err.statusCode = 404;
|
||||
throw err;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** @returns {Array<object>} */
|
||||
snapshot() {
|
||||
const entry = this.get();
|
||||
if (!entry) return [];
|
||||
return [Object.assign({}, entry.manager.snapshot(), { task: entry.controller.snapshot() })];
|
||||
}
|
||||
|
||||
/** @returns {Array<object>} */
|
||||
positions() {
|
||||
const entry = this.get();
|
||||
if (!entry) return [];
|
||||
const snap = entry.manager.snapshot();
|
||||
return [{
|
||||
id: entry.id,
|
||||
label: snap.label || entry.id,
|
||||
username: snap.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
|
||||
}];
|
||||
}
|
||||
|
||||
/** @param {string} id */
|
||||
stopTask(id) {
|
||||
const entry = this.require(id);
|
||||
entry.controller.stop();
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** @param {string} id */
|
||||
reconnect(id) {
|
||||
const entry = this.require(id);
|
||||
entry.manager.disconnect();
|
||||
setTimeout(() => entry.manager.connect(), 500).unref?.();
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** @param {string} id @param {string} command */
|
||||
sendCommand(id, command) {
|
||||
const entry = this.require(id);
|
||||
const ok = entry.manager.sendCommand(String(command).replace(/^\//, ''));
|
||||
return { ok, message: ok ? '已发送:' + command : '发送失败(未完成登录验证)' };
|
||||
}
|
||||
|
||||
/** @param {string} id @param {string} message */
|
||||
sendChat(id, message) {
|
||||
const entry = this.require(id);
|
||||
const ok = entry.manager.sendChat(String(message || '').trim());
|
||||
return { ok, message: ok ? '已发送' : '发送失败(未完成登录验证)' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把区块键集合压平成 `[x1, z1, x2, z2, ...]`。
|
||||
* @param {Set<number>} keys
|
||||
@@ -367,4 +875,31 @@ function timingSafeEqual(a, b) {
|
||||
return diff === 0;
|
||||
}
|
||||
|
||||
module.exports = { WebConsole, flattenChunks, boundsOf, timingSafeEqual, STATUS_PUSH_INTERVAL_MS };
|
||||
/** 查询参数转正整数 */
|
||||
function toPositiveInt(value, fallback) {
|
||||
const n = Number.parseInt(value, 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : fallback;
|
||||
}
|
||||
|
||||
/** 查询参数转可选整数(缺省返回 undefined,便于过滤函数跳过) */
|
||||
function toOptionalInt(value) {
|
||||
if (value === undefined || value === null || value === '') return undefined;
|
||||
const n = Number.parseInt(value, 10);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
/** 文件名安全化 */
|
||||
function sanitizeFile(name) {
|
||||
return String(name).replace(/[\\/:*?"<>|]/g, '_');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
WebConsole,
|
||||
SingleBotPool,
|
||||
flattenChunks,
|
||||
boundsOf,
|
||||
timingSafeEqual,
|
||||
STATUS_PUSH_INTERVAL_MS,
|
||||
LOGIN_PATH,
|
||||
SESSION_PATH
|
||||
};
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
'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);
|
||||
assert.deepStrictEqual(range, { minX: -5, maxX: 4, minZ: -5, maxZ: 4, count: 100 });
|
||||
// 瓦片范围必须完整覆盖 -20480..20480
|
||||
assert.strictEqual(range.minX * service.blocksPerTile(0), -20480);
|
||||
assert.strictEqual((range.maxX + 1) * service.blocksPerTile(0), 20480);
|
||||
});
|
||||
|
||||
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('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('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,193 @@
|
||||
'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 } = require('../src/bot/botManager');
|
||||
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 || {}));
|
||||
if (opts.config && opts.config.server) {
|
||||
Object.assign(config.server, opts.config.server);
|
||||
}
|
||||
const manager = new BotManager({
|
||||
id: 'BotA',
|
||||
username: 'BotA',
|
||||
password: 'pw',
|
||||
host: 'mc.example.com',
|
||||
port: 25565,
|
||||
version: '1.21.4',
|
||||
label: '主号'
|
||||
}, config, new Logger({ echo: false }), { chatLog });
|
||||
const sent = [];
|
||||
manager.bot = { chat: (text) => sent.push(text) };
|
||||
return { manager, chatLog, config, sent };
|
||||
}
|
||||
|
||||
/** 等一轮宏任务,让指令节流定时器真正把内容写出 */
|
||||
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);
|
||||
});
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
'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 } = 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');
|
||||
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)
|
||||
});
|
||||
return { pool, storePath, 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');
|
||||
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']);
|
||||
});
|
||||
@@ -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.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);
|
||||
});
|
||||
@@ -0,0 +1,386 @@
|
||||
'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 } = 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'))
|
||||
});
|
||||
|
||||
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);
|
||||
assert.strictEqual(payload.bigMap.tiles.count, 100);
|
||||
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 } = 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 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, 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, 100);
|
||||
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 } = 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();
|
||||
}
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户