由 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 语法自检通过。
431 行
15 KiB
JavaScript
431 行
15 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 { ExpansionController, State } = require('../src/controller/expansionController');
|
|
const { TerritoryModel } = require('../src/core/territoryModel');
|
|
const { defaultConfig } = require('../src/config/expansionConfig');
|
|
const { Logger } = require('../src/util/logger');
|
|
const { FakeBot } = require('./helpers/fakeBot');
|
|
const chunkPos = require('../src/core/chunkPos');
|
|
|
|
/** 假寻路服务 */
|
|
class FakePathing {
|
|
constructor() {
|
|
this.calcFailed = false;
|
|
this.currentTarget = null;
|
|
this._pathing = false;
|
|
this.pathCalls = 0;
|
|
}
|
|
|
|
pathTo(chunk) {
|
|
this.currentTarget = chunk;
|
|
this._pathing = true;
|
|
this.pathCalls++;
|
|
return true;
|
|
}
|
|
|
|
cancel() {
|
|
this._pathing = false;
|
|
}
|
|
|
|
isPathing() {
|
|
return this._pathing;
|
|
}
|
|
|
|
applyConfig() {}
|
|
}
|
|
|
|
/** 假数据服务 */
|
|
class FakeDataService {
|
|
constructor(model) {
|
|
this.model = model;
|
|
this.sessionClaims = [];
|
|
this.refreshCount = 0;
|
|
}
|
|
|
|
getModel() {
|
|
return this.model;
|
|
}
|
|
|
|
setSelectedStateName(name) {
|
|
this.model.setSelfStateName(name);
|
|
}
|
|
|
|
addSessionClaim(chunk) {
|
|
this.sessionClaims.push(chunk);
|
|
this.model.addSessionClaim(chunk);
|
|
}
|
|
|
|
refreshAsync() {
|
|
this.refreshCount++;
|
|
}
|
|
|
|
async detectStateByPlayer() {
|
|
return null;
|
|
}
|
|
|
|
snapshot() {
|
|
return { stateCount: this.model.stateCount };
|
|
}
|
|
}
|
|
|
|
/** 假机器人管理器 */
|
|
class FakeBotManager {
|
|
constructor(bot, model) {
|
|
this.bot = bot;
|
|
this.pathing = new FakePathing();
|
|
this.grim = null;
|
|
this.eater = null;
|
|
this.boat = null;
|
|
this.guard = null;
|
|
this.connection = { username: 'TestBot' };
|
|
this.commands = [];
|
|
this._model = model;
|
|
}
|
|
|
|
isReady() {
|
|
return true;
|
|
}
|
|
|
|
isSpawned() {
|
|
return true;
|
|
}
|
|
|
|
sendCommand(command) {
|
|
if (command) this.commands.push(command);
|
|
}
|
|
|
|
applyConfig() {}
|
|
}
|
|
|
|
/**
|
|
* 搭建被测环境。
|
|
* @param {{states?: Record<string, Array<[number, number]>>, selfName?: string, x?: number, z?: number, config?: object}} [options]
|
|
*/
|
|
function setup(options) {
|
|
const opts = options || {};
|
|
const states = opts.states || { A: [[0, 0]] };
|
|
const map = new Map();
|
|
for (const [name, coords] of Object.entries(states)) {
|
|
map.set(name, {
|
|
name,
|
|
capital: { x: coords[0][0], z: coords[0][1] },
|
|
chunks: new Set(coords.map(([x, z]) => chunkPos.key(x, z))),
|
|
color: 0x75bd20
|
|
});
|
|
}
|
|
const model = new TerritoryModel(map);
|
|
if (opts.selfName) model.setSelfStateName(opts.selfName);
|
|
|
|
const bot = new FakeBot({ x: (opts.x === undefined ? 0 : opts.x) * 16 + 8, y: 64, z: (opts.z === undefined ? 0 : opts.z) * 16 + 8 });
|
|
const botManager = new FakeBotManager(bot, model);
|
|
const dataService = new FakeDataService(model);
|
|
const logger = new Logger({ echo: false });
|
|
const controller = new ExpansionController({ botManager, dataService, logger });
|
|
|
|
const config = defaultConfig();
|
|
config.enforceClaimTime = false;
|
|
config.autoEat = false;
|
|
config.autoAttack = false;
|
|
config.durabilityGuard = false;
|
|
config.autoBoat = false;
|
|
Object.assign(config, opts.config || {});
|
|
|
|
return { controller, botManager, dataService, model, bot, logger, config };
|
|
}
|
|
|
|
test('start:自动识别所在邦国并下发开垦模式指令', async () => {
|
|
const { controller, botManager, config, model } = setup({ selfName: '' });
|
|
const ok = await controller.start(config);
|
|
assert.strictEqual(ok, true);
|
|
assert.strictEqual(model.selfStateNameValue(), 'A', '应自动识别为所在邦国 A');
|
|
assert.ok(botManager.commands.includes('u claimmode'), '应发送开垦模式指令');
|
|
assert.strictEqual(controller.state, State.PATHING);
|
|
assert.ok(controller.target, '应选出目标区块');
|
|
assert.strictEqual(botManager.pathing.pathCalls, 1, '应下发一次寻路');
|
|
});
|
|
|
|
test('start:指定不存在的邦国时拒绝启动', async () => {
|
|
const { controller, config } = setup();
|
|
config.stateName = '不存在的国';
|
|
const ok = await controller.start(config);
|
|
assert.strictEqual(ok, false);
|
|
assert.strictEqual(controller.state, State.IDLE);
|
|
assert.ok(controller.status.includes('不存在'));
|
|
});
|
|
|
|
test('start:不在开垦时段时登记等待自动开始', async () => {
|
|
const { controller, config } = setup();
|
|
const hour = new Date().getHours();
|
|
config.enforceClaimTime = true;
|
|
// 构造一个一定不含当前小时的时段
|
|
config.claimStartHour = (hour + 2) % 24;
|
|
config.claimEndHour = (hour + 3) % 24;
|
|
if (config.claimStartHour >= config.claimEndHour) config.claimEndHour = config.claimStartHour + 1;
|
|
|
|
const ok = await controller.start(config);
|
|
assert.strictEqual(ok, true);
|
|
assert.strictEqual(controller.isWaitingForClaimTime(), true);
|
|
assert.strictEqual(controller.state, State.IDLE);
|
|
assert.ok(controller.status.includes('等待开垦时段'));
|
|
});
|
|
|
|
test('到达目标区块后进入等待开垦状态', async () => {
|
|
const { controller, botManager, config, bot } = setup();
|
|
await controller.start(config);
|
|
const target = controller.target;
|
|
// 把机器人移到目标区块
|
|
bot.entity.position.x = target.x * 16 + 8;
|
|
bot.entity.position.z = target.z * 16 + 8;
|
|
controller.tick();
|
|
assert.strictEqual(controller.state, State.WAITING);
|
|
assert.strictEqual(botManager.pathing.isPathing(), false, '到达后应取消寻路');
|
|
});
|
|
|
|
test('开垦成功消息推进计数并进入 SUCCESS', async () => {
|
|
const { controller, config, bot, dataService } = setup();
|
|
await controller.start(config);
|
|
const target = controller.target;
|
|
bot.entity.position.x = target.x * 16 + 8;
|
|
bot.entity.position.z = target.z * 16 + 8;
|
|
controller.tick();
|
|
assert.strictEqual(controller.state, State.WAITING);
|
|
|
|
controller.monitor.onChat(`疆土(${target.x},${target.z}) [${target.x * 16},${target.z * 16}] 已为你的邦国开拓`);
|
|
assert.strictEqual(controller.completedClaims, 1);
|
|
assert.strictEqual(controller.state, State.SUCCESS);
|
|
assert.strictEqual(dataService.sessionClaims.length, 1);
|
|
assert.ok(dataService.refreshCount > 0, '占领成功后应触发一次地图刷新');
|
|
});
|
|
|
|
test('同一区块的重复成功消息只计一次', async () => {
|
|
const { controller, config, bot } = setup();
|
|
await controller.start(config);
|
|
const target = controller.target;
|
|
bot.entity.position.x = target.x * 16 + 8;
|
|
bot.entity.position.z = target.z * 16 + 8;
|
|
controller.tick();
|
|
const message = `疆土(${target.x},${target.z}) [0,0] 已为你的邦国开拓`;
|
|
controller.monitor.onChat(message);
|
|
controller.monitor.onChat(message);
|
|
assert.strictEqual(controller.completedClaims, 1);
|
|
});
|
|
|
|
test('通用成功消息只在机器人确实位于目标区块时生效', async () => {
|
|
const { controller, config, bot } = setup();
|
|
await controller.start(config);
|
|
const target = controller.target;
|
|
// 机器人不在目标区块上
|
|
bot.entity.position.x = (target.x + 5) * 16;
|
|
bot.entity.position.z = (target.z + 5) * 16;
|
|
controller.playerChunk = { x: target.x + 5, z: target.z + 5 };
|
|
controller.monitor.onChat('你的疆土已开拓');
|
|
assert.strictEqual(controller.completedClaims, 0, '不在目标区块时不得计为成功');
|
|
|
|
bot.entity.position.x = target.x * 16 + 8;
|
|
bot.entity.position.z = target.z * 16 + 8;
|
|
controller.playerChunk = { x: target.x, z: target.z };
|
|
controller.monitor.onChat('你的疆土已开拓');
|
|
assert.strictEqual(controller.completedClaims, 1);
|
|
});
|
|
|
|
test('目标已被他人占领时跳过', async () => {
|
|
const { controller, config, model } = setup();
|
|
await controller.start(config);
|
|
const target = controller.target;
|
|
// 地图刷新后该区块变成他国领土
|
|
model.allClaimedChunks().add(chunkPos.key(target.x, target.z));
|
|
controller.tick();
|
|
assert.strictEqual(controller.state, State.NEXT);
|
|
assert.strictEqual(controller.status, '跳过已占领区块');
|
|
});
|
|
|
|
test('目标不再与己方领土接壤时跳过', async () => {
|
|
const { controller, config, model } = setup();
|
|
await controller.start(config);
|
|
const target = controller.target;
|
|
// 清空己方领土,使目标不再接壤
|
|
model.selfState().chunks.clear();
|
|
controller.tick();
|
|
assert.strictEqual(controller.state, State.NEXT);
|
|
assert.strictEqual(controller.status, '跳过不接壤区块');
|
|
});
|
|
|
|
test('寻路超时会重新寻路,重试耗尽后走两步', async () => {
|
|
const { controller, botManager, config } = setup({ config: { waitTimeoutSeconds: 5, retries: 2, sideStepMaxRounds: 2 } });
|
|
await controller.start(config);
|
|
const callsAfterStart = botManager.pathing.pathCalls;
|
|
// 把阶段开始时间拨回 10 秒前,触发超时
|
|
controller.stageStartedMillis = Date.now() - 10000;
|
|
controller.tick();
|
|
assert.strictEqual(controller.retryCount, 1);
|
|
assert.strictEqual(controller.state, State.PATHING, '未耗尽重试时应继续寻路');
|
|
assert.strictEqual(botManager.pathing.pathCalls, callsAfterStart + 1);
|
|
|
|
controller.stageStartedMillis = Date.now() - 10000;
|
|
controller.tick();
|
|
assert.strictEqual(controller.sideStepping, true, '重试耗尽后应进入走两步阶段');
|
|
assert.strictEqual(controller.sideStepCount, 1);
|
|
});
|
|
|
|
test('连续超时累计达到阈值时回城', async () => {
|
|
const { controller, botManager, config } = setup({
|
|
config: { waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0, returnThresholdSeconds: 1 }
|
|
});
|
|
await controller.start(config);
|
|
controller.stageStartedMillis = Date.now() - 10000;
|
|
controller.tick(); // 第一次超时:重试次数达到上限(retries=1),走两步轮数为 0 → giveUp → 回城
|
|
assert.strictEqual(controller.state, State.RETURNING);
|
|
assert.ok(botManager.commands.includes('u return'), '应发送回城指令');
|
|
});
|
|
|
|
test('回城等待结束后重新规划(非耐久保护触发)', async () => {
|
|
const { controller, config } = setup({
|
|
config: { waitTimeoutSeconds: 5, retries: 1, sideStepMaxRounds: 0, returnThresholdSeconds: 1, returnWaitSeconds: 1 }
|
|
});
|
|
await controller.start(config);
|
|
controller.stageStartedMillis = Date.now() - 10000;
|
|
controller.tick();
|
|
assert.strictEqual(controller.state, State.RETURNING);
|
|
controller.returnStartMillis = Date.now() - 5000;
|
|
controller.tick();
|
|
assert.strictEqual(controller.state, State.PATHING, '回城完成后应重新规划并开始寻路');
|
|
assert.strictEqual(controller.consecutiveTimeoutMillis, 0, '连续超时计数应清零');
|
|
});
|
|
|
|
test('耐久保护触发回城并在传送后停止任务', async () => {
|
|
const { controller, botManager, config } = setup({ config: { durabilityGuard: true, returnWaitSeconds: 1 } });
|
|
botManager.guard = {
|
|
check: () => ({ slot: 0, name: '主手', percent: 5, itemName: 'diamond_sword' }),
|
|
reset: () => {}
|
|
};
|
|
await controller.start(config);
|
|
controller.tick();
|
|
assert.strictEqual(controller.state, State.RETURNING);
|
|
assert.ok(botManager.commands.includes('u return'));
|
|
|
|
controller.returnStartMillis = Date.now() - 5000;
|
|
controller.tick();
|
|
assert.strictEqual(controller.state, State.STOPPED, '耐久保护回城后应停止任务');
|
|
});
|
|
|
|
test('达成目标数量后进入 COMPLETE', async () => {
|
|
const { controller, config, bot } = setup({ config: { targetCount: 1 } });
|
|
await controller.start(config);
|
|
const target = controller.target;
|
|
bot.entity.position.x = target.x * 16 + 8;
|
|
bot.entity.position.z = target.z * 16 + 8;
|
|
controller.tick(); // → WAITING
|
|
controller.monitor.onChat(`疆土(${target.x},${target.z}) [0,0] 已为你的邦国开拓`); // → SUCCESS
|
|
controller.tick(); // SUCCESS → NEXT
|
|
controller.tick(); // NEXT → 达成目标 → COMPLETE
|
|
assert.strictEqual(controller.state, State.COMPLETE);
|
|
assert.strictEqual(controller.completedClaims, 1);
|
|
assert.strictEqual(controller.isRunning(), false);
|
|
});
|
|
|
|
test('stop 后进入 STOPPED 且不再运行', async () => {
|
|
const { controller, botManager, config } = setup();
|
|
await controller.start(config);
|
|
controller.stop();
|
|
assert.strictEqual(controller.state, State.STOPPED);
|
|
assert.strictEqual(controller.isRunning(), false);
|
|
assert.strictEqual(botManager.pathing.isPathing(), false, '停止时应取消寻路');
|
|
});
|
|
|
|
test('stop 可取消「等待自动开始」', async () => {
|
|
const { controller, config } = setup();
|
|
const hour = new Date().getHours();
|
|
config.enforceClaimTime = true;
|
|
config.claimStartHour = (hour + 2) % 24;
|
|
config.claimEndHour = (hour + 3) % 24;
|
|
if (config.claimStartHour >= config.claimEndHour) config.claimEndHour = config.claimStartHour + 1;
|
|
await controller.start(config);
|
|
assert.strictEqual(controller.isWaitingForClaimTime(), true);
|
|
controller.stop();
|
|
assert.strictEqual(controller.isWaitingForClaimTime(), false);
|
|
assert.strictEqual(controller.state, State.IDLE);
|
|
});
|
|
|
|
test('自动游泳:水中按住跳跃键,离开水后松开', async () => {
|
|
const { controller, botManager, config, bot } = setup();
|
|
botManager.grim = {
|
|
controls: {},
|
|
setControlState(control, state) { this.controls[control] = state; },
|
|
setLook() {}
|
|
};
|
|
await controller.start(config);
|
|
bot.entity.isInWater = true;
|
|
controller.tick();
|
|
assert.strictEqual(botManager.grim.controls.jump, true, '水中应按住跳跃键上浮');
|
|
|
|
bot.entity.isInWater = false;
|
|
controller.tick();
|
|
assert.strictEqual(botManager.grim.controls.jump, false, '离水后应松开跳跃键');
|
|
});
|
|
|
|
test('船上不自动游泳', async () => {
|
|
const { controller, botManager, config, bot } = setup();
|
|
botManager.grim = {
|
|
controls: {},
|
|
setControlState(control, state) { this.controls[control] = state; },
|
|
setLook() {}
|
|
};
|
|
await controller.start(config);
|
|
bot.entity.isInWater = true;
|
|
bot.vehicle = { id: 1 };
|
|
controller.tick();
|
|
assert.notStrictEqual(botManager.grim.controls.jump, true, '坐船时不应按跳跃键');
|
|
});
|
|
|
|
test('snapshot 暴露任务状态供控制台展示', async () => {
|
|
const { controller, config } = setup();
|
|
await controller.start(config);
|
|
const snapshot = controller.snapshot();
|
|
assert.strictEqual(snapshot.running, true);
|
|
assert.strictEqual(snapshot.state, State.PATHING);
|
|
assert.ok(snapshot.target);
|
|
assert.strictEqual(snapshot.completedClaims, 0);
|
|
assert.strictEqual(snapshot.withinClaimTime, true);
|
|
assert.strictEqual(snapshot.claimModeSent, true);
|
|
});
|
|
|
|
test('exportLogs 写出状态摘要与全部日志', async () => {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'autostore-log-'));
|
|
const file = path.join(dir, 'debug.log');
|
|
const { controller, config } = setup();
|
|
await controller.start(config);
|
|
const result = controller.exportLogs(file);
|
|
assert.strictEqual(result, file);
|
|
const content = fs.readFileSync(file, 'utf8');
|
|
assert.ok(content.includes('AutoStore Bot 调试日志'));
|
|
assert.ok(content.includes('状态:'));
|
|
assert.ok(content.includes('开始自动扩地'));
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
test('未启动任务时 tick 不做任何事', () => {
|
|
const { controller } = setup();
|
|
assert.doesNotThrow(() => controller.tick());
|
|
assert.strictEqual(controller.state, State.IDLE);
|
|
});
|
|
|
|
test('规划队列为空时直接进入 COMPLETE', async () => {
|
|
// 己方领土被完全包围且无空位:构造一个只有 1 个区块、四周全被他国占领的局面
|
|
const { controller, config } = setup({
|
|
states: {
|
|
A: [[0, 0]],
|
|
B: [[1, 0], [-1, 0], [0, 1], [0, -1]]
|
|
},
|
|
selfName: 'A'
|
|
});
|
|
await controller.start(config);
|
|
assert.strictEqual(controller.state, State.COMPLETE);
|
|
assert.strictEqual(controller.status, '没有可用的相邻无主区块');
|
|
});
|