针对「机器人一直在走、净位移却只有物理速度的 1/5」这个现象。 mineflayer 在 physics.js 里把 flags.hasHorizontalCollision 写成 undefined (源码注释还标着「1.21.3+」),位标志序列化后恒为 0 —— 而原版客户端在水平碰到 方块时会上报 1。机器人在崎岖地形上几乎一直在水平碰撞,却始终上报「没碰撞」, 正是反作弊 Prediction 最容易判错的形态:服务端按「该碰撞」预测、客户端说 「没碰撞」,于是被判异常并拉回。 物理引擎其实早已算出真实值(moveEntity 里给 entity.isCollidedHorizontally 赋值), 只是从没被发出去。现在在 _fixOutboundOnGround 里按真实值补上,由 grim.reportHorizontalCollision(默认开)控制。 实测依据(王城外山区,2 秒粒度采样 120 秒): - controls 几乎每 tick 都是 forward+sprint,velocity 约 0.2~3 格/秒; - 净位移只有 48.6 格 / 120 秒 = 0.405 格/秒,差额就是被服务端拉回的部分。 顺带把 _fixOutboundOnGround 拆成 _fixOnGroundFlag / _fixHorizontalCollisionFlag 两个职责单一的方法;控制台配置面板新增对应开关, packetStats.horizontalCollisionFixed 暴露补写次数。 测试 473 → 477。
621 行
30 KiB
JavaScript
621 行
30 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* 自动扩地配置。
|
||
*
|
||
* 字段命名与原 Java 版 `ExpansionConfig` 保持一一对应(便于对照移植正确性),
|
||
* 新增能力统一收拢在三个分组对象里:
|
||
* - `pathing`:mineflayer-pathfinder 专属的寻路开关
|
||
* - `grim`:Grim 反作弊合规层的阈值
|
||
* - `server`:与服务器交互的指令与节流
|
||
*/
|
||
|
||
/** 扩地策略枚举 */
|
||
const STRATEGY = {
|
||
/** 方形外扩(优先填洞) */
|
||
SQUARE: 1,
|
||
/** 区域扩地(S 形蛇形扫描) */
|
||
REGION: 2,
|
||
/** 玩家周围扩地 */
|
||
AROUND_PLAYER: 3,
|
||
/** 绕国一圈 */
|
||
PERIMETER: 4
|
||
};
|
||
|
||
/** 水域处理策略 */
|
||
const WATER_MODE = {
|
||
/** 绕开水域(默认,最安全) */
|
||
AVOID: 'avoid',
|
||
/** 游泳通过 */
|
||
SWIM: 'swim',
|
||
/** 放船渡水 */
|
||
BOAT: 'boat'
|
||
};
|
||
|
||
/**
|
||
* 生成一份默认配置。
|
||
* @returns {object}
|
||
*/
|
||
function defaultConfig() {
|
||
return {
|
||
// ==================== 扩地策略(与 Java 版同名) ====================
|
||
/** 扩地策略:1=方形外扩,2=区域扩地,3=玩家周围,4=绕国一圈 */
|
||
strategy: STRATEGY.SQUARE,
|
||
/** 目标邦国名称;为空时表示自动识别玩家当前所在邦国 */
|
||
stateName: '',
|
||
/** 目标扩地数量;-1 表示持续扩地直到无可用区块 */
|
||
targetCount: -1,
|
||
/** 扩圈上限(0 表示不限制) */
|
||
maxRings: 0,
|
||
|
||
// ==================== 区域扩地范围(与 Java 版同名) ====================
|
||
regionCenterX: 0,
|
||
regionCenterZ: 0,
|
||
regionRadius: 5,
|
||
/** 矩形边界(区块坐标);maxX < minX 表示未圈选,回退到中心+半径 */
|
||
regionMinX: 0,
|
||
regionMaxX: -1,
|
||
regionMinZ: 0,
|
||
regionMaxZ: -1,
|
||
|
||
// ==================== 超时与重试(与 Java 版同名) ====================
|
||
/** 等待 / 寻路超时时间(秒) */
|
||
waitTimeoutSeconds: 60,
|
||
/** 超时后的最大重试次数 */
|
||
retries: 2,
|
||
/** 连续寻路超时累计达到该秒数后,发送回城指令并重新开始 */
|
||
returnThresholdSeconds: 60,
|
||
/**
|
||
* 是否允许「连续超时后自动回城」。
|
||
*
|
||
* **默认关闭**,因为回城是破坏性的:它会把机器人从当前位置传送回安全点,
|
||
* 而这个安全点往往并不比原地更好(可能同样是卡死点),
|
||
* 更麻烦的是**会把玩家在游戏客户端里手动摆好的位置一并清掉**。
|
||
* 需要用它来逃离卡死状态时再打开。
|
||
*/
|
||
returnOnTimeout: false,
|
||
/** 回城指令(不带前导斜杠),默认服务器自带的 `/u return` */
|
||
returnCommand: 'u return',
|
||
/** 建立连接后多久内不自动回城(秒):刚被玩家摆好位置的机器人不该被立刻拉走 */
|
||
returnGraceSeconds: 300,
|
||
/** 发送回城指令后等待传送完成的时间(秒) */
|
||
returnWaitSeconds: 12,
|
||
/** 单个目标最多触发「往边上走两步」自救的轮数 */
|
||
sideStepMaxRounds: 3,
|
||
|
||
// ==================== 开垦时段限制(新增,原版硬编码 11:00-22:00) ====================
|
||
/** 是否启用开垦时段限制(服务器只在特定时段接受开拓) */
|
||
enforceClaimTime: true,
|
||
/** 允许开拓的现实时间起始小时(含,本地时区) */
|
||
claimStartHour: 11,
|
||
/** 允许开拓的现实时间结束小时(不含,本地时区) */
|
||
claimEndHour: 22,
|
||
|
||
// ==================== 寻路基础开关(与 Java 版同名) ====================
|
||
allowSprint: true,
|
||
allowSwim: true,
|
||
allowJump: true,
|
||
allowBreak: false,
|
||
allowPlace: false,
|
||
/**
|
||
* 寻路高度偏好:优先往高处走。
|
||
*
|
||
* ⚠️ **默认关闭,因为当前实现会让寻路器完全规划不出路径。**
|
||
* 实测(2026-10-01,王城平台上):打开时 `inputTicks = 0`(一次移动指令都不发)、
|
||
* 任务永远停在「寻路超时,重新寻路」;关掉后 `inputTicks = 237`,立刻开始移动。
|
||
*
|
||
* 原因见 `AltitudeGoal` 的注释:它把高度偏置加在启发值上,
|
||
* 而 mineflayer-pathfinder 用「h 最小」挑 partial 路径的终点(`bestNode`),
|
||
* 于是偏置会把 partial 路径引向「又高又偏」的节点,永远到不了目标附近。
|
||
* 想重新启用,应把偏置改成**移动代价**(低处加 cost),而不是改启发值。
|
||
*/
|
||
altitudePreference: false,
|
||
/** 高度偏好权重:每上升 1 格折算的启发成本减少量(当前实现下不生效) */
|
||
altitudeWeight: 0.1,
|
||
|
||
// ==================== 生存辅助(与 Java 版同名) ====================
|
||
avoidMobs: true,
|
||
autoEat: true,
|
||
/** 触发自动进食的饥饿值阈值(0-20) */
|
||
eatThreshold: 14,
|
||
autoAttack: true,
|
||
durabilityGuard: true,
|
||
/** 耐久保护阈值:剩余耐久百分比 ≤ 该值时回城(1-99) */
|
||
durabilityThreshold: 15,
|
||
/**
|
||
* 掉线重连后是否自动恢复被中断的扩地任务。
|
||
*
|
||
* 本服会周期性把机器人踢下线(实测约 1~6 分钟一次),每次都会触发
|
||
* 「世界已退出,任务停止」。若不自动恢复,任务就永远停在那里 ——
|
||
* 机器人虽然能被原点自救送回真实世界,却再也不会继续扩地。
|
||
* 手动停止 / 到时段外 / 耐久保护停机都不会被自动恢复。
|
||
*/
|
||
autoResumeAfterReconnect: true,
|
||
/** 是否启用自动用船过水 */
|
||
autoBoat: false,
|
||
/** 触发自动用船的水面最小宽度(格) */
|
||
autoBoatMinWidth: 4,
|
||
/** 自动用船最短水域长度(格) */
|
||
autoBoatMinLength: 6,
|
||
/** 水域处理策略:avoid / swim / boat */
|
||
waterMode: WATER_MODE.AVOID,
|
||
|
||
// ==================== 数据源(与 Java 版同名) ====================
|
||
/** 地图自动刷新间隔(秒),最小 10 秒 */
|
||
refreshIntervalSeconds: 30,
|
||
/** 是否启用 SimpCore 开放 API 作为占领判定补充来源 */
|
||
apiEnabled: false,
|
||
/** SimpCore 开放 API Key(留空时回退读环境变量 SIMP_API_KEY) */
|
||
apiKey: '',
|
||
/** API 领土数据轮询间隔(秒),最小 60 */
|
||
apiRefreshIntervalSeconds: 180,
|
||
/**
|
||
* 是否启用 SimpCore API-V2(ServerOpenAPI 插件)。
|
||
*
|
||
* 注意:**API 与 API-V2 是两套独立、互不通用的密钥体系**,需要在密钥中心分别申请。
|
||
* V2 用于查询服务器实时状态(在线玩家 / 装备 / 游戏时间 / 末影龙),
|
||
* 接口级配额很小(末影龙仅 1 次/分钟),因此不做自动轮询,只在控制台按需查询。
|
||
*/
|
||
apiV2Enabled: false,
|
||
/** API-V2 Key(留空时回退读环境变量 SIMP_API_V2_KEY) */
|
||
apiV2Key: '',
|
||
|
||
// ==================== 寻路器专属(新增) ====================
|
||
pathing: {
|
||
/** 是否允许跑酷跳跃(mineflayer 起跳时机与原版有偏差,默认关闭更安全) */
|
||
allowParkour: false,
|
||
/** 是否允许 1×1 搭塔(Grim 的 place 类检查极易命中,默认关闭) */
|
||
allow1by1towers: false,
|
||
/** 允许的最大下落高度(格),超过则绕行 */
|
||
maxDropDown: 3,
|
||
/** 进入液体格的额外代价(越大越倾向绕开水域) */
|
||
liquidCost: 40,
|
||
/** 是否允许打开门/栅栏门 */
|
||
canOpenDoors: false,
|
||
/**
|
||
* 单段寻路的最长方块数(默认 512)。
|
||
*
|
||
* mineflayer-pathfinder 只能规划**已加载区块**范围内(实测本服约 8 区块 ≈ 130 格),
|
||
* 而扩地目标常在几十个区块之外,A* 会瞬间返回 noPath。
|
||
* 因此长距离会被拆成若干段:走到已加载范围边缘附近再重新规划下一段。
|
||
* 这个值只是单段的上限,实际段长由「已加载范围」动态决定。
|
||
*/
|
||
maxSegmentBlocks: 96,
|
||
/** 分段寻路时距已加载范围边缘留出的安全边距(方块),避免踩在未加载边界上 */
|
||
segmentMarginBlocks: 24,
|
||
/**
|
||
* A* 单次搜索的时间预算(毫秒)。
|
||
* 太小会在复杂地形里搜不完就放弃,表现为「目标不可达」。
|
||
*/
|
||
thinkTimeoutMs: 10000,
|
||
/** A* 每 tick 的时间片(毫秒),越大单帧越卡但搜得更快 */
|
||
tickTimeoutMs: 40,
|
||
/**
|
||
* A* 的代价搜索半径:节点代价超过「起点到目标的启发值 + 该值」就被剪枝。
|
||
* 负数表示不限制(复杂地形里最稳,但搜索更慢)。
|
||
*/
|
||
searchRadius: 256,
|
||
/**
|
||
* 寻路执行器:
|
||
* - `pathfinder`(默认):由 mineflayer-pathfinder 自己执行;
|
||
* - `manual`:**备选方案**(TODO 问题 1)。只用 A* 算路径,执行改为自己按键
|
||
* (朝路径点转 → 按住前进 → 遇坎跳),控制键时序与手动移动测试完全一致。
|
||
* 仅在实测确认 pathfinder 的执行层被服务端回弹时才需要切换。
|
||
*/
|
||
executor: 'pathfinder',
|
||
/** 手动执行器单段执行的超时(毫秒),防止卡死时无限按键 */
|
||
manualTimeoutMs: 180000
|
||
},
|
||
|
||
// ==================== 战斗辅助(新增) ====================
|
||
combat: {
|
||
/** 自动攻击索敌半径(格):只打进入该范围的敌对生物,不追击 */
|
||
scanRange: 4.5,
|
||
/** 是否攻击末影人(mineflayer 读不到「是否被激怒」,默认不打) */
|
||
attackEnderman: false,
|
||
/** 是否攻击蜘蛛(mineflayer 读不到环境亮度,默认不打) */
|
||
attackSpiders: false,
|
||
/** 是否攻击猪灵(仅在未穿金盔甲时才会攻击) */
|
||
attackPiglins: true,
|
||
/** 是否跳过高危生物(监守者 / 末影龙 / 凋灵),默认跳过 */
|
||
avoidDangerousMobs: true
|
||
},
|
||
|
||
// ==================== Grim 反作弊合规层(新增) ====================
|
||
grim: {
|
||
/** 总开关:关闭后不做任何限速/校验(仅用于本地测试服) */
|
||
enabled: true,
|
||
/** 每 tick 允许的最大旋转角度(度);mineflayer 默认 yawSpeed=3.0 rad/s ≈ 8.6°/tick */
|
||
maxRotationPerTickDeg: 8.6,
|
||
/** 攻击判定距离(格),生存模式实体交互距离为 3.0 */
|
||
attackReach: 3.0,
|
||
/** 攻击前是否要求视线可达(防 Reach / LineOfSight 检查) */
|
||
requireLineOfSight: true,
|
||
/** 是否遵守攻击冷却(1.9+ 武器攻速) */
|
||
respectAttackCooldown: true,
|
||
/** 进食期间是否停止移动(原版进食会取消疾跑并减速,防 NoSlow / MultiActions) */
|
||
stopMovementWhileEating: true,
|
||
/**
|
||
* 是否禁止疾跑跳跃。
|
||
* 疾跑跳本身是原版合法动作,Grim 的预测支持;默认放行。
|
||
* 若实测被标记,可开启此项退化为普通跳跃(代价是移动变慢)。
|
||
*/
|
||
blockSprintJump: false,
|
||
/** 是否禁止疾跑与潜行/使用物品同时进行(防 MultiActions) */
|
||
blockConflictingActions: true,
|
||
/** 是否在启动时校验物理参数未被篡改 */
|
||
checkPhysicsIntegrity: true,
|
||
/** 是否禁止在骑乘状态下攻击 / 进食(防 Vehicle 类检查) */
|
||
blockActionsWhileRiding: true,
|
||
/**
|
||
* 是否按世界数据修正出站位置包的 onGround 标志(默认开启)。
|
||
*
|
||
* mineflayer 收到服务端 `position` 包时会把 `entity.onGround` 置为 false,
|
||
* 在频繁下发位置包的服务器上会导致客户端持续上报「站在地上却在空中」,
|
||
* 被反作弊判定异常后每 tick 拉回一次,表现为机器人原地抖动、完全走不动。
|
||
* 开启后发包前会用脚下方块复核,只有确实不一致时才改写。
|
||
*/
|
||
reportOnGroundFromWorld: true,
|
||
/**
|
||
* 是否把物理引擎的 `isCollidedHorizontally` 如实写进出站包的
|
||
* `flags.hasHorizontalCollision`(默认开启)。
|
||
*
|
||
* mineflayer 在 `physics.js` 里把这个位标志写成 `undefined`,序列化后**恒为 0**
|
||
* —— 而原版客户端在水平碰到方块时会上报 1。机器人在崎岖地形上几乎一直在
|
||
* 水平碰撞,却始终上报「没碰撞」,正是反作弊 Prediction 最容易判错的形态:
|
||
* 服务端按「该碰撞」预测,客户端说「没碰撞」,于是被判异常并拉回。
|
||
* 实测(2026-10-01)机器人一直按着 forward+sprint,物理速度约 2 格/秒,
|
||
* 但净位移只有 0.4~0.5 格/秒,差额正是被服务端拉回的部分。
|
||
*
|
||
* 物理引擎已经算出了真实值(`bot.entity.isCollidedHorizontally`),只是没发出去。
|
||
*/
|
||
reportHorizontalCollision: true,
|
||
/**
|
||
* 【方案 A】是否把物理引擎的 onGround 与本地世界数据对齐(默认开启)。
|
||
*
|
||
* 服务端频繁下发 `position` 包时,mineflayer 会把 `entity.onGround` 置为 false,
|
||
* 物理引擎因此一直走「空中」分支(水平加速只有地面的 1/5 且每 tick 被清零),
|
||
* 表现就是「按着前进键、水平速度恒为 0」。开启后每个物理 tick 都会在
|
||
* 「物理引擎算完、位置包发出之前」用脚下方块复核,必要时强制同步 onGround,
|
||
* 让移动回到地面分支。与 `grim.enabled` 无关(实测关闭合规层后回弹依旧)。
|
||
*/
|
||
syncGroundToPhysics: true,
|
||
/**
|
||
* 【方案 B】是否忽略「例行同步」位置包带来的 onGround=false(默认开启)。
|
||
*
|
||
* 服务端下发的目标位置与本地位置几乎一致时,说明它只是例行同步坐标,
|
||
* 并不代表我们真的在空中;此时只采纳坐标、保留我们自己的 onGround,
|
||
* 避免物理引擎的地面状态被服务端反复打断。
|
||
* 位置差超过 `setbackSyncEpsilon` 时按真正的传送处理,不做任何干预。
|
||
*/
|
||
trustSetbackOnGround: true,
|
||
/**
|
||
* 方案 B 的判定阈值(格):服务端目标位置与本地位置之差不超过它时视为「例行同步」。
|
||
* 实测可在控制台观察 `packetStats.lastSetbackDelta.distance` 后再调整。
|
||
*/
|
||
setbackSyncEpsilon: 0.5,
|
||
/**
|
||
* 是否每 tick 补发 `player_input` 包(默认开启,**线上服务器必需**)。
|
||
*
|
||
* 1.21.2+ 服务端的移动预测依赖它,原版客户端每 tick 都发,而 mineflayer 只在
|
||
* 骑乘 / 跳下 / 潜行时发 —— 地面移动的 forward / jump / sprint 完全不发。
|
||
* 带反作弊的服务器会判定「无输入却位移」并每 tick 把玩家拉回,
|
||
* 表现就是机器人原地抖动、怎么按方向键都不动。
|
||
*/
|
||
sendPlayerInput: true,
|
||
/**
|
||
* 是否每 tick 补发 `tick_end` 包(默认开启)。
|
||
*
|
||
* 原版 1.21.2+ 客户端在每 tick 末尾发送,反作弊用它判定 tick 边界;
|
||
* mineflayer 完全不发,会导致 PacketOrder / Timer 类判定异常。
|
||
*/
|
||
sendTickEnd: true
|
||
},
|
||
|
||
// ==================== 大地图(新增) ====================
|
||
bigMap: {
|
||
/** 是否启用大地图(爬取服务器地图站点的瓦片图片与领土标记) */
|
||
enabled: true,
|
||
/** 地图站点上的世界目录名;留空则回退环境变量 DYNMAP_WORLD */
|
||
world: '',
|
||
/** 启动时预爬取的层级(0 = 全世界一张大图,本站为 10×10=100 张瓦片) */
|
||
zoom: 0,
|
||
/** 允许浏览的最大层级(本站 squaremap 的 zoom.max 为 3) */
|
||
maxZoom: 3,
|
||
/**
|
||
* 爬取半径(方块),**以机器人为中心**(见 followBot)。
|
||
*
|
||
* 层级 0 每张瓦片覆盖 4096 格,所以:
|
||
* 1024 格半径 = 1 张瓦片,4096 格 = 4 张,8192 格 = 9 张,20480 格 = 100 张。
|
||
* 只关心机器人周边时把半径调小,爬取和传输开销会差一个数量级。
|
||
*/
|
||
rangeBlocks: 4096,
|
||
/**
|
||
* 是否以机器人当前位置为中心爬取与显示大地图(默认开启)。
|
||
*
|
||
* 关闭时以世界原点 (0,0) 为中心 —— 只有在想看整张世界地图时才需要。
|
||
*/
|
||
followBot: true,
|
||
/** 启动时是否自动预爬取 */
|
||
crawlOnStart: true,
|
||
/** 瓦片下载并发数(对源站友好,默认 4) */
|
||
concurrency: 4,
|
||
/**
|
||
* 是否后台预热高层级瓦片(默认开启)。
|
||
*
|
||
* 只有「启动预爬取层级」(默认 0)会批量爬,层级 1~3 原本要靠用户缩放时按需拉取,
|
||
* 首次浏览要等好几秒。开启后机器人移动到新区域时会顺带把周边高层级瓦片拉下来。
|
||
*/
|
||
prewarm: true,
|
||
/** 预热覆盖半径(方块),以机器人为中心 */
|
||
prewarmRangeBlocks: 1024,
|
||
/** 从「启动预爬取层级」往上预热几层(受「最大可浏览层级」限制) */
|
||
prewarmLevels: 3,
|
||
/** 机器人移动超过该距离(方块)才重新预热,避免频繁请求源站 */
|
||
prewarmMinMoveBlocks: 512,
|
||
/** 距上次预热超过该时间(毫秒)也重新预热一次(默认 10 分钟) */
|
||
prewarmIntervalMs: 600000
|
||
},
|
||
|
||
// ==================== 多机器人协调(新增) ====================
|
||
coordination: {
|
||
/**
|
||
* 目标区块租约时长(秒)。
|
||
*
|
||
* 多个机器人同时扩地时,都用同一份领土模型挑候选区块,很容易撞在同一块上。
|
||
* 租约表让「谁要去占哪一块」互相可见:抢到的机器人独占该区块,其余机器人跳过;
|
||
* 持有者持续续租,机器人掉线 / 卡死后租约到期自动释放,不会永久锁死区块。
|
||
*/
|
||
targetLeaseTtlSeconds: 180
|
||
},
|
||
|
||
// ==================== 通知提醒(新增) ====================
|
||
notify: {
|
||
/**
|
||
* 是否启用 Webhook 通知(默认关闭)。
|
||
*
|
||
* 需要人工介入时(等待登录验证码、原点自救失败)把消息推到飞书 / Lark 自定义机器人,
|
||
* 不用一直盯着控制台 —— 验证码只有 120 秒时限,错过就得等下次重登。
|
||
*/
|
||
enabled: false,
|
||
/** Webhook 地址(留空时回退环境变量 NOTIFY_WEBHOOK_URL) */
|
||
webhookUrl: '',
|
||
/** 服务器要求输入验证码时提醒 */
|
||
onVerification: true,
|
||
/** 原点自救失败时提醒(说明可能其实是登录服,需要人工发验证码) */
|
||
onOriginRescueFailed: true
|
||
},
|
||
|
||
// ==================== 服务器交互(新增) ====================
|
||
server: {
|
||
/** 启用开垦模式的指令(不带斜杠),留空表示服务器自动开启 */
|
||
claimCommand: 'u claimmode',
|
||
/** 登录指令模板,`{password}` 会被替换为 .env 中的 MC_PASSWORD;留空则不发送 */
|
||
loginCommand: 'login {password}',
|
||
/** 注册指令模板,`{password}` 会被替换;留空则不发送 */
|
||
registerCommand: '',
|
||
/**
|
||
* 账号绑定 / 二次验证指令模板(`{password}` 会被替换),留空则不发送。
|
||
*
|
||
* 用于「简幻通」这类外站账号体系:新账号必须先用 `/auth <UID> <验证码>` 或扫码绑定。
|
||
* 扫码类验证无法自动完成,此时保持留空即可——程序会在日志里明确提示需要人工验证一次;
|
||
* 若该服务器提供固定验证码,把 `auth {password}` 之类填进来就能自动过。
|
||
*/
|
||
authCommand: '',
|
||
/**
|
||
* 允许在「登录验证通过之前」发送的指令前缀。
|
||
*
|
||
* 存在先有鸡还是先有蛋的问题:有些服务器的登录流程本身就是一条指令
|
||
* (`/login`、`/register`,简幻通体系还要先 `/auth`),
|
||
* 若把登录门禁一刀切,这些指令就永远发不出去,账号会卡在未验证状态被反复踢出。
|
||
* 因此这里列出的前缀可以绕过门禁,其余指令仍要求已完成验证。
|
||
*/
|
||
preLoginCommands: ['login', 'l', 'register', 'reg', 'auth'],
|
||
/**
|
||
* 是否允许在登录验证通过之前发送公屏聊天。
|
||
*
|
||
* 默认关闭。服务器**明确要求验证**时会自动放行(验证码常常是直接当聊天内容发出去的),
|
||
* 这个开关用于兜底那些没有任何提示、却必须先说话才能完成登录的服务器。
|
||
*/
|
||
allowChatBeforeLogin: false,
|
||
/**
|
||
* 登录结果确认超时(毫秒)。
|
||
* 部分离线服登录成功后不回明确提示,超时后按「已登录」放行,
|
||
* 否则机器人会永远卡在未登录状态而无法接受控制台操作;设为 0 表示禁用兜底。
|
||
*/
|
||
loginConfirmTimeoutMs: 10000,
|
||
/** 聊天指令最小间隔(毫秒),防刷屏 */
|
||
commandCooldownMs: 1200,
|
||
/** 断线后是否自动重连 */
|
||
autoReconnect: true,
|
||
/** 重连间隔(毫秒) */
|
||
reconnectDelayMs: 10000,
|
||
/**
|
||
* 重连间隔上限(毫秒)。
|
||
*
|
||
* 连续重连失败(例如账号未完成服务器验证被反复踢出)时按指数退避拉长间隔,
|
||
* 避免每 10 秒一次地硬撞服务器;成功进入世界后计数清零、间隔回到 reconnectDelayMs。
|
||
*/
|
||
reconnectMaxDelayMs: 120000
|
||
}
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 深拷贝配置。
|
||
* @param {object} config
|
||
* @returns {object}
|
||
*/
|
||
function copyConfig(config) {
|
||
return JSON.parse(JSON.stringify(config));
|
||
}
|
||
|
||
/**
|
||
* 把外部传入(配置文件 / Web 控制台)的部分配置合并到默认配置上,并做范围钳制。
|
||
* 只接受已知字段,未知字段一律丢弃,避免脏配置污染运行状态。
|
||
*
|
||
* @param {object} partial 用户提供的部分配置
|
||
* @param {object} [base] 基线配置(默认取 defaultConfig())
|
||
* @returns {object} 规范化后的完整配置
|
||
*/
|
||
function normalizeConfig(partial, base) {
|
||
const defaults = base || defaultConfig();
|
||
const merged = copyConfig(defaults);
|
||
if (!partial || typeof partial !== 'object') return validateConfig(merged);
|
||
|
||
for (const key of Object.keys(merged)) {
|
||
const value = partial[key];
|
||
if (value === undefined) continue;
|
||
if (value && typeof value === 'object' && !Array.isArray(value) && typeof merged[key] === 'object') {
|
||
for (const subKey of Object.keys(merged[key])) {
|
||
if (value[subKey] !== undefined) merged[key][subKey] = value[subKey];
|
||
}
|
||
} else {
|
||
merged[key] = value;
|
||
}
|
||
}
|
||
return validateConfig(merged);
|
||
}
|
||
|
||
/**
|
||
* 钳制配置中的越界值,防止异常配置导致「永不回城」或「刚出门就回城」。
|
||
*
|
||
* 会先把缺失的字段(含嵌套分组)补齐为默认值,因此可以直接传入部分配置。
|
||
*
|
||
* @param {object} config
|
||
* @returns {object} 同一个对象(原地修正)
|
||
*/
|
||
function validateConfig(config) {
|
||
const base = defaultConfig();
|
||
for (const key of Object.keys(base)) {
|
||
const defaultValue = base[key];
|
||
if (defaultValue !== null && typeof defaultValue === 'object') {
|
||
config[key] = Object.assign({}, defaultValue, config[key] || {});
|
||
} else if (config[key] === undefined) {
|
||
config[key] = defaultValue;
|
||
}
|
||
}
|
||
|
||
config.strategy = clampEnum(config.strategy, [1, 2, 3, 4], STRATEGY.SQUARE);
|
||
config.targetCount = clampInt(config.targetCount, -1, 1e9, -1);
|
||
config.maxRings = clampInt(config.maxRings, 0, 1000, 0);
|
||
config.regionRadius = clampInt(config.regionRadius, 1, 512, 5);
|
||
config.regionMinX = clampInt(config.regionMinX, -1e6, 1e6, 0);
|
||
config.regionMaxX = clampInt(config.regionMaxX, -1e6, 1e6, -1);
|
||
config.regionMinZ = clampInt(config.regionMinZ, -1e6, 1e6, 0);
|
||
config.regionMaxZ = clampInt(config.regionMaxZ, -1e6, 1e6, -1);
|
||
config.waitTimeoutSeconds = clampInt(config.waitTimeoutSeconds, 5, 3600, 60);
|
||
config.retries = clampInt(config.retries, 1, 20, 2);
|
||
config.returnThresholdSeconds = clampInt(config.returnThresholdSeconds, 0, 3600, 60);
|
||
config.returnOnTimeout = config.returnOnTimeout === true;
|
||
if (typeof config.returnCommand !== 'string') config.returnCommand = 'u return';
|
||
// 去掉玩家可能顺手写上的前导斜杠,发送时会统一补
|
||
config.returnCommand = config.returnCommand.trim().replace(/^\/+/, '');
|
||
config.returnGraceSeconds = clampInt(config.returnGraceSeconds, 0, 3600, 300);
|
||
config.returnWaitSeconds = clampInt(config.returnWaitSeconds, 1, 120, 12);
|
||
config.sideStepMaxRounds = clampInt(config.sideStepMaxRounds, 0, 10, 3);
|
||
config.claimStartHour = clampInt(config.claimStartHour, 0, 23, 11);
|
||
config.claimEndHour = clampInt(config.claimEndHour, 1, 24, 22);
|
||
config.eatThreshold = clampInt(config.eatThreshold, 0, 20, 14);
|
||
config.durabilityThreshold = clampInt(config.durabilityThreshold, 1, 99, 15);
|
||
config.autoResumeAfterReconnect = config.autoResumeAfterReconnect !== false;
|
||
config.autoBoatMinWidth = clampInt(config.autoBoatMinWidth, 1, 64, 4);
|
||
config.autoBoatMinLength = clampInt(config.autoBoatMinLength, 1, 128, 6);
|
||
config.refreshIntervalSeconds = clampInt(config.refreshIntervalSeconds, 10, 3600, 30);
|
||
config.apiRefreshIntervalSeconds = clampInt(config.apiRefreshIntervalSeconds, 60, 3600, 180);
|
||
config.altitudeWeight = clampNumber(config.altitudeWeight, 0, 10, 0.1);
|
||
|
||
config.pathing.maxDropDown = clampInt(config.pathing.maxDropDown, 0, 64, 3);
|
||
config.pathing.liquidCost = clampInt(config.pathing.liquidCost, 1, 1000, 40);
|
||
config.pathing.maxSegmentBlocks = clampInt(config.pathing.maxSegmentBlocks, 32, 4096, 512);
|
||
config.pathing.segmentMarginBlocks = clampInt(config.pathing.segmentMarginBlocks, 0, 256, 24);
|
||
config.pathing.thinkTimeoutMs = clampInt(config.pathing.thinkTimeoutMs, 100, 300000, 10000);
|
||
config.pathing.tickTimeoutMs = clampInt(config.pathing.tickTimeoutMs, 5, 5000, 40);
|
||
// searchRadius 允许为负(不限制),因此单独钳制下限
|
||
config.pathing.searchRadius = clampInt(config.pathing.searchRadius, -1, 100000, 256);
|
||
if (config.pathing.executor !== 'manual') config.pathing.executor = 'pathfinder';
|
||
config.pathing.manualTimeoutMs = clampInt(config.pathing.manualTimeoutMs, 10000, 3600000, 180000);
|
||
|
||
config.combat.scanRange = clampNumber(config.combat.scanRange, 1, 32, 4.5);
|
||
config.coordination.targetLeaseTtlSeconds = clampInt(config.coordination.targetLeaseTtlSeconds, 30, 3600, 180);
|
||
|
||
config.grim.maxRotationPerTickDeg = clampNumber(config.grim.maxRotationPerTickDeg, 1, 180, 8.6);
|
||
config.grim.attackReach = clampNumber(config.grim.attackReach, 1, 6, 3.0);
|
||
config.grim.setbackSyncEpsilon = clampNumber(config.grim.setbackSyncEpsilon, 0, 5, 0.5);
|
||
|
||
config.bigMap.zoom = clampInt(config.bigMap.zoom, 0, 8, 0);
|
||
config.bigMap.maxZoom = clampInt(config.bigMap.maxZoom, 0, 8, 3);
|
||
// 预爬取层级不可能超过可浏览的最大层级,否则会爬到源站不存在的瓦片
|
||
if (config.bigMap.zoom > config.bigMap.maxZoom) config.bigMap.zoom = config.bigMap.maxZoom;
|
||
config.bigMap.rangeBlocks = clampInt(config.bigMap.rangeBlocks, 256, 200000, 4096);
|
||
config.bigMap.concurrency = clampInt(config.bigMap.concurrency, 1, 16, 4);
|
||
config.bigMap.prewarmRangeBlocks = clampInt(config.bigMap.prewarmRangeBlocks, 256, 200000, 1024);
|
||
config.bigMap.prewarmLevels = clampInt(config.bigMap.prewarmLevels, 0, 8, 3);
|
||
config.bigMap.prewarmMinMoveBlocks = clampInt(config.bigMap.prewarmMinMoveBlocks, 0, 100000, 512);
|
||
config.bigMap.prewarmIntervalMs = clampInt(config.bigMap.prewarmIntervalMs, 60000, 86400000, 600000);
|
||
if (typeof config.bigMap.world !== 'string') config.bigMap.world = '';
|
||
if (typeof config.bigMap.followBot !== 'boolean') config.bigMap.followBot = true;
|
||
|
||
config.server.commandCooldownMs = clampInt(config.server.commandCooldownMs, 0, 60000, 1200);
|
||
config.server.reconnectDelayMs = clampInt(config.server.reconnectDelayMs, 1000, 600000, 10000);
|
||
config.server.reconnectMaxDelayMs = clampInt(config.server.reconnectMaxDelayMs, config.server.reconnectDelayMs, 3600000, 120000);
|
||
config.server.loginConfirmTimeoutMs = clampInt(config.server.loginConfirmTimeoutMs, 0, 300000, 10000);
|
||
if (typeof config.server.authCommand !== 'string') config.server.authCommand = '';
|
||
if (typeof config.server.allowChatBeforeLogin !== 'boolean') config.server.allowChatBeforeLogin = false;
|
||
if (!Array.isArray(config.server.preLoginCommands)) {
|
||
config.server.preLoginCommands = defaultConfig().server.preLoginCommands;
|
||
} else {
|
||
config.server.preLoginCommands = config.server.preLoginCommands
|
||
.filter((item) => typeof item === 'string' && item.trim())
|
||
.map((item) => item.trim().toLowerCase().replace(/^\//, ''));
|
||
}
|
||
|
||
if (config.waterMode !== WATER_MODE.SWIM && config.waterMode !== WATER_MODE.BOAT) {
|
||
config.waterMode = WATER_MODE.AVOID;
|
||
}
|
||
if (typeof config.stateName !== 'string') config.stateName = '';
|
||
if (typeof config.apiKey !== 'string') config.apiKey = '';
|
||
if (typeof config.server.claimCommand !== 'string') config.server.claimCommand = '';
|
||
if (typeof config.server.loginCommand !== 'string') config.server.loginCommand = '';
|
||
if (typeof config.server.registerCommand !== 'string') config.server.registerCommand = '';
|
||
if (typeof config.server.authCommand !== 'string') config.server.authCommand = '';
|
||
if (typeof config.notify.webhookUrl !== 'string') config.notify.webhookUrl = '';
|
||
return config;
|
||
}
|
||
|
||
function clampInt(value, min, max, fallback) {
|
||
const n = Number(value);
|
||
if (!Number.isFinite(n)) return fallback;
|
||
return Math.min(max, Math.max(min, Math.trunc(n)));
|
||
}
|
||
|
||
/**
|
||
* 枚举字段校验:不在允许集合内时回退到默认值。
|
||
*
|
||
* 之所以不用「钳制到最近边界」,是因为策略这类枚举没有大小含义,
|
||
* 把非法的 99 静默变成「策略 4 绕国一圈」比回退到默认策略更危险。
|
||
*
|
||
* @param {*} value
|
||
* @param {Array<*>} allowed
|
||
* @param {*} fallback
|
||
* @returns {*}
|
||
*/
|
||
function clampEnum(value, allowed, fallback) {
|
||
return allowed.includes(value) ? value : fallback;
|
||
}
|
||
|
||
function clampNumber(value, min, max, fallback) {
|
||
const n = Number(value);
|
||
if (!Number.isFinite(n)) return fallback;
|
||
return Math.min(max, Math.max(min, n));
|
||
}
|
||
|
||
module.exports = {
|
||
STRATEGY,
|
||
WATER_MODE,
|
||
defaultConfig,
|
||
copyConfig,
|
||
normalizeConfig,
|
||
validateConfig
|
||
};
|