'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 <验证码>` 或扫码绑定。 * 扫码类验证无法自动完成,此时保持留空即可——程序会在日志里明确提示需要人工验证一次; * 若该服务器提供固定验证码,把 `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 };