文件
BotStore/src/config/expansionConfig.js
T
lyizai 2ae2833568 feat: 补上出站包一直漏报的 hasHorizontalCollision(1.10.0)
针对「机器人一直在走、净位移却只有物理速度的 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。
2026-10-01 17:04:25 +08:00

621 行
30 KiB
JavaScript
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
'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
};