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,全部通过。
这个提交包含在:
+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();
|
||||
|
||||
在新工单中引用
屏蔽一个用户