大地图跟随机器人 + 可调图幅 - 爬取与显示区域改为以机器人当前位置为中心(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,全部通过。
221 行
9.5 KiB
JavaScript
221 行
9.5 KiB
JavaScript
'use strict';
|
||
|
||
const test = require('node:test');
|
||
const assert = require('node:assert');
|
||
const fs = require('fs');
|
||
const os = require('os');
|
||
const path = require('path');
|
||
|
||
const { BigMapService, TILE_PIXELS, DEFAULT_MAX_ZOOM } = require('../src/core/bigMapService');
|
||
const { defaultConfig } = require('../src/config/expansionConfig');
|
||
|
||
/** 造一张假的 PNG 内容(只需非空即可,服务端只做字节透传) */
|
||
function fakePng(size) {
|
||
const buffer = Buffer.alloc(size || 64);
|
||
buffer[0] = 0x89;
|
||
buffer[1] = 0x50;
|
||
return buffer;
|
||
}
|
||
|
||
/** 建一个使用临时缓存目录、带假 fetch 的大地图服务 */
|
||
function setup(options) {
|
||
const opts = options || {};
|
||
const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-bigmap-'));
|
||
const config = defaultConfig();
|
||
Object.assign(config.bigMap, opts.bigMap || {});
|
||
const calls = [];
|
||
const fetchImpl = opts.fetchImpl || (async (url) => {
|
||
calls.push(url);
|
||
if (opts.failAll) return { ok: false, status: 404, arrayBuffer: async () => new ArrayBuffer(0) };
|
||
const data = fakePng(opts.tileSize || 64);
|
||
return { ok: true, status: 200, arrayBuffer: async () => data.buffer.slice(data.byteOffset, data.byteOffset + data.length) };
|
||
});
|
||
const service = new BigMapService(config, null, {
|
||
baseUrl: 'http://bgjq.simpfun.cn',
|
||
world: 'minecraft_overworld',
|
||
cacheDir,
|
||
fetchImpl
|
||
});
|
||
return { service, config, cacheDir, calls };
|
||
}
|
||
|
||
test('坐标系:最大层级 1 方块 = 1 像素,逐级翻倍', () => {
|
||
const { service } = setup();
|
||
assert.strictEqual(service.maxZoom, DEFAULT_MAX_ZOOM);
|
||
assert.strictEqual(service.blocksPerTile(3), TILE_PIXELS, '最大层级每张瓦片覆盖 512 方块');
|
||
assert.strictEqual(service.blocksPerTile(2), TILE_PIXELS * 2);
|
||
assert.strictEqual(service.blocksPerTile(1), TILE_PIXELS * 4);
|
||
assert.strictEqual(service.blocksPerTile(0), TILE_PIXELS * 8, '层级 0 每张瓦片覆盖 4096 方块');
|
||
});
|
||
|
||
test('坐标系:王城 (10722, 1547) 落在 z=3 的瓦片 (20, 3) 内(对源站实测标定)', () => {
|
||
const { service } = setup();
|
||
const bounds = service.tileBlockBounds(3, 20, 3);
|
||
assert.deepStrictEqual(bounds, { minBlockX: 10240, maxBlockX: 10752, minBlockZ: 1536, maxBlockZ: 2048 });
|
||
assert.ok(10722 >= bounds.minBlockX && 10722 < bounds.maxBlockX);
|
||
assert.ok(1547 >= bounds.minBlockZ && 1547 < bounds.maxBlockZ);
|
||
});
|
||
|
||
test('tileRange 覆盖 ±rangeBlocks:层级 0 全世界为 10×10=100 张', () => {
|
||
const { service } = setup();
|
||
const range = service.tileRange(0, 20480, 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(
|
||
service.tileUrl(2, -3, 7),
|
||
'http://bgjq.simpfun.cn/tiles/minecraft_overworld/2/-3_7.png'
|
||
);
|
||
assert.ok(service.tilePath(2, -3, 7).endsWith(path.join('minecraft_overworld', '2', '-3_7.png')));
|
||
});
|
||
|
||
test('getTile 首次回源并落盘,第二次命中磁盘不再回源', async () => {
|
||
const { service, calls } = setup();
|
||
const first = await service.getTile(1, 2, 3);
|
||
assert.ok(first && first.length > 0);
|
||
assert.strictEqual(calls.length, 1);
|
||
|
||
// 清掉内存缓存,验证磁盘缓存生效
|
||
service._memory.clear();
|
||
const second = await service.getTile(1, 2, 3);
|
||
assert.ok(second && second.length > 0);
|
||
assert.strictEqual(calls.length, 1, '第二次不应再回源');
|
||
});
|
||
|
||
test('getTile 回源失败返回 null(控制台画占位色块)', async () => {
|
||
const { service } = setup({ failAll: true });
|
||
assert.strictEqual(await service.getTile(0, 0, 0), null);
|
||
});
|
||
|
||
test('crawlAll 批量爬取范围内全部瓦片并统计进度', async () => {
|
||
const { service, calls } = setup({ bigMap: { rangeBlocks: 4096 } });
|
||
const result = await service.crawlAll(0);
|
||
// rangeBlocks=4096、层级 0 每张 4096 方块 → 2×2=4 张
|
||
assert.strictEqual(result.total, 4);
|
||
assert.strictEqual(result.done, 4);
|
||
assert.strictEqual(result.failed, 0);
|
||
assert.strictEqual(result.running, false);
|
||
assert.strictEqual(calls.length, 4);
|
||
assert.strictEqual(service.countCached(0), 4);
|
||
});
|
||
|
||
test('crawlAll 已缓存的瓦片不重复下载', async () => {
|
||
const { service, calls } = setup({ bigMap: { rangeBlocks: 4096 } });
|
||
await service.crawlAll(0);
|
||
assert.strictEqual(calls.length, 4);
|
||
await service.crawlAll(0);
|
||
assert.strictEqual(calls.length, 4, '第二次爬取应全部命中缓存');
|
||
});
|
||
|
||
test('crawlAll 统计失败张数', async () => {
|
||
const { service } = setup({ failAll: true, bigMap: { rangeBlocks: 4096 } });
|
||
const result = await service.crawlAll(0);
|
||
assert.strictEqual(result.total, 4);
|
||
assert.strictEqual(result.done, 0);
|
||
assert.strictEqual(result.failed, 4);
|
||
});
|
||
|
||
test('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();
|
||
assert.strictEqual(snap.world, 'minecraft_overworld');
|
||
assert.strictEqual(snap.tilePixels, TILE_PIXELS);
|
||
assert.strictEqual(snap.blocksPerTile, 4096);
|
||
assert.strictEqual(snap.tiles.count, 100);
|
||
assert.strictEqual(snap.cached, 0);
|
||
assert.ok(snap.source.tileUrlTemplate.includes('{z}/{x}_{y}.png'));
|
||
});
|
||
|
||
test('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);
|
||
assert.strictEqual(service.countCached(0), 4);
|
||
const removed = service.clearCache();
|
||
assert.strictEqual(removed, 4);
|
||
assert.strictEqual(service.countCached(0), 0);
|
||
assert.strictEqual(service._memory.size, 0);
|
||
});
|
||
|
||
test('配置里 zoom 大于 maxZoom 时被钳制(避免爬到源站不存在的瓦片)', () => {
|
||
const { service, config } = setup({ bigMap: { zoom: 7, maxZoom: 3 } });
|
||
// 直接走一次配置归一化,验证钳制逻辑
|
||
const { normalizeConfig } = require('../src/config/expansionConfig');
|
||
const normalized = normalizeConfig(config);
|
||
assert.strictEqual(normalized.bigMap.zoom, 3);
|
||
assert.strictEqual(service.maxZoom, 3);
|
||
});
|