'use strict'; /** * Grim 反作弊合规层。 * * 目标:让 mineflayer 机器人在装有 GrimAC 的服务器上「行为上像一个正常原版客户端」。 * * 核心手段(每一项都对应一类 Grim 检查): * * 1. **旋转不超前**(防 Prediction / Aim 类检查) * mineflayer 的 `bot.look()` 会立刻把 `bot.entity.yaw` 改到目标值, * 而朝向数据包是按 `physics.yawSpeed` 逐 tick 慢慢补发的。 * 这会造成「本地已经朝新方向走、服务端还以为朝旧方向」的预测分歧 —— Grim 的 * Prediction 检查正是逐 tick 复算玩家位置并与上报位置比对,分歧必然标记。 * 因此本层接管 `bot.look`:只把目标朝向记下来,在每个 physicsTick 里把 * `bot.entity.yaw/pitch` 按 ≤ `grim.maxRotationPerTickDeg` 的步长推进, * 保证「本地朝向」永远等于「服务端已收到的朝向」,最多差 1 tick。 * * 2. **攻击前三重校验**(防 Reach / LineOfSight / Post / MultiActions) * - 旋转已同步(服务端认为你正看着目标) * - 眼睛到目标碰撞箱的距离 ≤ `grim.attackReach`(生存模式 3.0) * - 视线可达(中间没有实心方块遮挡) * - 攻击冷却已好(1.9+ 武器攻速) * - 不处于骑乘 / 进食 / 使用物品状态 * * 3. **动作互斥**(防 MultiActions) * 疾跑 + 潜行、疾跑 + 使用物品、疾跑 + 跳跃(可配)等原版客户端不会出现的组合一律拦下。 * * 4. **物理参数只读校验**(防「改物理常数绕过预测」这类自杀式操作) * `audit()` 会核对 `bot.physics` 的关键常量是否仍为原版值。 */ /** 物理仿真步长(秒),与 mineflayer 的 PHYSICS_INTERVAL_MS=50 对应 */ const PHYSICS_TIMESTEP = 0.05; /** 原版物理常量基线,用于 audit() 校验未被篡改 */ const VANILLA_PHYSICS_BASELINE = { gravity: 0.08, playerSpeed: 0.1, sprintSpeed: 0.3, sneakSpeed: 0.3, stepHeight: 0.6, airborneInertia: 0.91, waterInertia: 0.8 }; /** 攻击速度(次/秒)→ 冷却毫秒;按物品名匹配,未命中回退 4.0(空手) */ const ATTACK_SPEED_RULES = [ [/^netherite_sword$|^diamond_sword$|^iron_sword$|^golden_sword$|^stone_sword$|^wooden_sword$/, 1.6], [/^netherite_axe$|^diamond_axe$/, 1.0], [/^iron_axe$/, 0.9], [/^golden_axe$|^stone_axe$|^wooden_axe$/, 0.8], [/^trident$/, 1.1], [/^.*_pickaxe$/, 1.2], [/^.*_shovel$/, 1.0], [/^.*_hoe$/, 1.0] ]; /** 命中这些方块名视为「不遮挡视线」 */ const NON_OCCLUDING = new Set([ 'air', 'cave_air', 'void_air', 'water', 'lava', 'seagrass', 'tall_seagrass', 'kelp', 'kelp_plant', 'bubble_column', 'torch', 'wall_torch', 'soul_torch', 'soul_wall_torch', 'redstone_torch', 'redstone_wall_torch', 'lever', 'tripwire', 'tripwire_hook', 'vine', 'short_grass', 'grass', 'fern', 'large_fern', 'dead_bush', 'sugar_cane', 'lily_pad', 'rail', 'powered_rail', 'detector_rail', 'activator_rail' ]); class GrimCompat { /** * @param {import('mineflayer').Bot} bot * @param {object} config 完整配置对象(读取 config.grim / config.allowSprint 等) * @param {import('../util/logger').Logger} [logger] */ constructor(bot, config, logger) { this.bot = bot; this.config = config; this.logger = logger || null; /** 目标朝向(弧度) */ this.targetYaw = bot.entity ? bot.entity.yaw : 0; this.targetPitch = bot.entity ? bot.entity.pitch : 0; /** 上一 tick 的朝向,用于限速 */ this.lastAppliedYaw = this.targetYaw; this.lastAppliedPitch = this.targetPitch; /** 等待旋转完成的 resolver 列表 */ this._rotationWaiters = []; /** 最近一次攻击时间戳(毫秒) */ this._lastAttackAt = 0; /** 拦截到的不合规动作计数,供 Web 控制台展示 */ this.blockedActions = 0; this._installed = false; this._originalLook = null; this._originalLookAt = null; this._originalSetControlState = null; this._onPhysicsTick = this._onPhysicsTick.bind(this); } /** 单 tick 允许的最大旋转弧度(取配置值与 mineflayer 物理限速的较小者) */ get maxStepRad() { const configured = degToRad(this.config.grim.maxRotationPerTickDeg); const physicsLimit = PHYSICS_TIMESTEP * (this.bot.physics ? this.bot.physics.yawSpeed : 3.0); return Math.min(configured, physicsLimit); } /** * 安装合规层:接管 look / setControlState,并挂上 physicsTick 推进器。 * 必须在 bot 完成 spawn 之后调用(此时 bot.look / bot.physics 才存在)。 */ install() { if (this._installed) return; const bot = this.bot; if (!this.config.grim.enabled) { this._installed = true; return; } this.targetYaw = bot.entity.yaw; this.targetPitch = bot.entity.pitch; this.lastAppliedYaw = bot.entity.yaw; this.lastAppliedPitch = bot.entity.pitch; this._originalLook = bot.look.bind(bot); this._originalLookAt = bot.lookAt.bind(bot); this._originalSetControlState = bot.setControlState.bind(bot); bot.look = (yaw, pitch) => this.setLook(yaw, pitch === undefined ? this.targetPitch : pitch); bot.lookAt = (point) => this.lookAtPoint(point); bot.setControlState = (control, state) => this.setControlState(control, state); bot.on('physicsTick', this._onPhysicsTick); bot.on('end', () => this._releaseRotationWaiters()); this._installed = true; this._log('Grim 合规层已启用(旋转限速 ' + this.config.grim.maxRotationPerTickDeg + '°/tick,' + '攻击距离 ' + this.config.grim.attackReach + ' 格)'); } /** 卸载合规层(恢复原始方法,主要供测试使用) */ uninstall() { if (!this._installed) return; const bot = this.bot; bot.removeListener('physicsTick', this._onPhysicsTick); if (this._originalLook) bot.look = this._originalLook; if (this._originalLookAt) bot.lookAt = this._originalLookAt; if (this._originalSetControlState) bot.setControlState = this._originalSetControlState; this._installed = false; } // ==================== 旋转 ==================== /** * 设置目标朝向(不立即生效,由 physicsTick 逐步推进)。 * @param {number} yaw 目标 yaw(弧度) * @param {number} [pitch] 目标 pitch(弧度) * @returns {Promise} 旋转真正同步到服务端后 resolve */ setLook(yaw, pitch) { if (typeof yaw !== 'number' || Number.isNaN(yaw)) return Promise.resolve(); this.targetYaw = yaw; this.targetPitch = typeof pitch === 'number' && !Number.isNaN(pitch) ? clamp(pitch, -Math.PI / 2, Math.PI / 2) : this.targetPitch; if (this.isRotationSettled()) return Promise.resolve(); return new Promise((resolve) => { this._rotationWaiters.push(resolve); }); } /** * 朝指定世界坐标看过去。 * @param {{x: number, y: number, z: number}} point * @returns {Promise} */ lookAtPoint(point) { const eye = this.bot.entity.position.offset(0, this.bot.entity.eyeHeight || 1.62, 0); const dx = point.x - eye.x; const dy = point.y - eye.y; const dz = point.z - eye.z; const yaw = Math.atan2(-dx, -dz); const groundDistance = Math.sqrt(dx * dx + dz * dz); const pitch = Math.atan2(dy, groundDistance); return this.setLook(yaw, pitch); } /** * 看向实体(取碰撞箱中心)。 * @param {object} entity * @returns {Promise} */ lookAtEntity(entity) { const height = entity.height || 1.8; return this.lookAtPoint({ x: entity.position.x, y: entity.position.y + height * 0.85, z: entity.position.z }); } /** @returns {boolean} 当前朝向是否已经与目标一致(即服务端已收到完整旋转) */ isRotationSettled() { return Math.abs(shortestAngleDelta(this.bot.entity.yaw, this.targetYaw)) < 1e-4 && Math.abs(this.bot.entity.pitch - this.targetPitch) < 1e-4; } /** * 等待旋转同步完成。 * @param {number} [timeoutMs] 超时(默认 3000ms) * @returns {Promise} 是否在超时前完成 */ awaitRotationSettled(timeoutMs) { if (this.isRotationSettled()) return Promise.resolve(true); const limit = timeoutMs === undefined ? 3000 : timeoutMs; return new Promise((resolve) => { let done = false; const timer = setTimeout(() => { if (done) return; done = true; resolve(false); }, limit); this._rotationWaiters.push(() => { if (done) return; done = true; clearTimeout(timer); resolve(true); }); }); } /** physicsTick:把 entity.yaw/pitch 朝目标推进一小步(mineflayer 会在同一 tick 把该朝向发包) */ _onPhysicsTick() { const bot = this.bot; if (!bot.entity) return; const step = this.maxStepRad; const yawDelta = shortestAngleDelta(bot.entity.yaw, this.targetYaw); if (Math.abs(yawDelta) > 1e-6) { bot.entity.yaw += clamp(yawDelta, -step, step); } else { bot.entity.yaw = this.targetYaw; } const pitchDelta = this.targetPitch - bot.entity.pitch; if (Math.abs(pitchDelta) > 1e-6) { bot.entity.pitch += clamp(pitchDelta, -step, step); } else { bot.entity.pitch = this.targetPitch; } if (this.isRotationSettled()) this._releaseRotationWaiters(); } _releaseRotationWaiters() { const waiters = this._rotationWaiters; this._rotationWaiters = []; for (const resolve of waiters) { try { resolve(); } catch { // 忽略订阅者异常 } } } // ==================== 控制键 ==================== /** * 带互斥规则的控制键设置(替换 bot.setControlState)。 * * 拦下的组合(原版客户端不可能出现,Grim 的 MultiActions 会标记): * - 疾跑 + 潜行 * - 疾跑 + 使用物品(进食 / 拉弓 / 举盾) * - 疾跑 + 跳跃(仅在 grim.blockSprintJump 开启时拦,默认放行,因为原版合法) * * @param {string} control 'forward'|'back'|'left'|'right'|'jump'|'sprint'|'sneak' * @param {boolean} state */ setControlState(control, state) { const bot = this.bot; if (!this.config.grim.enabled) { this._originalSetControlState(control, state); return; } if (state && this.config.grim.blockConflictingActions) { const conflicting = this._findConflict(control); if (conflicting) { this.blockedActions++; this._log(`已拦下不合规动作组合:${control} + ${conflicting}`); return; } } if (state && control === 'sprint' && this.config.grim.blockSprintJump && bot.getControlState('jump')) { this.blockedActions++; return; } this._originalSetControlState(control, state); } /** @returns {string|null} 与指定控制键冲突且当前已按下的控制键 */ _findConflict(control) { const bot = this.bot; const sprinting = control === 'sprint' || bot.getControlState('sprint'); if (control === 'sneak' && sprinting) return 'sprint'; if (control === 'sprint' && bot.getControlState('sneak')) return 'sneak'; if (this.config.grim.stopMovementWhileEating && this.isUsingItem()) { if (control === 'sprint' || control === 'forward' || control === 'jump') return 'use_item'; } return null; } /** 松开所有移动控制键 */ releaseAllControls() { for (const control of ['forward', 'back', 'left', 'right', 'jump', 'sprint', 'sneak']) { try { this._originalSetControlState ? this._originalSetControlState(control, false) : this.bot.setControlState(control, false); } catch { // 忽略 } } } // ==================== 攻击 ==================== /** @returns {boolean} 是否正在使用物品(进食 / 拉弓 / 举盾) */ isUsingItem() { return this.bot.usingHeldItem === true; } /** * 当前手持物品的两次攻击之间的冷却时间(毫秒)。 * @returns {number} */ attackCooldownMs() { const item = this.bot.heldItem; const name = item && item.name ? item.name : ''; for (const [pattern, speed] of ATTACK_SPEED_RULES) { if (pattern.test(name)) return Math.round(1000 / speed); } return Math.round(1000 / 4.0); // 空手 / 其它物品:4 次每秒 } /** @returns {number} 距离下次可攻击还剩多少毫秒(0 表示已冷却好) */ attackCooldownRemainingMs() { const remaining = this.attackCooldownMs() - (Date.now() - this._lastAttackAt); return remaining > 0 ? remaining : 0; } /** * 检查是否可以攻击目标(不实际攻击)。 * @param {object} entity * @returns {{ok: boolean, reason?: string}} */ canAttack(entity) { const bot = this.bot; if (!entity || !entity.position || !entity.isValid) { return { ok: false, reason: 'invalid_entity' }; } if (this.config.grim.blockActionsWhileRiding && bot.vehicle) { return { ok: false, reason: 'riding' }; } if (this.isUsingItem()) { return { ok: false, reason: 'using_item' }; } const distance = eyeDistanceToEntity(bot, entity); if (distance > this.config.grim.attackReach) { return { ok: false, reason: 'out_of_reach' }; } if (this.config.grim.respectAttackCooldown && this.attackCooldownRemainingMs() > 0) { return { ok: false, reason: 'cooldown' }; } if (this.config.grim.requireLineOfSight && !this.hasLineOfSight(entity)) { return { ok: false, reason: 'no_line_of_sight' }; } return { ok: true }; } /** * 视线可达判定:从眼睛朝目标碰撞箱中心射线,中途不得被实心方块遮挡。 * @param {object} entity * @returns {boolean} */ hasLineOfSight(entity) { const bot = this.bot; if (!bot.world || typeof bot.world.raycast !== 'function') return true; const eye = bot.entity.position.offset(0, bot.entity.eyeHeight || 1.62, 0); const target = { x: entity.position.x, y: entity.position.y + (entity.height || 1.8) * 0.5, z: entity.position.z }; const dx = target.x - eye.x; const dy = target.y - eye.y; const dz = target.z - eye.z; const length = Math.sqrt(dx * dx + dy * dy + dz * dz); if (length < 1e-6) return true; const direction = { x: dx / length, y: dy / length, z: dz / length }; try { const hit = bot.world.raycast(eye, direction, length, (block) => { return block && block.boundingBox !== 'empty' && !NON_OCCLUDING.has(block.name); }); return !hit; } catch { return true; } } /** * 执行一次合规攻击:先同步朝向,再校验距离 / 视线 / 冷却,最后出手。 * * @param {object} entity 目标实体 * @param {{skipRotation?: boolean}} [options] * @returns {Promise} 是否成功出手 */ async attackEntity(entity, options) { const opts = options || {}; if (!this.config.grim.enabled) { bot_attack(this.bot, entity); this._lastAttackAt = Date.now(); return true; } if (!opts.skipRotation) { await this.lookAtEntity(entity); const settled = await this.awaitRotationSettled(600); if (!settled) return false; } const check = this.canAttack(entity); if (!check.ok) return false; bot_attack(this.bot, entity); this._lastAttackAt = Date.now(); return true; } // ==================== 使用物品 ==================== /** * 合规地使用一次手持物品(进食 / 放船等)。 * * 原版客户端在使用物品时会自动取消疾跑并减速,这里显式松开移动键, * 避免 Grim 的 NoSlow / MultiActions 检查。 * * @param {{holdMs?: number, stopMovement?: boolean}} [options] * @returns {Promise} */ async useItem(options) { const opts = options || {}; const bot = this.bot; if (this.config.grim.blockActionsWhileRiding && bot.vehicle) return false; if (this.config.grim.stopMovementWhileEating && opts.stopMovement !== false) { for (const control of ['forward', 'back', 'left', 'right', 'sprint', 'jump']) { this.setControlState(control, false); } // 等待一个 tick,让服务端看到「已停止移动」后再开始使用物品 await bot.waitForTicks(1).catch(() => {}); } bot.activateItem(); const holdMs = opts.holdMs === undefined ? 100 : opts.holdMs; await sleep(holdMs); return true; } /** 结束使用物品(进食完成 / 放船完成后调用) */ stopUsingItem() { try { this.bot.deactivateItem(); } catch { // 忽略 } } // ==================== 自检 ==================== /** * 物理与游戏状态自检:确认没有被改成非原版状态。 * @returns {{ok: boolean, issues: string[]}} */ audit() { const bot = this.bot; const issues = []; if (!bot.physics) { issues.push('bot.physics 不存在,物理引擎未启用'); } else if (this.config.grim.checkPhysicsIntegrity) { for (const [key, expected] of Object.entries(VANILLA_PHYSICS_BASELINE)) { const actual = bot.physics[key]; if (typeof actual === 'number' && Math.abs(actual - expected) > 1e-9) { issues.push(`物理常量 ${key} 被改为 ${actual}(原版应为 ${expected})`); } } } if (bot.physicsEnabled === false) { issues.push('bot.physicsEnabled 为 false,移动不会走本地预测,极易触发 Grim 的 Prediction 检查'); } if (bot.game && bot.game.gameMode === 'creative') { issues.push('当前为创造模式,飞行/瞬间破坏会直接触发 Grim 检查'); } if (bot.entity && bot.entity.elytraFlying) { issues.push('正在鞘翅飞行,mineflayer 的鞘翅物理与原版不一致'); } if (bot.creative && bot.creative.flying) { issues.push('bot.creative.flying 为 true,必须关闭'); } return { ok: issues.length === 0, issues }; } /** @returns {object} 供 Web 控制台展示的合规状态快照 */ snapshot() { const audit = this.audit(); return { enabled: this.config.grim.enabled, maxRotationPerTickDeg: this.config.grim.maxRotationPerTickDeg, effectiveStepDeg: Number(radToDeg(this.maxStepRad).toFixed(2)), attackReach: this.config.grim.attackReach, rotationSettled: this.isRotationSettled(), attackCooldownMs: this.attackCooldownRemainingMs(), blockedActions: this.blockedActions, physicsOk: audit.ok, issues: audit.issues }; } _log(message) { if (this.logger) this.logger.log(message); } } /** * 计算从 from 到 to 的最短角差(结果落在 (-π, π])。 * @param {number} from * @param {number} to * @returns {number} */ function shortestAngleDelta(from, to) { const TWO_PI = Math.PI * 2; let delta = (to - from) % TWO_PI; if (delta > Math.PI) delta -= TWO_PI; else if (delta < -Math.PI) delta += TWO_PI; return delta; } /** * 眼睛到实体碰撞箱的最近距离。 * @param {import('mineflayer').Bot} bot * @param {object} entity * @returns {number} */ function eyeDistanceToEntity(bot, entity) { const eye = bot.entity.position.offset(0, bot.entity.eyeHeight || 1.62, 0); const half = (entity.width || 0.6) / 2; const height = entity.height || 1.8; const cx = clamp(eye.x, entity.position.x - half, entity.position.x + half); const cy = clamp(eye.y, entity.position.y, entity.position.y + height); const cz = clamp(eye.z, entity.position.z - half, entity.position.z + half); const dx = cx - eye.x; const dy = cy - eye.y; const dz = cz - eye.z; return Math.sqrt(dx * dx + dy * dy + dz * dz); } function bot_attack(bot, entity) { bot.attack(entity, true); } function clamp(value, min, max) { return Math.min(max, Math.max(min, value)); } function degToRad(deg) { return (deg * Math.PI) / 180; } function radToDeg(rad) { return (rad * 180) / Math.PI; } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } module.exports = { GrimCompat, PHYSICS_TIMESTEP, VANILLA_PHYSICS_BASELINE, ATTACK_SPEED_RULES, shortestAngleDelta, eyeDistanceToEntity, degToRad, radToDeg };