由 Fabric 客户端模组 AutoStore v1.3.1 重构为独立 Node.js 机器人,无需游戏客户端。 核心实现: - 领土数据:Dynmap markers.json 拉取解析(奇偶填充识别国中飞地)、SimpCore 开放 API 第四数据源、 带缓存击穿的时间戳请求与内容未变化跳过解析 - 扩地规划:四种策略(方形外扩 / 区域 S 形扫描 / 玩家周围 / 绕国一圈)逐行对照移植, 接壤判定只算己方领土,飞地优先填补 - 扩地状态机:规划→寻路→等待开垦→成功/失败→下一目标→完成,含超时重试、 往边上走两步自救、连续超时回城、耐久保护回城 - 生存辅助:自动进食、自动攻击(豁免中立生物)、自动游泳防溺水、自动用船(默认关闭) - 寻路:Baritone → mineflayer-pathfinder,高度偏好用包装 Goal 等价移植 Grim 反作弊合规层(新增,核心差异点): - 旋转不超前:接管 bot.look 按 ≤8.6°/tick 推进朝向,消除 mineflayer 「本地朝向瞬间跳变、数据包滞后补发」导致的 Prediction 分歧 - 攻击三重校验:朝向已同步 + 距离 ≤3.0 格 + 视线可达 + 攻击冷却 + 非骑乘/进食 - 动作互斥:拦下疾跑+潜行、疾跑+使用物品等原版不可能出现的组合 - 物理参数只读自检 + 寻路约束(禁挖/禁放/禁跑酷/禁搭塔/限下落/绕开水域) 网页控制台(新增): - 状态面板、Canvas 领土地图(缩放/平移/图层)、分组配置表单、实时日志流 - REST + WebSocket 接口,支持 WEB_TOKEN 令牌鉴权 修复: - containsFuzzy 只取固定长度前缀的末行编辑距离,导致完全相同的子串也匹配不上 - 占领去重依赖 Set.add 返回值(JS 中恒为真),导致同一区块被重复计数 - 非法枚举值不再钳制到最近边界,改为回退默认值 测试:154 个用例,npm test 全绿;npm run check 语法自检通过。
199 行
7.0 KiB
JavaScript
199 行
7.0 KiB
JavaScript
'use strict';
|
|
|
|
const test = require('node:test');
|
|
const assert = require('node:assert');
|
|
|
|
const { flattenChunks, boundsOf, timingSafeEqual, WebConsole } = require('../src/web/server');
|
|
const { TerritoryModel } = require('../src/core/territoryModel');
|
|
const { defaultConfig } = require('../src/config/expansionConfig');
|
|
const { Logger } = require('../src/util/logger');
|
|
const chunkPos = require('../src/core/chunkPos');
|
|
|
|
test('flattenChunks 压平为 [x1,z1,x2,z2,...]', () => {
|
|
const keys = new Set([chunkPos.key(1, 2), chunkPos.key(-3, 4)]);
|
|
const flat = flattenChunks(keys);
|
|
assert.strictEqual(flat.length, 4);
|
|
const pairs = [];
|
|
for (let i = 0; i < flat.length; i += 2) pairs.push([flat[i], flat[i + 1]]);
|
|
pairs.sort((a, b) => a[0] - b[0]);
|
|
assert.deepStrictEqual(pairs, [[-3, 4], [1, 2]]);
|
|
});
|
|
|
|
test('boundsOf 计算外接矩形并对空集合返回 null', () => {
|
|
const keys = new Set([chunkPos.key(1, 2), chunkPos.key(-3, 4), chunkPos.key(5, -6)]);
|
|
assert.deepStrictEqual(boundsOf(keys), { minX: -3, maxX: 5, minZ: -6, maxZ: 4 });
|
|
assert.strictEqual(boundsOf(new Set()), null);
|
|
});
|
|
|
|
test('timingSafeEqual 只接受等长且完全相同的字符串', () => {
|
|
assert.strictEqual(timingSafeEqual('abc', 'abc'), true);
|
|
assert.strictEqual(timingSafeEqual('abc', 'abd'), false);
|
|
assert.strictEqual(timingSafeEqual('abc', 'abcd'), false);
|
|
assert.strictEqual(timingSafeEqual(undefined, 'abc'), false);
|
|
});
|
|
|
|
/** 搭建一个使用桩依赖的控制台实例 */
|
|
function setupConsole() {
|
|
const map = new Map([[
|
|
'A',
|
|
{
|
|
name: 'A',
|
|
capital: { x: 0, z: 0 },
|
|
chunks: new Set([chunkPos.key(0, 0), chunkPos.key(1, 0)]),
|
|
color: 0x2f81f7
|
|
}
|
|
]]);
|
|
const model = new TerritoryModel(map);
|
|
model.setSelfStateName('A');
|
|
model.addSessionClaim({ x: 1, z: 0 });
|
|
|
|
const logger = new Logger({ echo: false });
|
|
const botManager = {
|
|
snapshot: () => ({ state: 'online', username: 'TestBot' }),
|
|
sendCommand: () => {}
|
|
};
|
|
const controller = {
|
|
target: { x: 2, z: 0 },
|
|
playerChunk: { x: 0, z: 0 },
|
|
snapshot: () => ({ state: 'PATHING', running: true }),
|
|
exportLogs: () => 'data/debug.log',
|
|
stop: () => {},
|
|
start: async () => true,
|
|
status: '测试中'
|
|
};
|
|
const dataService = {
|
|
getModel: () => model,
|
|
snapshot: () => ({ stateCount: 1, claimedChunks: 2 }),
|
|
refreshAsync: () => {},
|
|
fetchAllTerritoryOnce: async () => ({ ok: true, message: '已拉取 0 个区块' })
|
|
};
|
|
const config = defaultConfig();
|
|
const webConsole = new WebConsole({
|
|
botManager,
|
|
controller,
|
|
dataService,
|
|
logger,
|
|
getConfig: () => config,
|
|
applyConfig: (next) => next
|
|
}, { host: '127.0.0.1', port: 0 });
|
|
return { webConsole, model, logger, controller };
|
|
}
|
|
|
|
test('mapPayload 返回己方 / 会话 / 飞地 / 目标与各邦国摘要', () => {
|
|
const { webConsole } = setupConsole();
|
|
const payload = webConsole.mapPayload(false);
|
|
assert.strictEqual(payload.selfState, 'A');
|
|
assert.strictEqual(payload.self.length, 4, '2 个区块 → 4 个数字');
|
|
assert.strictEqual(payload.session.length, 2);
|
|
assert.deepStrictEqual(payload.target, { x: 2, z: 0 });
|
|
assert.strictEqual(payload.states.length, 1);
|
|
assert.strictEqual(payload.states[0].name, 'A');
|
|
assert.strictEqual(payload.states[0].color, '#2f81f7');
|
|
assert.strictEqual(payload.states[0].count, 2);
|
|
assert.strictEqual(payload.otherChunks, undefined, '默认不返回全量他国区块');
|
|
});
|
|
|
|
test('mapPayload(full) 额外返回他国区块', () => {
|
|
const { webConsole, model } = setupConsole();
|
|
model.states.set('B', {
|
|
name: 'B',
|
|
capital: { x: 9, z: 9 },
|
|
chunks: new Set([chunkPos.key(9, 9)]),
|
|
color: 0xff43de
|
|
});
|
|
const payload = webConsole.mapPayload(true);
|
|
assert.strictEqual(payload.full, true);
|
|
assert.strictEqual(payload.otherChunks.length, 1);
|
|
assert.strictEqual(payload.otherChunks[0].name, 'B');
|
|
assert.strictEqual(payload.otherChunks[0].color, '#ff43de');
|
|
});
|
|
|
|
test('statusPayload 汇总 bot / 任务 / 数据源状态', () => {
|
|
const { webConsole } = setupConsole();
|
|
const payload = webConsole.statusPayload();
|
|
assert.strictEqual(payload.bot.state, 'online');
|
|
assert.strictEqual(payload.task.state, 'PATHING');
|
|
assert.strictEqual(payload.data.stateCount, 1);
|
|
assert.strictEqual(payload.claimTime.start, 11);
|
|
assert.strictEqual(payload.claimTime.end, 22);
|
|
});
|
|
|
|
test('HTTP 接口:/api/status、/api/config、/api/map 可访问', async () => {
|
|
const { webConsole } = setupConsole();
|
|
await webConsole.start();
|
|
const base = webConsole.baseUrl();
|
|
try {
|
|
const status = await fetch(base + '/api/status').then((r) => r.json());
|
|
assert.strictEqual(status.ok, true);
|
|
assert.strictEqual(status.data.bot.state, 'online');
|
|
|
|
const config = await fetch(base + '/api/config').then((r) => r.json());
|
|
assert.strictEqual(config.ok, true);
|
|
assert.strictEqual(config.data.strategy, 1);
|
|
|
|
const map = await fetch(base + '/api/map?full=1').then((r) => r.json());
|
|
assert.strictEqual(map.ok, true);
|
|
assert.strictEqual(map.data.full, true);
|
|
|
|
const html = await fetch(base + '/').then((r) => r.text());
|
|
assert.ok(html.includes('AutoStore Bot'), '首页应返回控制台 HTML');
|
|
} finally {
|
|
await webConsole.stop();
|
|
}
|
|
});
|
|
|
|
test('HTTP 接口:配置令牌后拒绝无令牌请求', async () => {
|
|
const { webConsole } = setupConsole();
|
|
webConsole.token = 'secret-token';
|
|
await webConsole.start();
|
|
const base = webConsole.baseUrl();
|
|
try {
|
|
const unauthorized = await fetch(base + '/api/status');
|
|
assert.strictEqual(unauthorized.status, 401);
|
|
|
|
const authorized = await fetch(base + '/api/status?token=secret-token');
|
|
assert.strictEqual(authorized.status, 200);
|
|
|
|
const viaHeader = await fetch(base + '/api/status', { headers: { 'X-Auth-Token': 'secret-token' } });
|
|
assert.strictEqual(viaHeader.status, 200);
|
|
} finally {
|
|
await webConsole.stop();
|
|
}
|
|
});
|
|
|
|
test('HTTP 接口:POST /api/config 会走 applyConfig 并返回生效配置', async () => {
|
|
const { webConsole } = setupConsole();
|
|
await webConsole.start();
|
|
const base = webConsole.baseUrl();
|
|
try {
|
|
const response = await fetch(base + '/api/config', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ strategy: 4, targetCount: 12 })
|
|
}).then((r) => r.json());
|
|
assert.strictEqual(response.ok, true);
|
|
assert.strictEqual(response.data.strategy, 4);
|
|
assert.strictEqual(response.data.targetCount, 12);
|
|
// 未提供的字段保持默认
|
|
assert.strictEqual(response.data.retries, 2);
|
|
} finally {
|
|
await webConsole.stop();
|
|
}
|
|
});
|
|
|
|
test('HTTP 接口:POST /api/command 缺参数时返回 400', async () => {
|
|
const { webConsole } = setupConsole();
|
|
await webConsole.start();
|
|
const base = webConsole.baseUrl();
|
|
try {
|
|
const response = await fetch(base + '/api/command', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({})
|
|
});
|
|
assert.strictEqual(response.status, 400);
|
|
} finally {
|
|
await webConsole.stop();
|
|
}
|
|
});
|