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 行删除
+71 -2
查看文件
@@ -58,13 +58,56 @@ test('坐标系:王城 (10722, 1547) 落在 z=3 的瓦片 (20, 3) 内(对源
test('tileRange 覆盖 ±rangeBlocks:层级 0 全世界为 10×10=100 张', () => {
const { service } = setup();
const range = service.tileRange(0, 20480);
assert.deepStrictEqual(range, { minX: -5, maxX: 4, minZ: -5, maxZ: 4, count: 100 });
const range = service.tileRange(0, 20480, 0, 0);
assert.strictEqual(range.minX, -5);
assert.strictEqual(range.maxX, 4);
assert.strictEqual(range.minZ, -5);
assert.strictEqual(range.maxZ, 4);
assert.strictEqual(range.count, 100);
// 瓦片范围必须完整覆盖 -20480..20480
assert.strictEqual(range.minX * service.blocksPerTile(0), -20480);
assert.strictEqual((range.maxX + 1) * service.blocksPerTile(0), 20480);
});
test('tileRange 支持以机器人位置为中心:图幅越小爬的瓦片越少', () => {
const { service } = setup();
// 层级 0 每张瓦片 4096 格:半径 20480 → 100 张,4096 → 4 张
assert.strictEqual(service.tileRange(0, 20480, 0, 0).count, 100);
assert.strictEqual(service.tileRange(0, 4096, 0, 0).count, 4);
// 半径 1024 且中心落在某张瓦片正中(瓦片 4 覆盖 16384..20480,中心 18432)→ 只要 1 张
const centered = service.tileRange(0, 1024, 18432, 18432);
assert.strictEqual(centered.count, 1);
assert.strictEqual(centered.minX, 4);
assert.strictEqual(centered.minZ, 4);
assert.strictEqual(centered.centerBlockX, 18432);
// 该瓦片必须真的覆盖机器人坐标
const bounds = service.tileBlockBounds(0, centered.minX, centered.minZ);
assert.ok(18432 >= bounds.minBlockX && 18432 < bounds.maxBlockX);
assert.ok(18432 >= bounds.minBlockZ && 18432 < bounds.maxBlockZ);
});
test('tileRange 中心移到远处时瓦片下标整体平移,不再盯着世界原点', () => {
const { service } = setup();
const atOrigin = service.tileRange(0, 4096, 0, 0);
const atBot = service.tileRange(0, 4096, 20000, -20000);
assert.notStrictEqual(atOrigin.minX, atBot.minX);
// 机器人所在的瓦片(正 X、负 Z 象限)必须落在范围内
const tileX = Math.floor(20000 / 4096);
const tileZ = Math.floor(-20000 / 4096);
assert.ok(tileX >= atBot.minX && tileX <= atBot.maxX, '瓦片 X 下标应覆盖机器人');
assert.ok(tileZ >= atBot.minZ && tileZ <= atBot.maxZ, '瓦片 Z 下标应覆盖机器人');
assert.ok(tileX > 0 && tileZ < 0, '机器人位于正 X、负 Z 象限');
const bounds = service.tileBlockBounds(0, tileX, tileZ);
assert.ok(20000 >= bounds.minBlockX && 20000 < bounds.maxBlockX);
});
test('tileRange 缺省中心为世界原点', () => {
const { service } = setup();
const range = service.tileRange(0, 4096);
assert.strictEqual(range.centerBlockX, 0);
assert.strictEqual(range.centerBlockZ, 0);
});
test('tileUrl / tilePath 拼接源站地址与磁盘缓存路径', () => {
const { service } = setup();
assert.strictEqual(
@@ -120,6 +163,22 @@ test('crawlAll 统计失败张数', async () => {
assert.strictEqual(result.failed, 4);
});
test('crawlAll 支持以指定中心爬取(只拉机器人周边那几张)', async () => {
const { service, calls } = setup();
const result = await service.crawlAll(0, {
centerBlockX: 18432,
centerBlockZ: 18432,
rangeBlocks: 1024
});
assert.strictEqual(result.total, 1, '中心落在瓦片正中且半径 1024 时只需 1 张瓦片');
assert.strictEqual(result.done, 1);
assert.strictEqual(result.centerBlockX, 18432);
assert.strictEqual(result.rangeBlocks, 1024);
// 只请求了机器人所在的那一张,而不是世界原点附近
assert.strictEqual(calls.length, 1);
assert.ok(calls[0].includes('/4_4.png'), '实际请求的瓦片 URL:' + calls[0]);
});
test('snapshot 暴露坐标系、瓦片范围与缓存数量', () => {
const { service } = setup({ bigMap: { rangeBlocks: 20480, zoom: 0 } });
const snap = service.snapshot();
@@ -131,6 +190,16 @@ test('snapshot 暴露坐标系、瓦片范围与缓存数量', () => {
assert.ok(snap.source.tileUrlTemplate.includes('{z}/{x}_{y}.png'));
});
test('snapshot 接受中心参数,瓦片范围随机器人位置变化', () => {
const { service } = setup({ bigMap: { rangeBlocks: 1024, zoom: 0 } });
const atOrigin = service.snapshot({ centerBlockX: 0, centerBlockZ: 0 });
const atBot = service.snapshot({ centerBlockX: 18432, centerBlockZ: 18432 });
assert.strictEqual(atBot.tiles.count, 1, '中心落在瓦片正中时只要 1 张');
assert.notStrictEqual(atOrigin.tiles.minX, atBot.tiles.minX, '中心不同,瓦片下标必须不同');
assert.strictEqual(atBot.tiles.centerBlockX, 18432);
assert.strictEqual(atBot.tiles.minX, 4);
});
test('clearCache 清空磁盘与内存缓存', async () => {
const { service } = setup({ bigMap: { rangeBlocks: 4096 } });
await service.crawlAll(0);
+78 -2
查看文件
@@ -109,7 +109,9 @@ test('statusPayload 汇总全部机器人与新增日志 / 大地图状态', ()
assert.ok(payload.expansionLog);
assert.ok(payload.chatLog);
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);
});
@@ -310,7 +312,10 @@ test('HTTP:大地图元信息、领土标记与瓦片代理', async () => {
const meta = await fetch(base + '/api/bigmap').then((r) => r.json());
assert.strictEqual(meta.data.world, 'minecraft_overworld');
assert.strictEqual(meta.data.tilePixels, 512);
assert.strictEqual(meta.data.tiles.count, 100);
assert.strictEqual(meta.data.tiles.count, 4);
assert.strictEqual(meta.data.rangeBlocks, 4096);
// 机器人还没进世界 → 中心回退到世界原点
assert.strictEqual(meta.data.center.source, 'origin');
assert.strictEqual(meta.data.bots.length, 2, '大地图应带上全部机器人坐标');
const markers = await fetch(base + '/api/bigmap/markers').then((r) => r.json());
@@ -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 () => {
const { webConsole } = setup();
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);
});