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 行删除
+35 -4
查看文件
@@ -3,6 +3,7 @@
const { EventEmitter } = require('events');
const { BotManager } = require('./botManager');
const { ScheduledMessenger } = require('./scheduledMessenger');
const { ExpansionController } = require('../controller/expansionController');
const { normalizeConfig } = require('../config/expansionConfig');
const { sanitizeId } = require('../config/botsConfig');
@@ -130,11 +131,16 @@ class BotPool extends EventEmitter {
manager.on('spawn', () => this.emit('botSpawn', id));
manager.on('end', () => this.emit('botEnd', id));
// 定时消息器:按固定间隔重复发送同一条内容(如每 300 秒 /u restore confirm)
const messenger = new ScheduledMessenger(manager, this.logger);
messenger.setTasks(setting.schedules || []);
this.entries.set(id, {
id,
connection,
manager,
controller,
messenger,
/** 账号来源:env(.env 配置)/ store(控制台新增) */
source: connection.source || 'env',
/** 该机器人实际生效的配置(全局 + 覆盖) */
@@ -190,7 +196,10 @@ class BotPool extends EventEmitter {
const entry = this._addBot(connection);
if (!this.defaultId) this.defaultId = id;
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;
}
@@ -209,6 +218,7 @@ class BotPool extends EventEmitter {
throw badRequest(`${entry.connection.username} 是在 .env 的 MC_BOTS 里配置的,`
+ '请修改 .env 后重启;控制台只能移除在这里添加的玩家');
}
entry.messenger.stop();
entry.manager.disconnect();
this.entries.delete(id);
delete this.settings[id];
@@ -234,6 +244,7 @@ class BotPool extends EventEmitter {
this._log(`${entry.connection.username} 已在控制台禁用,跳过连接`);
continue;
}
entry.messenger.start();
entry.manager.connect();
}
}
@@ -246,6 +257,7 @@ class BotPool extends EventEmitter {
} catch {
// 忽略
}
entry.messenger.stop();
entry.manager.disconnect();
}
}
@@ -301,13 +313,14 @@ class BotPool extends EventEmitter {
// ==================== 配置 ====================
/** @returns {object} 机器人的非敏感设置(启用 / 备注 / 自动开始 / 配置覆盖) */
/** @returns {object} 机器人的非敏感设置(启用 / 备注 / 自动开始 / 定时任务 / 配置覆盖) */
_settingOf(id) {
const raw = this.settings[id] || {};
return {
enabled: raw.enabled !== false,
autoStart: raw.autoStart === true,
label: raw.label || '',
schedules: Array.isArray(raw.schedules) ? raw.schedules : [],
config: raw.config && typeof raw.config === 'object' ? raw.config : {}
};
}
@@ -356,6 +369,13 @@ class BotPool extends EventEmitter {
entry.config = this.getConfigFor(id);
entry.manager.applyConfig(entry.config);
entry.controller.config = entry.config;
// 定时任务列表整体替换:把归一化结果写回设置,
// 保证「接口返回值 / 落盘内容 / 运行时状态」三者一致
if (patch && patch.schedules !== undefined) {
const normalized = entry.messenger.setTasks(this._settingOf(id).schedules);
this.settings[id] = Object.assign({}, this.settings[id], { schedules: normalized });
this._persistSettings();
}
return this._settingOf(id);
}
@@ -368,10 +388,20 @@ class BotPool extends EventEmitter {
enabled: setting.enabled,
autoStart: setting.autoStart,
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() {
if (!this.botStore) return;
this.botStore.save({ bots: this.settings });
@@ -486,7 +516,8 @@ class BotPool extends EventEmitter {
enabled: setting.enabled,
autoStart: setting.autoStart,
source: entry.source,
task: entry.controller.snapshot()
task: entry.controller.snapshot(),
schedules: entry.messenger.snapshot()
}));
}
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 };