diff --git a/.env.example b/.env.example index 1522f04..63a1209 100644 --- a/.env.example +++ b/.env.example @@ -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=:,: +# 用户名/密码里含逗号或冒号时,改用 JSON 数组写法: +# MC_BOTS=[{"username":"bot1","password":"p1"},{"username":"bot2","password":"p2","label":"二号机"}] + +# ---- 单机器人兜底:未设置 MC_BOTS 时生效 ---- MC_USERNAME= +MC_PASSWORD= + # offline=离线服(服务器用 /login 密码登录);microsoft=正版 MC_AUTH=offline -# 服务器登录/注册密码(离线服必填;正版服留空) -MC_PASSWORD= - # ==================== Web 控制台 ==================== WEB_HOST=127.0.0.1 WEB_PORT=3081 -# 控制台访问令牌(留空则仅监听 127.0.0.1 且不校验;非本机访问必须设置) +# 控制台令牌(可写进 URL 的 ?token=,便于直接点击打开;留空则本机免登录) WEB_TOKEN= +# 控制台登录密码:配置后页面会弹出登录框,验证通过才允许操作机器人的控制台 / 聊天框 / 扩地任务 +WEB_PASSWORD= -# ==================== 领土数据源 ==================== -# Dynmap 地图站点根地址(末尾不带斜杠),程序会请求 /tiles//markers.json +# ==================== 领土数据源与大地图 ==================== +# 地图站点根地址(末尾不带斜杠) +# 程序会请求: +# /tiles//markers.json 领土标记 +# /tiles///_.png 大地图瓦片图片 DYNMAP_BASE=http://bgjq.simpfun.cn -# markers.json 所属世界名(对应 Dynmap 配置里的 world 目录名) +# 世界目录名(对应地图站点上的世界名) DYNMAP_WORLD=minecraft_overworld # ==================== SimpCore 开放 API(可选) ==================== diff --git a/CHANGELOG.md b/CHANGELOG.md index 80605a4..0fb1813 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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=` 按机器人视角返回其邦国、目标与玩家区块 + +### 修复 + +- 修正控制台地图瓦片 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 改为网页控制台, diff --git a/README.md b/README.md index f9b3b69..f733da6 100644 --- a/README.md +++ b/README.md @@ -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///_.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///_.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=` | 领土地图数据(`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/// 大地图瓦片缓存 ``` --- diff --git a/package.json b/package.json index 0c6c452..5769ec7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/check-web-ids.js b/scripts/check-web-ids.js new file mode 100644 index 0000000..6b0fce3 --- /dev/null +++ b/scripts/check-web-ids.js @@ -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('前端元素引用自检通过'); diff --git a/src/bot/botManager.js b/src/bot/botManager.js index 2e47d2a..6d1d97f 100644 --- a/src/bot/botManager.js +++ b/src/bot/botManager.js @@ -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 }; diff --git a/src/bot/botPool.js b/src/bot/botPool.js new file mode 100644 index 0000000..49a48ae --- /dev/null +++ b/src/bot/botPool.js @@ -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} 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} 机器人 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} 全部机器人 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} 所有机器人的状态快照(含连接、任务与位置) + */ + 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} 所有机器人的位置(大地图打点用) + */ + 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 }; diff --git a/src/config/botStore.js b/src/config/botStore.js new file mode 100644 index 0000000..fa92d75 --- /dev/null +++ b/src/config/botStore.js @@ -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}} + */ + 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}} 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 }; diff --git a/src/config/botsConfig.js b/src/config/botsConfig.js new file mode 100644 index 0000000..e4eab52 --- /dev/null +++ b/src/config/botsConfig.js @@ -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 }; diff --git a/src/config/expansionConfig.js b/src/config/expansionConfig.js index f53eb5d..5f73a39 100644 --- a/src/config/expansionConfig.js +++ b/src/config/expansionConfig.js @@ -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; diff --git a/src/controller/expansionController.js b/src/controller/expansionController.js index 5095de9..e4e9b45 100644 --- a/src/controller/expansionController.js +++ b/src/controller/expansionController.js @@ -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(), diff --git a/src/core/bigMapService.js b/src/core/bigMapService.js new file mode 100644 index 0000000..d348bf3 --- /dev/null +++ b/src/core/bigMapService.js @@ -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///_.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///_.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} 内存 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} 瓦片 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} 爬取结果统计 + */ + 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 +}; diff --git a/src/core/chatLog.js b/src/core/chatLog.js new file mode 100644 index 0000000..4aa817c --- /dev/null +++ b/src/core/chatLog.js @@ -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>} 机器人 id → 消息环形缓冲 */ + this.buffers = new Map(); + /** @type {Map} 机器人 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} + */ + 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 +}; diff --git a/src/core/expansionLog.js b/src/core/expansionLog.js new file mode 100644 index 0000000..d520759 --- /dev/null +++ b/src/core/expansionLog.js @@ -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} 最近的开拓记录(时间升序) */ + 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} + */ + 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 }; diff --git a/src/core/expansionPlanner.js b/src/core/expansionPlanner.js index 92b5847..73b4599 100644 --- a/src/core/expansionPlanner.js +++ b/src/core/expansionPlanner.js @@ -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 }; diff --git a/src/core/territoryDataService.js b/src/core/territoryDataService.js index 4c53f90..5f4bb68 100644 --- a/src/core/territoryDataService.js +++ b/src/core/territoryDataService.js @@ -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} */ const capitals = new Map(); + /** @type {Map} 邦国名 → 扁平多边形列表(大地图直接绘制用) */ + 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, diff --git a/src/core/territoryModel.js b/src/core/territoryModel.js index a9bd430..d08443a 100644 --- a/src/core/territoryModel.js +++ b/src/core/territoryModel.js @@ -19,10 +19,12 @@ const API_DEFAULT_COLOR = 0xff43de; class TerritoryModel { /** - * @param {Map, color: number}>} [states] + * @param {Map, color: number, polygons?: number[][]}>} [states] */ constructor(states) { - /** @type {Map, color: number}>} */ + /** + * @type {Map, color: number, polygons?: number[][]}>} + */ this.states = new Map(states || []); /** @type {Set} 所有邦国已占领区块的并集 */ this.allClaimed = new Set(); @@ -126,7 +128,36 @@ class TerritoryModel { /** @returns {Set} 己方邦国的飞地(疏漏区块);未选择邦国时为空集 */ selfFlyingChunks() { - return this._flyingByStateMap().get(this.selfStateName) || EMPTY_SET; + return this.flyingChunksOf(this.selfStateName); + } + + /** + * 指定邦国的飞地(疏漏区块)。 + * + * 多机器人场景下每个机器人可能在不同邦国,因此不能只依赖全局的 selfStateName, + * 必须支持按邦国名查询。 + * + * @param {string} name 邦国名 + * @returns {Set} + */ + flyingChunksOf(name) { + if (!name) return EMPTY_SET; + return this._flyingByStateMap().get(name) || EMPTY_SET; + } + + /** + * 取「己方」区块集合:指定邦国领土 ∪ 会话新占领区块。 + * + * @param {string} name 邦国名 + * @returns {Set} 新区块集合(调用方可安全修改) + */ + 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>} 各邦国飞地区块(懒计算并缓存) */ @@ -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 覆盖的邦国原样保留 diff --git a/src/index.js b/src/index.js index 60748fe..0ca1f6d 100644 --- a/src/index.js +++ b/src/index.js @@ -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(); diff --git a/src/web/public/app.js b/src/web/public/app.js index 80e7aa9..75308a4 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -3,18 +3,31 @@ /** * AutoStore Bot Web 控制台前端逻辑。 * - * 三块职责: - * 1. 通过 WebSocket 接收实时状态与日志,渲染左侧状态面板; - * 2. 根据配置 schema 动态生成右侧配置表单,读写 `/api/config`; - * 3. 用 Canvas 绘制领土地图(支持缩放 / 平移 / 图层开关)。 + * 六块职责: + * 1. 控制台登录(登录验证通过后才允许操作各机器人的控制台 / 聊天框); + * 2. 通过 WebSocket 接收实时状态、运行日志、公屏消息与开拓记录; + * 3. 渲染多机器人列表与选中机器人的状态面板; + * 4. 根据配置 schema 动态生成配置表单(支持「仅对当前机器人生效」的覆盖模式); + * 5. Canvas 绘制领土地图(区块级)与大地图(爬取服务器地图瓦片 + 领土多边形); + * 6. 游戏公屏聊天框与开拓日志表格。 */ // ==================== 工具 ==================== const $ = (id) => document.getElementById(id); -/** 取 URL 上的 token 参数,后续所有请求都带上 */ -const TOKEN = new URLSearchParams(location.search).get('token') || ''; +/** 控制台凭证:优先 URL 上的 ?token=,其次浏览器本地保存(登录框写入) */ +let TOKEN = new URLSearchParams(location.search).get('token') || localStorage.getItem('autostore_token') || ''; + +function setToken(value) { + TOKEN = value || ''; + try { + if (TOKEN) localStorage.setItem('autostore_token', TOKEN); + else localStorage.removeItem('autostore_token'); + } catch { + // 隐私模式下 localStorage 可能不可用,忽略 + } +} function api(path, options) { const opts = options || {}; @@ -27,6 +40,10 @@ function api(path, options) { } catch { body = { ok: false, message: 'HTTP ' + res.status }; } + if (res.status === 401) { + showLogin('登录已失效,请重新登录'); + throw new Error(body.message || '未授权'); + } if (!res.ok || body.ok === false) { throw new Error(body.message || ('HTTP ' + res.status)); } @@ -43,11 +60,19 @@ function toast(message, isError) { toastTimer = setTimeout(() => { el.className = 'toast'; }, 3500); } +const pad2 = (n) => String(n).padStart(2, '0'); + function fmtTime(ts) { if (!ts) return '-'; const d = new Date(ts); - const pad = (n) => String(n).padStart(2, '0'); - return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; + return `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`; +} + +function fmtDateTime(ts) { + if (!ts) return '-'; + const d = new Date(ts); + return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ` + + `${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`; } function fmtAgo(ts) { @@ -59,6 +84,19 @@ function fmtAgo(ts) { return fmtTime(ts); } +function hexToRgba(hex, alpha) { + const value = String(hex).replace('#', ''); + const r = parseInt(value.slice(0, 2), 16) || 0; + const g = parseInt(value.slice(2, 4), 16) || 0; + const b = parseInt(value.slice(4, 6), 16) || 0; + return `rgba(${r},${g},${b},${alpha})`; +} + +function withToken(path) { + if (!TOKEN) return path; + return path + (path.includes('?') ? '&' : '?') + 'token=' + encodeURIComponent(TOKEN); +} + // ==================== 配置 schema ==================== const STRATEGY_OPTIONS = [ @@ -84,7 +122,7 @@ const CONFIG_SCHEMA = { title: '扩地策略', fields: [ { path: 'strategy', label: '策略', type: 'select', options: STRATEGY_OPTIONS }, - { path: 'stateName', label: '目标邦国', type: 'text', hint: '留空则自动识别机器人所在邦国' }, + { path: 'stateName', label: '目标邦国', type: 'text', hint: '留空则自动识别机器人所在邦国;多机器人时建议各自指定' }, { path: 'targetCount', label: '目标扩地数量', type: 'number', min: -1, hint: '-1 表示持续扩地直到无可用区块' }, { path: 'maxRings', label: '扩圈上限', type: 'number', min: 0, hint: '0 表示不限制' } ] @@ -194,6 +232,20 @@ const CONFIG_SCHEMA = { ] } ], + bigmap: [ + { + title: '大地图(爬取服务器地图站点)', + fields: [ + { path: 'bigMap.enabled', label: '启用大地图', type: 'checkbox' }, + { path: 'bigMap.world', label: '世界目录名', type: 'text', hint: '留空则回退环境变量 DYNMAP_WORLD' }, + { path: 'bigMap.zoom', label: '启动预爬取层级', type: 'number', min: 0, max: 8, hint: '0 = 全世界一张大图(本站 10×10=100 张瓦片)' }, + { path: 'bigMap.maxZoom', label: '最大可浏览层级', type: 'number', min: 0, max: 8, hint: '本站 squaremap 为 3(该层级 1 方块 = 1 像素)' }, + { path: 'bigMap.rangeBlocks', label: '覆盖半径(方块)', type: 'number', min: 256, hint: '默认 20480,与服务器世界边界一致' }, + { path: 'bigMap.crawlOnStart', label: '启动时自动预爬取', type: 'checkbox' }, + { path: 'bigMap.concurrency', label: '瓦片下载并发数', type: 'number', min: 1, max: 16 } + ] + } + ], grim: [ { title: 'Grim 反作弊合规层', @@ -216,8 +268,9 @@ const CONFIG_SCHEMA = { title: '服务器交互', fields: [ { path: 'server.claimCommand', label: '开垦模式指令', type: 'text', hint: '不带斜杠;留空表示服务器自动开启' }, - { path: 'server.loginCommand', label: '登录指令模板', type: 'text', hint: '用 {password} 占位,密码取自 .env 的 MC_PASSWORD' }, + { path: 'server.loginCommand', label: '登录指令模板', type: 'text', hint: '用 {password} 占位,密码取自 .env 的 MC_BOTS / MC_PASSWORD' }, { path: 'server.registerCommand', label: '注册指令模板', type: 'text', hint: '留空则不自动注册' }, + { path: 'server.loginConfirmTimeoutMs', label: '登录结果确认超时(毫秒)', type: 'number', min: 0, hint: '超时未收到登录提示则按已登录放行;0 表示禁用兜底' }, { path: 'server.commandCooldownMs', label: '指令最小间隔(毫秒)', type: 'number', min: 0 }, { path: 'server.autoReconnect', label: '断线自动重连', type: 'checkbox' }, { path: 'server.reconnectDelayMs', label: '重连间隔(毫秒)', type: 'number', min: 1000 } @@ -226,9 +279,94 @@ const CONFIG_SCHEMA = { ] }; +// ==================== 全局状态 ==================== + +/** 当前选中的机器人 id */ +let selectedBotId = ''; +/** 最近一次状态负载 */ +let lastStatus = null; +/** 机器人 id → 该机器人的设置快照 */ +const botSettings = new Map(); + +// ==================== 登录 ==================== + +let appStarted = false; + +function showLogin(message) { + $('loginOverlay').classList.remove('hidden'); + if (message) $('loginError').textContent = message; +} + +function hideLogin() { + $('loginOverlay').classList.add('hidden'); + $('loginError').textContent = ''; +} + +async function checkSession() { + try { + const res = await fetch('/api/session', { + headers: TOKEN ? { 'X-Auth-Token': TOKEN } : {} + }).then((r) => r.json()); + const data = res.data || {}; + $('authBadge').textContent = data.required ? (data.authenticated ? '已登录' : '未登录') : '免登录'; + $('authBadge').className = 'badge ' + (data.authenticated || !data.required ? 'badge-online' : 'badge-idle'); + if (data.required && !data.authenticated) { + showLogin(); + return false; + } + hideLogin(); + return true; + } catch { + return true; + } +} + +async function doLogin() { + const password = $('loginPassword').value; + if (!password) { + $('loginError').textContent = '请输入密码 / 令牌'; + return; + } + try { + const res = await fetch('/api/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }) + }).then((r) => r.json()); + if (!res.ok) { + $('loginError').textContent = res.message || '登录失败'; + return; + } + setToken(password); + hideLogin(); + $('authBadge').textContent = '已登录'; + $('authBadge').className = 'badge badge-online'; + if (appStarted) { + // 之前已初始化过(退出登录后再登录):只需恢复实时推送 + wsEnabled = true; + connectWs(); + } else { + await startApp(); + } + toast('登录成功'); + } catch (err) { + $('loginError').textContent = '登录失败:' + err.message; + } +} + +function doLogout() { + setToken(''); + wsEnabled = false; + $('authBadge').textContent = '未登录'; + $('authBadge').className = 'badge badge-idle'; + if (ws) { try { ws.close(); } catch { /* 忽略 */ } ws = null; } + showLogin('已退出登录'); +} + // ==================== 配置读写 ==================== let currentConfig = null; +let activeTab = 'strategy'; function getByPath(obj, path) { return path.split('.').reduce((acc, key) => (acc == null ? undefined : acc[key]), obj); @@ -244,8 +382,6 @@ function setByPath(obj, path, value) { cursor[keys[keys.length - 1]] = value; } -let activeTab = 'strategy'; - function renderConfig() { const body = $('configBody'); body.innerHTML = ''; @@ -334,8 +470,13 @@ function collectConfig() { async function loadConfig() { try { - const res = await api('/api/config'); - currentConfig = res.data; + if ($('chkBotOverride').checked && selectedBotId) { + const res = await api('/api/bots/' + encodeURIComponent(selectedBotId) + '/settings'); + currentConfig = res.data.config; + } else { + const res = await api('/api/config'); + currentConfig = res.data; + } renderConfig(); } catch (err) { toast('读取配置失败:' + err.message, true); @@ -345,29 +486,147 @@ async function loadConfig() { async function saveConfig() { try { const config = collectConfig(); - const res = await api('/api/config', { method: 'POST', body: JSON.stringify(config) }); - currentConfig = res.data; + if ($('chkBotOverride').checked && selectedBotId) { + const res = await api('/api/bots/' + encodeURIComponent(selectedBotId) + '/settings', { + method: 'POST', + body: JSON.stringify({ config }) + }); + currentConfig = res.data.config; + toast('已保存为「' + selectedBotId + '」的专属配置'); + } else { + const res = await api('/api/config', { method: 'POST', body: JSON.stringify(config) }); + currentConfig = res.data; + toast('全局配置已保存并应用'); + } renderConfig(); - toast('配置已保存并应用'); } catch (err) { toast('保存配置失败:' + err.message, true); } } +// ==================== 机器人列表 ==================== + +const BOT_STATE_TEXT = { online: '已连接', connecting: '连接中', offline: '已断开', idle: '未连接' }; + +/** 上次渲染机器人列表用的签名:状态每秒推送一次,签名不变就不重建 DOM */ +let botListSignature = ''; + +function botSignature(bots) { + return bots.map((b) => [ + b.id, b.state, b.enabled, b.task ? b.task.state : '', b.task ? b.task.completedClaims : '', + b.position ? Math.round(b.position.x) : '', b.position ? Math.round(b.position.z) : '' + ].join(':')).join('|'); +} + +function renderBotList(status) { + const bots = status.bots || []; + $('botCount').textContent = String(bots.length); + const signature = botSignature(bots) + '#' + selectedBotId; + if (signature === botListSignature) return; + botListSignature = signature; + + const list = $('botList'); + list.innerHTML = ''; + if (bots.length === 0) { + const empty = document.createElement('div'); + empty.className = 'exp-empty'; + empty.textContent = '未配置任何机器人:请在 .env 设置 MC_BOTS 或 MC_USERNAME / MC_PASSWORD'; + list.appendChild(empty); + return; + } + for (const bot of bots) { + const item = document.createElement('div'); + item.className = 'bot-item' + (bot.id === selectedBotId ? ' active' : ''); + item.dataset.botId = bot.id; + + const dot = document.createElement('span'); + dot.className = 'dot ' + (bot.enabled === false ? 'disabled' : (bot.state || 'idle')); + item.appendChild(dot); + + const info = document.createElement('div'); + const name = document.createElement('div'); + name.className = 'name'; + name.textContent = bot.label && bot.label !== bot.username + ? `${bot.label}(${bot.username})` : (bot.username || bot.id); + const sub = document.createElement('div'); + sub.className = 'sub'; + const pos = bot.position ? `${bot.position.x}, ${bot.position.z}` : '未知位置'; + sub.textContent = `${BOT_STATE_TEXT[bot.state] || bot.state} · ${pos}`; + info.appendChild(name); + info.appendChild(sub); + item.appendChild(info); + + const meta = document.createElement('div'); + meta.className = 'meta'; + meta.textContent = (bot.task ? bot.task.state : 'IDLE') + ' · ' + (bot.task ? bot.task.completedClaims : 0); + item.appendChild(meta); + + item.addEventListener('click', () => selectBot(bot.id)); + list.appendChild(item); + } + + // 机器人选择下拉 + const select = $('botSelect'); + const previous = select.value; + select.innerHTML = ''; + for (const bot of bots) { + const opt = document.createElement('option'); + opt.value = bot.id; + opt.textContent = bot.username || bot.id; + select.appendChild(opt); + } + const desired = bots.some((b) => b.id === selectedBotId) ? selectedBotId + : (bots.some((b) => b.id === previous) ? previous : (status.defaultBotId || (bots[0] && bots[0].id) || '')); + select.value = desired; + if (desired && desired !== selectedBotId) selectBot(desired, true); + + // 开拓日志的机器人过滤器 + const expFilter = $('expFilterBot'); + const expPrevious = expFilter.value; + expFilter.innerHTML = ''; + for (const bot of bots) { + const opt = document.createElement('option'); + opt.value = bot.id; + opt.textContent = bot.username || bot.id; + expFilter.appendChild(opt); + } + expFilter.value = expPrevious; +} + +function selectBot(id, silent) { + if (!id) return; + const changed = id !== selectedBotId; + selectedBotId = id; + botListSignature = ''; + $('botSelect').value = id; + $('consoleTarget').textContent = id; + for (const item of document.querySelectorAll('.bot-item')) { + item.classList.toggle('active', item.dataset.botId === id); + } + if (changed || !silent) { + loadConfig(); + loadChat(); + } + if (lastStatus) renderStatus(lastStatus); +} + // ==================== 状态渲染 ==================== -let lastStatus = null; +function selectedBot(status) { + const bots = (status && status.bots) || []; + return bots.find((b) => b.id === selectedBotId) || bots[0] || null; +} function renderStatus(status) { lastStatus = status; - const bot = status.bot || {}; - const task = status.task || {}; - const data = status.data || {}; + renderBotList(status); + + const bot = selectedBot(status) || {}; + const task = bot.task || {}; // ===== 顶栏徽标 ===== const connBadge = $('connBadge'); - const stateText = { online: '已连接', connecting: '连接中', offline: '已断开', idle: '未连接' }; - connBadge.textContent = stateText[bot.state] || bot.state || '未知'; + connBadge.textContent = BOT_STATE_TEXT[bot.state] || bot.state || '未知'; connBadge.className = 'badge ' + (bot.state === 'online' ? 'badge-online' : 'badge-offline'); const taskBadge = $('taskBadge'); @@ -385,12 +644,24 @@ function renderStatus(status) { $('kHealth').textContent = bot.health === null || bot.health === undefined ? '-' : `${bot.health.toFixed(1)} / ${bot.food === null || bot.food === undefined ? '-' : bot.food}`; - $('kBotState').textContent = (bot.loggedIn ? '已登录 · ' : '') + (bot.riding ? '乘船中 · ' : '') + (bot.inWater ? '水中' : '陆地'); + $('kBotState').textContent = (bot.riding ? '乘船中 · ' : '') + (bot.inWater ? '水中' : '陆地'); + const loginEl = $('kLogin'); + loginEl.textContent = bot.loginFailed ? '登录被拒绝' : (bot.loggedIn ? '已通过' : '未通过'); + loginEl.style.color = bot.loginFailed ? 'var(--red)' : (bot.loggedIn ? 'var(--green)' : 'var(--yellow)'); $('kReconnect').textContent = String(bot.reconnectAttempts || 0); + if (document.activeElement !== $('chkEnabled')) $('chkEnabled').checked = bot.enabled !== false; + if (document.activeElement !== $('chkAutoStart')) $('chkAutoStart').checked = bot.autoStart === true; + + const canOperate = bot.canOperate === true; + $('consoleHint').textContent = canOperate + ? '登录验证通过:可以直接向该机器人发送指令或公屏消息。' + : '该机器人尚未通过游戏内登录验证,发送指令 / 消息会被拒绝。'; + $('consoleHint').style.color = canOperate ? 'var(--muted)' : 'var(--yellow)'; // ===== 任务卡片 ===== $('kTaskState').textContent = task.state || 'IDLE'; $('kTaskStatus').textContent = task.status || '-'; + $('kTaskStateName').textContent = task.stateName || '-'; $('kTarget').textContent = task.target ? `${task.target.x}, ${task.target.z}` : '-'; $('kQueue').textContent = String(task.queuedTargets || 0); $('kCompleted').textContent = String(task.completedClaims || 0); @@ -401,6 +672,7 @@ function renderStatus(status) { $('progressBar').style.width = (task.lastProgress || 0) + '%'; // ===== 数据源卡片 ===== + const data = status.data || {}; $('kMarkersUrl').textContent = data.markersUrl || '-'; $('kLastFetch').textContent = data.lastSuccessfulFetchMillis ? fmtAgo(data.lastSuccessfulFetchMillis) : '从未'; $('kDataError').textContent = data.lastError || '无'; @@ -414,11 +686,24 @@ function renderStatus(status) { : '未启用'; if (data.apiError) $('kApi').textContent += ' · ' + data.apiError; + // ===== 开拓日志统计 ===== + if (status.expansionLog) { + $('btnExportExp').title = `累计 ${status.expansionLog.total} 条,今日 ${status.expansionLog.today} 条`; + } + + // ===== 大地图爬取进度 ===== + if (status.bigMap && status.bigMap.crawl) { + const crawl = status.bigMap.crawl; + $('btnCrawlMap').textContent = crawl.running + ? `爬取中 ${crawl.done}/${crawl.total}` + : '爬取地图'; + } + // ===== Grim 卡片 ===== const grim = bot.grim; if (grim) { $('kGrimEnabled').textContent = grim.enabled ? '已启用' : '已关闭'; - $('kGrimRot').textContent = `${grim.maxRotationPerTickDeg}°(实际 ${grim.effectiveStepDeg}°/tick)`; + $('kGrimRot').textContent = `${grim.maxRotationPerTickDeg}°(实际 ${grim.effectiveStepDeg}°/s)`; $('kGrimSettled').textContent = grim.rotationSettled ? '是' : '否'; $('kGrimReach').textContent = grim.attackReach + ' 格'; $('kGrimCooldown').textContent = grim.attackCooldownMs > 0 ? grim.attackCooldownMs + ' ms' : '就绪'; @@ -433,9 +718,11 @@ function renderStatus(status) { issues.appendChild(li); } } + + drawMap(); } -// ==================== 日志 ==================== +// ==================== 运行日志 ==================== const MAX_LOG_LINES = 800; @@ -458,8 +745,151 @@ function renderLogs(entries) { for (const entry of entries) appendLog(entry); } +// ==================== 公屏聊天 ==================== + +const MAX_CHAT_LINES = 600; +/** 已渲染的公屏消息(用于「全部机器人」切换时重绘) */ +let chatEntries = []; + +const CHAT_KIND_CLASS = { + chat: 'chat-line', + system: 'chat-line chat-system', + action: 'chat-line chat-action', + out: 'chat-line chat-out', + command: 'chat-line chat-command' +}; + +function chatMatchesFilter(entry) { + if ($('chkChatAllBots').checked) return true; + return entry.botId === selectedBotId; +} + +function appendChat(entry, skipScroll) { + const view = $('chatView'); + const line = document.createElement('span'); + line.className = CHAT_KIND_CLASS[entry.kind] || 'chat-line'; + + const time = document.createElement('span'); + time.className = 'chat-time'; + time.textContent = '[' + fmtTime(entry.t) + ']'; + line.appendChild(time); + + const bot = document.createElement('span'); + bot.className = 'chat-bot'; + bot.textContent = (entry.bot || entry.botId) + ' ›'; + line.appendChild(bot); + + if (entry.username) { + const user = document.createElement('span'); + user.className = 'chat-user'; + user.textContent = entry.username + ':'; + line.appendChild(user); + } + line.appendChild(document.createTextNode(entry.message)); + + view.appendChild(line); + while (view.childNodes.length > MAX_CHAT_LINES) view.removeChild(view.firstChild); + if (!skipScroll && $('chkChatAutoScroll').checked) view.scrollTop = view.scrollHeight; +} + +function renderChat() { + $('chatView').innerHTML = ''; + for (const entry of chatEntries) { + if (chatMatchesFilter(entry)) appendChat(entry, true); + } + const view = $('chatView'); + if ($('chkChatAutoScroll').checked) view.scrollTop = view.scrollHeight; +} + +async function loadChat() { + try { + const ids = $('chkChatAllBots').checked + ? (((lastStatus && lastStatus.bots) || []).map((b) => b.id)) + : [selectedBotId]; + const usable = ids.filter(Boolean); + if (usable.length === 0) { + chatEntries = []; + renderChat(); + return; + } + const results = await Promise.all(usable.map((id) => api( + '/api/bots/' + encodeURIComponent(id) + '/chat?limit=200' + ).then((res) => res.data || []).catch(() => []))); + chatEntries = results.flat().sort((a, b) => a.t - b.t).slice(-MAX_CHAT_LINES); + renderChat(); + } catch (err) { + toast('读取公屏日志失败:' + err.message, true); + } +} + +// ==================== 开拓日志 ==================== + +const MAX_EXP_ROWS = 500; +let expEntries = []; + +function expMatchesFilter(entry) { + const filter = $('expFilterBot').value; + return !filter || entry.botId === filter; +} + +function appendExp(entry, skipScroll) { + const body = $('expBody'); + const row = document.createElement('tr'); + const cells = [ + fmtDateTime(entry.t), + entry.bot || entry.botId, + entry.state || '-', + `${entry.cx}, ${entry.cz}`, + entry.bx === null || entry.bx === undefined ? '-' : `${entry.bx}, ${entry.bz}`, + entry.source || '-' + ]; + for (const text of cells) { + const td = document.createElement('td'); + td.textContent = text; + row.appendChild(td); + } + body.appendChild(row); + while (body.childNodes.length > MAX_EXP_ROWS) body.removeChild(body.firstChild); + if (!skipScroll && $('chkExpAutoScroll').checked) { + const view = $('expView'); + view.scrollTop = view.scrollHeight; + } +} + +function renderExp() { + $('expBody').innerHTML = ''; + const filtered = expEntries.filter(expMatchesFilter); + if (filtered.length === 0) { + const body = $('expBody'); + const row = document.createElement('tr'); + const td = document.createElement('td'); + td.colSpan = 6; + td.className = 'exp-empty'; + td.textContent = '暂无开拓记录'; + row.appendChild(td); + body.appendChild(row); + return; + } + for (const entry of filtered) appendExp(entry, true); + const view = $('expView'); + if ($('chkExpAutoScroll').checked) view.scrollTop = view.scrollHeight; +} + +async function loadExpansionLog() { + try { + const res = await api('/api/expansion-log?limit=300'); + expEntries = (res.data && res.data.entries) || []; + renderExp(); + } catch (err) { + toast('读取开拓日志失败:' + err.message, true); + } +} + // ==================== 地图 ==================== +/** 当前地图视图:big(大地图) | territory(领土地图) */ +let mapMode = 'big'; + const mapView = { centerX: 0, centerZ: 0, @@ -468,67 +898,294 @@ const mapView = { lastX: 0, lastY: 0, data: null, - /** 是否已经按玩家位置做过首次居中 */ centered: false }; +const bigMapView = { + /** 视图中心(方块坐标) */ + centerX: 0, + centerZ: 0, + /** 缩放:每个方块占多少 CSS 像素 */ + pxPerBlock: 0.02, + data: null, + markers: null, + /** 瓦片图片缓存:`z/x_y` → HTMLImageElement */ + tiles: new Map(), + pending: new Set(), + fitted: false, + dragging: false, + lastX: 0, + lastY: 0 +}; + +function canvasContext() { + const canvas = $('mapCanvas'); + return { canvas, ctx: canvas.getContext('2d'), rect: canvas.getBoundingClientRect() }; +} + function initMap() { const canvas = $('mapCanvas'); - const ctx = canvas.getContext('2d'); const resize = () => { const rect = canvas.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; canvas.width = Math.max(1, Math.floor(rect.width * dpr)); canvas.height = Math.max(1, Math.floor(rect.height * dpr)); - ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + canvas.getContext('2d').setTransform(dpr, 0, 0, dpr, 0, 0); drawMap(); }; window.addEventListener('resize', resize); canvas.addEventListener('mousedown', (e) => { - mapView.dragging = true; - mapView.lastX = e.clientX; - mapView.lastY = e.clientY; + // 只让当前视图接管拖拽,否则两个视图会同时平移 + if (mapMode === 'big') { + bigMapView.dragging = true; + bigMapView.lastX = e.clientX; + bigMapView.lastY = e.clientY; + } else { + mapView.dragging = true; + mapView.lastX = e.clientX; + mapView.lastY = e.clientY; + } + }); + window.addEventListener('mouseup', () => { + mapView.dragging = false; + bigMapView.dragging = false; }); - window.addEventListener('mouseup', () => { mapView.dragging = false; }); window.addEventListener('mousemove', (e) => { - if (!mapView.dragging) return; - const dx = e.clientX - mapView.lastX; - const dy = e.clientY - mapView.lastY; - mapView.lastX = e.clientX; - mapView.lastY = e.clientY; - mapView.centerX -= dx / mapView.scale; - mapView.centerZ -= dy / mapView.scale; - drawMap(); + if (mapView.dragging) { + const dx = e.clientX - mapView.lastX; + const dy = e.clientY - mapView.lastY; + mapView.lastX = e.clientX; + mapView.lastY = e.clientY; + mapView.centerX -= dx / mapView.scale; + mapView.centerZ -= dy / mapView.scale; + drawMap(); + } else if (bigMapView.dragging) { + const dx = e.clientX - bigMapView.lastX; + const dy = e.clientY - bigMapView.lastY; + bigMapView.lastX = e.clientX; + bigMapView.lastY = e.clientY; + bigMapView.centerX -= dx / bigMapView.pxPerBlock; + bigMapView.centerZ -= dy / bigMapView.pxPerBlock; + drawMap(); + } }); canvas.addEventListener('wheel', (e) => { e.preventDefault(); + if (mapMode === 'big') { + const factor = e.deltaY < 0 ? 1.2 : 1 / 1.2; + bigMapView.pxPerBlock = Math.min(4, Math.max(0.002, bigMapView.pxPerBlock * factor)); + drawMap(); + return; + } const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15; mapView.scale = Math.min(24, Math.max(0.15, mapView.scale * factor)); drawMap(); }, { passive: false }); setTimeout(resize, 0); - window.addEventListener('resize', resize); } -function chunkToScreen(cx, cz, canvas) { - const rect = canvas.getBoundingClientRect(); - const w = rect.width; - const h = rect.height; +/** 区块坐标 → 画布坐标(领土地图视图) */ +function chunkToScreen(cx, cz) { + const rect = $('mapCanvas').getBoundingClientRect(); const scale = mapView.scale; return { - x: w / 2 + (cx - mapView.centerX) * scale, - y: h / 2 + (cz - mapView.centerZ) * scale, + x: rect.width / 2 + (cx - mapView.centerX) * scale, + y: rect.height / 2 + (cz - mapView.centerZ) * scale, scale }; } +/** 方块坐标 → 画布坐标(大地图视图) */ +function blockToScreen(bx, bz) { + const rect = $('mapCanvas').getBoundingClientRect(); + const px = bigMapView.pxPerBlock; + return { + x: rect.width / 2 + (bx - bigMapView.centerX) * px, + y: rect.height / 2 + (bz - bigMapView.centerZ) * px + }; +} + +/** 大地图瓦片层级:让每张瓦片在屏幕上约为 512 像素 */ +function currentTileZoom() { + const data = bigMapView.data; + const maxZoom = data ? data.maxZoom : 3; + const raw = Math.round(maxZoom + Math.log2(bigMapView.pxPerBlock)); + return Math.min(maxZoom, Math.max(0, raw)); +} + +/** 层级 z 下每张瓦片覆盖的方块数(与后端 bigMapService.blocksPerTile 一致) */ +function blocksPerTile(z) { + const data = bigMapView.data; + const maxZoom = data ? data.maxZoom : 3; + const tilePixels = data ? data.tilePixels : 512; + return tilePixels * Math.pow(2, maxZoom - z); +} + function drawMap() { - const canvas = $('mapCanvas'); - const ctx = canvas.getContext('2d'); - const rect = canvas.getBoundingClientRect(); + if (mapMode === 'big') drawBigMap(); + else drawTerritoryMap(); +} + +// ==================== 大地图渲染 ==================== + +function drawBigMap() { + const { ctx, rect } = canvasContext(); + const w = rect.width; + const h = rect.height; + ctx.clearRect(0, 0, w, h); + ctx.fillStyle = '#05070a'; + ctx.fillRect(0, 0, w, h); + + const data = bigMapView.data; + if (!data || data.enabled === false) { + ctx.fillStyle = '#48505e'; + ctx.font = '13px "Microsoft YaHei", sans-serif'; + ctx.fillText(data && data.enabled === false ? '大地图已关闭(配置 → 大地图)' : '正在读取大地图信息…', 16, 24); + return; + } + + const px = bigMapView.pxPerBlock; + const zoom = currentTileZoom(); + const perTile = blocksPerTile(zoom); + + // ===== 可见方块范围 → 需要的瓦片下标范围 ===== + const minBlockX = bigMapView.centerX - w / 2 / px; + const maxBlockX = bigMapView.centerX + w / 2 / px; + const minBlockZ = bigMapView.centerZ - h / 2 / px; + const maxBlockZ = bigMapView.centerZ + h / 2 / px; + const minTileX = Math.floor(minBlockX / perTile); + const maxTileX = Math.floor(maxBlockX / perTile); + const minTileZ = Math.floor(minBlockZ / perTile); + const maxTileZ = Math.floor(maxBlockZ / perTile); + + let drawn = 0; + let missing = 0; + for (let tx = minTileX; tx <= maxTileX; tx++) { + for (let tz = minTileZ; tz <= maxTileZ; tz++) { + const key = `${zoom}/${tx}_${tz}`; + const img = bigMapView.tiles.get(key); + if (img && img.complete && img.naturalWidth > 0) { + const x0 = w / 2 + (tx * perTile - bigMapView.centerX) * px; + const y0 = h / 2 + (tz * perTile - bigMapView.centerZ) * px; + ctx.drawImage(img, x0, y0, perTile * px, perTile * px); + drawn++; + } else { + missing++; + requestTile(zoom, tx, tz, key); + } + } + } + + // ===== 领土标记(多边形直接按方块坐标绘制)===== + if ($('chkTerritory').checked && bigMapView.markers) { + ctx.save(); + ctx.translate(w / 2 - bigMapView.centerX * px, h / 2 - bigMapView.centerZ * px); + ctx.scale(px, px); + ctx.lineWidth = 1 / px; + for (const state of bigMapView.markers.states) { + if (!state.path) continue; + ctx.fillStyle = hexToRgba(state.color, 0.26); + ctx.fill(state.path, 'evenodd'); + ctx.strokeStyle = hexToRgba(state.color, 0.85); + ctx.stroke(state.path); + } + // 本次会话新占领:只有区块没有多边形,按 16×16 方块画 + ctx.fillStyle = 'rgba(63,185,80,0.9)'; + for (const flat of bigMapView.sessionChunks || []) { + for (let i = 0; i < flat.length; i += 2) { + ctx.fillRect(flat[i] * 16, flat[i + 1] * 16, 16, 16); + } + } + ctx.restore(); + } + + // ===== 王城 ===== + if (bigMapView.markers) { + for (const state of bigMapView.markers.states) { + if (!state.capital) continue; + const p = blockToScreen(state.capital.x * 16, state.capital.z * 16); + if (p.x < -30 || p.y < -30 || p.x > w + 30 || p.y > h + 30) continue; + ctx.fillStyle = '#bc8cff'; + ctx.beginPath(); + ctx.arc(p.x, p.y, 3.5, 0, Math.PI * 2); + ctx.fill(); + if (px >= 0.02) { + ctx.fillStyle = 'rgba(188,140,255,0.9)'; + ctx.font = '11px "Microsoft YaHei", sans-serif'; + ctx.fillText(state.name, p.x + 6, p.y + 4); + } + } + } + + // ===== 所有机器人的坐标 ===== + drawBotMarkers(ctx, (bot) => blockToScreen(bot.position.x, bot.position.z)); + + const crawl = data.crawl || {}; + $('mapInfo').textContent = `大地图 · 层级 ${zoom}(${perTile} 方块/瓦片) · ` + + `${px < 0.05 ? (1 / px).toFixed(0) + ' 方块/像素' : px.toFixed(2) + ' 像素/方块'} · ` + + `中心 ${Math.round(bigMapView.centerX)}, ${Math.round(bigMapView.centerZ)}` + + (missing > 0 ? ` · 加载中 ${missing} 张` : ` · 已绘制 ${drawn} 张`) + + (crawl.running ? ` · 预爬取 ${crawl.done}/${crawl.total}` : ''); +} + +/** 请求一张瓦片(带凭证),加载完成后重绘 */ +function requestTile(zoom, tx, tz, key) { + if (bigMapView.pending.has(key)) return; + bigMapView.pending.add(key); + const img = new Image(); + img.onload = () => { + bigMapView.pending.delete(key); + bigMapView.tiles.set(key, img); + // 只缓存最近使用的瓦片,避免长时间缩放把内存吃满 + if (bigMapView.tiles.size > 600) { + const oldest = bigMapView.tiles.keys().next(); + if (!oldest.done) bigMapView.tiles.delete(oldest.value); + } + drawMap(); + }; + img.onerror = () => { + bigMapView.pending.delete(key); + }; + // 路由形如 /api/bigmap/tile/:z/:x/:y.png(源站的 {z}/{x}_{y}.png 在服务端代理) + img.src = withToken(`/api/bigmap/tile/${zoom}/${tx}/${tz}.png`); +} + +/** 在画布上绘制所有机器人的坐标点(含名字) */ +function drawBotMarkers(ctx, project) { + const bots = (lastStatus && lastStatus.bots) || []; + const showLabels = $('chkBotLabels').checked; + const rect = $('mapCanvas').getBoundingClientRect(); + for (const bot of bots) { + if (!bot.position) continue; + const p = project(bot); + if (!Number.isFinite(p.x) || !Number.isFinite(p.y)) continue; + if (p.x < -40 || p.y < -40 || p.x > rect.width + 40 || p.y > rect.height + 40) continue; + ctx.beginPath(); + ctx.arc(p.x, p.y, 5, 0, Math.PI * 2); + ctx.fillStyle = bot.state === 'online' ? '#ffd33d' : '#6e7681'; + ctx.fill(); + ctx.strokeStyle = '#0d1117'; + ctx.lineWidth = 1.5; + ctx.stroke(); + if (showLabels) { + const label = bot.username || bot.id; + ctx.font = '11px "Microsoft YaHei", sans-serif'; + const width = ctx.measureText(label).width; + ctx.fillStyle = 'rgba(5,7,10,0.72)'; + ctx.fillRect(p.x + 8, p.y - 14, width + 8, 16); + ctx.fillStyle = bot.state === 'online' ? '#ffd33d' : '#8b949e'; + ctx.fillText(label, p.x + 12, p.y - 2); + } + } +} + +// ==================== 领土地图渲染 ==================== + +function drawTerritoryMap() { + const { ctx, rect } = canvasContext(); const w = rect.width; const h = rect.height; ctx.clearRect(0, 0, w, h); @@ -542,7 +1199,7 @@ function drawMap() { const drawChunks = (flat, color) => { ctx.fillStyle = color; for (let i = 0; i < flat.length; i += 2) { - const p = chunkToScreen(flat[i], flat[i + 1], canvas); + const p = chunkToScreen(flat[i], flat[i + 1]); if (p.x < -cell || p.y < -cell || p.x > w + cell || p.y > h + cell) continue; ctx.fillRect(p.x, p.y, cell, cell); } @@ -560,11 +1217,11 @@ function drawMap() { const endZ = Math.ceil(mapView.centerZ + halfZ); ctx.beginPath(); for (let x = startX; x <= endX; x++) { - const sx = Math.round(chunkToScreen(x, 0, canvas).x) + 0.5; + const sx = Math.round(chunkToScreen(x, 0).x) + 0.5; ctx.moveTo(sx, 0); ctx.lineTo(sx, h); } for (let z = startZ; z <= endZ; z++) { - const sy = Math.round(chunkToScreen(0, z, canvas).y) + 0.5; + const sy = Math.round(chunkToScreen(0, z).y) + 0.5; ctx.moveTo(0, sy); ctx.lineTo(w, sy); } ctx.stroke(); @@ -577,7 +1234,6 @@ function drawMap() { return; } - // 全部邦国(按需加载) if ($('chkFull').checked && data.otherChunks) { for (const state of data.otherChunks) { drawChunks(state.chunks, hexToRgba(state.color, 0.55)); @@ -591,7 +1247,7 @@ function drawMap() { // 王城 for (const state of data.states || []) { if (!state.capital) continue; - const p = chunkToScreen(state.capital.x, state.capital.z, canvas); + const p = chunkToScreen(state.capital.x, state.capital.z); if (p.x < -20 || p.y < -20 || p.x > w + 20 || p.y > h + 20) continue; ctx.fillStyle = '#bc8cff'; ctx.beginPath(); @@ -606,7 +1262,7 @@ function drawMap() { // 当前目标 if (data.target) { - const p = chunkToScreen(data.target.x, data.target.z, canvas); + const p = chunkToScreen(data.target.x, data.target.z); ctx.strokeStyle = '#d29922'; ctx.lineWidth = 2; const size = Math.max(8, scale * 2); @@ -619,43 +1275,24 @@ function drawMap() { ctx.stroke(); } - // 机器人位置 - if (data.playerChunk) { - const p = chunkToScreen(data.playerChunk.x, data.playerChunk.z, canvas); - ctx.fillStyle = '#ffffff'; - ctx.beginPath(); - ctx.arc(p.x + scale / 2, p.y + scale / 2, Math.max(3, scale * 0.6), 0, Math.PI * 2); - ctx.fill(); - ctx.strokeStyle = '#0d1117'; - ctx.lineWidth = 1.5; - ctx.stroke(); - } + // 所有机器人的坐标(多机器人一览) + drawBotMarkers(ctx, (bot) => chunkToScreen(bot.chunk.x, bot.chunk.z)); - $('mapInfo').textContent = `缩放 ${scale.toFixed(2)} px/区块 · 中心 ${Math.round(mapView.centerX)}, ${Math.round(mapView.centerZ)}`; + $('mapInfo').textContent = `领土地图 · 缩放 ${scale.toFixed(2)} px/区块 · ` + + `中心 ${Math.round(mapView.centerX)}, ${Math.round(mapView.centerZ)}`; } -function hexToRgba(hex, alpha) { - const value = String(hex).replace('#', ''); - const r = parseInt(value.slice(0, 2), 16) || 0; - const g = parseInt(value.slice(2, 4), 16) || 0; - const b = parseInt(value.slice(4, 6), 16) || 0; - return `rgba(${r},${g},${b},${alpha})`; -} +// ==================== 地图数据加载 ==================== let mapLoading = false; -/** 上次拉取「全部邦国」地图数据的时间戳(全量数据体积大,需限频) */ let lastFullFetchAt = 0; -/** 全量地图数据的最短拉取间隔:默认 5 分钟 */ const FULL_REFRESH_INTERVAL_MS = 5 * 60 * 1000; /** - * 拉取地图数据。 + * 拉取领土地图数据。 * * `full=1` 会带上全部邦国的区块(实测 11.5 万区块约 1.5MB JSON), - * 因此只在「用户勾选全部邦国」「手动刷新」「超过 5 分钟」时才拉全量, - * 其余定时刷新只拉己方领土,并复用上一次的全量数据,避免每 20 秒传 1.5MB。 - * - * @param {boolean} [forceFull] 强制拉取全量数据 + * 因此只在「用户勾选全部邦国」「手动刷新」「超过 5 分钟」时才拉全量。 */ async function loadMap(forceFull) { if (mapLoading) return; @@ -665,13 +1302,12 @@ async function loadMap(forceFull) { const needFull = $('chkFull').checked && (forceFull === true || !cached || !cached.otherChunks || Date.now() - lastFullFetchAt > FULL_REFRESH_INTERVAL_MS); - const res = await api('/api/map?full=' + (needFull ? '1' : '0')); + const path = '/api/map?full=' + (needFull ? '1' : '0') + + (selectedBotId ? '&bot=' + encodeURIComponent(selectedBotId) : ''); + const res = await api(path); const next = res.data; - if (needFull) { - lastFullFetchAt = Date.now(); - } else if (cached && cached.otherChunks) { - next.otherChunks = cached.otherChunks; - } + if (needFull) lastFullFetchAt = Date.now(); + else if (cached && cached.otherChunks) next.otherChunks = cached.otherChunks; mapView.data = next; if (!mapView.centered && next.playerChunk) { mapView.centerX = next.playerChunk.x; @@ -686,12 +1322,97 @@ async function loadMap(forceFull) { } } +let bigMapLoading = false; +let bigMapMarkersAt = 0; + +/** 拉取大地图元信息与领土标记 */ +async function loadBigMap(force) { + if (bigMapLoading) return; + bigMapLoading = true; + try { + const res = await api('/api/bigmap'); + const data = res.data; + bigMapView.data = data; + // 领土标记是 160KB 级别的数据,按需刷新(默认 5 分钟一次) + if (data.markers && data.markers.states && (force || Date.now() - bigMapMarkersAt > FULL_REFRESH_INTERVAL_MS)) { + setBigMapMarkers(data.markers); + bigMapMarkersAt = Date.now(); + } + // 会话新占领的区块:复用领土地图的数据 + if (mapView.data) bigMapView.sessionChunks = [mapView.data.session || []]; + + if (!bigMapView.fitted && data.rangeBlocks) { + fitBigMapToWorld(); + } + drawMap(); + } catch (err) { + toast('读取大地图失败:' + err.message, true); + } finally { + bigMapLoading = false; + } +} + +/** 把领土多边形预编译为 Path2D(按方块坐标),避免每帧重复构建路径 */ +function setBigMapMarkers(markers) { + const states = (markers && markers.states) || []; + for (const state of states) { + const path = new Path2D(); + let has = false; + for (const flat of state.polygons || []) { + if (flat.length < 6) continue; + path.moveTo(flat[0], flat[1]); + for (let i = 2; i < flat.length; i += 2) path.lineTo(flat[i], flat[i + 1]); + path.closePath(); + has = true; + } + state.path = has ? path : null; + } + bigMapView.markers = { world: markers.world, updatedAt: markers.updatedAt, states }; +} + +/** 让全世界刚好铺满画布 */ +function fitBigMapToWorld() { + const data = bigMapView.data; + const rect = $('mapCanvas').getBoundingClientRect(); + const radius = (data && data.rangeBlocks) || 20480; + bigMapView.centerX = 0; + bigMapView.centerZ = 0; + bigMapView.pxPerBlock = Math.min(rect.width, rect.height) / (2 * radius + 1); + bigMapView.fitted = true; +} + +/** 把视图中心移到所有机器人的几何中心 */ +function centerBigMapOnBots() { + const bots = ((lastStatus && lastStatus.bots) || []).filter((b) => b.position); + if (bots.length === 0) return; + let x = 0; + let z = 0; + for (const bot of bots) { + x += bot.position.x; + z += bot.position.z; + } + bigMapView.centerX = x / bots.length; + bigMapView.centerZ = z / bots.length; + // 让所有机器人尽量都落在视野内 + let maxSpan = 512; + for (const bot of bots) { + maxSpan = Math.max(maxSpan, Math.abs(bot.position.x - bigMapView.centerX) * 2, + Math.abs(bot.position.z - bigMapView.centerZ) * 2); + } + const rect = $('mapCanvas').getBoundingClientRect(); + bigMapView.pxPerBlock = Math.min(2, Math.max(0.002, Math.min(rect.width, rect.height) / (maxSpan * 1.2))); + drawMap(); +} + // ==================== WebSocket ==================== let ws = null; let wsRetry = 0; +/** 退出登录后停用实时推送,避免无凭证的握手被反复拒绝 */ +let wsEnabled = true; function connectWs() { + if (!wsEnabled) return; const proto = location.protocol === 'https:' ? 'wss' : 'ws'; const query = TOKEN ? '?token=' + encodeURIComponent(TOKEN) : ''; ws = new WebSocket(`${proto}://${location.host}/ws${query}`); @@ -706,8 +1427,18 @@ function connectWs() { if (payload.type === 'status') renderStatus(payload.data); else if (payload.type === 'log') appendLog(payload.data); else if (payload.type === 'logs') renderLogs(payload.data); + else if (payload.type === 'chat') { + chatEntries.push(payload.data); + if (chatEntries.length > MAX_CHAT_LINES) chatEntries.shift(); + if (chatMatchesFilter(payload.data)) appendChat(payload.data); + } else if (payload.type === 'expansion') { + expEntries.push(payload.data); + if (expEntries.length > MAX_EXP_ROWS) expEntries.shift(); + if (expMatchesFilter(payload.data)) appendExp(payload.data); + } }; ws.onclose = () => { + if (!wsEnabled) return; wsRetry++; const delay = Math.min(15000, 1000 * wsRetry); setTimeout(connectWs, delay); @@ -718,32 +1449,42 @@ function connectWs() { // ==================== 事件绑定 ==================== function bindEvents() { - $('btnStart').addEventListener('click', async () => { - try { - const res = await api('/api/task/start', { method: 'POST', body: JSON.stringify(collectConfig()) }); - toast(res.message || '任务已启动'); - renderStatus(res.data); - loadMap(); - } catch (err) { - toast('启动失败:' + err.message, true); + $('botSelect').addEventListener('change', () => selectBot($('botSelect').value)); + + $('btnStart').addEventListener('click', () => startTask(selectedBotId)); + $('btnStop').addEventListener('click', () => stopTask(selectedBotId)); + $('btnReconnect').addEventListener('click', () => reconnectBot(selectedBotId)); + + $('btnStartAll').addEventListener('click', async () => { + const bots = (lastStatus && lastStatus.bots) || []; + for (const bot of bots) { + // 串行启动:避免所有机器人同时挤进服务器 + await startTask(bot.id, true); } + toast('已依次下发开始指令'); + }); + $('btnStopAll').addEventListener('click', async () => { + const bots = (lastStatus && lastStatus.bots) || []; + for (const bot of bots) { + try { + await api('/api/bots/' + encodeURIComponent(bot.id) + '/task/stop', { method: 'POST' }); + } catch { + // 单个失败不影响其它 + } + } + toast('已停止全部机器人'); }); - $('btnStop').addEventListener('click', async () => { + $('btnSaveBotSetting').addEventListener('click', async () => { + if (!selectedBotId) return; try { - await api('/api/task/stop', { method: 'POST' }); - toast('已请求停止'); + await api('/api/bots/' + encodeURIComponent(selectedBotId) + '/settings', { + method: 'POST', + body: JSON.stringify({ enabled: $('chkEnabled').checked, autoStart: $('chkAutoStart').checked }) + }); + toast('机器人设置已保存'); } catch (err) { - toast('停止失败:' + err.message, true); - } - }); - - $('btnReconnect').addEventListener('click', async () => { - try { - await api('/api/bot/reconnect', { method: 'POST' }); - toast('正在重连'); - } catch (err) { - toast('重连失败:' + err.message, true); + toast('保存失败:' + err.message, true); } }); @@ -770,6 +1511,7 @@ function bindEvents() { $('btnSaveConfig').addEventListener('click', saveConfig); $('btnReloadConfig').addEventListener('click', loadConfig); + $('chkBotOverride').addEventListener('change', loadConfig); $('configTabs').addEventListener('click', (e) => { const tab = e.target.closest('.tab'); @@ -781,14 +1523,51 @@ function bindEvents() { renderConfig(); }); + // ===== 地图视图切换 ===== + $('mapTabs').addEventListener('click', (e) => { + const tab = e.target.closest('.tab'); + if (!tab) return; + mapMode = tab.dataset.view; + for (const el of $('mapTabs').querySelectorAll('.tab')) { + el.classList.toggle('active', el === tab); + } + $('bigMapControls').classList.toggle('hidden', mapMode !== 'big'); + $('territoryMapControls').classList.toggle('hidden', mapMode !== 'territory'); + $('legendBig').classList.toggle('hidden', mapMode !== 'big'); + $('legendTerritory').classList.toggle('hidden', mapMode !== 'territory'); + if (mapMode === 'big') loadBigMap(); + drawMap(); + }); + + // ===== 日志面板切换 ===== + $('logTabs').addEventListener('click', (e) => { + const tab = e.target.closest('.tab'); + if (!tab) return; + const pane = tab.dataset.pane; + for (const el of $('logTabs').querySelectorAll('.tab')) { + el.classList.toggle('active', el === tab); + } + $('logView').classList.toggle('hidden', pane !== 'log'); + $('chatView').classList.toggle('hidden', pane !== 'chat'); + $('expView').classList.toggle('hidden', pane !== 'exp'); + $('logControls').classList.toggle('hidden', pane !== 'log'); + $('chatControls').classList.toggle('hidden', pane !== 'chat'); + $('expControls').classList.toggle('hidden', pane !== 'exp'); + if (pane === 'chat') loadChat(); + if (pane === 'exp') loadExpansionLog(); + }); + $('chkFull').addEventListener('change', () => loadMap(true)); $('chkFlying').addEventListener('change', drawMap); $('chkGrid').addEventListener('change', drawMap); + $('chkTerritory').addEventListener('change', drawMap); + $('chkBotLabels').addEventListener('change', drawMap); $('btnMapCenter').addEventListener('click', () => { - if (lastStatus && lastStatus.task && lastStatus.task.playerChunk) { - mapView.centerX = lastStatus.task.playerChunk.x; - mapView.centerZ = lastStatus.task.playerChunk.z; + const data = mapView.data; + if (data && data.playerChunk) { + mapView.centerX = data.playerChunk.x; + mapView.centerZ = data.playerChunk.z; drawMap(); } }); @@ -819,14 +1598,29 @@ function bindEvents() { if (!Number.isFinite(minX)) return; mapView.centerX = (minX + maxX) / 2; mapView.centerZ = (minZ + maxZ) / 2; - const canvas = $('mapCanvas'); - const rect = canvas.getBoundingClientRect(); + const rect = $('mapCanvas').getBoundingClientRect(); const scaleX = rect.width / Math.max(1, maxX - minX + 2); const scaleZ = rect.height / Math.max(1, maxZ - minZ + 2); mapView.scale = Math.min(24, Math.max(0.15, Math.min(scaleX, scaleZ))); drawMap(); }); + $('btnMapFitWorld').addEventListener('click', () => { + fitBigMapToWorld(); + drawMap(); + }); + $('btnMapCenterBots').addEventListener('click', centerBigMapOnBots); + + $('btnCrawlMap').addEventListener('click', async () => { + try { + const res = await api('/api/bigmap/crawl', { method: 'POST', body: JSON.stringify({}) }); + toast(res.message); + loadBigMap(true); + } catch (err) { + toast('爬取失败:' + err.message, true); + } + }); + $('btnClearLog').addEventListener('click', () => { $('logView').innerHTML = ''; }); $('btnExportLog').addEventListener('click', async () => { try { @@ -837,41 +1631,159 @@ function bindEvents() { } }); - $('btnSendCmd').addEventListener('click', sendCommand); + $('btnClearChat').addEventListener('click', () => { + chatEntries = []; + $('chatView').innerHTML = ''; + }); + $('chkChatAllBots').addEventListener('change', renderChat); + $('btnExportChat').addEventListener('click', async () => { + if (!selectedBotId) return; + try { + const res = await api('/api/bots/' + encodeURIComponent(selectedBotId) + '/chat/export', { method: 'POST' }); + toast('已导出到 ' + res.data.file); + } catch (err) { + toast('导出失败:' + err.message, true); + } + }); + + $('btnExportExp').addEventListener('click', async () => { + try { + const res = await api('/api/expansion-log/export', { + method: 'POST', + body: JSON.stringify({ botId: $('expFilterBot').value || undefined }) + }); + toast('已导出到 ' + res.data.file); + } catch (err) { + toast('导出失败:' + err.message, true); + } + }); + $('btnClearExp').addEventListener('click', () => { + expEntries = []; + renderExp(); + }); + $('expFilterBot').addEventListener('change', renderExp); + + $('btnSendCmd').addEventListener('click', sendConsole); $('cmdInput').addEventListener('keydown', (e) => { - if (e.key === 'Enter') sendCommand(); + if (e.key === 'Enter') sendConsole(); + }); + $('btnQuickChat').addEventListener('click', sendQuickChat); + $('quickChatInput').addEventListener('keydown', (e) => { + if (e.key === 'Enter') sendQuickChat(); }); for (const btn of document.querySelectorAll('[data-cmd]')) { btn.addEventListener('click', () => { + $('sendMode').value = 'command'; $('cmdInput').value = btn.dataset.cmd; - sendCommand(); + sendConsole(); }); } - async function sendCommand() { - const command = $('cmdInput').value.trim(); - if (!command) return; + $('btnLogin').addEventListener('click', doLogin); + $('loginPassword').addEventListener('keydown', (e) => { + if (e.key === 'Enter') doLogin(); + }); + $('btnLogout').addEventListener('click', doLogout); + + async function sendConsole() { + const text = $('cmdInput').value.trim(); + if (!text || !selectedBotId) return; + const mode = $('sendMode').value; + const path = mode === 'command' + ? `/api/bots/${encodeURIComponent(selectedBotId)}/command` + : `/api/bots/${encodeURIComponent(selectedBotId)}/chat`; + const body = mode === 'command' ? { command: text } : { message: text }; try { - await api('/api/command', { method: 'POST', body: JSON.stringify({ command }) }); - toast('已发送:' + command); + await api(path, { method: 'POST', body: JSON.stringify(body) }); + $('cmdInput').value = ''; + toast('已发送'); + } catch (err) { + toast('发送失败:' + err.message, true); + } + } + + async function sendQuickChat() { + const text = $('quickChatInput').value.trim(); + if (!text || !selectedBotId) return; + try { + await api(`/api/bots/${encodeURIComponent(selectedBotId)}/chat`, { + method: 'POST', + body: JSON.stringify({ message: text }) + }); + $('quickChatInput').value = ''; + toast('公屏消息已发送'); } catch (err) { toast('发送失败:' + err.message, true); } } } +async function startTask(botId, silent) { + if (!botId) { + toast('请先选择机器人', true); + return; + } + try { + // 勾选「仅对当前机器人生效」时把表单配置作为该机器人的专属配置下发; + // 否则不带 body,直接用它当前生效的配置(全局默认 + 已有覆盖)启动 + const body = $('chkBotOverride').checked ? collectConfig() : null; + const res = await api('/api/bots/' + encodeURIComponent(botId) + '/task/start', { + method: 'POST', + body: JSON.stringify(body || {}) + }); + if (!silent) toast(res.message || '任务已启动'); + if (res.data) renderStatus(res.data); + loadMap(); + } catch (err) { + toast('启动失败:' + err.message, true); + } +} + +async function stopTask(botId) { + if (!botId) return; + try { + await api('/api/bots/' + encodeURIComponent(botId) + '/task/stop', { method: 'POST' }); + toast('已请求停止'); + } catch (err) { + toast('停止失败:' + err.message, true); + } +} + +async function reconnectBot(botId) { + if (!botId) return; + try { + await api('/api/bots/' + encodeURIComponent(botId) + '/reconnect', { method: 'POST' }); + toast('正在重连'); + } catch (err) { + toast('重连失败:' + err.message, true); + } +} + // ==================== 启动 ==================== -async function main() { +async function startApp() { + if (appStarted) return; + appStarted = true; + wsEnabled = true; initMap(); bindEvents(); await loadConfig(); await loadMap(); + await loadBigMap(true); + await loadExpansionLog(); connectWs(); - // 地图数据每 20 秒自动刷新一次(服务端 markers 刷新间隔通常为 30 秒) - setInterval(() => loadMap(), 20000); + // 领土地图数据每 20 秒刷新一次(服务端 markers 刷新间隔通常为 30 秒) + setInterval(() => { if (wsEnabled) loadMap(); }, 20000); + // 大地图元信息(含机器人坐标与爬取进度)每 10 秒刷新一次 + setInterval(() => { if (wsEnabled) loadBigMap(); }, 10000); // 相对时间("x 秒前")需要定期重绘 - setInterval(() => { if (lastStatus) renderStatus(lastStatus); }, 5000); + setInterval(() => { if (wsEnabled && lastStatus) renderStatus(lastStatus); }, 5000); +} + +async function main() { + const ok = await checkSession(); + if (ok) await startApp(); + else $('loginPassword').focus(); } main(); diff --git a/src/web/public/index.html b/src/web/public/index.html index 3762838..44ce21d 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -12,12 +12,17 @@

AutoStore Bot

-

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

+

多机器人自动扩地 · mineflayer · Grim 合规 · 大地图

+ 未连接 空闲 + 未登录 @@ -27,6 +32,17 @@
+
+
+

机器人(0)

+
+ + +
+
+
+
+

连接

@@ -37,8 +53,14 @@
所在区块
-
生命 / 饥饿
-
状态
-
+
登录验证
-
重连次数
0
+
+ + + +
@@ -46,6 +68,7 @@
状态机
IDLE
描述
未启动
+
开拓邦国
-
当前目标
-
队列
0
已完成
0
@@ -90,12 +113,22 @@
- +
-

领土地图

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

运行日志

-
+
+ + + +
+
+ +
+ +
- +
@@ -138,12 +206,17 @@
+
+
@@ -151,9 +224,17 @@
-

手动指令

+
+

控制台 / 聊天框

+ - +
+

登录验证通过后可向该机器人发送指令或公屏消息。

- + +
@@ -162,10 +243,28 @@
+
+ + +
+ + +
diff --git a/src/web/public/style.css b/src/web/public/style.css index 3dcdfe1..96595b5 100644 --- a/src/web/public/style.css +++ b/src/web/public/style.css @@ -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); diff --git a/src/web/server.js b/src/web/server.js index 5382aef..a43465f 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -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} 机器人列表快照 */ + 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} */ + snapshot() { + const entry = this.get(); + if (!entry) return []; + return [Object.assign({}, entry.manager.snapshot(), { task: entry.controller.snapshot() })]; + } + + /** @returns {Array} */ + 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} 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 +}; diff --git a/test/bigMapService.test.js b/test/bigMapService.test.js new file mode 100644 index 0000000..36ee6ce --- /dev/null +++ b/test/bigMapService.test.js @@ -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); +}); diff --git a/test/botManager.test.js b/test/botManager.test.js new file mode 100644 index 0000000..50df9e3 --- /dev/null +++ b/test/botManager.test.js @@ -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); +}); diff --git a/test/botPool.test.js b/test/botPool.test.js new file mode 100644 index 0000000..fcccb06 --- /dev/null +++ b/test/botPool.test.js @@ -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']); +}); diff --git a/test/botsConfig.test.js b/test/botsConfig.test.js new file mode 100644 index 0000000..a058db7 --- /dev/null +++ b/test/botsConfig.test.js @@ -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({}), []); +}); diff --git a/test/chatLog.test.js b/test/chatLog.test.js new file mode 100644 index 0000000..aab3153 --- /dev/null +++ b/test/chatLog.test.js @@ -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); +}); diff --git a/test/expansionController.test.js b/test/expansionController.test.js index 4b07626..8a3d734 100644 --- a/test/expansionController.test.js +++ b/test/expansionController.test.js @@ -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 }); +}); diff --git a/test/expansionLog.test.js b/test/expansionLog.test.js new file mode 100644 index 0000000..080cc6d --- /dev/null +++ b/test/expansionLog.test.js @@ -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); +}); diff --git a/test/multiBotWeb.test.js b/test/multiBotWeb.test.js new file mode 100644 index 0000000..9610228 --- /dev/null +++ b/test/multiBotWeb.test.js @@ -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(); + } +});