feat: 大地图跟随机器人并支持可调图幅,新增定时消息,控制台界面重做并适配移动端

大地图跟随机器人 + 可调图幅
- 爬取与显示区域改为以机器人当前位置为中心(bigMap.followBot 默认开启),
  不再傻傻地爬世界原点周边
- 界面新增「图幅」下拉(±512/1024/2048/4096/8192/全世界)与「爬取周边」按钮,
  只爬当前图幅内的瓦片:层级 0 每张覆盖 4096 格,±4096 只需 9 张,全世界要 100 张
- POST /api/bigmap/crawl 支持 centerX/centerZ/rangeBlocks;GET /api/bigmap 支持
  rangeBlocks/bot 查询参数;启动预爬取会先等机器人进入世界再以其位置为中心

领土地图以机器人所在邦国为中心
- 之前初始视图用的是控制器的 playerChunk,而任务启动前它是 (0,0),
  导致地图永远停在世界原点附近。现在按「机器人所在区块 → 包含它的邦国 →
  该邦国王城/外接矩形」定位

定时重复发送消息(src/bot/scheduledMessenger.js)
- 每个机器人可配多条定时任务,按固定间隔重复发送同一内容,如每 300 秒 /u restore confirm
- 以 / 开头按游戏指令发送,否则按公屏消息发送
- 只在已连接且通过登录验证时发送;未就绪的周期顺延且不补发,避免重连后一次性连发
- 与指令节流队列共用,间隔下限 5 秒、单机器人最多 20 条
- 控制台新增「定时消息」卡片(新增/删除/启停 + 剩余秒数倒计时)

控制台界面重做 + 移动端适配
- 重新设计的深色主题:统一圆角、阴影、焦点态与悬停态,卡片式分层
- 响应式三档:≥1200px 三栏;≤1200px 两栏;≤820px 单栏 + 底部导航
- 移动端:触控目标 ≥36px、地图/日志高度按视口自适应、避让底部安全区、
  横屏压矮地图、尊重「减少动效」偏好、Toast 全宽
- 「添加玩家」折叠进 details;顶栏窄屏自动换行且按钮等分

修复
- POST /api/bigmap/crawl 响应把中心与图幅字段传错(实际爬取正确,返回值显示成原点)
- 定时任务保存后接口返回原始列表而非归一化结果,前端拿不到剩余秒数

测试 308 -> 325,全部通过。
这个提交包含在:
lyizai
2026-09-28 00:27:58 +08:00
父节点 fcd5fb2bd9
当前提交 a83dea9677
共修改 14 个文件,包含 1579 行新增和 343 行删除
+48
查看文件
@@ -2,6 +2,54 @@
本项目遵循 [约定式提交](https://www.conventionalcommits.org/zh-hans/) 与 [语义化版本](https://semver.org/lang/zh-CN/)。 本项目遵循 [约定式提交](https://www.conventionalcommits.org/zh-hans/) 与 [语义化版本](https://semver.org/lang/zh-CN/)。
## [1.4.0] - 2026-09-27
大地图改为跟随机器人、支持可调图幅;新增定时重复发送消息;控制台界面重做并适配移动端。
### 新增
- **定时重复发送消息 / 指令**(`src/bot/scheduledMessenger.js`)
- 每个机器人可配置多条定时任务,按固定间隔重复发送同一条内容,
例如每 300 秒发一次 `/u restore confirm` 维持疆土民心
- 以 `/` 开头的内容自动按游戏指令发送,否则按公屏消息发送
- 只在「已连接 + 已通过登录验证」时才发;未就绪的周期直接顺延、**不补发**,
避免重连后一次性连发多条
- 与指令节流队列共用,不会绕过 `server.commandCooldownMs` 刷屏
- 控制台新增「定时消息」卡片:新增 / 删除 / 启用停用,并显示距下次发送的剩余秒数
- 接口:`GET|POST /api/bots/:id/schedules`
- **大地图跟随机器人 + 可调图幅**
- 爬取与显示区域改为**以机器人当前位置为中心**(`bigMap.followBot`,默认开启),
不再傻傻地爬世界原点周边
- 界面新增「图幅」下拉框(±512 / ±1024 / ±2048 / ±4096 / ±8192 / 全世界),
切换后即时重新对焦并拉取对应范围
- 新增「爬取周边」按钮:只爬当前图幅内的瓦片。
层级 0 每张瓦片覆盖 4096 格,所以 ±4096 只需 **9 张**,而全世界要 100 张
- `POST /api/bigmap/crawl` 支持 `centerX` / `centerZ` / `rangeBlocks`;
`GET /api/bigmap` 支持 `rangeBlocks` 与 `bot` 查询参数
- 启动预爬取会先等机器人进入世界,以它的位置为中心;等不到才退回世界原点
- **领土地图以机器人所在邦国为中心**
- 之前初始视图用的是控制器的 `playerChunk`,而任务启动前它是 (0,0),
导致地图永远停在世界原点附近 —— 完全不是用户关心的位置
- 现在按「机器人所在区块 → 包含它的邦国 → 该邦国王城 / 外接矩形」定位
- **控制台界面重做 + 移动端适配**
- 重新设计的深色主题:统一圆角、阴影、焦点态与悬停态,卡片式分层
- 响应式三档:≥1200px 三栏;≤1200px 两栏(配置栏落到下方并自动分列);≤820px 单栏
- 移动端:底部导航(状态 / 地图 / 日志 / 配置)一键定位、触控目标放大到 ≥36px、
地图与日志高度按视口自适应、避让底部安全区、Toast 全宽
- 横屏手机压矮地图高度;尊重系统「减少动效」偏好
- 「添加玩家」折叠进 details,减少左栏长度
- 顶栏在窄屏下自动换行,按钮等分排布
### 变更
- 大地图默认图幅从「全世界 ±20480」改为「±4096 格」,默认预爬取瓦片数从 100 张降到 9 张
- 「回到机器人」保留并修正:没有坐标时给出明确提示,而不是静默无反应
### 修复
- `POST /api/bigmap/crawl` 的响应把中心与图幅字段传错(实际爬取是对的,但返回值显示成世界原点)
- 定时任务保存后接口返回的是原始列表而非归一化结果,导致前端拿不到「剩余秒数」
## [1.3.0] - 2026-09-27 ## [1.3.0] - 2026-09-27
按线上实测结果修复「机器人走不动」,并移植旧版机器人已验证的关键补包。 按线上实测结果修复「机器人走不动」,并移植旧版机器人已验证的关键补包。
+61 -11
查看文件
@@ -437,13 +437,59 @@ npm start
打开控制台后: 打开控制台后:
- **顶栏**:机器人下拉选择、连接 / 任务徽标、控制台登录状态、开始扩地 / 停止 / 重连 - **顶栏**:机器人下拉选择、连接 / 任务徽标、控制台登录状态、开始扩地 / 停止 / 重连
- **左栏**:机器人列表(状态 / 坐标 / 任务 / 已开拓数)、连接、任务、领土数据源、Grim 合规 - **左栏**:机器人列表、连接、任务、**定时消息**、领土数据源、Grim 合规
- **中栏**: - **中栏**:
- 地图页签 —— **大地图**(瓦片 + 领土多边形 + 全部机器人坐标)与 **领土地图**(区块级) - 地图页签 —— **大地图**(瓦片 + 领土多边形 + 全部机器人坐标)与 **领土地图**(区块级)
- 日志页签 —— **运行日志** / **公屏聊天** / **开拓日志** - 日志页签 —— **运行日志** / **公屏聊天** / **开拓日志**
- **右栏**:8 个标签页的配置表单(可切换「仅对当前机器人生效」)+ 控制台 / 聊天框 - **右栏**:控制台 / 聊天框(含移动测试)+ 8 个标签页的配置表单
地图图层:己方领土(蓝)、本次占领(绿)、飞地疏漏(红)、当前目标(黄)、机器人(白点 / 黄点)、王城(紫)。 ### 响应式与移动端
| 宽度 | 布局 |
| --- | --- |
| ≥1200px | 三栏(状态 / 地图日志 / 配置) |
| ≤1200px | 两栏,配置栏落到下方并自动分列 |
| ≤820px | 单栏 + 底部导航(状态 / 地图 / 日志 / 配置一键定位) |
移动端额外处理:触控目标放大到 ≥36px、地图与日志高度按视口自适应、
避让底部安全区(`env(safe-area-inset-bottom)`)、横屏时压矮地图高度、
尊重系统「减少动效」偏好。
### 定时重复发送消息
挂机时常需要周期性发同一条指令(例如每 300 秒 `/u restore confirm` 维持疆土民心)。
左栏「定时消息」卡片里点「+ 新增」,填内容与间隔即可;以 `/` 开头按游戏指令发送,否则按公屏消息发送。
实现上刻意**不用**朴素的 `setInterval`:
- 只在「已连接 + 已通过登录验证」时才发送;
- 未就绪的周期直接顺延,**不补发** —— 否则重连后会把漏掉的几条一次性连发出去;
- 与指令节流队列共用,不会绕过 `server.commandCooldownMs` 变成刷屏;
- 间隔下限 5 秒、单个机器人最多 20 条,防止误配置把账号玩进黑名单。
---
## 大地图
`src/core/bigMapService.js` 爬取服务器地图站点(本项目部署的是
[squaremap](https://github.com/jpenilla/squaremap))的瓦片图片与领土标记,
把所有机器人画在同一张图上。
### 以机器人为中心 + 可调图幅
大地图的用途是看**自己机器人周边**,所以默认以机器人当前位置为中心(`bigMap.followBot`)。
界面上的「图幅」下拉框决定半径:
| 图幅 | 层级 0 需要的瓦片数 |
| --- | --- |
| ±512 格 | 1 |
| ±1024 格 | 1~4 |
| ±4096 格(默认) | 9 |
| ±8192 格 | 25 |
| 全世界(±20480) | 100 |
层级 0 每张瓦片覆盖 4096 格,所以「只看周边」比「拉全世界」省一到两个数量级。
点「爬取周边」只会爬当前图幅内的瓦片;「回到机器人」把视图拉回机器人所在位置。
### REST / WebSocket 接口 ### REST / WebSocket 接口
@@ -456,6 +502,8 @@ npm start
| GET | `/api/bots/:id/status` | 单个机器人状态 | | GET | `/api/bots/:id/status` | 单个机器人状态 |
| GET | `/api/bots/:id/settings` | 单个机器人的设置与生效配置 | | GET | `/api/bots/:id/settings` | 单个机器人的设置与生效配置 |
| POST | `/api/bots/:id/settings` | 保存备注名 / 启用 / 自动开始 / 配置覆盖 | | POST | `/api/bots/:id/settings` | 保存备注名 / 启用 / 自动开始 / 配置覆盖 |
| GET | `/api/bots/:id/schedules` | 该机器人的定时消息列表(含距下次发送的秒数) |
| POST | `/api/bots/:id/schedules` | 整体替换该机器人的定时消息列表 |
| POST | `/api/bots/:id/task/start` | 启动该机器人的扩地(body 为配置覆盖) | | POST | `/api/bots/:id/task/start` | 启动该机器人的扩地(body 为配置覆盖) |
| POST | `/api/bots/:id/task/stop` | 停止该机器人的扩地 | | POST | `/api/bots/:id/task/stop` | 停止该机器人的扩地 |
| POST | `/api/bots/:id/reconnect` | 重连该机器人 | | POST | `/api/bots/:id/reconnect` | 重连该机器人 |
@@ -469,10 +517,10 @@ npm start
| GET | `/api/expansion-log` | 开拓日志(`limit` / `botId` / `state` / `since`)+ 统计 | | GET | `/api/expansion-log` | 开拓日志(`limit` / `botId` / `state` / `since`)+ 统计 |
| POST | `/api/expansion-log/export` | 导出开拓日志 CSV 到 `data/` | | POST | `/api/expansion-log/export` | 导出开拓日志 CSV 到 `data/` |
| POST | `/api/expansion-log/clear` | 清空内存中的开拓日志(磁盘保留) | | POST | `/api/expansion-log/clear` | 清空内存中的开拓日志(磁盘保留) |
| GET | `/api/bigmap` | 大地图元信息(坐标系、瓦片范围、缓存进度、机器人坐标、领土标记) | | GET | `/api/bigmap` | 大地图元信息(坐标系、图幅、瓦片范围、缓存进度、机器人坐标、领土标记);支持 `rangeBlocks` / `bot` 参数 |
| GET | `/api/bigmap/markers` | 大地图领土标记(多边形 + 王城) | | GET | `/api/bigmap/markers` | 大地图领土标记(多边形 + 王城) |
| GET | `/api/bigmap/tile/:z/:x/:y` | 瓦片代理(缓存优先,避免跨域) | | GET | `/api/bigmap/tile/:z/:x/:y` | 瓦片代理(缓存优先,避免跨域) |
| POST | `/api/bigmap/crawl` | 手动触发批量爬取瓦片 | | POST | `/api/bigmap/crawl` | 爬取瓦片;默认爬机器人周边,支持 `centerX` / `centerZ` / `rangeBlocks` |
| POST | `/api/bigmap/cache/clear` | 清空瓦片缓存 | | POST | `/api/bigmap/cache/clear` | 清空瓦片缓存 |
| GET | `/api/config` | 全局配置 | | GET | `/api/config` | 全局配置 |
| POST | `/api/config` | 保存并立即应用全局配置 | | POST | `/api/config` | 保存并立即应用全局配置 |
@@ -499,8 +547,8 @@ npm start
| 寻路 | 疾跑 / 游泳 / 跳跃 / 挖方块 / 放方块、高度偏好、跑酷、搭塔、下落高度、液体代价 | | 寻路 | 疾跑 / 游泳 / 跳跃 / 挖方块 / 放方块、高度偏好、跑酷、搭塔、下落高度、液体代价 |
| 生存 | 自动进食、自动攻击、耐久保护、战斗筛选、水域策略、自动用船 | | 生存 | 自动进食、自动攻击、耐久保护、战斗筛选、水域策略、自动用船 |
| 数据 | 地图刷新间隔、SimpCore API | | 数据 | 地图刷新间隔、SimpCore API |
| 大地图 | 开关、世界名、预爬取层级、最大层级、覆盖半径、启动预爬取、下载并发数 | | 大地图 | 开关、世界名、预爬取层级、最大层级、**图幅半径、是否跟随机器人**、启动预爬取、下载并发数 |
| Grim | 合规层全部阈值(见上) | | Grim | 合规层全部阈值 + 协议补包开关(见上) |
| 服务器 | 开垦模式指令、登录 / 注册模板、登录确认超时、指令节流、重连 | | 服务器 | 开垦模式指令、登录 / 注册模板、登录确认超时、指令节流、重连 |
--- ---
@@ -534,9 +582,10 @@ npm run dev # 文件变更自动重启
确认兜底超时、公屏消息类型映射 确认兜底超时、公屏消息类型映射
- 开拓日志:字段记录、筛选、统计、JSONL 落盘与重启恢复、损坏行容错、环形缓冲、CSV 导出 - 开拓日志:字段记录、筛选、统计、JSONL 落盘与重启恢复、损坏行容错、环形缓冲、CSV 导出
- 公屏日志:按机器人隔离、类型 / 关键字过滤、分文件落盘、文件名安全化、文本导出 - 公屏日志:按机器人隔离、类型 / 关键字过滤、分文件落盘、文件名安全化、文本导出
- 大地图:坐标系换算(含对源站实测标定的王城用例)、瓦片范围、缓存命中、批量爬取、缓存清理 - 大地图:坐标系换算(含对源站实测标定的王城用例)、**以机器人为中心的图幅换算**、瓦片范围、缓存命中、批量爬取、缓存清理
- Web 控制台:地图负载、单 / 多机器人接口、聊天与开拓日志接口、瓦片代理、 - 定时消息:内容归一化与间隔钳制、到点发送与按周期重复、未就绪时顺延不补发、多任务独立计时、整体替换重置计时
令牌与登录鉴权、WebSocket 握手鉴权 - Web 控制台:地图负载、单 / 多机器人接口、聊天与开拓日志接口、定时任务接口、
图幅与中心计算、瓦片代理、令牌与登录鉴权、WebSocket 握手鉴权
### 目录结构 ### 目录结构
@@ -561,7 +610,8 @@ src/
├── bot/ ├── bot/
│ ├── botPool.js ★ 机器人池:同时挂多个玩家 │ ├── botPool.js ★ 机器人池:同时挂多个玩家
│ ├── botManager.js 连接、登录验证、公屏留档、指令 / 聊天发送 │ ├── botManager.js 连接、登录验证、公屏留档、指令 / 聊天发送
│ ├── grimCompat.js ★ Grim 反作弊合规层 │ ├── scheduledMessenger.js ★ 定时重复发送消息 / 指令
│ ├── grimCompat.js ★ Grim 反作弊合规层 + 协议补包(player_input / tick_end)
│ ├── pathingService.js 寻路服务(替代 Baritone) │ ├── pathingService.js 寻路服务(替代 Baritone)
│ ├── autoCombat.js 战斗筛选与耐久检查 │ ├── autoCombat.js 战斗筛选与耐久检查
│ ├── autoEat.js 自动进食 │ ├── autoEat.js 自动进食
+35 -4
查看文件
@@ -3,6 +3,7 @@
const { EventEmitter } = require('events'); const { EventEmitter } = require('events');
const { BotManager } = require('./botManager'); const { BotManager } = require('./botManager');
const { ScheduledMessenger } = require('./scheduledMessenger');
const { ExpansionController } = require('../controller/expansionController'); const { ExpansionController } = require('../controller/expansionController');
const { normalizeConfig } = require('../config/expansionConfig'); const { normalizeConfig } = require('../config/expansionConfig');
const { sanitizeId } = require('../config/botsConfig'); const { sanitizeId } = require('../config/botsConfig');
@@ -130,11 +131,16 @@ class BotPool extends EventEmitter {
manager.on('spawn', () => this.emit('botSpawn', id)); manager.on('spawn', () => this.emit('botSpawn', id));
manager.on('end', () => this.emit('botEnd', id)); manager.on('end', () => this.emit('botEnd', id));
// 定时消息器:按固定间隔重复发送同一条内容(如每 300 秒 /u restore confirm)
const messenger = new ScheduledMessenger(manager, this.logger);
messenger.setTasks(setting.schedules || []);
this.entries.set(id, { this.entries.set(id, {
id, id,
connection, connection,
manager, manager,
controller, controller,
messenger,
/** 账号来源:env(.env 配置)/ store(控制台新增) */ /** 账号来源:env(.env 配置)/ store(控制台新增) */
source: connection.source || 'env', source: connection.source || 'env',
/** 该机器人实际生效的配置(全局 + 覆盖) */ /** 该机器人实际生效的配置(全局 + 覆盖) */
@@ -190,7 +196,10 @@ class BotPool extends EventEmitter {
const entry = this._addBot(connection); const entry = this._addBot(connection);
if (!this.defaultId) this.defaultId = id; if (!this.defaultId) this.defaultId = id;
this._log(`已添加玩家 ${username}(密码保存在服务器本地凭据文件,权限 0600)`); this._log(`已添加玩家 ${username}(密码保存在服务器本地凭据文件,权限 0600)`);
if (opts.connect !== false && this._settingOf(id).enabled) entry.manager.connect(); if (opts.connect !== false && this._settingOf(id).enabled) {
entry.messenger.start();
entry.manager.connect();
}
return entry; return entry;
} }
@@ -209,6 +218,7 @@ class BotPool extends EventEmitter {
throw badRequest(`${entry.connection.username} 是在 .env 的 MC_BOTS 里配置的,` throw badRequest(`${entry.connection.username} 是在 .env 的 MC_BOTS 里配置的,`
+ '请修改 .env 后重启;控制台只能移除在这里添加的玩家'); + '请修改 .env 后重启;控制台只能移除在这里添加的玩家');
} }
entry.messenger.stop();
entry.manager.disconnect(); entry.manager.disconnect();
this.entries.delete(id); this.entries.delete(id);
delete this.settings[id]; delete this.settings[id];
@@ -234,6 +244,7 @@ class BotPool extends EventEmitter {
this._log(`${entry.connection.username} 已在控制台禁用,跳过连接`); this._log(`${entry.connection.username} 已在控制台禁用,跳过连接`);
continue; continue;
} }
entry.messenger.start();
entry.manager.connect(); entry.manager.connect();
} }
} }
@@ -246,6 +257,7 @@ class BotPool extends EventEmitter {
} catch { } catch {
// 忽略 // 忽略
} }
entry.messenger.stop();
entry.manager.disconnect(); entry.manager.disconnect();
} }
} }
@@ -301,13 +313,14 @@ class BotPool extends EventEmitter {
// ==================== 配置 ==================== // ==================== 配置 ====================
/** @returns {object} 机器人的非敏感设置(启用 / 备注 / 自动开始 / 配置覆盖) */ /** @returns {object} 机器人的非敏感设置(启用 / 备注 / 自动开始 / 定时任务 / 配置覆盖) */
_settingOf(id) { _settingOf(id) {
const raw = this.settings[id] || {}; const raw = this.settings[id] || {};
return { return {
enabled: raw.enabled !== false, enabled: raw.enabled !== false,
autoStart: raw.autoStart === true, autoStart: raw.autoStart === true,
label: raw.label || '', label: raw.label || '',
schedules: Array.isArray(raw.schedules) ? raw.schedules : [],
config: raw.config && typeof raw.config === 'object' ? raw.config : {} config: raw.config && typeof raw.config === 'object' ? raw.config : {}
}; };
} }
@@ -356,6 +369,13 @@ class BotPool extends EventEmitter {
entry.config = this.getConfigFor(id); entry.config = this.getConfigFor(id);
entry.manager.applyConfig(entry.config); entry.manager.applyConfig(entry.config);
entry.controller.config = entry.config; entry.controller.config = entry.config;
// 定时任务列表整体替换:把归一化结果写回设置,
// 保证「接口返回值 / 落盘内容 / 运行时状态」三者一致
if (patch && patch.schedules !== undefined) {
const normalized = entry.messenger.setTasks(this._settingOf(id).schedules);
this.settings[id] = Object.assign({}, this.settings[id], { schedules: normalized });
this._persistSettings();
}
return this._settingOf(id); return this._settingOf(id);
} }
@@ -368,10 +388,20 @@ class BotPool extends EventEmitter {
enabled: setting.enabled, enabled: setting.enabled,
autoStart: setting.autoStart, autoStart: setting.autoStart,
hasOverrides: Object.keys(setting.config).length > 0, hasOverrides: Object.keys(setting.config).length > 0,
config: this.getConfigFor(id) config: this.getConfigFor(id),
schedules: this.entry(id) ? this.entry(id).messenger.snapshot() : []
}; };
} }
/**
* 取运行时条目(不存在时返回 null,不抛异常)。
* @param {string} id
* @returns {object|null}
*/
entry(id) {
return this.entries.get(id) || null;
}
_persistSettings() { _persistSettings() {
if (!this.botStore) return; if (!this.botStore) return;
this.botStore.save({ bots: this.settings }); this.botStore.save({ bots: this.settings });
@@ -486,7 +516,8 @@ class BotPool extends EventEmitter {
enabled: setting.enabled, enabled: setting.enabled,
autoStart: setting.autoStart, autoStart: setting.autoStart,
source: entry.source, source: entry.source,
task: entry.controller.snapshot() task: entry.controller.snapshot(),
schedules: entry.messenger.snapshot()
})); }));
} }
return bots; return bots;
+166
查看文件
@@ -0,0 +1,166 @@
'use strict';
/** 单个任务的间隔下限(秒):再短就近似刷屏,容易被服务器或反作弊盯上 */
const MIN_INTERVAL_SECONDS = 5;
/** 单个机器人最多允许的定时任务数 */
const MAX_TASKS = 20;
/** 调度器的心跳间隔(毫秒):只做「到点了吗」的判断,开销可忽略 */
const TICK_INTERVAL_MS = 1000;
/**
* 定时消息器:按固定间隔重复发送同一条内容(公屏消息或游戏指令)。
*
* 典型用法是服务器要求周期性执行的指令,例如每 300 秒发一次 `/u restore confirm`
* 来维持疆土民心。这类「心跳式」指令是挂机机器人的刚需,
* 但绝不能简单地 setInterval —— 断线、未登录、正在回城时都不该发,
* 否则要么白发(服务端丢弃),要么在错误的时机打断状态机。
*
* 因此这里做成:
* - 每个任务独立计时,按「上次成功发送时间 + 间隔」判断是否到点;
* - 只在 `canOperate()`(已连接 + 已通过登录验证)时才发;
* - 未就绪时跳过本次并顺延,不补发(避免重连后一次性连发多条);
* - 与机器人共用指令节流队列,不会绕过 `server.commandCooldownMs` 刷屏。
*/
class ScheduledMessenger {
/**
* @param {import('./botManager').BotManager} manager
* @param {import('../util/logger').Logger} [logger]
*/
constructor(manager, logger) {
this.manager = manager;
this.logger = logger || null;
/** @type {Array<object>} 定时任务列表 */
this.tasks = [];
/** @type {Map<string, number>} 任务 id → 下次应发送的时间戳 */
this._nextDueAt = new Map();
this._timer = null;
}
/**
* 设置任务列表(整体替换),并重置各任务的计时。
*
* @param {Array<object>} tasks 原始任务定义
* @param {number} [now] 当前时间戳(默认取系统时间;测试里注入固定值即可不必真等)
* @returns {Array<object>} 归一化后的任务列表
*/
setTasks(tasks, now) {
this.tasks = normalizeTasks(tasks);
this._nextDueAt.clear();
const current = Number.isFinite(now) ? now : Date.now();
for (const task of this.tasks) {
// 首次不立即发送:给机器人留出进入世界与登录的时间
this._nextDueAt.set(task.id, current + task.intervalSeconds * 1000);
}
return this.tasks;
}
/** 启动调度心跳 */
start() {
if (this._timer) return;
this._timer = setInterval(() => this.tick(), TICK_INTERVAL_MS);
if (this._timer.unref) this._timer.unref();
}
/** 停止调度心跳 */
stop() {
if (this._timer) clearInterval(this._timer);
this._timer = null;
}
/**
* 心跳:把所有到点的任务发出去。
*
* 抽成公开方法是为了可测 —— 测试里直接调 `tick()` 并注入当前时间,
* 不必真的等 300 秒。
*
* @param {number} [now] 当前时间戳(默认取系统时间)
* @returns {number} 本次实际发出的条数
*/
tick(now) {
const current = Number.isFinite(now) ? now : Date.now();
let sent = 0;
for (const task of this.tasks) {
if (!task.enabled) continue;
const due = this._nextDueAt.get(task.id);
if (due === undefined) {
this._nextDueAt.set(task.id, current + task.intervalSeconds * 1000);
continue;
}
if (current < due) continue;
// 未就绪就顺延到下一个周期,不补发
if (!this.manager.canSendChat()) {
this._nextDueAt.set(task.id, current + task.intervalSeconds * 1000);
continue;
}
const ok = this.manager.sendChat(task.content);
this._nextDueAt.set(task.id, current + task.intervalSeconds * 1000);
if (ok) {
sent++;
this._log(`定时任务「${task.label || task.id}」已发送:${task.content}`);
}
}
return sent;
}
/** @returns {object} 供控制台展示的状态快照 */
snapshot() {
const now = Date.now();
return this.tasks.map((task) => {
const due = this._nextDueAt.get(task.id);
return Object.assign({}, task, {
nextDueAt: due || 0,
nextInSeconds: due ? Math.max(0, Math.round((due - now) / 1000)) : null
});
});
}
_log(message) {
if (this.logger) this.logger.log(`[${this.manager.connection.username}] ${message}`);
}
}
/**
* 归一化任务列表:丢弃非法项、补齐默认值、限制数量。
*
* @param {Array<object>} tasks
* @returns {Array<object>}
*/
function normalizeTasks(tasks) {
if (!Array.isArray(tasks)) return [];
const result = [];
const seen = new Set();
for (let i = 0; i < tasks.length && result.length < MAX_TASKS; i++) {
const raw = tasks[i];
if (!raw || typeof raw !== 'object') continue;
const content = String(raw.content === undefined || raw.content === null ? '' : raw.content).trim();
if (!content) continue;
let id = String(raw.id || '').trim();
if (!id || seen.has(id)) id = `task-${i + 1}`;
while (seen.has(id)) id += '_';
seen.add(id);
result.push({
id,
label: String(raw.label || '').trim(),
content,
enabled: raw.enabled !== false,
intervalSeconds: clampInterval(raw.intervalSeconds),
/** 以 `/` 开头的内容会被 sendChat 自动转成指令 */
kind: content.startsWith('/') ? 'command' : 'chat'
});
}
return result;
}
/**
* 把间隔钳制到合理范围。
* @param {*} value
* @returns {number}
*/
function clampInterval(value) {
const n = Number(value);
if (!Number.isFinite(n)) return 300;
return Math.min(86400, Math.max(MIN_INTERVAL_SECONDS, Math.trunc(n)));
}
module.exports = { ScheduledMessenger, normalizeTasks, clampInterval, MIN_INTERVAL_SECONDS, MAX_TASKS };
+16 -3
查看文件
@@ -231,8 +231,20 @@ function defaultConfig() {
zoom: 0, zoom: 0,
/** 允许浏览的最大层级(本站 squaremap 的 zoom.max 为 3) */ /** 允许浏览的最大层级(本站 squaremap 的 zoom.max 为 3) */
maxZoom: 3, maxZoom: 3,
/** 以原点为中心的覆盖半径(方块),默认与服务器世界边界 ±20480 一致 */ /**
rangeBlocks: 20480, * 爬取半径(方块),**以机器人为中心**(见 followBot)。
*
* 层级 0 每张瓦片覆盖 4096 格,所以:
* 1024 格半径 = 1 张瓦片,4096 格 = 4 张,8192 格 = 9 张,20480 格 = 100 张。
* 只关心机器人周边时把半径调小,爬取和传输开销会差一个数量级。
*/
rangeBlocks: 4096,
/**
* 是否以机器人当前位置为中心爬取与显示大地图(默认开启)。
*
* 关闭时以世界原点 (0,0) 为中心 —— 只有在想看整张世界地图时才需要。
*/
followBot: true,
/** 启动时是否自动预爬取 */ /** 启动时是否自动预爬取 */
crawlOnStart: true, crawlOnStart: true,
/** 瓦片下载并发数(对源站友好,默认 4) */ /** 瓦片下载并发数(对源站友好,默认 4) */
@@ -390,9 +402,10 @@ function validateConfig(config) {
config.bigMap.maxZoom = clampInt(config.bigMap.maxZoom, 0, 8, 3); config.bigMap.maxZoom = clampInt(config.bigMap.maxZoom, 0, 8, 3);
// 预爬取层级不可能超过可浏览的最大层级,否则会爬到源站不存在的瓦片 // 预爬取层级不可能超过可浏览的最大层级,否则会爬到源站不存在的瓦片
if (config.bigMap.zoom > config.bigMap.maxZoom) config.bigMap.zoom = config.bigMap.maxZoom; 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.rangeBlocks = clampInt(config.bigMap.rangeBlocks, 256, 200000, 4096);
config.bigMap.concurrency = clampInt(config.bigMap.concurrency, 1, 16, 4); config.bigMap.concurrency = clampInt(config.bigMap.concurrency, 1, 16, 4);
if (typeof config.bigMap.world !== 'string') config.bigMap.world = ''; if (typeof config.bigMap.world !== 'string') config.bigMap.world = '';
if (typeof config.bigMap.followBot !== 'boolean') config.bigMap.followBot = true;
config.server.commandCooldownMs = clampInt(config.server.commandCooldownMs, 0, 60000, 1200); config.server.commandCooldownMs = clampInt(config.server.commandCooldownMs, 0, 60000, 1200);
config.server.reconnectDelayMs = clampInt(config.server.reconnectDelayMs, 1000, 600000, 10000); config.server.reconnectDelayMs = clampInt(config.server.reconnectDelayMs, 1000, 600000, 10000);
+47 -17
查看文件
@@ -86,22 +86,40 @@ class BigMapService {
} }
/** /**
* 计算覆盖 `[-rangeBlocks, rangeBlocks)` 区间所需的瓦片下标范围(闭区间)。 * 计算覆盖以 `center` 为中心、半径 `rangeBlocks` 的方形区域所需的瓦片下标范围(闭区间)。
*
* 默认中心是原点(全世界),但实际使用时通常只关心**机器人所在位置周边**的图幅 ——
* 全世界层级 0 是 100 张瓦片,而 1024 格半径只需 1 张、4096 格半径只需 4 张,
* 爬取和传输的开销差一个数量级。
* *
* @param {number} zoom 层级 * @param {number} zoom 层级
* @param {number} [rangeBlocks] 覆盖半径(方块),缺省取配置 * @param {number} [rangeBlocks] 覆盖半径(方块),缺省取配置
* @returns {{minX: number, maxX: number, minZ: number, maxZ: number, count: number}} * @param {number} [centerBlockX] 中心方块 X,缺省 0
* @param {number} [centerBlockZ] 中心方块 Z,缺省 0
* @returns {{minX: number, maxX: number, minZ: number, maxZ: number, count: number,
* centerBlockX: number, centerBlockZ: number, rangeBlocks: number}}
*/ */
tileRange(zoom, rangeBlocks) { tileRange(zoom, rangeBlocks, centerBlockX, centerBlockZ) {
const radius = Number.isFinite(rangeBlocks) const radius = Number.isFinite(rangeBlocks)
? Math.abs(rangeBlocks) ? Math.abs(rangeBlocks)
: (this.config && this.config.bigMap ? this.config.bigMap.rangeBlocks : DEFAULT_RANGE_BLOCKS); : (this.config && this.config.bigMap ? this.config.bigMap.rangeBlocks : DEFAULT_RANGE_BLOCKS);
const centerX = Number.isFinite(centerBlockX) ? centerBlockX : 0;
const centerZ = Number.isFinite(centerBlockZ) ? centerBlockZ : 0;
const perTile = this.blocksPerTile(zoom); const perTile = this.blocksPerTile(zoom);
const minX = Math.floor(-radius / perTile); const minX = Math.floor((centerX - radius) / perTile);
const maxX = Math.ceil(radius / perTile) - 1; const maxX = Math.ceil((centerX + radius) / perTile) - 1;
const minZ = minX; const minZ = Math.floor((centerZ - radius) / perTile);
const maxZ = maxX; const maxZ = Math.ceil((centerZ + radius) / perTile) - 1;
return { minX, maxX, minZ, maxZ, count: (maxX - minX + 1) * (maxZ - minZ + 1) }; return {
minX,
maxX,
minZ,
maxZ,
count: (maxX - minX + 1) * (maxZ - minZ + 1),
centerBlockX: centerX,
centerBlockZ: centerZ,
rangeBlocks: radius
};
} }
/** /**
@@ -210,19 +228,22 @@ class BigMapService {
// ==================== 批量爬取 ==================== // ==================== 批量爬取 ====================
/** /**
* 批量爬取某一层级覆盖范围内的全部瓦片(已缓存则跳过)。 * 批量爬取某一层级、某一区域内的全部瓦片(已缓存则跳过)。
*
* 区域默认以原点为中心;实际使用时通常传机器人所在坐标,只爬它周边那几平方公里,
* 这样图幅可以自由调整而不用把整个世界都拉下来。
* *
* 重复调用时若已有爬取在途则直接返回当前进度,不会并发起多轮。 * 重复调用时若已有爬取在途则直接返回当前进度,不会并发起多轮。
* *
* @param {number} [zoom] 层级,缺省取配置里的 bigMap.zoom * @param {number} [zoom] 层级,缺省取配置里的 bigMap.zoom
* @param {{force?: boolean}} [options] force=true 时忽略磁盘缓存重新下载 * @param {{force?: boolean, centerBlockX?: number, centerBlockZ?: number, rangeBlocks?: number}} [options]
* @returns {Promise<object>} 爬取结果统计 * @returns {Promise<object>} 爬取结果统计
*/ */
async crawlAll(zoom, options) { async crawlAll(zoom, options) {
if (this.crawl.running) return this.snapshot().crawl; 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 opts = options || {};
const range = this.tileRange(z); const z = clampZoom(Number.isFinite(zoom) ? zoom : (this.config.bigMap.zoom || 0), this.maxZoom);
const range = this.tileRange(z, opts.rangeBlocks, opts.centerBlockX, opts.centerBlockZ);
this._crawlAbort = false; this._crawlAbort = false;
this.crawl = { this.crawl = {
running: true, running: true,
@@ -232,10 +253,14 @@ class BigMapService {
failed: 0, failed: 0,
startedAt: Date.now(), startedAt: Date.now(),
finishedAt: 0, finishedAt: 0,
lastError: '' lastError: '',
centerBlockX: range.centerBlockX,
centerBlockZ: range.centerBlockZ,
rangeBlocks: range.rangeBlocks
}; };
if (this.logger) { if (this.logger) {
this.logger.log(`开始爬取大地图瓦片:层级 ${z},范围 ${range.minX}..${range.maxX} × ${range.minZ}..${range.maxZ},共 ${range.count} 张`); this.logger.log(`开始爬取大地图瓦片:层级 ${z},中心 ${range.centerBlockX},${range.centerBlockZ},`
+ `半径 ${range.rangeBlocks} 格,瓦片 ${range.minX}..${range.maxX} × ${range.minZ}..${range.maxZ},共 ${range.count} 张`);
} }
const tasks = []; const tasks = [];
@@ -285,11 +310,15 @@ class BigMapService {
// ==================== 状态 ==================== // ==================== 状态 ====================
/** /**
* @param {{centerBlockX?: number, centerBlockZ?: number, rangeBlocks?: number}} [options]
* @returns {object} 大地图状态快照(供控制台与前端渲染使用) * @returns {object} 大地图状态快照(供控制台与前端渲染使用)
*/ */
snapshot() { snapshot(options) {
const opts = options || {};
const zoom = this.config && this.config.bigMap ? this.config.bigMap.zoom : 0; const zoom = this.config && this.config.bigMap ? this.config.bigMap.zoom : 0;
const range = this.tileRange(zoom); const centerX = Number.isFinite(opts.centerBlockX) ? opts.centerBlockX : 0;
const centerZ = Number.isFinite(opts.centerBlockZ) ? opts.centerBlockZ : 0;
const range = this.tileRange(zoom, opts.rangeBlocks, centerX, centerZ);
return { return {
enabled: !!(this.config && this.config.bigMap && this.config.bigMap.enabled), enabled: !!(this.config && this.config.bigMap && this.config.bigMap.enabled),
baseUrl: this.baseUrl, baseUrl: this.baseUrl,
@@ -298,7 +327,8 @@ class BigMapService {
maxZoom: this.maxZoom, maxZoom: this.maxZoom,
zoom, zoom,
blocksPerTile: this.blocksPerTile(zoom), blocksPerTile: this.blocksPerTile(zoom),
rangeBlocks: this.config && this.config.bigMap ? this.config.bigMap.rangeBlocks : DEFAULT_RANGE_BLOCKS, rangeBlocks: range.rangeBlocks,
followBot: !!(this.config && this.config.bigMap && this.config.bigMap.followBot),
tiles: range, tiles: range,
cached: this.countCached(zoom), cached: this.countCached(zoom),
source: { source: {
+39 -7
查看文件
@@ -20,6 +20,8 @@ const { WebConsole } = require('./web/server');
/** 控制器 tick 间隔(毫秒),50ms ≈ 20 TPS */ /** 控制器 tick 间隔(毫秒),50ms ≈ 20 TPS */
const TICK_INTERVAL_MS = 50; const TICK_INTERVAL_MS = 50;
/** 启动时等待机器人进入世界的上限(毫秒),超时后大地图退回以世界原点为中心 */
const WAIT_FOR_BOT_MS = 60000;
async function main() { async function main() {
const logger = new Logger({ echo: true }); const logger = new Logger({ echo: true });
@@ -116,13 +118,10 @@ async function main() {
dataService.start(config.refreshIntervalSeconds); dataService.start(config.refreshIntervalSeconds);
pool.start(); pool.start();
// 大地图:启动时预爬取低层级瓦片(全世界一张大图),保证控制台打开就能看到 // 大地图:启动时预爬取「机器人所在位置周边」的瓦片。
// 机器人还没进世界时先等一会儿 —— 以世界原点为中心爬是没意义的。
if (config.bigMap.enabled && config.bigMap.crawlOnStart) { if (config.bigMap.enabled && config.bigMap.crawlOnStart) {
setTimeout(() => { scheduleInitialCrawl(pool, bigMap, config, logger);
bigMap.crawlAll(config.bigMap.zoom).catch((err) => {
logger.log('大地图预爬取失败:' + (err && err.message ? err.message : String(err)), 'warn');
});
}, 3000).unref?.();
} }
const tickTimer = setInterval(() => { const tickTimer = setInterval(() => {
@@ -161,6 +160,39 @@ async function main() {
}); });
} }
/**
* 启动时预爬取大地图瓦片:优先等机器人进入世界,以它的位置为中心。
*
* 大地图的用途是看自己机器人周边,所以「以世界原点为中心爬」几乎总是爬错地方。
* 这里最多等 `WAIT_FOR_BOT_MS`,等到任意一个机器人有坐标就用它当中心;
* 等不到(比如机器人被禁用)再退回世界原点,保证控制台至少不是空白。
*
* @param {import('./bot/botPool').BotPool} pool
* @param {import('./core/bigMapService').BigMapService} bigMap
* @param {object} config
* @param {import('./util/logger').Logger} logger
*/
function scheduleInitialCrawl(pool, bigMap, config, logger) {
const deadline = Date.now() + WAIT_FOR_BOT_MS;
const attempt = () => {
const position = pool.positions().find((bot) => bot.position) || null;
if (!position && Date.now() < deadline) {
setTimeout(attempt, 3000).unref?.();
return;
}
const center = position
? { centerBlockX: position.position.x, centerBlockZ: position.position.z }
: { centerBlockX: 0, centerBlockZ: 0 };
if (!position) {
logger.log('等待机器人进入世界超时,大地图改以世界原点为中心预爬取', 'warn');
}
bigMap.crawlAll(config.bigMap.zoom, center).catch((err) => {
logger.log('大地图预爬取失败:' + (err && err.message ? err.message : String(err)), 'warn');
});
};
setTimeout(attempt, 3000).unref?.();
}
// 仅在作为入口运行时启动(被 require 时只导出工厂,便于测试与语法自检) // 仅在作为入口运行时启动(被 require 时只导出工厂,便于测试与语法自检)
if (require.main === module) { if (require.main === module) {
main().catch((err) => { main().catch((err) => {
@@ -169,4 +201,4 @@ if (require.main === module) {
}); });
} }
module.exports = { main }; module.exports = { main, scheduleInitialCrawl };
+304 -32
查看文件
@@ -19,6 +19,28 @@ const $ = (id) => document.getElementById(id);
/** 控制台凭证:优先 URL 上的 ?token=,其次浏览器本地保存(登录框写入) */ /** 控制台凭证:优先 URL 上的 ?token=,其次浏览器本地保存(登录框写入) */
let TOKEN = new URLSearchParams(location.search).get('token') || localStorage.getItem('autostore_token') || ''; let TOKEN = new URLSearchParams(location.search).get('token') || localStorage.getItem('autostore_token') || '';
/** 当前选中的地图图幅(半径,方块) */
function selectedRangeBlocks() {
const el = $('bigMapRange');
const n = Number(el && el.value);
return Number.isFinite(n) && n > 0 ? n : 4096;
}
/**
* 大地图 / 领土地图应该聚焦的位置:当前选中机器人的坐标。
*
* 这两张图的用途都是「看自己的机器人」,所以默认必须跟着机器人走,
* 而不是永远停在某个固定坐标上。
*
* @returns {{x: number, z: number}|null}
*/
function botFocusPosition() {
const bots = ((lastStatus && lastStatus.bots) || []).filter((b) => b.position);
if (bots.length === 0) return null;
const target = bots.find((b) => b.id === selectedBotId) || bots[0];
return { x: target.position.x, z: target.position.z };
}
function setToken(value) { function setToken(value) {
TOKEN = value || ''; TOKEN = value || '';
try { try {
@@ -628,6 +650,9 @@ function selectBot(id, silent) {
const changed = id !== selectedBotId; const changed = id !== selectedBotId;
selectedBotId = id; selectedBotId = id;
botListSignature = ''; botListSignature = '';
scheduleSignature = '';
mapView.centered = false;
bigMapView.fitted = false;
$('botSelect').value = id; $('botSelect').value = id;
$('consoleTarget').textContent = id; $('consoleTarget').textContent = id;
for (const item of document.querySelectorAll('.bot-item')) { for (const item of document.querySelectorAll('.bot-item')) {
@@ -724,11 +749,14 @@ function renderStatus(status) {
// ===== 大地图爬取进度 ===== // ===== 大地图爬取进度 =====
if (status.bigMap && status.bigMap.crawl) { if (status.bigMap && status.bigMap.crawl) {
const crawl = status.bigMap.crawl; const crawl = status.bigMap.crawl;
$('btnCrawlMap').textContent = crawl.running const btn = $('btnCrawlAround');
? `爬取中 ${crawl.done}/${crawl.total}` btn.textContent = crawl.running ? `爬取中 ${crawl.done}/${crawl.total}` : '爬取周边';
: '爬取地图'; btn.disabled = crawl.running === true;
} }
// ===== 定时消息 =====
renderSchedules(bot.schedules || []);
// ===== Grim 卡片 ===== // ===== Grim 卡片 =====
const grim = bot.grim; const grim = bot.grim;
if (grim) { if (grim) {
@@ -737,6 +765,19 @@ function renderStatus(status) {
$('kGrimSettled').textContent = grim.rotationSettled ? '是' : '否'; $('kGrimSettled').textContent = grim.rotationSettled ? '是' : '否';
$('kGrimReach').textContent = grim.attackReach + ' 格'; $('kGrimReach').textContent = grim.attackReach + ' 格';
$('kGrimCooldown').textContent = grim.attackCooldownMs > 0 ? grim.attackCooldownMs + ' ms' : '就绪'; $('kGrimCooldown').textContent = grim.attackCooldownMs > 0 ? grim.attackCooldownMs + ' ms' : '就绪';
const packets = grim.protocolPackets;
if (packets) {
// 这两个补包是「机器人能不能走」的关键,直接显示发送计数最直观
const parts = [];
if (packets.playerInputEnabled) {
parts.push(packets.playerInputSupported
? `player_input ${packets.playerInputSent}`
: 'player_input 当前版本不支持');
}
if (packets.tickEndEnabled) parts.push(`tick_end ${packets.tickEndSent}`);
$('kGrimPackets').textContent = parts.length > 0 ? parts.join(' · ') : '已关闭';
$('kGrimPackets').style.color = parts.length > 0 ? 'var(--green)' : 'var(--yellow)';
}
$('kGrimBlocked').textContent = String(grim.blockedActions || 0); $('kGrimBlocked').textContent = String(grim.blockedActions || 0);
$('kGrimAudit').textContent = grim.physicsOk ? '通过' : '异常'; $('kGrimAudit').textContent = grim.physicsOk ? '通过' : '异常';
$('kGrimAudit').style.color = grim.physicsOk ? 'var(--green)' : 'var(--red)'; $('kGrimAudit').style.color = grim.physicsOk ? 'var(--green)' : 'var(--red)';
@@ -752,6 +793,130 @@ function renderStatus(status) {
drawMap(); drawMap();
} }
// ==================== 定时消息 ====================
/** 上次渲染定时列表用的签名,避免每秒推送都重建 DOM */
let scheduleSignature = '';
/**
* 渲染当前机器人的定时发送列表。
*
* @param {Array<object>} schedules 服务端返回的任务快照(含剩余秒数)
*/
function renderSchedules(schedules) {
const list = $('scheduleList');
if (!list) return;
const signature = (schedules || [])
.map((t) => [t.id, t.enabled, t.content, t.intervalSeconds, t.nextInSeconds].join(':'))
.join('|');
if (signature === scheduleSignature) return;
scheduleSignature = signature;
list.innerHTML = '';
if (!schedules || schedules.length === 0) {
const empty = document.createElement('div');
empty.className = 'schedule-empty';
empty.textContent = '暂无定时任务。点「+ 新增」可让机器人每隔一段时间重复发送同一条消息或指令。';
list.appendChild(empty);
return;
}
for (const task of schedules) {
const item = document.createElement('div');
item.className = 'schedule-item' + (task.enabled ? '' : ' off');
const toggle = document.createElement('input');
toggle.type = 'checkbox';
toggle.checked = task.enabled;
toggle.title = '启用 / 停用';
toggle.addEventListener('change', () => toggleSchedule(task, toggle.checked));
item.appendChild(toggle);
const content = document.createElement('div');
content.className = 'content';
const code = document.createElement('code');
code.textContent = task.content;
code.title = task.content;
content.appendChild(code);
const sub = document.createElement('div');
sub.className = 'sub';
sub.textContent = `${task.kind === 'command' ? '指令' : '公屏'} · 每 ${task.intervalSeconds} 秒`
+ (task.label ? ` · ${task.label}` : '');
content.appendChild(sub);
item.appendChild(content);
const next = document.createElement('div');
next.className = 'sub next';
next.textContent = task.enabled && task.nextInSeconds !== null && task.nextInSeconds !== undefined
? `${task.nextInSeconds}s 后`
: '已停用';
item.appendChild(next);
const remove = document.createElement('button');
remove.className = 'remove';
remove.textContent = '×';
remove.title = '删除该定时任务';
remove.addEventListener('click', () => removeSchedule(task));
item.appendChild(remove);
list.appendChild(item);
}
}
/** 保存整份定时任务列表 */
async function saveSchedules(schedules) {
if (!selectedBotId) return;
try {
await api('/api/bots/' + encodeURIComponent(selectedBotId) + '/schedules', {
method: 'POST',
body: JSON.stringify({ schedules })
});
scheduleSignature = '';
toast('定时任务已保存');
} catch (err) {
toast('保存定时任务失败:' + err.message, true);
}
}
/** 取当前列表的原始定义(去掉服务端附加的剩余秒数字段) */
function currentSchedules() {
const bot = selectedBot(lastStatus || {});
return ((bot && bot.schedules) || []).map((t) => ({
id: t.id,
label: t.label,
content: t.content,
intervalSeconds: t.intervalSeconds,
enabled: t.enabled
}));
}
function toggleSchedule(task, enabled) {
const next = currentSchedules().map((t) => (t.id === task.id ? Object.assign({}, t, { enabled }) : t));
saveSchedules(next);
}
function removeSchedule(task) {
const next = currentSchedules().filter((t) => t.id !== task.id);
saveSchedules(next);
}
// ==================== 移动端导航 ====================
/** 小屏底部导航:点击滚动到对应区块 */
function bindMobileNav() {
const nav = $('mobileNav');
if (!nav) return;
nav.addEventListener('click', (e) => {
const item = e.target.closest('.mobile-nav-item');
if (!item) return;
for (const el of nav.querySelectorAll('.mobile-nav-item')) {
el.classList.toggle('active', el === item);
}
const target = $(item.dataset.scroll);
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
}
// ==================== 运行日志 ==================== // ==================== 运行日志 ====================
const MAX_LOG_LINES = 800; const MAX_LOG_LINES = 800;
@@ -1339,11 +1504,7 @@ async function loadMap(forceFull) {
if (needFull) lastFullFetchAt = Date.now(); if (needFull) lastFullFetchAt = Date.now();
else if (cached && cached.otherChunks) next.otherChunks = cached.otherChunks; else if (cached && cached.otherChunks) next.otherChunks = cached.otherChunks;
mapView.data = next; mapView.data = next;
if (!mapView.centered && next.playerChunk) { if (!mapView.centered) focusTerritoryOnBotCountry(next);
mapView.centerX = next.playerChunk.x;
mapView.centerZ = next.playerChunk.z;
mapView.centered = true;
}
drawMap(); drawMap();
} catch (err) { } catch (err) {
toast('读取地图失败:' + err.message, true); toast('读取地图失败:' + err.message, true);
@@ -1352,6 +1513,51 @@ async function loadMap(forceFull) {
} }
} }
/**
* 把领土地图对准「机器人所在的邦国」。
*
* 之前这里用的是控制器的 playerChunk,而控制器在任务启动前是 (0,0),
* 结果地图永远停在世界的原点附近 —— 而用户关心的是自己机器人所在的那个邦国。
* 现在按「机器人所在区块 → 包含它的邦国 → 该邦国的王城/外接矩形」来定位。
*
* @param {object} data `/api/map` 返回的数据
*/
function focusTerritoryOnBotCountry(data) {
if (!data) return;
const focus = botFocusPosition();
const botChunk = focus ? { x: Math.floor(focus.x / 16), z: Math.floor(focus.z / 16) } : null;
// 1) 优先用机器人所在区块反查邦国(最准确)
let state = null;
if (botChunk) {
state = (data.states || []).find((s) => s.bounds
&& botChunk.x >= s.bounds.minX && botChunk.x <= s.bounds.maxX
&& botChunk.z >= s.bounds.minZ && botChunk.z <= s.bounds.maxZ);
}
// 2) 退而求其次:用服务端标记的己方邦国
if (!state && data.selfState) state = (data.states || []).find((s) => s.name === data.selfState);
// 3) 再退:直接用机器人所在区块
if (!state && botChunk) {
mapView.centerX = botChunk.x;
mapView.centerZ = botChunk.z;
mapView.scale = 2;
mapView.centered = true;
return;
}
if (!state) return;
if (state.capital) {
mapView.centerX = state.capital.x;
mapView.centerZ = state.capital.z;
} else if (state.bounds) {
mapView.centerX = (state.bounds.minX + state.bounds.maxX) / 2;
mapView.centerZ = (state.bounds.minZ + state.bounds.maxZ) / 2;
}
mapView.scale = 2;
mapView.centered = true;
mapView.countryName = state.name;
}
let bigMapLoading = false; let bigMapLoading = false;
let bigMapMarkersAt = 0; let bigMapMarkersAt = 0;
@@ -1360,7 +1566,10 @@ async function loadBigMap(force) {
if (bigMapLoading) return; if (bigMapLoading) return;
bigMapLoading = true; bigMapLoading = true;
try { try {
const res = await api('/api/bigmap'); // 带上图幅与关注的机器人:服务端据此算出要爬/要用的瓦片范围
const query = '?rangeBlocks=' + selectedRangeBlocks()
+ (selectedBotId ? '&bot=' + encodeURIComponent(selectedBotId) : '');
const res = await api('/api/bigmap' + query);
const data = res.data; const data = res.data;
bigMapView.data = data; bigMapView.data = data;
// 领土标记是 160KB 级别的数据,按需刷新(默认 5 分钟一次) // 领土标记是 160KB 级别的数据,按需刷新(默认 5 分钟一次)
@@ -1371,9 +1580,7 @@ async function loadBigMap(force) {
// 会话新占领的区块:复用领土地图的数据 // 会话新占领的区块:复用领土地图的数据
if (mapView.data) bigMapView.sessionChunks = [mapView.data.session || []]; if (mapView.data) bigMapView.sessionChunks = [mapView.data.session || []];
if (!bigMapView.fitted && data.rangeBlocks) { if (!bigMapView.fitted) fitBigMapToRegion();
fitBigMapToWorld();
}
drawMap(); drawMap();
} catch (err) { } catch (err) {
toast('读取大地图失败:' + err.message, true); toast('读取大地图失败:' + err.message, true);
@@ -1400,21 +1607,30 @@ function setBigMapMarkers(markers) {
bigMapView.markers = { world: markers.world, updatedAt: markers.updatedAt, states }; bigMapView.markers = { world: markers.world, updatedAt: markers.updatedAt, states };
} }
/** 让全世界刚好铺满画布 */ /**
function fitBigMapToWorld() { * 让「选定图幅」刚好铺满画布,并以机器人所在位置为中心。
const data = bigMapView.data; *
* 图幅由界面上的「图幅」下拉框决定(±512 ~ 全世界),
* 这样既能看到机器人周边细节,也能一键切到全局视角。
*/
function fitBigMapToRegion() {
const rect = $('mapCanvas').getBoundingClientRect(); const rect = $('mapCanvas').getBoundingClientRect();
const radius = (data && data.rangeBlocks) || 20480; const radius = selectedRangeBlocks();
bigMapView.centerX = 0; const focus = botFocusPosition();
bigMapView.centerZ = 0; const data = bigMapView.data;
bigMapView.centerX = focus ? focus.x : (data && data.center ? data.center.x : 0);
bigMapView.centerZ = focus ? focus.z : (data && data.center ? data.center.z : 0);
bigMapView.pxPerBlock = Math.min(rect.width, rect.height) / (2 * radius + 1); bigMapView.pxPerBlock = Math.min(rect.width, rect.height) / (2 * radius + 1);
bigMapView.fitted = true; bigMapView.fitted = true;
} }
/** 把视图中心移到所有机器人的几何中心 */ /** 把视图中心移到所有机器人的几何中心(「回到机器人」按钮) */
function centerBigMapOnBots() { function centerBigMapOnBots() {
const bots = ((lastStatus && lastStatus.bots) || []).filter((b) => b.position); const bots = ((lastStatus && lastStatus.bots) || []).filter((b) => b.position);
if (bots.length === 0) return; if (bots.length === 0) {
toast('机器人还没有坐标(未进入世界)', true);
return;
}
let x = 0; let x = 0;
let z = 0; let z = 0;
for (const bot of bots) { for (const bot of bots) {
@@ -1540,6 +1756,35 @@ function bindEvents() {
} }
}); });
// ===== 定时消息编辑器 =====
$('btnAddSchedule').addEventListener('click', () => {
$('schedContent').value = '';
$('schedInterval').value = '300';
$('schedLabel').value = '';
$('scheduleEditor').classList.remove('hidden');
$('schedContent').focus();
});
$('btnCancelSchedule').addEventListener('click', () => {
$('scheduleEditor').classList.add('hidden');
});
$('btnSaveSchedule').addEventListener('click', async () => {
const content = $('schedContent').value.trim();
if (!content) {
toast('请填写要发送的内容', true);
return;
}
const next = currentSchedules();
next.push({
id: 'task-' + Date.now().toString(36),
content,
intervalSeconds: Number($('schedInterval').value) || 300,
label: $('schedLabel').value.trim(),
enabled: true
});
$('scheduleEditor').classList.add('hidden');
await saveSchedules(next);
});
$('btnRefreshMap').addEventListener('click', async () => { $('btnRefreshMap').addEventListener('click', async () => {
try { try {
await api('/api/map/refresh', { method: 'POST' }); await api('/api/map/refresh', { method: 'POST' });
@@ -1616,12 +1861,18 @@ function bindEvents() {
$('chkBotLabels').addEventListener('change', drawMap); $('chkBotLabels').addEventListener('change', drawMap);
$('btnMapCenter').addEventListener('click', () => { $('btnMapCenter').addEventListener('click', () => {
const data = mapView.data; const focus = botFocusPosition();
if (data && data.playerChunk) { if (focus) {
mapView.centerX = data.playerChunk.x; mapView.centerX = Math.floor(focus.x / 16);
mapView.centerZ = data.playerChunk.z; mapView.centerZ = Math.floor(focus.z / 16);
drawMap(); } else if (mapView.data && mapView.data.playerChunk) {
mapView.centerX = mapView.data.playerChunk.x;
mapView.centerZ = mapView.data.playerChunk.z;
} else {
toast('机器人还没有坐标(未进入世界)', true);
return;
} }
drawMap();
}); });
$('btnMapFit').addEventListener('click', () => { $('btnMapFit').addEventListener('click', () => {
@@ -1657,17 +1908,37 @@ function bindEvents() {
drawMap(); drawMap();
}); });
$('btnMapFitWorld').addEventListener('click', () => {
fitBigMapToWorld();
drawMap();
});
$('btnMapCenterBots').addEventListener('click', centerBigMapOnBots); $('btnMapCenterBots').addEventListener('click', centerBigMapOnBots);
$('btnCrawlMap').addEventListener('click', async () => { // 图幅切换:重新对焦并拉取对应范围的瓦片信息
$('bigMapRange').addEventListener('change', () => {
bigMapView.fitted = false;
fitBigMapToRegion();
drawMap();
loadBigMap(true);
});
// 爬取机器人周边:只爬当前图幅内的瓦片,而不是把整个世界拉下来
$('btnCrawlAround').addEventListener('click', async () => {
const focus = botFocusPosition();
const radius = selectedRangeBlocks();
if (!focus) {
toast('机器人还没有坐标,无法按周边爬取;请在配置里指定中心或先让机器人进入世界', true);
return;
}
try { try {
const res = await api('/api/bigmap/crawl', { method: 'POST', body: JSON.stringify({}) }); const res = await api('/api/bigmap/crawl', {
toast(res.message); method: 'POST',
body: JSON.stringify({
centerX: focus.x,
centerZ: focus.z,
rangeBlocks: radius,
bot: selectedBotId
})
});
toast(`正在爬取 ${Math.round(focus.x)},${Math.round(focus.z)} 周边 ±${radius} 格的瓦片`);
loadBigMap(true); loadBigMap(true);
void res;
} catch (err) { } catch (err) {
toast('爬取失败:' + err.message, true); toast('爬取失败:' + err.message, true);
} }
@@ -1886,6 +2157,7 @@ async function startApp() {
wsEnabled = true; wsEnabled = true;
initMap(); initMap();
bindEvents(); bindEvents();
bindMobileNav();
await loadConfig(); await loadConfig();
await loadMap(); await loadMap();
await loadBigMap(true); await loadBigMap(true);
+137 -79
查看文件
@@ -2,7 +2,8 @@
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#0d1117">
<title>AutoStore Bot 控制台</title> <title>AutoStore Bot 控制台</title>
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
</head> </head>
@@ -10,46 +11,62 @@
<header class="topbar"> <header class="topbar">
<div class="brand"> <div class="brand">
<span class="logo">AS</span> <span class="logo">AS</span>
<div> <div class="brand-text">
<h1>AutoStore Bot</h1> <h1>AutoStore Bot</h1>
<p>多机器人自动扩地 · mineflayer · Grim 合规 · 大地图</p> <p>多机器人自动扩地 · mineflayer · Grim 合规 · 大地图</p>
</div> </div>
</div> </div>
<div class="topbar-right"> <div class="topbar-right">
<label class="inline-field"> <label class="field-inline">
当前机器人 <span class="field-label">当前机器人</span>
<select id="botSelect" class="input input-sm"></select> <select id="botSelect" class="input input-sm"></select>
</label> </label>
<span id="connBadge" class="badge badge-offline">未连接</span> <div class="badges">
<span id="taskBadge" class="badge badge-idle">空闲</span> <span id="connBadge" class="badge badge-offline">未连接</span>
<span id="authBadge" class="badge badge-idle" title="控制台登录状态">未登录</span> <span id="taskBadge" class="badge badge-idle">空闲</span>
<button id="btnStart" class="btn btn-primary">开始扩地</button> <span id="authBadge" class="badge badge-idle" title="控制台登录状态">未登录</span>
<button id="btnStop" class="btn btn-danger">停止</button> </div>
<button id="btnReconnect" class="btn">重连</button> <div class="topbar-actions">
<button id="btnStart" class="btn btn-primary">开始扩地</button>
<button id="btnStop" class="btn btn-danger">停止</button>
<button id="btnReconnect" class="btn">重连</button>
</div>
</div> </div>
</header> </header>
<nav class="mobile-nav" id="mobileNav">
<button class="mobile-nav-item active" data-scroll="panelStatus">状态</button>
<button class="mobile-nav-item" data-scroll="panelMap">地图</button>
<button class="mobile-nav-item" data-scroll="panelLogs">日志</button>
<button class="mobile-nav-item" data-scroll="panelConfig">配置</button>
</nav>
<main class="layout"> <main class="layout">
<!-- ==================== 左栏:状态面板 ==================== --> <!-- ==================== 左栏:状态面板 ==================== -->
<section class="column col-left"> <section class="column col-left" id="panelStatus">
<div class="card"> <div class="card">
<div class="card-head"> <div class="card-head">
<h2>机器人(<span id="botCount">0</span>)</h2> <h2>机器人(<span id="botCount">0</span>)</h2>
<div class="row"> <div class="row row-compact">
<button id="btnStartAll" class="btn btn-sm">全部开始</button> <button id="btnStartAll" class="btn btn-sm">全部开始</button>
<button id="btnStopAll" class="btn btn-sm">全部停止</button> <button id="btnStopAll" class="btn btn-sm">全部停止</button>
</div> </div>
</div> </div>
<div id="botList" class="bot-list"></div> <div id="botList" class="bot-list"></div>
<div class="add-bot"> <details class="add-bot">
<div class="row"> <summary>+ 添加玩家</summary>
<input id="newBotName" class="input input-sm" type="text" placeholder="玩家名" maxlength="16"> <div class="field">
<input id="newBotPass" class="input input-sm" type="password" placeholder="服务器密码(可留空)"> <input id="newBotName" class="input" type="text" placeholder="玩家名(1-16 位字母/数字/下划线)" maxlength="16">
<input id="newBotLabel" class="input input-sm" type="text" placeholder="备注(可留空)">
<button id="btnAddBot" class="btn btn-sm btn-primary">添加玩家</button>
</div> </div>
<div class="field">
<input id="newBotPass" class="input" type="password" placeholder="服务器密码(可留空)" autocomplete="new-password">
</div>
<div class="field">
<input id="newBotLabel" class="input" type="text" placeholder="备注(可留空)">
</div>
<button id="btnAddBot" class="btn btn-sm btn-primary btn-block">添加</button>
<p class="hint">密码只保存在服务器本地凭据文件(权限 0600),不会进仓库、也不会出现在任何接口响应里。.env 里配置的账号需改 .env 才能移除。</p> <p class="hint">密码只保存在服务器本地凭据文件(权限 0600),不会进仓库、也不会出现在任何接口响应里。.env 里配置的账号需改 .env 才能移除。</p>
</div> </details>
</div> </div>
<div class="card"> <div class="card">
@@ -89,6 +106,35 @@
<div class="progress"><div id="progressBar" class="progress-fill"></div></div> <div class="progress"><div id="progressBar" class="progress-fill"></div></div>
</div> </div>
<div class="card">
<div class="card-head">
<h2>定时消息</h2>
<button id="btnAddSchedule" class="btn btn-sm">+ 新增</button>
</div>
<div id="scheduleList" class="schedule-list"></div>
<div id="scheduleEditor" class="schedule-editor hidden">
<div class="field">
<label>内容(以 / 开头按游戏指令发送)</label>
<input id="schedContent" class="input" type="text" placeholder="/u restore confirm">
</div>
<div class="field-row">
<div class="field">
<label>间隔(秒)</label>
<input id="schedInterval" class="input" type="number" min="5" value="300">
</div>
<div class="field">
<label>备注</label>
<input id="schedLabel" class="input" type="text" placeholder="恢复民心">
</div>
</div>
<div class="row row-compact">
<button id="btnSaveSchedule" class="btn btn-sm btn-primary">保存</button>
<button id="btnCancelSchedule" class="btn btn-sm">取消</button>
</div>
<p class="hint">只会在机器人已连接且通过登录验证时发送;未就绪的周期会跳过,不会补发。</p>
</div>
</div>
<div class="card"> <div class="card">
<h2>领土数据源</h2> <h2>领土数据源</h2>
<dl class="kv"> <dl class="kv">
@@ -101,7 +147,7 @@
<dt>己方飞地</dt><dd id="kFlying">0</dd> <dt>己方飞地</dt><dd id="kFlying">0</dd>
<dt>SimpCore API</dt><dd id="kApi">未启用</dd> <dt>SimpCore API</dt><dd id="kApi">未启用</dd>
</dl> </dl>
<div class="row"> <div class="row row-compact">
<button id="btnRefreshMap" class="btn btn-sm">刷新地图</button> <button id="btnRefreshMap" class="btn btn-sm">刷新地图</button>
<button id="btnFetchAll" class="btn btn-sm">拉取全服领土</button> <button id="btnFetchAll" class="btn btn-sm">拉取全服领土</button>
</div> </div>
@@ -115,6 +161,7 @@
<dt>朝向已同步</dt><dd id="kGrimSettled">-</dd> <dt>朝向已同步</dt><dd id="kGrimSettled">-</dd>
<dt>攻击距离</dt><dd id="kGrimReach">-</dd> <dt>攻击距离</dt><dd id="kGrimReach">-</dd>
<dt>攻击冷却</dt><dd id="kGrimCooldown">-</dd> <dt>攻击冷却</dt><dd id="kGrimCooldown">-</dd>
<dt>协议补包</dt><dd id="kGrimPackets">-</dd>
<dt>已拦下动作</dt><dd id="kGrimBlocked">0</dd> <dt>已拦下动作</dt><dd id="kGrimBlocked">0</dd>
<dt>物理自检</dt><dd id="kGrimAudit">-</dd> <dt>物理自检</dt><dd id="kGrimAudit">-</dd>
</dl> </dl>
@@ -123,26 +170,36 @@
</section> </section>
<!-- ==================== 中栏:地图与日志 ==================== --> <!-- ==================== 中栏:地图与日志 ==================== -->
<section class="column col-center"> <section class="column col-center" id="panelMap">
<div class="card card-map"> <div class="card card-map">
<div class="card-head"> <div class="card-head card-head-wrap">
<div class="tabs tabs-inline" id="mapTabs"> <div class="tabs tabs-inline" id="mapTabs">
<button class="tab active" data-view="big">大地图</button> <button class="tab active" data-view="big">大地图</button>
<button class="tab" data-view="territory">领土地图</button> <button class="tab" data-view="territory">领土地图</button>
</div> </div>
<div class="row" id="bigMapControls"> <div class="row row-compact" id="bigMapControls">
<label class="field-inline">
<span class="field-label">图幅</span>
<select id="bigMapRange" class="input input-sm">
<option value="512">±512 格</option>
<option value="1024">±1024 格</option>
<option value="2048">±2048 格</option>
<option value="4096" selected>±4096 格</option>
<option value="8192">±8192 格</option>
<option value="20480">全世界</option>
</select>
</label>
<label class="chk"><input type="checkbox" id="chkTerritory" checked> 领土标记</label> <label class="chk"><input type="checkbox" id="chkTerritory" checked> 领土标记</label>
<label class="chk"><input type="checkbox" id="chkBotLabels" checked> 机器人名字</label> <label class="chk"><input type="checkbox" id="chkBotLabels" checked> 名字</label>
<button id="btnMapFitWorld" class="btn btn-sm">全世界</button> <button id="btnCrawlAround" class="btn btn-sm btn-primary">爬取周边</button>
<button id="btnMapCenterBots" class="btn btn-sm">回到机器人</button> <button id="btnMapCenterBots" class="btn btn-sm">回到机器人</button>
<button id="btnCrawlMap" class="btn btn-sm">爬取地图</button>
</div> </div>
<div class="row hidden" id="territoryMapControls"> <div class="row row-compact hidden" id="territoryMapControls">
<label class="chk"><input type="checkbox" id="chkFull" checked> 显示全部邦国</label> <label class="chk"><input type="checkbox" id="chkFull" checked> 全部邦国</label>
<label class="chk"><input type="checkbox" id="chkFlying" checked> 标出飞地</label> <label class="chk"><input type="checkbox" id="chkFlying" checked> 飞地</label>
<label class="chk"><input type="checkbox" id="chkGrid" checked> 网格</label> <label class="chk"><input type="checkbox" id="chkGrid" checked> 网格</label>
<button id="btnMapCenter" class="btn btn-sm">回到玩家</button> <button id="btnMapCenter" class="btn btn-sm">回到机器人</button>
<button id="btnMapFit" class="btn btn-sm">全图</button> <button id="btnMapFit" class="btn btn-sm">看己方全境</button>
</div> </div>
</div> </div>
<canvas id="mapCanvas"></canvas> <canvas id="mapCanvas"></canvas>
@@ -165,26 +222,27 @@
</div> </div>
</div> </div>
<div class="card card-log"> <div class="card card-log" id="panelLogs">
<div class="card-head"> <div class="card-head card-head-wrap">
<div class="tabs tabs-inline" id="logTabs"> <div class="tabs tabs-inline" id="logTabs">
<button class="tab active" data-pane="log">运行日志</button> <button class="tab active" data-pane="log">运行日志</button>
<button class="tab" data-pane="chat">公屏聊天</button> <button class="tab" data-pane="chat">公屏聊天</button>
<button class="tab" data-pane="exp">开拓日志</button> <button class="tab" data-pane="exp">开拓日志</button>
</div> </div>
<div class="row" id="logControls"> <div class="row row-compact" id="logControls">
<label class="chk"><input type="checkbox" id="chkAutoScroll" checked> 自动滚动</label> <label class="chk"><input type="checkbox" id="chkAutoScroll" checked> 自动滚动</label>
<button id="btnClearLog" class="btn btn-sm">清屏</button> <button id="btnClearLog" class="btn btn-sm">清屏</button>
<button id="btnExportLog" class="btn btn-sm">导出日志</button> <button id="btnExportLog" class="btn btn-sm">导出</button>
</div> </div>
<div class="row hidden" id="chatControls"> <div class="row row-compact hidden" id="chatControls">
<label class="chk"><input type="checkbox" id="chkChatAutoScroll" checked> 自动滚动</label> <label class="chk"><input type="checkbox" id="chkChatAutoScroll" checked> 自动滚动</label>
<label class="chk"><input type="checkbox" id="chkChatAllBots"> 全部机器人</label> <label class="chk"><input type="checkbox" id="chkChatAllBots"> 全部机器人</label>
<button id="btnClearChat" class="btn btn-sm">清屏</button> <button id="btnClearChat" class="btn btn-sm">清屏</button>
<button id="btnExportChat" class="btn btn-sm">导出公屏日志</button> <button id="btnExportChat" class="btn btn-sm">导出</button>
</div> </div>
<div class="row hidden" id="expControls"> <div class="row row-compact hidden" id="expControls">
<label class="chk">机器人 <label class="field-inline">
<span class="field-label">机器人</span>
<select id="expFilterBot" class="input input-sm"><option value="">全部</option></select> <select id="expFilterBot" class="input input-sm"><option value="">全部</option></select>
</label> </label>
<label class="chk"><input type="checkbox" id="chkExpAutoScroll" checked> 自动滚动</label> <label class="chk"><input type="checkbox" id="chkExpAutoScroll" checked> 自动滚动</label>
@@ -194,7 +252,7 @@
</div> </div>
<div id="logView" class="log-view"></div> <div id="logView" class="log-view"></div>
<div id="chatView" class="log-view hidden"></div> <div id="chatView" class="log-view hidden"></div>
<div id="expView" class="log-view hidden"> <div id="expView" class="log-view log-table hidden">
<table class="exp-table"> <table class="exp-table">
<thead> <thead>
<tr><th>时间</th><th>机器人</th><th>邦国</th><th>区块</th><th>方块坐标</th><th>来源</th></tr> <tr><th>时间</th><th>机器人</th><th>邦国</th><th>区块</th><th>方块坐标</th><th>来源</th></tr>
@@ -206,12 +264,46 @@
</section> </section>
<!-- ==================== 右栏:配置与控制 ==================== --> <!-- ==================== 右栏:配置与控制 ==================== -->
<section class="column col-right"> <section class="column col-right" id="panelConfig">
<div class="card">
<div class="card-head">
<h2>控制台 / 聊天框</h2>
<span class="mono small" id="consoleTarget">-</span>
</div>
<p class="hint" id="consoleHint">登录验证通过后可向该机器人发送指令或公屏消息。</p>
<div class="row row-compact">
<select id="sendMode" class="input input-sm">
<option value="chat">公屏聊天</option>
<option value="command">游戏指令</option>
</select>
<input id="cmdInput" class="input input-grow" type="text" placeholder="输入内容后回车发送">
<button id="btnSendCmd" class="btn btn-sm btn-primary">发送</button>
</div>
<div class="row row-compact">
<button class="btn btn-sm" data-cmd="u return">/u return</button>
<button class="btn btn-sm" data-cmd="u claimmode">/u claimmode</button>
<button class="btn btn-sm" data-cmd="spawn">/spawn</button>
<button class="btn btn-sm" data-cmd="list">/list</button>
</div>
<div class="row row-compact">
<input id="quickChatInput" class="input input-grow" type="text" placeholder="快捷公屏消息">
<button id="btnQuickChat" class="btn btn-sm">公屏发送</button>
</div>
<div class="row row-compact">
<span class="field-label">移动测试</span>
<button class="btn btn-sm" data-move="forward">前</button>
<button class="btn btn-sm" data-move="back">后</button>
<button class="btn btn-sm" data-move="left">左</button>
<button class="btn btn-sm" data-move="right">右</button>
<button class="btn btn-sm" data-move="jump">跳</button>
</div>
</div>
<div class="card card-config"> <div class="card card-config">
<div class="card-head"> <div class="card-head">
<h2>配置</h2> <h2>配置</h2>
<div class="row"> <div class="row row-compact">
<button id="btnSaveConfig" class="btn btn-sm btn-primary">保存配置</button> <button id="btnSaveConfig" class="btn btn-sm btn-primary">保存</button>
<button id="btnReloadConfig" class="btn btn-sm">重新载入</button> <button id="btnReloadConfig" class="btn btn-sm">重新载入</button>
</div> </div>
</div> </div>
@@ -231,40 +323,6 @@
</div> </div>
<div id="configBody" class="config-body"></div> <div id="configBody" class="config-body"></div>
</div> </div>
<div class="card">
<div class="card-head">
<h2>控制台 / 聊天框</h2>
<span class="mono small" id="consoleTarget">-</span>
</div>
<p class="hint" id="consoleHint">登录验证通过后可向该机器人发送指令或公屏消息。</p>
<div class="row">
<select id="sendMode" class="input input-sm">
<option value="chat">公屏聊天</option>
<option value="command">游戏指令</option>
</select>
<input id="cmdInput" class="input" type="text" placeholder="输入内容后回车发送">
<button id="btnSendCmd" class="btn btn-sm">发送</button>
</div>
<div class="row">
<button class="btn btn-sm" data-cmd="u return">/u return</button>
<button class="btn btn-sm" data-cmd="u claimmode">/u claimmode</button>
<button class="btn btn-sm" data-cmd="spawn">/spawn</button>
<button class="btn btn-sm" data-cmd="list">/list</button>
</div>
<div class="row">
<input id="quickChatInput" class="input" type="text" placeholder="快捷公屏消息(如:大家好)">
<button id="btnQuickChat" class="btn btn-sm">公屏发送</button>
</div>
<div class="row">
<span class="hint">移动测试:</span>
<button class="btn btn-sm" data-move="forward">前</button>
<button class="btn btn-sm" data-move="back">后</button>
<button class="btn btn-sm" data-move="left">左</button>
<button class="btn btn-sm" data-move="right">右</button>
<button class="btn btn-sm" data-move="jump">跳</button>
</div>
</div>
</section> </section>
</main> </main>
@@ -275,8 +333,8 @@
<p class="hint">验证通过后才允许操作各机器人的控制台、聊天框与扩地任务。</p> <p class="hint">验证通过后才允许操作各机器人的控制台、聊天框与扩地任务。</p>
<input id="loginPassword" class="input" type="password" placeholder="控制台密码 / 令牌" autocomplete="current-password"> <input id="loginPassword" class="input" type="password" placeholder="控制台密码 / 令牌" autocomplete="current-password">
<div class="row"> <div class="row">
<button id="btnLogin" class="btn btn-primary">登录</button> <button id="btnLogin" class="btn btn-primary btn-grow">登录</button>
<button id="btnLogout" class="btn btn-sm">退出登录</button> <button id="btnLogout" class="btn">退出登录</button>
</div> </div>
<p id="loginError" class="hint error-text"></p> <p id="loginError" class="hint error-text"></p>
</div> </div>
+360 -180
查看文件
@@ -1,16 +1,38 @@
/* ==========================================================================
AutoStore Bot 控制台样式
设计要点:
- 深色主题,卡片式布局,三层栅格(左状态 / 中地图日志 / 右配置)
- 响应式:≤1200px 收成两栏,≤820px 收成单栏并启用底部导航
- 移动端:触控目标 ≥ 36px、地图自适应高度、底部安全区避让
========================================================================== */
:root { :root {
--bg: #0d1117; --bg: #0b0f16;
--panel: #161b22; --bg-soft: #10151d;
--panel-2: #1c2330; --panel: #151b25;
--border: #30363d; --panel-2: #1b2230;
--text: #c9d1d9; --panel-3: #212a3a;
--muted: #8b949e; --border: #2b3444;
--border-soft: #222a37;
--text: #d7dee8;
--text-dim: #9aa7b8;
--muted: #6b7787;
--accent: #e94560; --accent: #e94560;
--accent-2: #2f81f7; --accent-hover: #f2586f;
--accent-2: #3b8ef7;
--accent-2-hover: #5aa0f9;
--green: #3fb950; --green: #3fb950;
--yellow: #d29922; --yellow: #d6a01f;
--red: #f85149; --red: #f85149;
--purple: #bc8cff; --purple: #bc8cff;
--radius-sm: 6px;
--radius: 10px;
--radius-lg: 14px;
--shadow: 0 1px 2px rgba(0, 0, 0, .3), 0 8px 24px -12px rgba(0, 0, 0, .5);
--gap: 12px;
--topbar-h: 58px;
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
@@ -18,114 +40,141 @@
html, body { html, body {
margin: 0; margin: 0;
padding: 0; padding: 0;
height: 100%; min-height: 100%;
background: var(--bg); background: radial-gradient(1200px 600px at 20% -10%, #16202e 0%, var(--bg) 55%);
color: var(--text); color: var(--text);
font-family: "Segoe UI", "Microsoft YaHei", system-ui, -apple-system, sans-serif; font-family: "Segoe UI", "Microsoft YaHei", system-ui, -apple-system, "Helvetica Neue", sans-serif;
font-size: 14px; font-size: 14px;
line-height: 1.5;
-webkit-text-size-adjust: 100%;
} }
.mono { font-family: Consolas, "Cascadia Mono", monospace; } .mono { font-family: Consolas, "Cascadia Mono", ui-monospace, monospace; }
.small { font-size: 12px; } .small { font-size: 12px; }
.hidden { display: none !important; }
.error-text { color: var(--red); }
/* ==================== 顶栏 ==================== */ /* ==================== 顶栏 ==================== */
.topbar { .topbar {
position: sticky;
top: 0;
z-index: 30;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 10px 18px; gap: 12px;
background: linear-gradient(90deg, #161b22, #1b2230); flex-wrap: wrap;
border-bottom: 1px solid var(--border); padding: 10px 16px;
position: sticky; background: rgba(16, 21, 29, .92);
top: 0; backdrop-filter: blur(10px);
z-index: 10; border-bottom: 1px solid var(--border-soft);
} }
.brand { display: flex; align-items: center; gap: 12px; }
.brand { display: flex; align-items: center; gap: 11px; min-width: 0; }
.logo { .logo {
width: 38px; height: 38px; width: 36px; height: 36px;
display: grid; place-items: center; display: grid; place-items: center;
border-radius: 9px; border-radius: var(--radius);
background: var(--accent); background: linear-gradient(135deg, var(--accent), #b5324a);
color: #fff; font-weight: 700; letter-spacing: 1px; color: #fff; font-weight: 700; letter-spacing: .5px;
box-shadow: 0 4px 14px -6px rgba(233, 69, 96, .9);
flex: 0 0 auto;
} }
.brand h1 { font-size: 16px; margin: 0; } .brand-text { min-width: 0; }
.brand p { font-size: 12px; margin: 2px 0 0; color: var(--muted); } .brand h1 { font-size: 15px; margin: 0; white-space: nowrap; }
.topbar-right { display: flex; align-items: center; gap: 8px; } .brand p { font-size: 11.5px; margin: 1px 0 0; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.topbar-right { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.badges { display: flex; gap: 6px; flex-wrap: wrap; }
.topbar-actions { display: flex; gap: 6px; flex-wrap: wrap; }
.badge { .badge {
padding: 4px 10px; padding: 3px 10px;
border-radius: 999px; border-radius: 999px;
font-size: 12px; font-size: 11.5px;
border: 1px solid var(--border); border: 1px solid var(--border);
background: var(--panel-2); background: var(--panel-2);
white-space: nowrap;
} }
.badge-online { color: var(--green); border-color: #1f6f34; } .badge-online { color: var(--green); border-color: #1f6f34; background: rgba(63, 185, 80, .1); }
.badge-offline { color: var(--muted); } .badge-offline { color: var(--muted); }
.badge-running { color: var(--accent-2); border-color: #1f4f8f; } .badge-running { color: var(--accent-2); border-color: #1f4f8f; background: rgba(59, 142, 247, .12); }
.badge-idle { color: var(--muted); } .badge-idle { color: var(--muted); }
.badge-error { color: var(--red); border-color: #8f2b26; } .badge-error { color: var(--red); border-color: #8f2b26; }
/* ==================== 按钮 ==================== */
.btn { .btn {
padding: 6px 12px; padding: 6px 12px;
border-radius: 6px; border-radius: var(--radius-sm);
border: 1px solid var(--border); border: 1px solid var(--border);
background: var(--panel-2); background: var(--panel-2);
color: var(--text); color: var(--text);
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 13px;
transition: background .15s, border-color .15s; font-family: inherit;
white-space: nowrap;
transition: background .15s, border-color .15s, transform .06s, opacity .15s;
} }
.btn:hover { background: #232b39; border-color: #48505e; } .btn:hover:not(:disabled) { background: var(--panel-3); border-color: #3c4759; }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; } .btn:active:not(:disabled) { transform: translateY(1px); }
.btn-primary:hover { background: #d63851; border-color: #d63851; } .btn:disabled { opacity: .55; cursor: not-allowed; }
.btn-danger { background: #3a1d22; border-color: #7a2b34; color: #ff8b98; } .btn-primary { background: var(--accent-2); border-color: var(--accent-2); color: #fff; }
.btn-danger:hover { background: #4a2329; } .btn-primary:hover:not(:disabled) { background: var(--accent-2-hover); border-color: var(--accent-2-hover); }
.btn-danger { background: rgba(248, 81, 73, .12); border-color: #7a2b34; color: #ff9aa2; }
.btn-danger:hover:not(:disabled) { background: rgba(248, 81, 73, .2); }
.btn-sm { padding: 4px 9px; font-size: 12px; } .btn-sm { padding: 4px 9px; font-size: 12px; }
.btn-block { width: 100%; }
.btn-grow { flex: 1 1 auto; }
/* ==================== 布局 ==================== */ /* ==================== 布局 ==================== */
.layout { .layout {
display: grid; display: grid;
grid-template-columns: 320px minmax(460px, 1fr) 380px; grid-template-columns: 320px minmax(420px, 1fr) 380px;
gap: 12px; gap: var(--gap);
padding: 12px; padding: var(--gap);
align-items: start; align-items: start;
} }
.column { display: flex; flex-direction: column; gap: 12px; min-width: 0; } .column { display: flex; flex-direction: column; gap: var(--gap); min-width: 0; }
/* ==================== 卡片 ==================== */
.card { .card {
background: var(--panel); background: linear-gradient(180deg, var(--panel) 0%, var(--bg-soft) 100%);
border: 1px solid var(--border); border: 1px solid var(--border-soft);
border-radius: 10px; border-radius: var(--radius-lg);
padding: 12px; padding: 13px;
box-shadow: var(--shadow);
} }
.card h2 { .card h2 {
font-size: 13px; font-size: 12px;
margin: 0 0 8px; margin: 0 0 9px;
color: var(--muted); color: var(--text-dim);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: .6px; letter-spacing: .7px;
font-weight: 600;
} }
.card-head { .card-head {
display: flex; align-items: center; justify-content: space-between; display: flex; align-items: center; justify-content: space-between;
gap: 8px; margin-bottom: 8px; gap: 8px; margin-bottom: 9px;
} }
.card-head h2 { margin: 0; } .card-head h2 { margin: 0; }
.card-head-wrap { flex-wrap: wrap; }
.row { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-top: 8px; } .row { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-top: 8px; }
.row-compact { margin-top: 6px; }
/* ==================== 键值表 ==================== */ /* ==================== 键值表 ==================== */
.kv { .kv {
display: grid; display: grid;
grid-template-columns: 88px 1fr; grid-template-columns: 92px 1fr;
gap: 4px 8px; gap: 4px 8px;
margin: 0; margin: 0;
font-size: 13px; font-size: 13px;
} }
.kv dt { color: var(--muted); } .kv dt { color: var(--muted); }
.kv dd { margin: 0; word-break: break-all; } .kv dd { margin: 0; word-break: break-word; }
.progress { .progress {
margin-top: 10px; height: 6px; border-radius: 3px; margin-top: 10px; height: 6px; border-radius: 3px;
background: #0b0f14; overflow: hidden; background: #080b11; overflow: hidden;
} }
.progress-fill { .progress-fill {
height: 100%; width: 0%; height: 100%; width: 0%;
@@ -136,194 +185,325 @@ html, body {
.issues { margin: 8px 0 0; padding-left: 18px; color: var(--yellow); font-size: 12px; } .issues { margin: 8px 0 0; padding-left: 18px; color: var(--yellow); font-size: 12px; }
.issues li { margin-bottom: 3px; } .issues li { margin-bottom: 3px; }
/* ==================== 机器人列表 ==================== */
.bot-list { display: flex; flex-direction: column; gap: 6px; }
.bot-item {
display: grid;
grid-template-columns: 10px minmax(0, 1fr) auto;
gap: 9px; align-items: center;
padding: 8px 10px;
border: 1px solid var(--border-soft);
border-radius: var(--radius);
background: var(--panel-2);
cursor: pointer;
transition: border-color .15s, background .15s;
}
.bot-item:hover { border-color: #3c4759; }
.bot-item.active { border-color: var(--accent-2); background: rgba(59, 142, 247, .08); }
.bot-item .dot { width: 9px; height: 9px; border-radius: 50%; background: var(--muted); }
.bot-item .dot.online { background: var(--green); box-shadow: 0 0 8px rgba(63, 185, 80, .7); }
.bot-item .dot.connecting { background: var(--yellow); }
.bot-item .dot.offline { background: var(--red); }
.bot-item .dot.disabled { background: #454e5e; }
.bot-item .name { font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.bot-item .sub { font-size: 11px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.bot-item .meta { font-size: 11px; color: var(--muted); text-align: right; white-space: nowrap; }
.bot-item .remove {
margin-left: 6px; padding: 0 6px;
border: 1px solid var(--border); border-radius: 4px;
background: transparent; color: var(--muted); cursor: pointer;
font-size: 13px; line-height: 18px;
}
.bot-item .remove:hover { color: var(--red); border-color: #7a2b34; }
.add-bot { margin-top: 10px; padding-top: 10px; border-top: 1px dashed var(--border); }
.add-bot summary {
cursor: pointer; font-size: 12.5px; color: var(--accent-2);
list-style: none; user-select: none;
}
.add-bot summary::-webkit-details-marker { display: none; }
.add-bot summary::before { content: '▸ '; }
.add-bot[open] summary::before { content: '▾ '; }
.add-bot .field { margin-top: 8px; }
.add-bot .hint { font-size: 11px; color: var(--muted); margin: 8px 0 0; }
/* ==================== 定时消息 ==================== */
.schedule-list { display: flex; flex-direction: column; gap: 6px; }
.schedule-item {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto;
gap: 8px; align-items: center;
padding: 7px 9px;
border: 1px solid var(--border-soft);
border-radius: var(--radius);
background: var(--panel-2);
font-size: 12.5px;
}
.schedule-item.off { opacity: .55; }
.schedule-item .content { min-width: 0; }
.schedule-item .content code {
display: block;
font-family: Consolas, ui-monospace, monospace;
color: var(--text);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.schedule-item .sub { font-size: 11px; color: var(--muted); }
.schedule-item .remove {
padding: 0 6px; border: 1px solid var(--border); border-radius: 4px;
background: transparent; color: var(--muted); cursor: pointer; line-height: 18px;
}
.schedule-item .remove:hover { color: var(--red); border-color: #7a2b34; }
.schedule-empty { font-size: 12px; color: var(--muted); padding: 4px 0; }
.schedule-editor {
margin-top: 10px; padding-top: 10px;
border-top: 1px dashed var(--border);
}
.schedule-editor .hint { font-size: 11px; color: var(--muted); margin: 6px 0 0; }
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
/* ==================== 地图 ==================== */ /* ==================== 地图 ==================== */
.card-map { display: flex; flex-direction: column; } .card-map { display: flex; flex-direction: column; }
#mapCanvas { #mapCanvas {
width: 100%; width: 100%;
height: 440px; height: clamp(300px, 46vh, 520px);
background: #05070a; background: #05070a;
border: 1px solid var(--border); border: 1px solid var(--border-soft);
border-radius: 8px; border-radius: var(--radius);
display: block; display: block;
cursor: grab; cursor: grab;
touch-action: none;
} }
#mapCanvas:active { cursor: grabbing; } #mapCanvas:active { cursor: grabbing; }
.map-legend { .map-legend {
display: flex; gap: 14px; flex-wrap: wrap; display: flex; gap: 12px; flex-wrap: wrap;
margin-top: 8px; font-size: 12px; color: var(--muted); margin-top: 8px; font-size: 11.5px; color: var(--muted);
align-items: center; align-items: center;
} }
.sw { .sw {
display: inline-block; width: 10px; height: 10px; display: inline-block; width: 10px; height: 10px;
border-radius: 2px; margin-right: 4px; vertical-align: -1px; border-radius: 2px; margin-right: 4px; vertical-align: -1px;
} }
.sw-self { background: #2f81f7; } .sw-self { background: #3b8ef7; }
.sw-session { background: #3fb950; } .sw-session { background: #3fb950; }
.sw-flying { background: #f85149; } .sw-flying { background: #f85149; }
.sw-target { background: #d29922; } .sw-target { background: #d6a01f; }
.sw-player { background: #ffffff; } .sw-player { background: #ffffff; }
.sw-capital { background: #bc8cff; } .sw-capital { background: #bc8cff; }
.sw-bot { background: #ffd33d; } .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; }
.bot-item .remove {
margin-left: 6px;
padding: 0 5px;
border: 1px solid var(--border);
border-radius: 4px;
background: transparent;
color: var(--muted);
cursor: pointer;
font-size: 12px;
line-height: 16px;
}
.bot-item .remove:hover { color: var(--red); border-color: #7a2b34; }
.add-bot { margin-top: 10px; padding-top: 10px; border-top: 1px dashed var(--border); }
.add-bot .row { margin-top: 0; }
.add-bot .input-sm { flex: 1 1 60px; min-width: 0; }
.add-bot .hint { font-size: 11px; color: #5d6672; margin: 6px 0 0; }
/* ==================== 公屏聊天 ==================== */
.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; } .card-log { display: flex; flex-direction: column; }
.log-view { .log-view {
height: 300px; height: clamp(220px, 34vh, 380px);
overflow-y: auto; overflow-y: auto;
background: #05070a; background: #05070a;
border: 1px solid var(--border); border: 1px solid var(--border-soft);
border-radius: 8px; border-radius: var(--radius);
padding: 8px; padding: 8px 10px;
font-family: Consolas, "Cascadia Mono", monospace; font-family: Consolas, "Cascadia Mono", ui-monospace, monospace;
font-size: 12px; font-size: 12px;
line-height: 1.55; line-height: 1.6;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-all; word-break: break-word;
overscroll-behavior: contain;
} }
.log-line { display: block; } .log-line { display: block; }
.log-time { color: #586069; margin-right: 6px; } .log-time { color: #55606f; margin-right: 6px; }
.log-info { color: var(--text); } .log-info { color: var(--text); }
.log-warn { color: var(--yellow); } .log-warn { color: var(--yellow); }
.log-error { color: var(--red); } .log-error { color: var(--red); }
.log-success { color: var(--green); } .log-success { color: var(--green); }
/* ==================== 配置 ==================== */ .chat-line { display: block; }
.chat-time { color: #55606f; margin-right: 6px; }
.chat-bot { color: var(--accent-2); margin-right: 4px; }
.chat-user { color: var(--purple); margin-right: 4px; }
.chat-out { color: var(--green); }
.chat-command { color: var(--yellow); }
.chat-system { color: var(--text-dim); }
.chat-action { color: var(--muted); }
.log-table { white-space: normal; padding: 0; }
.exp-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.exp-table th {
position: sticky; top: 0; z-index: 1;
text-align: left; color: var(--text-dim); font-weight: 600;
background: #05070a; padding: 5px 7px;
border-bottom: 1px solid var(--border);
}
.exp-table td { padding: 3px 7px; border-bottom: 1px solid #10151d; white-space: nowrap; }
.exp-table tbody tr:hover { background: #0d1219; }
.exp-empty { color: var(--muted); padding: 8px; }
/* ==================== 表单 ==================== */
.card-config { display: flex; flex-direction: column; } .card-config { display: flex; flex-direction: column; }
.tabs { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 8px; } .config-scope { margin-bottom: 8px; }
.tabs { display: flex; gap: 5px; flex-wrap: wrap; margin-bottom: 9px; }
.tabs-inline { margin-bottom: 0; }
.tab { .tab {
padding: 4px 10px; font-size: 12px; cursor: pointer; padding: 4px 10px; font-size: 12px; cursor: pointer;
background: var(--panel-2); border: 1px solid var(--border); background: var(--panel-2); border: 1px solid var(--border-soft);
border-radius: 6px; color: var(--muted); border-radius: 999px; color: var(--muted);
font-family: inherit;
transition: background .15s, color .15s, border-color .15s;
} }
.tab:hover { color: var(--text); }
.tab.active { color: #fff; background: var(--accent-2); border-color: var(--accent-2); } .tab.active { color: #fff; background: var(--accent-2); border-color: var(--accent-2); }
.config-body { max-height: 520px; overflow-y: auto; } .config-body { max-height: min(58vh, 560px); overflow-y: auto; padding-right: 2px; }
.field { margin-bottom: 9px; } .field { margin-bottom: 9px; }
.field label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 3px; } .field label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 3px; }
.field .hint { font-size: 11px; color: #5d6672; margin-top: 2px; } .field .hint { font-size: 11px; color: var(--muted); margin-top: 3px; line-height: 1.45; }
.input, select.input { .input, select.input {
width: 100%; padding: 5px 8px; width: 100%; padding: 6px 9px;
background: #0b0f14; border: 1px solid var(--border); background: #080b11; border: 1px solid var(--border);
border-radius: 6px; color: var(--text); font-size: 13px; border-radius: var(--radius-sm); color: var(--text); font-size: 13px;
font-family: inherit;
outline: none; outline: none;
transition: border-color .15s, box-shadow .15s;
} }
.input:focus { border-color: var(--accent-2); } .input:focus { border-color: var(--accent-2); box-shadow: 0 0 0 3px rgba(59, 142, 247, .15); }
.field-inline { display: flex; align-items: center; gap: 8px; } .field-inline { display: flex; align-items: center; gap: 6px; }
.field-inline input[type=checkbox] { width: 15px; height: 15px; } .field-inline input[type=checkbox] { width: 15px; height: 15px; accent-color: var(--accent-2); }
.field-label { font-size: 12px; color: var(--muted); white-space: nowrap; }
.input-sm { width: auto; min-width: 104px; padding: 4px 8px; font-size: 12px; }
.input-grow { flex: 1 1 120px; min-width: 0; width: auto; }
.group-title { .group-title {
font-size: 12px; color: var(--accent-2); font-size: 12px; color: var(--accent-2);
margin: 12px 0 6px; padding-bottom: 4px; margin: 13px 0 7px; padding-bottom: 4px;
border-bottom: 1px dashed var(--border); border-bottom: 1px dashed var(--border);
font-weight: 600;
} }
.group-title:first-child { margin-top: 0; } .group-title:first-child { margin-top: 0; }
.chk { display: flex; align-items: center; gap: 5px; font-size: 12px; color: var(--text-dim); cursor: pointer; }
.chk input[type=checkbox] { accent-color: var(--accent-2); }
/* ==================== 提示 ==================== */ /* ==================== 提示 ==================== */
.toast { .toast {
position: fixed; right: 18px; bottom: 18px; position: fixed; right: 16px; bottom: 16px;
background: var(--panel-2); border: 1px solid var(--border); background: var(--panel-3); border: 1px solid var(--border);
border-left: 3px solid var(--accent-2); border-left: 3px solid var(--accent-2);
padding: 10px 14px; border-radius: 8px; padding: 10px 14px; border-radius: var(--radius);
font-size: 13px; max-width: 380px; font-size: 13px; max-width: min(380px, calc(100vw - 32px));
opacity: 0; transform: translateY(8px); opacity: 0; transform: translateY(8px);
transition: opacity .2s, transform .2s; transition: opacity .2s, transform .2s;
pointer-events: none; pointer-events: none;
z-index: 100; z-index: 100;
box-shadow: var(--shadow);
} }
.toast.show { opacity: 1; transform: translateY(0); } .toast.show { opacity: 1; transform: translateY(0); }
.toast.error { border-left-color: var(--red); } .toast.error { border-left-color: var(--red); }
@media (max-width: 1280px) { /* ==================== 登录遮罩 ==================== */
.layout { grid-template-columns: 1fr; } .overlay {
.config-body { max-height: none; } position: fixed; inset: 0; z-index: 200;
display: grid; place-items: center;
padding: 16px;
background: rgba(5, 7, 10, .8);
backdrop-filter: blur(3px);
}
.overlay-box {
width: min(380px, 100%);
background: linear-gradient(180deg, var(--panel) 0%, var(--bg-soft) 100%);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 20px;
box-shadow: var(--shadow);
}
.overlay-box h2 { font-size: 16px; margin: 0 0 6px; color: var(--text); text-transform: none; letter-spacing: 0; }
.overlay-box .input { margin: 12px 0; }
/* ==================== 移动端底部导航 ==================== */
.mobile-nav { display: none; }
/* ==================== 响应式 ==================== */
/* 中等屏幕:配置栏移到下方,两栏布局 */
@media (max-width: 1200px) {
.layout { grid-template-columns: minmax(280px, 320px) minmax(0, 1fr); }
.col-right { grid-column: 1 / -1; }
.col-right { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); align-items: start; }
.config-body { max-height: min(46vh, 460px); }
}
/* 平板 / 手机:单栏 + 底部导航 */
@media (max-width: 820px) {
:root { --gap: 10px; }
.topbar {
padding: 8px 12px;
gap: 8px;
align-items: center;
}
.brand p { display: none; }
.topbar-right { width: 100%; justify-content: space-between; }
.topbar-right .field-inline { flex: 1 1 100%; }
.topbar-right .field-inline select { flex: 1 1 auto; width: auto; }
.topbar-actions { width: 100%; }
.topbar-actions .btn { flex: 1 1 0; text-align: center; }
.badges { width: 100%; }
.layout {
grid-template-columns: 1fr;
padding: 10px 10px calc(64px + env(safe-area-inset-bottom, 0px));
}
.col-right { grid-column: auto; display: flex; }
/* 底部导航:把「状态 / 地图 / 日志 / 配置」四块一键定位 */
.mobile-nav {
display: flex;
position: fixed;
left: 0; right: 0; bottom: 0;
z-index: 40;
background: rgba(16, 21, 29, .96);
backdrop-filter: blur(10px);
border-top: 1px solid var(--border-soft);
padding-bottom: env(safe-area-inset-bottom, 0px);
}
.mobile-nav-item {
flex: 1 1 0;
padding: 11px 4px;
background: transparent;
border: none;
color: var(--muted);
font-size: 12px;
font-family: inherit;
cursor: pointer;
border-top: 2px solid transparent;
}
.mobile-nav-item.active { color: var(--accent-2); border-top-color: var(--accent-2); }
/* 触控目标放大 */
.btn { padding: 8px 13px; font-size: 13px; }
.btn-sm { padding: 6px 11px; font-size: 12.5px; }
.input, select.input { padding: 8px 10px; font-size: 14px; }
.input-sm { padding: 6px 9px; font-size: 12.5px; }
.tab { padding: 6px 12px; font-size: 12.5px; }
#mapCanvas { height: clamp(260px, 52vh, 420px); }
.log-view { height: clamp(240px, 46vh, 400px); font-size: 12.5px; }
.config-body { max-height: none; }
.card { padding: 12px; }
.kv { grid-template-columns: 84px 1fr; }
.toast { left: 12px; right: 12px; bottom: calc(64px + env(safe-area-inset-bottom, 0px)); max-width: none; }
}
@media (max-width: 420px) {
.brand h1 { font-size: 14px; }
.schedule-item { grid-template-columns: auto minmax(0, 1fr) auto; }
.schedule-item .next { display: none; }
.field-row { grid-template-columns: 1fr; }
}
/* 横屏手机:地图压矮一点,避免看不到下面的日志 */
@media (max-height: 520px) and (orientation: landscape) {
#mapCanvas { height: 46vh; }
.log-view { height: 40vh; }
}
/* 尊重「减少动效」偏好 */
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; animation: none !important; }
} }
+74 -6
查看文件
@@ -313,12 +313,34 @@ class WebConsole {
if (body.enabled !== undefined) patch.enabled = body.enabled === true; if (body.enabled !== undefined) patch.enabled = body.enabled === true;
if (body.autoStart !== undefined) patch.autoStart = body.autoStart === true; if (body.autoStart !== undefined) patch.autoStart = body.autoStart === true;
if (body.config !== undefined) patch.config = body.config; if (body.config !== undefined) patch.config = body.config;
if (body.schedules !== undefined) patch.schedules = body.schedules;
const saved = this._setBotSetting(entry, patch); const saved = this._setBotSetting(entry, patch);
res.json({ ok: true, data: saved }); res.json({ ok: true, data: saved });
return null; return null;
}); });
}); });
// 定时消息:整体替换某机器人的定时发送列表
app.post('/api/bots/:id/schedules', (req, res) => {
this._withEntry(req, res, (entry) => {
const schedules = (req.body && req.body.schedules) || [];
this._setBotSetting(entry, { schedules });
// 返回带「剩余秒数」的快照,前端可直接渲染倒计时
res.json({
ok: true,
data: this._settingSnapshot(entry).schedules || [],
message: '定时任务已保存'
});
return null;
});
});
app.get('/api/bots/:id/schedules', (req, res) => {
this._withEntry(req, res, (entry) => {
res.json({ ok: true, data: this._settingSnapshot(entry).schedules || [] });
return null;
});
});
app.post('/api/bots/:id/task/start', async (req, res) => { app.post('/api/bots/:id/task/start', async (req, res) => {
try { try {
const entry = this.pool.require(req.params.id); const entry = this.pool.require(req.params.id);
@@ -464,7 +486,7 @@ class WebConsole {
res.json({ ok: true, data: { enabled: false } }); res.json({ ok: true, data: { enabled: false } });
return; return;
} }
res.json({ ok: true, data: this.bigMapPayload() }); res.json({ ok: true, data: this.bigMapPayload(req.query || {}) });
}); });
app.get('/api/bigmap/markers', (req, res) => { app.get('/api/bigmap/markers', (req, res) => {
@@ -498,6 +520,7 @@ class WebConsole {
} }
}); });
// 爬取地图瓦片:默认爬「机器人所在位置周边」的图幅,也可显式指定中心与半径
app.post('/api/bigmap/crawl', async (req, res) => { app.post('/api/bigmap/crawl', async (req, res) => {
if (!this.bigMap) { if (!this.bigMap) {
res.status(500).json({ ok: false, message: '未启用大地图' }); res.status(500).json({ ok: false, message: '未启用大地图' });
@@ -505,11 +528,22 @@ class WebConsole {
} }
const body = req.body || {}; const body = req.body || {};
const zoom = Number.isFinite(Number(body.zoom)) ? Number(body.zoom) : undefined; const zoom = Number.isFinite(Number(body.zoom)) ? Number(body.zoom) : undefined;
const center = this._bigMapCenter({ query: { bot: body.bot } }, body);
const rangeBlocks = Number.isFinite(Number(body.rangeBlocks)) ? Number(body.rangeBlocks) : undefined;
// 爬取可能持续数十秒:立刻返回,进度通过 /api/bigmap 轮询 // 爬取可能持续数十秒:立刻返回,进度通过 /api/bigmap 轮询
this.bigMap.crawlAll(zoom, { force: body.force === true }).catch((err) => { this.bigMap.crawlAll(zoom, {
force: body.force === true,
centerBlockX: center.x,
centerBlockZ: center.z,
rangeBlocks
}).catch((err) => {
this.logger.log('大地图爬取失败:' + (err && err.message ? err.message : String(err)), 'error'); this.logger.log('大地图爬取失败:' + (err && err.message ? err.message : String(err)), 'error');
}); });
res.json({ ok: true, message: '已开始爬取地图瓦片', data: this.bigMap.snapshot() }); res.json({
ok: true,
message: `已开始爬取 ${Math.round(center.x)},${Math.round(center.z)} 周边的瓦片`,
data: this.bigMap.snapshot({ centerBlockX: center.x, centerBlockZ: center.z, rangeBlocks })
});
}); });
app.post('/api/bigmap/cache/clear', (req, res) => { app.post('/api/bigmap/cache/clear', (req, res) => {
@@ -687,7 +721,7 @@ class WebConsole {
defaultBotId: this.pool.defaultId || '', defaultBotId: this.pool.defaultId || '',
expansionLog: this.expansionLog ? this.expansionLog.stats() : null, expansionLog: this.expansionLog ? this.expansionLog.stats() : null,
chatLog: this.chatLog ? this.chatLog.snapshot() : null, chatLog: this.chatLog ? this.chatLog.snapshot() : null,
bigMap: this.bigMap ? this.bigMap.snapshot() : null, bigMap: this.bigMap ? this.bigMapPayload() : null,
serverTime: Date.now(), serverTime: Date.now(),
claimTime: { claimTime: {
start: this.getConfig().claimStartHour, start: this.getConfig().claimStartHour,
@@ -749,10 +783,44 @@ class WebConsole {
return result; return result;
} }
/**
* 计算大地图的关注中心。
*
* 默认以「当前选中的机器人」所在位置为中心 —— 大地图的意义就是看自己机器人周边,
* 而不是永远盯着世界原点 (0,0)。没有可用坐标时回退到原点。
*
* @param {import('express').Request} req
* @param {{centerX?: number, centerZ?: number}} [override] 显式指定的中心优先
* @returns {{x: number, z: number, source: string}}
*/
_bigMapCenter(req, override) {
const opts = override || {};
if (Number.isFinite(Number(opts.centerX)) && Number.isFinite(Number(opts.centerZ))) {
return { x: Number(opts.centerX), z: Number(opts.centerZ), source: 'explicit' };
}
const follow = !this.bigMap || !this.bigMap.config || !this.bigMap.config.bigMap
|| this.bigMap.config.bigMap.followBot !== false;
if (follow) {
const requested = req && req.query ? req.query.bot : undefined;
const entry = this.pool.get(requested) || this.defaultEntry;
const position = entry ? entry.manager.snapshot().position : null;
if (position) return { x: position.x, z: position.z, source: 'bot' };
}
return { x: 0, z: 0, source: 'origin' };
}
/** @returns {object} 大地图元信息(坐标系、瓦片范围、缓存进度、机器人与领土) */ /** @returns {object} 大地图元信息(坐标系、瓦片范围、缓存进度、机器人与领土) */
bigMapPayload() { bigMapPayload(query) {
const snapshot = this.bigMap.snapshot(); const params = query || {};
const center = this._bigMapCenter({ query: params });
const rangeBlocks = Number.isFinite(Number(params.rangeBlocks)) ? Number(params.rangeBlocks) : undefined;
const snapshot = this.bigMap.snapshot({
centerBlockX: center.x,
centerBlockZ: center.z,
rangeBlocks
});
return Object.assign(snapshot, { return Object.assign(snapshot, {
center: { x: center.x, z: center.z, source: center.source },
bots: this.pool.positions(), bots: this.pool.positions(),
markers: this.bigMapMarkersPayload() markers: this.bigMapMarkersPayload()
}); });
+71 -2
查看文件
@@ -58,13 +58,56 @@ test('坐标系:王城 (10722, 1547) 落在 z=3 的瓦片 (20, 3) 内(对源
test('tileRange 覆盖 ±rangeBlocks:层级 0 全世界为 10×10=100 张', () => { test('tileRange 覆盖 ±rangeBlocks:层级 0 全世界为 10×10=100 张', () => {
const { service } = setup(); const { service } = setup();
const range = service.tileRange(0, 20480); const range = service.tileRange(0, 20480, 0, 0);
assert.deepStrictEqual(range, { minX: -5, maxX: 4, minZ: -5, maxZ: 4, count: 100 }); assert.strictEqual(range.minX, -5);
assert.strictEqual(range.maxX, 4);
assert.strictEqual(range.minZ, -5);
assert.strictEqual(range.maxZ, 4);
assert.strictEqual(range.count, 100);
// 瓦片范围必须完整覆盖 -20480..20480 // 瓦片范围必须完整覆盖 -20480..20480
assert.strictEqual(range.minX * service.blocksPerTile(0), -20480); assert.strictEqual(range.minX * service.blocksPerTile(0), -20480);
assert.strictEqual((range.maxX + 1) * service.blocksPerTile(0), 20480); assert.strictEqual((range.maxX + 1) * service.blocksPerTile(0), 20480);
}); });
test('tileRange 支持以机器人位置为中心:图幅越小爬的瓦片越少', () => {
const { service } = setup();
// 层级 0 每张瓦片 4096 格:半径 20480 → 100 张,4096 → 4 张
assert.strictEqual(service.tileRange(0, 20480, 0, 0).count, 100);
assert.strictEqual(service.tileRange(0, 4096, 0, 0).count, 4);
// 半径 1024 且中心落在某张瓦片正中(瓦片 4 覆盖 16384..20480,中心 18432)→ 只要 1 张
const centered = service.tileRange(0, 1024, 18432, 18432);
assert.strictEqual(centered.count, 1);
assert.strictEqual(centered.minX, 4);
assert.strictEqual(centered.minZ, 4);
assert.strictEqual(centered.centerBlockX, 18432);
// 该瓦片必须真的覆盖机器人坐标
const bounds = service.tileBlockBounds(0, centered.minX, centered.minZ);
assert.ok(18432 >= bounds.minBlockX && 18432 < bounds.maxBlockX);
assert.ok(18432 >= bounds.minBlockZ && 18432 < bounds.maxBlockZ);
});
test('tileRange 中心移到远处时瓦片下标整体平移,不再盯着世界原点', () => {
const { service } = setup();
const atOrigin = service.tileRange(0, 4096, 0, 0);
const atBot = service.tileRange(0, 4096, 20000, -20000);
assert.notStrictEqual(atOrigin.minX, atBot.minX);
// 机器人所在的瓦片(正 X、负 Z 象限)必须落在范围内
const tileX = Math.floor(20000 / 4096);
const tileZ = Math.floor(-20000 / 4096);
assert.ok(tileX >= atBot.minX && tileX <= atBot.maxX, '瓦片 X 下标应覆盖机器人');
assert.ok(tileZ >= atBot.minZ && tileZ <= atBot.maxZ, '瓦片 Z 下标应覆盖机器人');
assert.ok(tileX > 0 && tileZ < 0, '机器人位于正 X、负 Z 象限');
const bounds = service.tileBlockBounds(0, tileX, tileZ);
assert.ok(20000 >= bounds.minBlockX && 20000 < bounds.maxBlockX);
});
test('tileRange 缺省中心为世界原点', () => {
const { service } = setup();
const range = service.tileRange(0, 4096);
assert.strictEqual(range.centerBlockX, 0);
assert.strictEqual(range.centerBlockZ, 0);
});
test('tileUrl / tilePath 拼接源站地址与磁盘缓存路径', () => { test('tileUrl / tilePath 拼接源站地址与磁盘缓存路径', () => {
const { service } = setup(); const { service } = setup();
assert.strictEqual( assert.strictEqual(
@@ -120,6 +163,22 @@ test('crawlAll 统计失败张数', async () => {
assert.strictEqual(result.failed, 4); assert.strictEqual(result.failed, 4);
}); });
test('crawlAll 支持以指定中心爬取(只拉机器人周边那几张)', async () => {
const { service, calls } = setup();
const result = await service.crawlAll(0, {
centerBlockX: 18432,
centerBlockZ: 18432,
rangeBlocks: 1024
});
assert.strictEqual(result.total, 1, '中心落在瓦片正中且半径 1024 时只需 1 张瓦片');
assert.strictEqual(result.done, 1);
assert.strictEqual(result.centerBlockX, 18432);
assert.strictEqual(result.rangeBlocks, 1024);
// 只请求了机器人所在的那一张,而不是世界原点附近
assert.strictEqual(calls.length, 1);
assert.ok(calls[0].includes('/4_4.png'), '实际请求的瓦片 URL:' + calls[0]);
});
test('snapshot 暴露坐标系、瓦片范围与缓存数量', () => { test('snapshot 暴露坐标系、瓦片范围与缓存数量', () => {
const { service } = setup({ bigMap: { rangeBlocks: 20480, zoom: 0 } }); const { service } = setup({ bigMap: { rangeBlocks: 20480, zoom: 0 } });
const snap = service.snapshot(); const snap = service.snapshot();
@@ -131,6 +190,16 @@ test('snapshot 暴露坐标系、瓦片范围与缓存数量', () => {
assert.ok(snap.source.tileUrlTemplate.includes('{z}/{x}_{y}.png')); assert.ok(snap.source.tileUrlTemplate.includes('{z}/{x}_{y}.png'));
}); });
test('snapshot 接受中心参数,瓦片范围随机器人位置变化', () => {
const { service } = setup({ bigMap: { rangeBlocks: 1024, zoom: 0 } });
const atOrigin = service.snapshot({ centerBlockX: 0, centerBlockZ: 0 });
const atBot = service.snapshot({ centerBlockX: 18432, centerBlockZ: 18432 });
assert.strictEqual(atBot.tiles.count, 1, '中心落在瓦片正中时只要 1 张');
assert.notStrictEqual(atOrigin.tiles.minX, atBot.tiles.minX, '中心不同,瓦片下标必须不同');
assert.strictEqual(atBot.tiles.centerBlockX, 18432);
assert.strictEqual(atBot.tiles.minX, 4);
});
test('clearCache 清空磁盘与内存缓存', async () => { test('clearCache 清空磁盘与内存缓存', async () => {
const { service } = setup({ bigMap: { rangeBlocks: 4096 } }); const { service } = setup({ bigMap: { rangeBlocks: 4096 } });
await service.crawlAll(0); await service.crawlAll(0);
+78 -2
查看文件
@@ -109,7 +109,9 @@ test('statusPayload 汇总全部机器人与新增日志 / 大地图状态', ()
assert.ok(payload.expansionLog); assert.ok(payload.expansionLog);
assert.ok(payload.chatLog); assert.ok(payload.chatLog);
assert.ok(payload.bigMap); assert.ok(payload.bigMap);
assert.strictEqual(payload.bigMap.tiles.count, 100); // 默认图幅 4096 格半径、层级 0 每张 4096 格 → 4 张瓦片(不再是把全世界拉下来)
assert.strictEqual(payload.bigMap.tiles.count, 4);
assert.strictEqual(payload.bigMap.followBot, true);
assert.strictEqual(pool.size, 2); assert.strictEqual(pool.size, 2);
}); });
@@ -310,7 +312,10 @@ test('HTTP:大地图元信息、领土标记与瓦片代理', async () => {
const meta = await fetch(base + '/api/bigmap').then((r) => r.json()); const meta = await fetch(base + '/api/bigmap').then((r) => r.json());
assert.strictEqual(meta.data.world, 'minecraft_overworld'); assert.strictEqual(meta.data.world, 'minecraft_overworld');
assert.strictEqual(meta.data.tilePixels, 512); assert.strictEqual(meta.data.tilePixels, 512);
assert.strictEqual(meta.data.tiles.count, 100); assert.strictEqual(meta.data.tiles.count, 4);
assert.strictEqual(meta.data.rangeBlocks, 4096);
// 机器人还没进世界 → 中心回退到世界原点
assert.strictEqual(meta.data.center.source, 'origin');
assert.strictEqual(meta.data.bots.length, 2, '大地图应带上全部机器人坐标'); assert.strictEqual(meta.data.bots.length, 2, '大地图应带上全部机器人坐标');
const markers = await fetch(base + '/api/bigmap/markers').then((r) => r.json()); const markers = await fetch(base + '/api/bigmap/markers').then((r) => r.json());
@@ -435,6 +440,77 @@ test('HTTP:可在控制台新增与移除玩家,响应中不含密码', asyn
} }
}); });
test('HTTP:定时消息可读可写,且不影响其它设置', async () => {
const { webConsole, pool } = setup();
await webConsole.start();
const base = webConsole.baseUrl();
try {
const initial = await fetch(base + '/api/bots/BotA/schedules').then((r) => r.json());
assert.deepStrictEqual(initial.data, []);
const saved = await fetch(base + '/api/bots/BotA/schedules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
schedules: [
{ id: 'restore', label: '恢复民心', content: '/u restore confirm', intervalSeconds: 300 },
{ content: '大家好', intervalSeconds: 600, enabled: false },
{ content: ' ' }
]
})
}).then((r) => r.json());
assert.strictEqual(saved.ok, true);
assert.strictEqual(saved.data.length, 2, '空内容应被丢弃');
assert.strictEqual(saved.data[0].kind, 'command', '以 / 开头识别为指令');
assert.strictEqual(saved.data[1].kind, 'chat');
assert.strictEqual(saved.data[1].enabled, false);
// 定时任务也应出现在状态负载里,前端据此渲染
const status = await fetch(base + '/api/status').then((r) => r.json());
const bot = status.data.bots.find((b) => b.id === 'BotA');
assert.strictEqual(bot.schedules.length, 2);
assert.ok(bot.schedules[0].nextDueAt > 0);
// 间隔过小会被钳制,防止刷屏
const clamped = await fetch(base + '/api/bots/BotA/schedules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ schedules: [{ content: 'x', intervalSeconds: 1 }] })
}).then((r) => r.json());
assert.strictEqual(clamped.data[0].intervalSeconds, 5);
// 保存定时任务不应清掉别的设置
assert.strictEqual(pool.settingSnapshot('BotA').enabled, true);
} finally {
await webConsole.stop();
}
});
test('HTTP:大地图图幅可按机器人位置与半径计算瓦片范围', async () => {
const { webConsole, pool } = setup();
// 给机器人一个真实坐标,验证中心会跟着它走
const manager = pool.get('BotB').manager;
manager.bot = { entity: { position: { x: 18432, y: 72, z: 18432 } } };
manager.state = 'online';
await webConsole.start();
const base = webConsole.baseUrl();
try {
const meta = await fetch(base + '/api/bigmap?rangeBlocks=1024&bot=BotB').then((r) => r.json());
assert.strictEqual(meta.data.center.source, 'bot');
assert.strictEqual(meta.data.center.x, 18432);
assert.strictEqual(meta.data.rangeBlocks, 1024);
assert.strictEqual(meta.data.tiles.count, 1, '1024 格半径且中心落在瓦片正中时只要 1 张瓦片');
assert.strictEqual(meta.data.tiles.minX, 4);
// 换成大图幅后瓦片数随之增加
const wide = await fetch(base + '/api/bigmap?rangeBlocks=8192&bot=BotB').then((r) => r.json());
assert.ok(wide.data.tiles.count > meta.data.tiles.count);
assert.strictEqual(wide.data.rangeBlocks, 8192);
} finally {
await webConsole.stop();
}
});
test('HTTP:未配置密码时免登录(本机开发模式)', async () => { test('HTTP:未配置密码时免登录(本机开发模式)', async () => {
const { webConsole } = setup(); const { webConsole } = setup();
await webConsole.start(); await webConsole.start();
+143
查看文件
@@ -0,0 +1,143 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const { ScheduledMessenger, normalizeTasks, clampInterval, MIN_INTERVAL_SECONDS, MAX_TASKS } = require('../src/bot/scheduledMessenger');
/** 造一个可控的假 BotManager */
function fakeManager(options) {
const opts = options || {};
const sent = [];
return {
connection: { username: 'BotA' },
canSendChat: () => opts.canSend !== false,
sendChat: (content) => {
sent.push(content);
return true;
},
sent
};
}
test('normalizeTasks 丢弃空内容、补齐默认值、按 / 前缀判定类型', () => {
const tasks = normalizeTasks([
{ id: 'a', content: '/u restore confirm', intervalSeconds: 300, label: '恢复民心' },
{ content: '大家好', intervalSeconds: 60 },
{ content: ' ' },
null,
{ intervalSeconds: 10 }
]);
assert.strictEqual(tasks.length, 2);
assert.strictEqual(tasks[0].kind, 'command', '以 / 开头应识别为指令');
assert.strictEqual(tasks[0].enabled, true, '默认启用');
assert.strictEqual(tasks[1].kind, 'chat');
assert.ok(tasks[1].id, '缺 id 时应自动生成');
});
test('normalizeTasks 钳制间隔到合理范围并限制条数', () => {
assert.strictEqual(clampInterval(1), MIN_INTERVAL_SECONDS, '间隔不能小于下限,否则等同刷屏');
assert.strictEqual(clampInterval(999999), 86400);
assert.strictEqual(clampInterval('abc'), 300, '非法值回退到 5 分钟');
const many = [];
for (let i = 0; i < MAX_TASKS + 10; i++) many.push({ content: 'm' + i, intervalSeconds: 60 });
assert.strictEqual(normalizeTasks(many).length, MAX_TASKS);
});
test('id 重复时自动改名,保证每个任务独立计时', () => {
const tasks = normalizeTasks([
{ id: 'x', content: 'a', intervalSeconds: 60 },
{ id: 'x', content: 'b', intervalSeconds: 60 }
]);
assert.strictEqual(tasks.length, 2);
assert.notStrictEqual(tasks[0].id, tasks[1].id);
});
test('到点才发送,且按间隔重复', () => {
const manager = fakeManager();
const messenger = new ScheduledMessenger(manager, null);
const t0 = 1_000_000;
messenger.setTasks([{ id: 'restore', content: '/u restore confirm', intervalSeconds: 300 }], t0);
assert.strictEqual(messenger.tick(t0), 0, '刚设置完不应立即发送');
assert.strictEqual(messenger.tick(t0 + 299_000), 0, '未到间隔不发');
assert.strictEqual(messenger.tick(t0 + 300_000), 1, '到点应发送');
assert.deepStrictEqual(manager.sent, ['/u restore confirm']);
assert.strictEqual(messenger.tick(t0 + 599_000), 0);
assert.strictEqual(messenger.tick(t0 + 600_000), 1, '第二个周期应再发一次');
assert.strictEqual(manager.sent.length, 2);
});
test('未连接 / 未通过登录验证时跳过本次并顺延,不补发', () => {
const manager = fakeManager({ canSend: false });
const messenger = new ScheduledMessenger(manager, null);
const t0 = 1_000_000;
messenger.setTasks([{ id: 'a', content: 'hi', intervalSeconds: 60 }], t0);
assert.strictEqual(messenger.tick(t0 + 60_000), 0, '未就绪时不发送');
assert.deepStrictEqual(manager.sent, []);
// 顺延一个周期:即使中间恢复也不会把漏掉的补发出来
manager.canSendChat = () => true;
assert.strictEqual(messenger.tick(t0 + 61_000), 0, '不能补发上一周期的内容');
assert.strictEqual(messenger.tick(t0 + 120_000), 1);
assert.deepStrictEqual(manager.sent, ['hi']);
});
test('禁用的任务不发送', () => {
const manager = fakeManager();
const messenger = new ScheduledMessenger(manager, null);
messenger.setTasks([{ id: 'a', content: 'hi', intervalSeconds: 60, enabled: false }], 1_000_000);
assert.strictEqual(messenger.tick(1_000_000 + 120_000), 0);
assert.deepStrictEqual(manager.sent, []);
});
test('多个任务各自独立计时', () => {
const manager = fakeManager();
const messenger = new ScheduledMessenger(manager, null);
const t0 = 1_000_000;
messenger.setTasks([
{ id: 'fast', content: 'A', intervalSeconds: 30 },
{ id: 'slow', content: 'B', intervalSeconds: 300 }
], t0);
assert.strictEqual(messenger.tick(t0 + 30_000), 1);
assert.strictEqual(messenger.tick(t0 + 60_000), 1);
assert.strictEqual(messenger.tick(t0 + 300_000), 2, '两个任务在同一时刻都到点');
assert.deepStrictEqual(manager.sent, ['A', 'A', 'A', 'B']);
});
test('snapshot 暴露剩余秒数供控制台展示', () => {
const manager = fakeManager();
const messenger = new ScheduledMessenger(manager, null);
messenger.setTasks([{ id: 'a', label: '恢复', content: '/u restore confirm', intervalSeconds: 300 }], Date.now());
const snap = messenger.snapshot();
assert.strictEqual(snap.length, 1);
assert.strictEqual(snap[0].label, '恢复');
assert.strictEqual(snap[0].kind, 'command');
assert.ok(snap[0].nextDueAt > 0);
assert.ok(snap[0].nextInSeconds <= 300 && snap[0].nextInSeconds >= 0);
});
test('setTasks 整体替换并重置计时', () => {
const manager = fakeManager();
const messenger = new ScheduledMessenger(manager, null);
const t0 = 1_000_000;
messenger.setTasks([{ id: 'a', content: 'A', intervalSeconds: 60 }], t0);
messenger.tick(t0 + 60_000);
assert.strictEqual(manager.sent.length, 1);
messenger.setTasks([{ id: 'b', content: 'B', intervalSeconds: 60 }], t0 + 60_000);
assert.strictEqual(messenger.snapshot().length, 1);
assert.strictEqual(messenger.tick(t0 + 61_000), 0, '替换后重新计时,不应立刻发送');
assert.strictEqual(messenger.tick(t0 + 200_000), 1);
assert.deepStrictEqual(manager.sent, ['A', 'B']);
});
test('start / stop 控制心跳定时器', () => {
const messenger = new ScheduledMessenger(fakeManager(), null);
messenger.start();
assert.ok(messenger._timer, 'start 后应有定时器');
const timer = messenger._timer;
messenger.start();
assert.strictEqual(messenger._timer, timer, '重复 start 不应叠加定时器');
messenger.stop();
assert.strictEqual(messenger._timer, null);
});