安全模块大规模重构:151文件从com.aluer.security分包到8个子包
新包结构: - com.aluer.anticheat.combat (16) — KillAura/Reach/Criticals/CrystalAura/Totem等战斗反作弊 - com.aluer.anticheat.movement (18) — Fly/Speed/Jesus/NoFall/Timer/Phase等移动反作弊 - com.aluer.anticheat.world (14) — Nuker/Xray/AutoMine/VeinMiner等世界反作弊 - com.aluer.anticheat.player (8) — FakePlayer/AltAccount/NameSpoof等玩家反作弊 - com.aluer.network (32) — DDoS/Protocol/TokenBucket/Firewall等网络安全 - com.aluer.server (10) — ChunkLoad/EntityCount/Redstone/CrashExploit等服务器保护 - com.aluer.chat (5) — ChatFlood/Advertisement/Phishing/CommandAbuse等聊天安全 - com.aluer.defense (50) — WAF/IDS/IPS/Honeypot/EDR/Forensics等纵深防御 所有package声明+跨包import已更新,323测试全绿
This commit is contained in:
@@ -0,0 +1,344 @@
|
||||
package com.aluer.anticheat.movement;
|
||||
|
||||
import com.aluer.config.ServerGuardConfig;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* 空中跳跃(AirJump)检测服务 — V5.2 反作弊移动模块
|
||||
*
|
||||
* 检测原理:
|
||||
* 1. 空中跳跃数据包检测 — Minecraft中跳跃只能在玩家双脚接触地面时执行。
|
||||
* 当玩家onGround=false时,跳跃数据包将被服务端忽略。
|
||||
* AirJump hack通过修改客户端,在空中(onGround=false)时也能发送有效的跳跃数据包,
|
||||
* 使Y速度变为正值,实现在半空"再次跳跃"。
|
||||
* 2. 离地高度+正Y速度检测 — 正常跳跃时Y速度变为正值的瞬间玩家在地面(脚Y值与地面方块顶面相同)。
|
||||
* AirJump时Y速度变为正值时玩家已经离地超过1方块。检测玩家离地高度与Y速度变化的关系。
|
||||
* 3. 连续空中跳跃检测 — 某些AirJump hack允许无限次空中跳跃(类似二段跳但无限制)。
|
||||
* 检测短时间内的多次Y速度由负转正的模式。
|
||||
* 4. 跳跃间隔与高度分析 — 正常跳跃有冷却时间(约0.4秒),AirJump可以在任意时刻触发。
|
||||
* 检测异常的Y速度变化间隔和跳跃高度。
|
||||
*
|
||||
* 配置开关:serverguard.security.super-evolution.anti-air-jump
|
||||
*/
|
||||
@Service
|
||||
public class AntiAirJumpService {
|
||||
|
||||
private final ServerGuardConfig config;
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的移动历史(playerName -> 移动记录列表)
|
||||
*/
|
||||
private final Map<String, List<AirJumpRecord>> playerMoveHistory = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的Y速度历史(playerName -> Y速度列表)
|
||||
*/
|
||||
private final Map<String, List<Double>> playerYVelocityHistory = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的连续空中跳跃次数(playerName -> 连续次数)
|
||||
*/
|
||||
private final Map<String, Integer> playerAirJumpCount = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家最近一次跳跃的时间戳(playerName -> 时间戳)
|
||||
*/
|
||||
private final Map<String, Instant> playerLastJump = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的地面高度(playerName -> 最近地面Y坐标)
|
||||
*/
|
||||
private final Map<String, Double> playerGroundY = new ConcurrentHashMap<>();
|
||||
|
||||
private final AtomicLong totalChecks = new AtomicLong(0);
|
||||
private final AtomicLong airJumpViolations = new AtomicLong(0);
|
||||
|
||||
/**
|
||||
* 离地高度阈值(方块)— 玩家Y坐标与最近地面Y坐标之差超过此值
|
||||
* 且Y速度又变为正值 → 空中跳跃
|
||||
*/
|
||||
private static final double OFF_GROUND_THRESHOLD = 1.0;
|
||||
|
||||
/**
|
||||
* Y速度正值阈值 — 低于此值的波动忽略(浮点误差/网络微调)
|
||||
*/
|
||||
private static final double POSITIVE_VELOCITY_THRESHOLD = 0.01;
|
||||
|
||||
/**
|
||||
* 连续空中跳跃阈值 — 连续超过此值判定为AirJump hack
|
||||
*/
|
||||
private static final int MAX_CONSECUTIVE_AIR_JUMPS = 2;
|
||||
|
||||
/**
|
||||
* 最小跳跃间隔(毫秒)— 正常跳跃之间至少有0.4秒间隔
|
||||
*/
|
||||
private static final long MIN_JUMP_INTERVAL_MS = 400;
|
||||
|
||||
/**
|
||||
* 每个玩家保留的最大记录数
|
||||
*/
|
||||
private static final int MAX_RECORDS_PER_PLAYER = 40;
|
||||
|
||||
/**
|
||||
* 无参构造函数 — 测试/默认配置使用
|
||||
*/
|
||||
public AntiAirJumpService() {
|
||||
this(new ServerGuardConfig());
|
||||
}
|
||||
|
||||
/**
|
||||
* Spring 注入构造函数
|
||||
* @param config 全局配置,提供模块开关控制
|
||||
*/
|
||||
public AntiAirJumpService(ServerGuardConfig config) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测玩家是否在空中执行跳跃(AirJump hack)
|
||||
*
|
||||
* @param playerName 玩家名称
|
||||
* @param playerUUID 玩家UUID
|
||||
* @param fromX 移动起始X坐标
|
||||
* @param fromY 移动起始Y坐标
|
||||
* @param fromZ 移动起始Z坐标
|
||||
* @param toX 移动结束X坐标
|
||||
* @param toY 移动结束Y坐标
|
||||
* @param toZ 移动结束Z坐标
|
||||
* @param yVelocity 玩家Y轴速度分量
|
||||
* @param prevYVelocity 上一tick的Y轴速度分量
|
||||
* @param onGround 玩家是否在地面
|
||||
* @param isJumping 玩家是否发送了跳跃数据包
|
||||
* @param groundY 玩家最近站在的地面Y坐标(方块顶面)
|
||||
* @param timestamp 时间戳
|
||||
* @return 检测结果
|
||||
*/
|
||||
public AirJumpCheckResult detect(String playerName, String playerUUID,
|
||||
double fromX, double fromY, double fromZ,
|
||||
double toX, double toY, double toZ,
|
||||
double yVelocity, double prevYVelocity,
|
||||
boolean onGround, boolean isJumping,
|
||||
double groundY, Instant timestamp) {
|
||||
// 模块开关检查 — 关闭时跳过所有检测
|
||||
if (!config.getSecurity().getSuperEvolution().isAntiAirJump()) {
|
||||
return AirJumpCheckResult.clean();
|
||||
}
|
||||
|
||||
totalChecks.incrementAndGet();
|
||||
|
||||
double dy = toY - fromY;
|
||||
double dx = toX - fromX;
|
||||
double dz = toZ - fromZ;
|
||||
double horizontalDist = Math.sqrt(dx * dx + dz * dz);
|
||||
|
||||
// 更新地面Y坐标
|
||||
if (onGround) {
|
||||
playerGroundY.put(playerName, toY);
|
||||
}
|
||||
|
||||
List<AirJumpRecord> history = playerMoveHistory.computeIfAbsent(
|
||||
playerName, k -> new ArrayList<>());
|
||||
List<Double> yVelHistory = playerYVelocityHistory.computeIfAbsent(
|
||||
playerName, k -> new ArrayList<>());
|
||||
|
||||
AirJumpRecord record = new AirJumpRecord(timestamp, fromX, fromY, fromZ,
|
||||
toX, toY, toZ, dy, yVelocity, prevYVelocity, onGround, isJumping,
|
||||
horizontalDist);
|
||||
history.add(record);
|
||||
yVelHistory.add(yVelocity);
|
||||
|
||||
while (history.size() > MAX_RECORDS_PER_PLAYER) {
|
||||
history.remove(0);
|
||||
}
|
||||
while (yVelHistory.size() > 30) {
|
||||
yVelHistory.remove(0);
|
||||
}
|
||||
|
||||
List<String> reasons = new ArrayList<>();
|
||||
|
||||
// 1. 核心检测:跳跃数据包+onGround=false+离地高度>1方块
|
||||
if (isJumping && !onGround && yVelocity > POSITIVE_VELOCITY_THRESHOLD) {
|
||||
double currentGroundY = playerGroundY.getOrDefault(playerName, fromY);
|
||||
double heightAboveGround = toY - currentGroundY;
|
||||
|
||||
if (heightAboveGround >= OFF_GROUND_THRESHOLD) {
|
||||
int airJumpCount = playerAirJumpCount.getOrDefault(playerName, 0) + 1;
|
||||
playerAirJumpCount.put(playerName, airJumpCount);
|
||||
|
||||
reasons.add("AIR_JUMP: jump packet while " + String.format("%.1f", heightAboveGround)
|
||||
+ " blocks above ground (yVel=" + String.format("%.3f", yVelocity)
|
||||
+ ", onGround=false)");
|
||||
|
||||
if (airJumpCount >= MAX_CONSECUTIVE_AIR_JUMPS) {
|
||||
airJumpViolations.incrementAndGet();
|
||||
reasons.add("CONSECUTIVE_AIR_JUMPS: " + airJumpCount
|
||||
+ " consecutive mid-air jumps detected");
|
||||
}
|
||||
}
|
||||
} else if (onGround) {
|
||||
// 回到地面,重置空中跳跃计数
|
||||
playerAirJumpCount.remove(playerName);
|
||||
}
|
||||
|
||||
// 2. Y速度由负转正+离地检测(即使没有显式的跳跃数据包)
|
||||
if (!onGround && yVelocity > POSITIVE_VELOCITY_THRESHOLD
|
||||
&& prevYVelocity <= 0 && !isJumping) {
|
||||
double currentGroundY = playerGroundY.getOrDefault(playerName, fromY);
|
||||
double heightAboveGround = toY - currentGroundY;
|
||||
|
||||
if (heightAboveGround >= OFF_GROUND_THRESHOLD) {
|
||||
reasons.add("VELOCITY_REVERSAL_IN_AIR: yVel went from "
|
||||
+ String.format("%.3f", prevYVelocity) + " to "
|
||||
+ String.format("%.3f", yVelocity) + " at "
|
||||
+ String.format("%.1f", heightAboveGround)
|
||||
+ " blocks above ground");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 连续空中跳跃模式 — 多次Y速度归零后重新变正
|
||||
if (!onGround && yVelocity > POSITIVE_VELOCITY_THRESHOLD
|
||||
&& yVelHistory.size() >= 8) {
|
||||
int positiveBursts = 0;
|
||||
boolean wasNegative = false;
|
||||
for (int i = Math.max(0, yVelHistory.size() - 8); i < yVelHistory.size(); i++) {
|
||||
double vel = yVelHistory.get(i);
|
||||
if (vel > POSITIVE_VELOCITY_THRESHOLD && wasNegative) {
|
||||
positiveBursts++;
|
||||
wasNegative = false;
|
||||
} else if (vel <= 0) {
|
||||
wasNegative = true;
|
||||
}
|
||||
}
|
||||
if (positiveBursts >= 3) {
|
||||
reasons.add("MULTIPLE_AIR_BURSTS: " + positiveBursts
|
||||
+ " upward velocity bursts in recent history while airborne");
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 异常跳跃间隔检测 — 如果上次跳跃时间戳存在且间隔过短
|
||||
Instant lastJump = playerLastJump.get(playerName);
|
||||
if (isJumping) {
|
||||
if (lastJump != null) {
|
||||
long intervalMs = timestamp.toEpochMilli() - lastJump.toEpochMilli();
|
||||
if (intervalMs < MIN_JUMP_INTERVAL_MS && !onGround) {
|
||||
reasons.add("RAPID_AIR_JUMP: jump interval "
|
||||
+ intervalMs + "ms (min normal: " + MIN_JUMP_INTERVAL_MS
|
||||
+ "ms) while airborne");
|
||||
}
|
||||
}
|
||||
playerLastJump.put(playerName, timestamp);
|
||||
}
|
||||
|
||||
if (reasons.size() >= 2) {
|
||||
return AirJumpCheckResult.flagged(reasons);
|
||||
} else if (reasons.size() == 1) {
|
||||
return AirJumpCheckResult.suspicious(reasons);
|
||||
}
|
||||
|
||||
return AirJumpCheckResult.clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* 玩家离线时清理追踪数据
|
||||
* @param playerName 离线玩家名称
|
||||
*/
|
||||
public void clearPlayer(String playerName) {
|
||||
playerMoveHistory.remove(playerName);
|
||||
playerYVelocityHistory.remove(playerName);
|
||||
playerAirJumpCount.remove(playerName);
|
||||
playerLastJump.remove(playerName);
|
||||
playerGroundY.remove(playerName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模块运行状态
|
||||
* @return 包含计数器和违规数量的Map
|
||||
*/
|
||||
public Map<String, Object> getStatus() {
|
||||
Map<String, Object> status = new LinkedHashMap<>();
|
||||
status.put("totalChecks", totalChecks.get());
|
||||
status.put("airJumpViolations", airJumpViolations.get());
|
||||
status.put("activeTrackedPlayers", playerMoveHistory.size());
|
||||
|
||||
// 列出疑似空中跳跃的玩家
|
||||
List<Map<String, Object>> airJumpSuspects = new ArrayList<>();
|
||||
for (Map.Entry<String, Integer> entry : playerAirJumpCount.entrySet()) {
|
||||
Map<String, Object> info = new LinkedHashMap<>();
|
||||
info.put("player", entry.getKey());
|
||||
info.put("airJumpCount", entry.getValue());
|
||||
airJumpSuspects.add(info);
|
||||
}
|
||||
status.put("airJumpSuspects", airJumpSuspects);
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部空中跳跃移动记录 — 记录玩家单次移动的完整数据
|
||||
*/
|
||||
private static class AirJumpRecord {
|
||||
final Instant timestamp;
|
||||
final double fromX, fromY, fromZ;
|
||||
final double toX, toY, toZ;
|
||||
final double dy;
|
||||
final double yVelocity;
|
||||
final double prevYVelocity;
|
||||
final boolean onGround;
|
||||
final boolean isJumping;
|
||||
final double horizontalDist;
|
||||
|
||||
AirJumpRecord(Instant timestamp, double fromX, double fromY, double fromZ,
|
||||
double toX, double toY, double toZ, double dy,
|
||||
double yVelocity, double prevYVelocity, boolean onGround,
|
||||
boolean isJumping, double horizontalDist) {
|
||||
this.timestamp = timestamp;
|
||||
this.fromX = fromX; this.fromY = fromY; this.fromZ = fromZ;
|
||||
this.toX = toX; this.toY = toY; this.toZ = toZ;
|
||||
this.dy = dy;
|
||||
this.yVelocity = yVelocity;
|
||||
this.prevYVelocity = prevYVelocity;
|
||||
this.onGround = onGround;
|
||||
this.isJumping = isJumping;
|
||||
this.horizontalDist = horizontalDist;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AirJump检测结果 — 不可变结果类
|
||||
*/
|
||||
public static class AirJumpCheckResult {
|
||||
private final boolean flagged;
|
||||
private final boolean suspicious;
|
||||
private final List<String> reasons;
|
||||
|
||||
private AirJumpCheckResult(boolean flagged, boolean suspicious, List<String> reasons) {
|
||||
this.flagged = flagged;
|
||||
this.suspicious = suspicious;
|
||||
this.reasons = reasons;
|
||||
}
|
||||
|
||||
/** 无异常 — 正常的跳跃或落地行为 */
|
||||
public static AirJumpCheckResult clean() {
|
||||
return new AirJumpCheckResult(false, false, List.of());
|
||||
}
|
||||
|
||||
/** 可疑 — 单次异常但不足够确认为AirJump hack */
|
||||
public static AirJumpCheckResult suspicious(List<String> reasons) {
|
||||
return new AirJumpCheckResult(false, true, reasons);
|
||||
}
|
||||
|
||||
/** 已标记 — 确认AirJump hack,连续在空中跳跃 */
|
||||
public static AirJumpCheckResult flagged(List<String> reasons) {
|
||||
return new AirJumpCheckResult(true, false, reasons);
|
||||
}
|
||||
|
||||
public boolean isFlagged() { return flagged; }
|
||||
public boolean isSuspicious() { return suspicious; }
|
||||
public boolean isClean() { return !flagged && !suspicious; }
|
||||
public List<String> getReasons() { return reasons; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
package com.aluer.anticheat.movement;
|
||||
|
||||
import com.aluer.config.ServerGuardConfig;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* 反饥饿消耗(AntiHunger)检测服务 — V5.2 反作弊移动模块
|
||||
*
|
||||
* 检测原理:
|
||||
* 1. 饥饿消耗与活动量不匹配检测 — 正常饥饿值约每40秒消耗1单位(步行),
|
||||
* 冲刺时约每10秒消耗1单位,跳跃额外消耗0.05-0.2单位/次。
|
||||
* AntiHunger hack通过伪造onGround=true欺骗服务端跳过饥饿消耗tick。
|
||||
* 2. 长时间零消耗检测 — 如果玩家持续高活动量(大量移动、跳跃、冲刺)但
|
||||
* 饥饿值在5+分钟内保持不变,标记为AntiHunger。
|
||||
* 3. 单位距离饥饿消耗检测 — 正常玩家每移动1000方块消耗约15-25单位饥饿值;
|
||||
* AntiHunger玩家的消耗为0-3单位/1000方块。
|
||||
* 4. 跳跃时onGround一致性检测 — hack在跳跃时发送onGround=true来跳过饥饿检查;
|
||||
* 服务端可根据Y速度变化自行推断onGround状态,与客户端声称的onGround对比。
|
||||
* 5. 冲刺状态与饥饿消耗一致性检测 — 冲刺时饥饿消耗速率是步行的约4倍,
|
||||
* 如果长时间冲刺而饥饿值不变,高度可疑。
|
||||
*
|
||||
* 配置开关:serverguard.security.super-evolution.anti-anti-hunger
|
||||
*/
|
||||
@Service
|
||||
public class AntiAntiHungerService {
|
||||
|
||||
private final ServerGuardConfig config;
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的活动/饥饿历史(playerName -> 时间窗口记录列表)
|
||||
*/
|
||||
private final Map<String, List<HungerRecord>> playerHungerHistory = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家自上次饥饿变化以来的累积活动量
|
||||
*/
|
||||
private final Map<String, PlayerActivity> playerActivitySinceLastHungerChange = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家当前的食物等级(playerName -> 当前饥饿值)
|
||||
*/
|
||||
private final Map<String, Integer> playerCurrentFoodLevel = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家最近一次饥饿值变化的时间戳
|
||||
*/
|
||||
private final Map<String, Instant> playerLastHungerChange = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪连续onGround=true但Y速度显示应处于空中的tick数
|
||||
*/
|
||||
private final Map<String, Integer> playerGroundSpoofTicks = new ConcurrentHashMap<>();
|
||||
|
||||
private final AtomicLong totalChecks = new AtomicLong(0);
|
||||
private final AtomicLong antiHungerViolations = new AtomicLong(0);
|
||||
|
||||
/**
|
||||
* 高活动量定义为每分钟移动超过50方块 — 低于此视为低活动量
|
||||
*/
|
||||
private static final double HIGH_ACTIVITY_DISTANCE_PER_MINUTE = 50.0;
|
||||
|
||||
/**
|
||||
* 空闲定义 — 每分钟移动少于10方块
|
||||
*/
|
||||
private static final double IDLE_DISTANCE_PER_MINUTE = 10.0;
|
||||
|
||||
/**
|
||||
* 零饥饿消耗最大容忍时间(分钟)— 高活动量下超过此时间标记
|
||||
*/
|
||||
private static final int MAX_ZERO_CONSUMPTION_MINUTES = 5;
|
||||
|
||||
/**
|
||||
* 正常每1000方块的饥饿消耗范围(单位)
|
||||
* 步行:约1000/4.317 ≈ 231秒 ≈ 5.8单位
|
||||
* 冲刺:约1000/5.612 ≈ 178秒 ≈ 17.8单位
|
||||
* 混合活动:约15-25单位
|
||||
*/
|
||||
private static final double MIN_HUNGER_PER_1000_BLOCKS = 10.0;
|
||||
private static final double MAX_HUNGER_PER_1000_BLOCKS = 30.0;
|
||||
private static final double SUSPICIOUS_HUNGER_PER_1000_BLOCKS = 5.0;
|
||||
|
||||
/**
|
||||
* 每个玩家保留的最大记录数
|
||||
*/
|
||||
private static final int MAX_RECORDS_PER_PLAYER = 120;
|
||||
|
||||
/**
|
||||
* 无参构造函数 — 测试/默认配置使用
|
||||
*/
|
||||
public AntiAntiHungerService() {
|
||||
this(new ServerGuardConfig());
|
||||
}
|
||||
|
||||
/**
|
||||
* Spring 注入构造函数
|
||||
* @param config 全局配置,提供模块开关控制
|
||||
*/
|
||||
public AntiAntiHungerService(ServerGuardConfig config) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测玩家是否使用AntiHunger hack绕过饥饿消耗
|
||||
*
|
||||
* @param playerName 玩家名称
|
||||
* @param playerUUID 玩家UUID
|
||||
* @param foodLevel 玩家当前食物等级(0-20)
|
||||
* @param isSprinting 玩家是否在冲刺
|
||||
* @param isJumping 玩家是否在跳跃(当前tick Y速度为正)
|
||||
* @param clientOnGround 客户端声称的onGround状态
|
||||
* @param serverCalcOnGround 服务端计算的onGround状态(基于Y坐标/碰撞检测)
|
||||
* @param fromX 移动起始X
|
||||
* @param fromY 移动起始Y
|
||||
* @param fromZ 移动起始Z
|
||||
* @param toX 移动结束X
|
||||
* @param toY 移动结束Y
|
||||
* @param toZ 移动结束Z
|
||||
* @param deltaSeconds 本tick时间间隔(秒)
|
||||
* @param timestamp 时间戳
|
||||
* @return 检测结果
|
||||
*/
|
||||
public AntiHungerCheckResult detect(String playerName, String playerUUID,
|
||||
int foodLevel, boolean isSprinting, boolean isJumping,
|
||||
boolean clientOnGround, boolean serverCalcOnGround,
|
||||
double fromX, double fromY, double fromZ,
|
||||
double toX, double toY, double toZ,
|
||||
double deltaSeconds, Instant timestamp) {
|
||||
// 模块开关检查 — 关闭时跳过所有检测
|
||||
if (!config.getSecurity().getSuperEvolution().isAntiAntiHunger()) {
|
||||
return AntiHungerCheckResult.clean();
|
||||
}
|
||||
|
||||
totalChecks.incrementAndGet();
|
||||
|
||||
// 计算水平移动距离
|
||||
double dx = toX - fromX;
|
||||
double dz = toZ - fromZ;
|
||||
double horizontalDist = Math.sqrt(dx * dx + dz * dz);
|
||||
|
||||
List<HungerRecord> history = playerHungerHistory.computeIfAbsent(
|
||||
playerName, k -> new ArrayList<>());
|
||||
|
||||
HungerRecord record = new HungerRecord(timestamp, foodLevel, isSprinting,
|
||||
isJumping, clientOnGround, serverCalcOnGround, horizontalDist, deltaSeconds);
|
||||
history.add(record);
|
||||
while (history.size() > MAX_RECORDS_PER_PLAYER) {
|
||||
history.remove(0);
|
||||
}
|
||||
|
||||
List<String> reasons = new ArrayList<>();
|
||||
|
||||
// ─── 检测1:饥饿值变化追踪 — 检测长时间零消耗 ───
|
||||
Integer previousFood = playerCurrentFoodLevel.get(playerName);
|
||||
if (previousFood != null && previousFood != foodLevel) {
|
||||
// 饥饿值发生了变化 — 重置活动量追踪
|
||||
playerActivitySinceLastHungerChange.remove(playerName);
|
||||
playerLastHungerChange.put(playerName, timestamp);
|
||||
}
|
||||
playerCurrentFoodLevel.put(playerName, foodLevel);
|
||||
|
||||
// 初始化或更新活动量追踪
|
||||
PlayerActivity activity = playerActivitySinceLastHungerChange.computeIfAbsent(
|
||||
playerName, k -> new PlayerActivity(timestamp));
|
||||
activity.addDistance(horizontalDist);
|
||||
if (isJumping) activity.incrementJumps();
|
||||
if (isSprinting) activity.addSprintTime(deltaSeconds);
|
||||
activity.lastUpdate = timestamp;
|
||||
|
||||
// 检查自上次饥饿变化以来的时间
|
||||
Instant lastChange = playerLastHungerChange.get(playerName);
|
||||
if (lastChange == null) {
|
||||
lastChange = timestamp;
|
||||
playerLastHungerChange.put(playerName, timestamp);
|
||||
}
|
||||
|
||||
long secondsSinceChange = (timestamp.toEpochMilli() - lastChange.toEpochMilli()) / 1000;
|
||||
double minutesSinceChange = secondsSinceChange / 60.0;
|
||||
|
||||
if (minutesSinceChange >= MAX_ZERO_CONSUMPTION_MINUTES
|
||||
&& activity.totalDistance > (HIGH_ACTIVITY_DISTANCE_PER_MINUTE * minutesSinceChange)) {
|
||||
// 高活动量 + 长时间零饥饿消耗 → AntiHunger
|
||||
reasons.add("ZERO_CONSUMPTION_HIGH_ACTIVITY: food level unchanged ("
|
||||
+ foodLevel + ") for " + String.format("%.1f", minutesSinceChange)
|
||||
+ " minutes despite " + String.format("%.0f", activity.totalDistance)
|
||||
+ " blocks moved, " + activity.totalJumps
|
||||
+ " jumps, " + String.format("%.1f", activity.totalSprintSeconds)
|
||||
+ "s sprinting");
|
||||
antiHungerViolations.incrementAndGet();
|
||||
} else if (minutesSinceChange >= MAX_ZERO_CONSUMPTION_MINUTES
|
||||
&& activity.totalDistance > (IDLE_DISTANCE_PER_MINUTE * minutesSinceChange)) {
|
||||
// 中等活动量 + 长时间无消耗 — 可疑
|
||||
reasons.add("ZERO_CONSUMPTION_MODERATE_ACTIVITY: food level unchanged for "
|
||||
+ String.format("%.1f", minutesSinceChange) + " min, "
|
||||
+ String.format("%.0f", activity.totalDistance) + " blocks moved");
|
||||
}
|
||||
|
||||
// ─── 检测2:每千方块饥饿消耗率 ───
|
||||
if (history.size() >= 30) {
|
||||
// 取最近30条记录分析
|
||||
int recentCount = Math.min(30, history.size());
|
||||
double recentTotalDist = 0;
|
||||
int firstFood = -1;
|
||||
int lastFood = -1;
|
||||
|
||||
for (int i = history.size() - recentCount; i < history.size(); i++) {
|
||||
HungerRecord hr = history.get(i);
|
||||
recentTotalDist += hr.horizontalDist;
|
||||
if (firstFood < 0) firstFood = hr.foodLevel;
|
||||
lastFood = hr.foodLevel;
|
||||
}
|
||||
|
||||
if (recentTotalDist > 100 && firstFood > 0 && lastFood > 0) {
|
||||
int hungerConsumed = Math.max(0, firstFood - lastFood);
|
||||
double hungerPer1000 = (hungerConsumed / recentTotalDist) * 1000.0;
|
||||
|
||||
if (hungerPer1000 < SUSPICIOUS_HUNGER_PER_1000_BLOCKS && hungerConsumed <= 2
|
||||
&& recentTotalDist > 500) {
|
||||
reasons.add("LOW_HUNGER_PER_DISTANCE: " + String.format("%.1f", hungerPer1000)
|
||||
+ " hunger consumed per 1000 blocks (normal: "
|
||||
+ String.format("%.0f", MIN_HUNGER_PER_1000_BLOCKS) + "-"
|
||||
+ String.format("%.0f", MAX_HUNGER_PER_1000_BLOCKS)
|
||||
+ ", suspicious threshold: <"
|
||||
+ String.format("%.1f", SUSPICIOUS_HUNGER_PER_1000_BLOCKS) + ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 检测3:onGround伪造检测 — 客户端声称在地面但服务端计算显示应在空中 ───
|
||||
if (clientOnGround && !serverCalcOnGround && isJumping) {
|
||||
int spoofTicks = playerGroundSpoofTicks.getOrDefault(playerName, 0) + 1;
|
||||
playerGroundSpoofTicks.put(playerName, spoofTicks);
|
||||
|
||||
if (spoofTicks >= 5) {
|
||||
reasons.add("GROUND_SPOOF_HUNGER: client claims onGround=true while "
|
||||
+ "server indicates airborne for " + spoofTicks
|
||||
+ " consecutive ticks (used to skip hunger ticks)");
|
||||
}
|
||||
} else if (!clientOnGround || !isJumping) {
|
||||
// 不在跳跃或客户端正确报告了空中状态,重置计数
|
||||
Integer spoofTicks = playerGroundSpoofTicks.remove(playerName);
|
||||
// 保留最近有ground spoof的记录作为证据
|
||||
if (spoofTicks != null && spoofTicks >= 3) {
|
||||
reasons.add("GROUND_SPOOF_PATTERN: " + spoofTicks
|
||||
+ " tick ground spoof burst ended");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 检测4:冲刺时间与饥饿消耗比率 ───
|
||||
if (activity.totalSprintSeconds > 120 && minutesSinceChange >= 3
|
||||
&& activity.totalDistance > 500) {
|
||||
// 冲刺超过2分钟,距离超过500方块,饥饿值未变化
|
||||
double sprintMinutes = activity.totalSprintSeconds / 60.0;
|
||||
// 纯冲刺下预期消耗约6单位/分钟
|
||||
double expectedConsumption = sprintMinutes * 6.0;
|
||||
if (expectedConsumption >= 10.0) {
|
||||
reasons.add("SPRINT_NO_HUNGER_DRAIN: "
|
||||
+ String.format("%.1f", sprintMinutes)
|
||||
+ " minutes of sprinting with zero food level change"
|
||||
+ " (expected ~" + String.format("%.0f", expectedConsumption)
|
||||
+ " units consumed)");
|
||||
antiHungerViolations.incrementAndGet();
|
||||
}
|
||||
}
|
||||
|
||||
if (reasons.size() >= 2) {
|
||||
return AntiHungerCheckResult.flagged(reasons);
|
||||
} else if (reasons.size() == 1) {
|
||||
return AntiHungerCheckResult.suspicious(reasons);
|
||||
}
|
||||
|
||||
return AntiHungerCheckResult.clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* 玩家离线时清理追踪数据
|
||||
* @param playerName 离线玩家名称
|
||||
*/
|
||||
public void clearPlayer(String playerName) {
|
||||
playerHungerHistory.remove(playerName);
|
||||
playerActivitySinceLastHungerChange.remove(playerName);
|
||||
playerCurrentFoodLevel.remove(playerName);
|
||||
playerLastHungerChange.remove(playerName);
|
||||
playerGroundSpoofTicks.remove(playerName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模块运行状态
|
||||
* @return 包含计数器和违规数量的Map
|
||||
*/
|
||||
public Map<String, Object> getStatus() {
|
||||
Map<String, Object> status = new LinkedHashMap<>();
|
||||
status.put("totalChecks", totalChecks.get());
|
||||
status.put("antiHungerViolations", antiHungerViolations.get());
|
||||
status.put("activeTrackedPlayers", playerHungerHistory.size());
|
||||
|
||||
// 列出长时间零消耗的玩家
|
||||
List<Map<String, Object>> zeroConsumptionPlayers = new ArrayList<>();
|
||||
for (Map.Entry<String, PlayerActivity> entry : playerActivitySinceLastHungerChange.entrySet()) {
|
||||
PlayerActivity act = entry.getValue();
|
||||
if (act.totalDistance > 200) {
|
||||
Map<String, Object> info = new LinkedHashMap<>();
|
||||
info.put("player", entry.getKey());
|
||||
info.put("distanceSinceChange", String.format("%.0f", act.totalDistance));
|
||||
info.put("jumpsSinceChange", act.totalJumps);
|
||||
info.put("sprintSeconds", String.format("%.1f", act.totalSprintSeconds));
|
||||
zeroConsumptionPlayers.add(info);
|
||||
}
|
||||
}
|
||||
status.put("zeroConsumptionSuspects", zeroConsumptionPlayers);
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部饥饿记录 — 记录玩家单tick的活动和饥饿状态
|
||||
*/
|
||||
private static class HungerRecord {
|
||||
final Instant timestamp;
|
||||
final int foodLevel;
|
||||
final boolean isSprinting;
|
||||
final boolean isJumping;
|
||||
final boolean clientOnGround;
|
||||
final boolean serverCalcOnGround;
|
||||
final double horizontalDist;
|
||||
final double deltaSeconds;
|
||||
|
||||
HungerRecord(Instant timestamp, int foodLevel, boolean isSprinting,
|
||||
boolean isJumping, boolean clientOnGround, boolean serverCalcOnGround,
|
||||
double horizontalDist, double deltaSeconds) {
|
||||
this.timestamp = timestamp;
|
||||
this.foodLevel = foodLevel;
|
||||
this.isSprinting = isSprinting;
|
||||
this.isJumping = isJumping;
|
||||
this.clientOnGround = clientOnGround;
|
||||
this.serverCalcOnGround = serverCalcOnGround;
|
||||
this.horizontalDist = horizontalDist;
|
||||
this.deltaSeconds = deltaSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部玩家活动追踪 — 累积自上次饥饿值变化以来的活动量
|
||||
*/
|
||||
private static class PlayerActivity {
|
||||
double totalDistance = 0;
|
||||
int totalJumps = 0;
|
||||
double totalSprintSeconds = 0;
|
||||
Instant startTime;
|
||||
Instant lastUpdate;
|
||||
|
||||
PlayerActivity(Instant startTime) {
|
||||
this.startTime = startTime;
|
||||
this.lastUpdate = startTime;
|
||||
}
|
||||
|
||||
void addDistance(double dist) { this.totalDistance += dist; }
|
||||
void incrementJumps() { this.totalJumps++; }
|
||||
void addSprintTime(double seconds) { this.totalSprintSeconds += seconds; }
|
||||
}
|
||||
|
||||
/**
|
||||
* AntiHunger检测结果 — 不可变结果类
|
||||
*/
|
||||
public static class AntiHungerCheckResult {
|
||||
private final boolean flagged;
|
||||
private final boolean suspicious;
|
||||
private final List<String> reasons;
|
||||
|
||||
private AntiHungerCheckResult(boolean flagged, boolean suspicious, List<String> reasons) {
|
||||
this.flagged = flagged;
|
||||
this.suspicious = suspicious;
|
||||
this.reasons = reasons;
|
||||
}
|
||||
|
||||
/** 无异常 — 正常的饥饿消耗速率 */
|
||||
public static AntiHungerCheckResult clean() {
|
||||
return new AntiHungerCheckResult(false, false, List.of());
|
||||
}
|
||||
|
||||
/** 可疑 — 饥饿消耗略低但活动量不足以完全确认 */
|
||||
public static AntiHungerCheckResult suspicious(List<String> reasons) {
|
||||
return new AntiHungerCheckResult(false, true, reasons);
|
||||
}
|
||||
|
||||
/** 已标记 — 确认AntiHunger hack,高活动量零饥饿消耗 */
|
||||
public static AntiHungerCheckResult flagged(List<String> reasons) {
|
||||
return new AntiHungerCheckResult(true, false, reasons);
|
||||
}
|
||||
|
||||
public boolean isFlagged() { return flagged; }
|
||||
public boolean isSuspicious() { return suspicious; }
|
||||
public boolean isClean() { return !flagged && !suspicious; }
|
||||
public List<String> getReasons() { return reasons; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
package com.aluer.anticheat.movement;
|
||||
|
||||
import com.aluer.config.ServerGuardConfig;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* 闪烁/快速断连(Blink)检测服务 — V5.0 反作弊扩展模块
|
||||
*
|
||||
* 检测原理:
|
||||
* 1. 快速断连-重连周期检测 — Blink hack的工作原理是短暂断开客户端与服务器
|
||||
* 的连接(使玩家在服务器端"消失"但客户端仍在正常游戏),然后重新连接。
|
||||
* 本模块追踪玩家的加入/退出事件时间戳,如果检测到短时间内的反复断开重连,
|
||||
* 则标记。
|
||||
* 2. 受击后立即断连检测 — 这是Blink hack最典型的用法:玩家受到攻击后立即
|
||||
* 断开连接以避免死亡,几秒后重新连接。检测玩家是否在受到伤害后短时间内断开。
|
||||
* 3. 断连期间PvP逃避检测 — 如果玩家在PvP场景中反复使用Blink(如接敌时断开,
|
||||
* 脱险后重连),这是一种严重的作弊行为。追踪战斗状态与断连时机的关系。
|
||||
* 4. 断连频率异常检测 — 正常玩家的断连通常由网络问题或主动退出造成,
|
||||
* 频率较低。高频断连(如每分钟多次)强烈提示Blink hack。
|
||||
*
|
||||
* 配置开关:serverguard.security.super-evolution.anti-blink
|
||||
*/
|
||||
@Service
|
||||
public class AntiBlinkService {
|
||||
|
||||
private final ServerGuardConfig config;
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的加入/退出事件时间戳(playerName -> 事件列表)
|
||||
* 用于分析断连-重连的周期模式
|
||||
*/
|
||||
private final Map<String, List<ConnectionEvent>> playerConnectionEvents = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家最近受到伤害的时间(playerName -> 最近伤害时间戳)
|
||||
* 用于关联受击与断连的关系
|
||||
*/
|
||||
private final Map<String, Instant> playerLastDamageTime = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的最近伤害量(playerName -> 伤害量)
|
||||
* 用于判断是否有躲避伤害的动机
|
||||
*/
|
||||
private final Map<String, Double> playerLastDamageAmount = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪当前在线的玩家(playerName -> 加入时间)
|
||||
* 用于快速判断玩家状态
|
||||
*/
|
||||
private final Map<String, Instant> onlinePlayers = new ConcurrentHashMap<>();
|
||||
|
||||
private final AtomicLong totalChecks = new AtomicLong(0);
|
||||
private final AtomicLong flaggedCount = new AtomicLong(0);
|
||||
|
||||
/**
|
||||
* 断连-重连周期阈值(秒)— 在此时间内断开又重连视为快速重连
|
||||
*/
|
||||
private static final long RECONNECT_THRESHOLD_SECONDS = 5;
|
||||
|
||||
/**
|
||||
* 受击后断连检测窗口(毫秒)— 受击后在此时间内断开视为逃避伤害
|
||||
*/
|
||||
private static final long DAMAGE_DISCONNECT_WINDOW_MS = 1_000;
|
||||
|
||||
/**
|
||||
* 最小断连-重连周期数 — 达到此次数才标记
|
||||
* 单次断连可能是真实网络问题
|
||||
*/
|
||||
private static final int MIN_RECONNECT_CYCLES = 2;
|
||||
|
||||
/**
|
||||
* 断连频率检测窗口(秒)— 在此窗口内统计断连次数
|
||||
*/
|
||||
private static final long DISCONNECT_FREQ_WINDOW_SECONDS = 60;
|
||||
|
||||
/**
|
||||
* 窗口内最大允许的断连次数 — 超过此值视为异常
|
||||
*/
|
||||
private static final int MAX_DISCONNECTS_PER_WINDOW = 3;
|
||||
|
||||
/**
|
||||
* 每个玩家保留的最大连接事件记录数
|
||||
*/
|
||||
private static final int MAX_CONNECTION_EVENTS = 30;
|
||||
|
||||
/**
|
||||
* 无参构造函数 — 测试/默认配置使用
|
||||
*/
|
||||
public AntiBlinkService() {
|
||||
this(new ServerGuardConfig());
|
||||
}
|
||||
|
||||
/**
|
||||
* Spring 注入构造函数
|
||||
* @param config 全局配置,提供模块开关控制
|
||||
*/
|
||||
public AntiBlinkService(ServerGuardConfig config) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录玩家加入服务器事件,同时检测断连-重连模式
|
||||
*
|
||||
* @param playerName 玩家名称
|
||||
* @param playerUUID 玩家UUID
|
||||
* @param timestamp 加入时间戳
|
||||
* @return 检测结果 — 如果检测到可疑的Blink模式返回flagged/suspicious
|
||||
*/
|
||||
public BlinkCheckResult onPlayerJoin(String playerName, String playerUUID, Instant timestamp) {
|
||||
// 模块开关检查
|
||||
if (!config.getSecurity().getSuperEvolution().isAntiBlink()) {
|
||||
return BlinkCheckResult.clean();
|
||||
}
|
||||
|
||||
totalChecks.incrementAndGet();
|
||||
List<String> reasons = new ArrayList<>();
|
||||
|
||||
// 记录加入事件
|
||||
List<ConnectionEvent> events = playerConnectionEvents.computeIfAbsent(
|
||||
playerName, k -> new ArrayList<>());
|
||||
events.add(new ConnectionEvent(timestamp, ConnectionEvent.Type.JOIN));
|
||||
while (events.size() > MAX_CONNECTION_EVENTS) {
|
||||
events.remove(0);
|
||||
}
|
||||
|
||||
// 标记为在线
|
||||
onlinePlayers.put(playerName, timestamp);
|
||||
|
||||
// 1. 快速断连-重连周期检测 — 查找最近的断开和加入事件
|
||||
Instant lastDisconnect = null;
|
||||
for (int i = events.size() - 1; i >= 0; i--) {
|
||||
if (events.get(i).type == ConnectionEvent.Type.QUIT) {
|
||||
lastDisconnect = events.get(i).timestamp;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (lastDisconnect != null) {
|
||||
long disconnectToReconnect = timestamp.toEpochMilli() - lastDisconnect.toEpochMilli();
|
||||
long secondsDiff = disconnectToReconnect / 1000;
|
||||
|
||||
if (secondsDiff <= RECONNECT_THRESHOLD_SECONDS) {
|
||||
// 短时间内断开又重连 — 这是Blink hack的典型模式
|
||||
reasons.add("RAPID_RECONNECT: disconnected and rejoined within "
|
||||
+ secondsDiff + "s (threshold: " + RECONNECT_THRESHOLD_SECONDS + "s)");
|
||||
|
||||
// 检查是否在受击后断开
|
||||
Instant lastDamage = playerLastDamageTime.get(playerName);
|
||||
if (lastDamage != null) {
|
||||
long damageToDisconnect = lastDisconnect.toEpochMilli() - lastDamage.toEpochMilli();
|
||||
if (damageToDisconnect >= 0 && damageToDisconnect <= DAMAGE_DISCONNECT_WINDOW_MS) {
|
||||
Double dmg = playerLastDamageAmount.getOrDefault(playerName, 0.0);
|
||||
reasons.add("DAMAGE_EVASION: took " + String.format("%.1f", dmg)
|
||||
+ " damage, disconnected " + damageToDisconnect + "ms later, "
|
||||
+ "rejoined " + secondsDiff + "s after");
|
||||
flaggedCount.incrementAndGet();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 断连频率统计 — 在时间窗口内统计断连-重连周期次数
|
||||
Instant freqWindowStart = timestamp.minusSeconds(DISCONNECT_FREQ_WINDOW_SECONDS);
|
||||
long rapidReconnects = 0;
|
||||
Instant prevQuit = null;
|
||||
for (ConnectionEvent e : events) {
|
||||
if (e.timestamp.isBefore(freqWindowStart)) continue;
|
||||
if (e.type == ConnectionEvent.Type.QUIT) {
|
||||
prevQuit = e.timestamp;
|
||||
} else if (e.type == ConnectionEvent.Type.JOIN && prevQuit != null) {
|
||||
long diff = e.timestamp.toEpochMilli() - prevQuit.toEpochMilli();
|
||||
if (diff <= RECONNECT_THRESHOLD_SECONDS * 1000) {
|
||||
rapidReconnects++;
|
||||
}
|
||||
prevQuit = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (rapidReconnects >= MIN_RECONNECT_CYCLES) {
|
||||
reasons.add("FREQUENT_RAPID_RECONNECT: " + rapidReconnects
|
||||
+ " rapid reconnect cycles in " + DISCONNECT_FREQ_WINDOW_SECONDS + "s");
|
||||
flaggedCount.incrementAndGet();
|
||||
}
|
||||
|
||||
// 汇总结果
|
||||
if (reasons.size() >= 2) {
|
||||
return BlinkCheckResult.flagged(reasons);
|
||||
} else if (reasons.size() == 1) {
|
||||
return BlinkCheckResult.suspicious(reasons);
|
||||
}
|
||||
|
||||
return BlinkCheckResult.clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录玩家退出服务器事件
|
||||
*
|
||||
* @param playerName 玩家名称
|
||||
* @param timestamp 退出时间戳
|
||||
*/
|
||||
public void onPlayerQuit(String playerName, Instant timestamp) {
|
||||
if (!config.getSecurity().getSuperEvolution().isAntiBlink()) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<ConnectionEvent> events = playerConnectionEvents.computeIfAbsent(
|
||||
playerName, k -> new ArrayList<>());
|
||||
events.add(new ConnectionEvent(timestamp, ConnectionEvent.Type.QUIT));
|
||||
while (events.size() > MAX_CONNECTION_EVENTS) {
|
||||
events.remove(0);
|
||||
}
|
||||
|
||||
onlinePlayers.remove(playerName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录玩家受到伤害事件 — 用于后续关联受击与断连
|
||||
*
|
||||
* @param playerName 玩家名称
|
||||
* @param damageAmount 受到的伤害量
|
||||
* @param timestamp 受击时间戳
|
||||
*/
|
||||
public void recordPlayerDamage(String playerName, double damageAmount, Instant timestamp) {
|
||||
if (!config.getSecurity().getSuperEvolution().isAntiBlink()) {
|
||||
return;
|
||||
}
|
||||
|
||||
playerLastDamageTime.put(playerName, timestamp);
|
||||
playerLastDamageAmount.put(playerName, damageAmount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 玩家离线(非Blink的正常退出)时清理追踪数据
|
||||
* @param playerName 玩家名称
|
||||
*/
|
||||
public void clearPlayer(String playerName) {
|
||||
playerConnectionEvents.remove(playerName);
|
||||
playerLastDamageTime.remove(playerName);
|
||||
playerLastDamageAmount.remove(playerName);
|
||||
onlinePlayers.remove(playerName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模块运行状态
|
||||
* @return 包含计数器和在线玩家数的Map
|
||||
*/
|
||||
public Map<String, Object> getStatus() {
|
||||
Map<String, Object> status = new LinkedHashMap<>();
|
||||
status.put("totalChecks", totalChecks.get());
|
||||
status.put("flaggedCount", flaggedCount.get());
|
||||
status.put("onlinePlayers", onlinePlayers.size());
|
||||
status.put("trackedPlayers", playerConnectionEvents.size());
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部连接事件记录 — 记录玩家的加入/退出行为
|
||||
*/
|
||||
private static class ConnectionEvent {
|
||||
enum Type { JOIN, QUIT }
|
||||
|
||||
final Instant timestamp;
|
||||
final Type type;
|
||||
|
||||
ConnectionEvent(Instant timestamp, Type type) {
|
||||
this.timestamp = timestamp;
|
||||
this.type = type;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Blink检测结果 — 不可变结果类
|
||||
*/
|
||||
public static class BlinkCheckResult {
|
||||
private final boolean flagged;
|
||||
private final boolean suspicious;
|
||||
private final List<String> reasons;
|
||||
|
||||
private BlinkCheckResult(boolean flagged, boolean suspicious, List<String> reasons) {
|
||||
this.flagged = flagged;
|
||||
this.suspicious = suspicious;
|
||||
this.reasons = reasons;
|
||||
}
|
||||
|
||||
/** 无异常 — 正常连接行为 */
|
||||
public static BlinkCheckResult clean() {
|
||||
return new BlinkCheckResult(false, false, List.of());
|
||||
}
|
||||
|
||||
/** 可疑 — 检测到快速重连但证据不够充分(可能只是网络波动) */
|
||||
public static BlinkCheckResult suspicious(List<String> reasons) {
|
||||
return new BlinkCheckResult(false, true, reasons);
|
||||
}
|
||||
|
||||
/** 已标记 — 多项规则命中,高度可能使用Blink hack */
|
||||
public static BlinkCheckResult flagged(List<String> reasons) {
|
||||
return new BlinkCheckResult(true, false, reasons);
|
||||
}
|
||||
|
||||
public boolean isFlagged() { return flagged; }
|
||||
public boolean isSuspicious() { return suspicious; }
|
||||
public boolean isClean() { return !flagged && !suspicious; }
|
||||
public List<String> getReasons() { return reasons; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
package com.aluer.anticheat.movement;
|
||||
|
||||
import com.aluer.config.ServerGuardConfig;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* 鞘翅飞行操控(ElytraFly)检测服务 — V5.0 反作弊扩展模块
|
||||
*
|
||||
* 检测原理:
|
||||
* 1. 水平飞行速度检测 — 正常鞘翅飞行的最大水平速度约为30 blocks/秒
|
||||
* (配合烟花火箭可以在短时间内更快)。ElytraFly外挂可以实现超过100 blocks/秒
|
||||
* 的持续高速飞行。本模块追踪滑翔状态下的水平速度,检测持续超速。
|
||||
* 2. 垂直高度操控检测 — 正常鞘翅飞行中,玩家高度应当持续下降(滑翔特性)。
|
||||
* 外挂可以修改高度变化率,实现无烟花平飞甚至上升。
|
||||
* 检测无烟花火箭情况下的持续高度保持或上升行为。
|
||||
* 3. 伪烟花推进检测 — 烟花火箭可以给鞘翅提供瞬间加速。
|
||||
* 外挂可以模拟烟花推进效果而不消耗烟花。检测玩家在没有使用烟花的情况下
|
||||
* 出现的类似烟花加速的速度模式。
|
||||
* 4. 鞘翅抗击退检测 — 鞘翅飞行状态下受到攻击应有正常的击退位移。
|
||||
* 结合AntiVelocity检测逻辑,检测鞘翅飞行时的击退修改。
|
||||
* 5. 持续高速飞行时间检测 — 即使使用烟花,鞘翅飞行速度也有上限。
|
||||
* 检测超长时间的极端高速飞行(远超正常烟花推进持续时间)。
|
||||
*
|
||||
* 配置开关:serverguard.security.super-evolution.anti-elytra-fly
|
||||
*/
|
||||
@Service
|
||||
public class AntiElytraFlyService {
|
||||
|
||||
private final ServerGuardConfig config;
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的鞘翅飞行轨迹(playerName -> 飞行记录列表)
|
||||
* 用于分析飞行速度、高度变化和加速度模式
|
||||
*/
|
||||
private final Map<String, List<ElytraMovement>> playerElytraHistory = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的鞘翅飞行会话统计(playerName -> 当前飞行统计)
|
||||
* 用于追踪单次飞行的持续时间、最高速度等
|
||||
*/
|
||||
private final Map<String, ElytraFlightSession> playerFlightSessions = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的最近烟花使用时间(playerName -> 最近烟花时间)
|
||||
* 用于检测伪烟花推进
|
||||
*/
|
||||
private final Map<String, Instant> playerLastFireworkTime = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪各玩家在被检测到的超速持续时间(playerName -> 超速开始时间)
|
||||
* 用于区分短时速度尖峰和持续超速
|
||||
*/
|
||||
private final Map<String, Instant> playerOverspeedSince = new ConcurrentHashMap<>();
|
||||
|
||||
private final AtomicLong totalChecks = new AtomicLong(0);
|
||||
private final AtomicLong flaggedCount = new AtomicLong(0);
|
||||
|
||||
/**
|
||||
* 鞘翅飞行最大正常水平速度(blocks/秒)— 不包含烟花加速
|
||||
* 正常无烟花鞘翅飞行约30 blocks/秒为上限
|
||||
*/
|
||||
private static final double MAX_NORMAL_ELYTRA_SPEED = 30.0;
|
||||
|
||||
/**
|
||||
* 鞘翅飞行最大烟花加速水平速度(blocks/秒)— 使用烟花火箭时
|
||||
* 烟花加速后短时间内可以达到更高的速度
|
||||
*/
|
||||
private static final double MAX_FIREWORK_ELYTRA_SPEED = 55.0;
|
||||
|
||||
/**
|
||||
* 绝对异常速度阈值(blocks/秒)— 超过此值几乎肯定是外挂
|
||||
* 即使使用烟花也无法合法达到此速度
|
||||
*/
|
||||
private static final double ABSOLUTE_MAX_SPEED = 80.0;
|
||||
|
||||
/**
|
||||
* 高度操控阈值 — 无烟花时持续保持或上升的最大tick数
|
||||
* 正常滑翔应持续下降,不能长时间保持高度
|
||||
*/
|
||||
private static final int MAX_SUSTAINED_ALTITUDE_TICKS = 40;
|
||||
|
||||
/**
|
||||
* 高度变化检测阈值(blocks/tick)— Y轴变化小于此值视为高度保持
|
||||
* 正常滑翔至少下降约0.02 blocks/tick
|
||||
*/
|
||||
private static final double ALTITUDE_CHANGE_THRESHOLD = -0.005;
|
||||
|
||||
/**
|
||||
* 持续超速检测最小时间(毫秒)— 超速状态需持续至少1秒才标记
|
||||
*/
|
||||
private static final long MIN_OVERSPEED_DURATION_MS = 1_000;
|
||||
|
||||
/**
|
||||
* 伪烟花检测窗口(毫秒)— 检测在无烟花的此时间内出现的异常加速
|
||||
*/
|
||||
private static final long PSEUDO_FIREWORK_WINDOW_MS = 2_000;
|
||||
|
||||
/**
|
||||
* 加速度阈值(blocks/秒^2)— 无烟花时的加速度不应超过此值
|
||||
*/
|
||||
private static final double MAX_NATURAL_ACCELERATION = 15.0;
|
||||
|
||||
/**
|
||||
* 每个玩家保留的最大飞行记录数
|
||||
*/
|
||||
private static final int MAX_ELYTRA_RECORDS = 60;
|
||||
|
||||
/**
|
||||
* 无参构造函数 — 测试/默认配置使用
|
||||
*/
|
||||
public AntiElytraFlyService() {
|
||||
this(new ServerGuardConfig());
|
||||
}
|
||||
|
||||
/**
|
||||
* Spring 注入构造函数
|
||||
* @param config 全局配置,提供模块开关控制
|
||||
*/
|
||||
public AntiElytraFlyService(ServerGuardConfig config) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测玩家鞘翅飞行是否存在速度/高度操控
|
||||
*
|
||||
* @param playerName 玩家名称
|
||||
* @param playerUUID 玩家UUID
|
||||
* @param fromX 移动起始X坐标
|
||||
* @param fromY 移动起始Y坐标
|
||||
* @param fromZ 移动起始Z坐标
|
||||
* @param toX 移动结束X坐标
|
||||
* @param toY 移动结束Y坐标
|
||||
* @param toZ 移动结束Z坐标
|
||||
* @param isGliding 玩家是否处于鞘翅滑翔状态
|
||||
* @param deltaSeconds 本次移动的时间间隔(秒)
|
||||
* @param hasFirework 玩家是否在此时段使用了烟花火箭
|
||||
* @param gameMode 玩家游戏模式
|
||||
* @param timestamp 时间戳
|
||||
* @return 检测结果
|
||||
*/
|
||||
public ElytraFlyCheckResult detect(String playerName, String playerUUID,
|
||||
double fromX, double fromY, double fromZ,
|
||||
double toX, double toY, double toZ,
|
||||
boolean isGliding, double deltaSeconds,
|
||||
boolean hasFirework, String gameMode,
|
||||
Instant timestamp) {
|
||||
// 模块开关检查 — 关闭时跳过所有检测
|
||||
if (!config.getSecurity().getSuperEvolution().isAntiElytraFly()) {
|
||||
return ElytraFlyCheckResult.clean();
|
||||
}
|
||||
|
||||
// 非滑翔状态不检测(但仍需要清理飞行会话)
|
||||
if (!isGliding) {
|
||||
playerFlightSessions.remove(playerName);
|
||||
playerOverspeedSince.remove(playerName);
|
||||
return ElytraFlyCheckResult.clean();
|
||||
}
|
||||
|
||||
// 创造/观察者模式不检测
|
||||
if ("creative".equalsIgnoreCase(gameMode) || "spectator".equalsIgnoreCase(gameMode)) {
|
||||
return ElytraFlyCheckResult.clean();
|
||||
}
|
||||
|
||||
totalChecks.incrementAndGet();
|
||||
List<String> reasons = new ArrayList<>();
|
||||
|
||||
// 避免除零
|
||||
if (deltaSeconds <= 0) {
|
||||
deltaSeconds = 0.05;
|
||||
}
|
||||
|
||||
// 计算水平速度
|
||||
double dx = toX - fromX;
|
||||
double dz = toZ - fromZ;
|
||||
double horizontalDistance = Math.sqrt(dx * dx + dz * dz);
|
||||
double horizontalSpeed = horizontalDistance / deltaSeconds;
|
||||
|
||||
// 计算垂直速度
|
||||
double dy = toY - fromY;
|
||||
double verticalSpeed = dy / deltaSeconds;
|
||||
|
||||
// 获取或创建飞行会话
|
||||
ElytraFlightSession session = playerFlightSessions.computeIfAbsent(
|
||||
playerName, k -> new ElytraFlightSession());
|
||||
session.update(horizontalSpeed, verticalSpeed, timestamp);
|
||||
|
||||
// 获取或创建飞行历史
|
||||
List<ElytraMovement> history = playerElytraHistory.computeIfAbsent(
|
||||
playerName, k -> new ArrayList<>());
|
||||
ElytraMovement movement = new ElytraMovement(timestamp, horizontalSpeed, verticalSpeed,
|
||||
hasFirework, deltaSeconds);
|
||||
history.add(movement);
|
||||
while (history.size() > MAX_ELYTRA_RECORDS) {
|
||||
history.remove(0);
|
||||
}
|
||||
|
||||
// 记录烟花使用时间
|
||||
if (hasFirework) {
|
||||
playerLastFireworkTime.put(playerName, timestamp);
|
||||
}
|
||||
|
||||
// 1. 绝对异常速度检测 — 超过绝对最大速度
|
||||
if (horizontalSpeed > ABSOLUTE_MAX_SPEED) {
|
||||
// 这个速度无论任何合法手段都无法达到
|
||||
flaggedCount.incrementAndGet();
|
||||
reasons.add("CRITICAL_SPEED: " + String.format("%.2f", horizontalSpeed)
|
||||
+ " blocks/s exceeds absolute max of "
|
||||
+ String.format("%.1f", ABSOLUTE_MAX_SPEED) + " blocks/s");
|
||||
}
|
||||
|
||||
// 2. 水平速度检测 — 区分有无烟花
|
||||
double speedLimit = hasFirework ? MAX_FIREWORK_ELYTRA_SPEED : MAX_NORMAL_ELYTRA_SPEED;
|
||||
|
||||
if (horizontalSpeed > speedLimit) {
|
||||
// 记录超速开始时间
|
||||
playerOverspeedSince.putIfAbsent(playerName, timestamp);
|
||||
|
||||
Instant overspeedStart = playerOverspeedSince.get(playerName);
|
||||
long overspeedDurationMs = timestamp.toEpochMilli() - overspeedStart.toEpochMilli();
|
||||
|
||||
if (overspeedDurationMs >= MIN_OVERSPEED_DURATION_MS) {
|
||||
// 持续超速 — 确认为外挂
|
||||
reasons.add("SUSTAINED_ELYTRA_OVERSPEED: "
|
||||
+ String.format("%.2f", horizontalSpeed) + " blocks/s for "
|
||||
+ (overspeedDurationMs / 1000) + "s (limit: "
|
||||
+ String.format("%.1f", speedLimit) + ", firework: " + hasFirework + ")");
|
||||
}
|
||||
} else {
|
||||
// 速度恢复正常
|
||||
playerOverspeedSince.remove(playerName);
|
||||
}
|
||||
|
||||
// 3. 高度操控检测 — 无烟花时持续保持高度或上升
|
||||
if (!hasFirework) {
|
||||
if (verticalSpeed > ALTITUDE_CHANGE_THRESHOLD) {
|
||||
// 高度未下降(保持或上升),递增计数
|
||||
session.sustainedAltitudeTicks++;
|
||||
if (session.sustainedAltitudeTicks > MAX_SUSTAINED_ALTITUDE_TICKS) {
|
||||
reasons.add("ALTITUDE_CONTROL: maintained/ascended for "
|
||||
+ session.sustainedAltitudeTicks + " ticks without firework "
|
||||
+ "(vertical speed: " + String.format("%.3f", verticalSpeed) + " blocks/tick)");
|
||||
}
|
||||
} else {
|
||||
// 正常下降
|
||||
session.sustainedAltitudeTicks = 0;
|
||||
}
|
||||
} else {
|
||||
// 有烟花时重置高度保持计数(烟花可以维持高度)
|
||||
session.sustainedAltitudeTicks = 0;
|
||||
}
|
||||
|
||||
// 4. 伪烟花推进检测 — 无烟花但出现类似烟花加速的速度模式
|
||||
Instant lastFirework = playerLastFireworkTime.get(playerName);
|
||||
boolean recentFirework = lastFirework != null
|
||||
&& timestamp.toEpochMilli() - lastFirework.toEpochMilli() < PSEUDO_FIREWORK_WINDOW_MS;
|
||||
|
||||
if (!hasFirework && !recentFirework && history.size() >= 2) {
|
||||
// 计算加速度
|
||||
ElytraMovement prev = history.get(history.size() - 2);
|
||||
double acceleration = (horizontalSpeed - prev.horizontalSpeed) / deltaSeconds;
|
||||
|
||||
// 无烟花时不应出现剧烈正加速度
|
||||
if (acceleration > MAX_NATURAL_ACCELERATION && horizontalSpeed > MAX_NORMAL_ELYTRA_SPEED) {
|
||||
reasons.add("PSEUDO_FIREWORK: acceleration " + String.format("%.2f", acceleration)
|
||||
+ " blocks/s^2 without firework (speed: " + String.format("%.2f", horizontalSpeed)
|
||||
+ " blocks/s, max natural: " + String.format("%.1f", MAX_NATURAL_ACCELERATION)
|
||||
+ " blocks/s^2)");
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 飞行持续时间与最大速度统计
|
||||
if (session.maxSpeed > MAX_FIREWORK_ELYTRA_SPEED) {
|
||||
long flightDuration = timestamp.toEpochMilli() - session.flightStartTime.toEpochMilli();
|
||||
if (flightDuration > 10_000) {
|
||||
// 超过10秒的高速飞行
|
||||
reasons.add("EXTREME_FLIGHT: max speed " + String.format("%.2f", session.maxSpeed)
|
||||
+ " blocks/s sustained over " + (flightDuration / 1000) + "s");
|
||||
}
|
||||
}
|
||||
|
||||
// 汇总结果
|
||||
if (reasons.size() >= 2) {
|
||||
flaggedCount.incrementAndGet();
|
||||
return ElytraFlyCheckResult.flagged(reasons);
|
||||
} else if (reasons.size() == 1) {
|
||||
return ElytraFlyCheckResult.suspicious(reasons);
|
||||
}
|
||||
|
||||
return ElytraFlyCheckResult.clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* 玩家停止滑翔时调用,结束飞行会话
|
||||
* @param playerName 玩家名称
|
||||
*/
|
||||
public void onStopGliding(String playerName) {
|
||||
playerFlightSessions.remove(playerName);
|
||||
playerOverspeedSince.remove(playerName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录玩家使用了烟花火箭(Agent监听器调用)
|
||||
* @param playerName 玩家名称
|
||||
* @param timestamp 使用时间戳
|
||||
*/
|
||||
public void recordFireworkUse(String playerName, Instant timestamp) {
|
||||
playerLastFireworkTime.put(playerName, timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* 玩家离线时清理其追踪数据,释放内存
|
||||
* @param playerName 离线玩家名称
|
||||
*/
|
||||
public void clearPlayer(String playerName) {
|
||||
playerElytraHistory.remove(playerName);
|
||||
playerFlightSessions.remove(playerName);
|
||||
playerLastFireworkTime.remove(playerName);
|
||||
playerOverspeedSince.remove(playerName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模块运行状态
|
||||
* @return 包含计数器、活跃追踪玩家数和最高速度的Map
|
||||
*/
|
||||
public Map<String, Object> getStatus() {
|
||||
Map<String, Object> status = new LinkedHashMap<>();
|
||||
status.put("totalChecks", totalChecks.get());
|
||||
status.put("flaggedCount", flaggedCount.get());
|
||||
status.put("activeTrackedPlayers", playerElytraHistory.size());
|
||||
status.put("activeFlightSessions", playerFlightSessions.size());
|
||||
|
||||
// 列出当前在飞的玩家及其最大速度
|
||||
List<Map<String, Object>> activeFlyers = new ArrayList<>();
|
||||
for (Map.Entry<String, ElytraFlightSession> e : playerFlightSessions.entrySet()) {
|
||||
ElytraFlightSession s = e.getValue();
|
||||
long duration = Instant.now().toEpochMilli() - s.flightStartTime.toEpochMilli();
|
||||
Map<String, Object> info = new LinkedHashMap<>();
|
||||
info.put("player", e.getKey());
|
||||
info.put("maxSpeed", String.format("%.2f", s.maxSpeed));
|
||||
info.put("flightDurationSeconds", duration / 1000);
|
||||
info.put("sustainedAltitudeTicks", s.sustainedAltitudeTicks);
|
||||
activeFlyers.add(info);
|
||||
}
|
||||
status.put("activeFlyers", activeFlyers);
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部鞘翅飞行移动记录
|
||||
*/
|
||||
private static class ElytraMovement {
|
||||
final Instant timestamp;
|
||||
final double horizontalSpeed;
|
||||
final double verticalSpeed;
|
||||
final boolean hasFirework;
|
||||
final double deltaSeconds;
|
||||
|
||||
ElytraMovement(Instant timestamp, double horizontalSpeed, double verticalSpeed,
|
||||
boolean hasFirework, double deltaSeconds) {
|
||||
this.timestamp = timestamp;
|
||||
this.horizontalSpeed = horizontalSpeed;
|
||||
this.verticalSpeed = verticalSpeed;
|
||||
this.hasFirework = hasFirework;
|
||||
this.deltaSeconds = deltaSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部鞘翅飞行会话统计 — 追踪单次飞行的持续时间和极值
|
||||
*/
|
||||
private static class ElytraFlightSession {
|
||||
final Instant flightStartTime;
|
||||
double maxSpeed;
|
||||
int sustainedAltitudeTicks;
|
||||
|
||||
ElytraFlightSession() {
|
||||
this.flightStartTime = Instant.now();
|
||||
this.maxSpeed = 0;
|
||||
this.sustainedAltitudeTicks = 0;
|
||||
}
|
||||
|
||||
void update(double horizontalSpeed, double verticalSpeed, Instant now) {
|
||||
this.maxSpeed = Math.max(this.maxSpeed, horizontalSpeed);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 鞘翅飞行检测结果 — 不可变结果类
|
||||
*/
|
||||
public static class ElytraFlyCheckResult {
|
||||
private final boolean flagged;
|
||||
private final boolean suspicious;
|
||||
private final List<String> reasons;
|
||||
|
||||
private ElytraFlyCheckResult(boolean flagged, boolean suspicious, List<String> reasons) {
|
||||
this.flagged = flagged;
|
||||
this.suspicious = suspicious;
|
||||
this.reasons = reasons;
|
||||
}
|
||||
|
||||
/** 无异常 — 正常鞘翅飞行 */
|
||||
public static ElytraFlyCheckResult clean() {
|
||||
return new ElytraFlyCheckResult(false, false, List.of());
|
||||
}
|
||||
|
||||
/** 可疑 — 速度偏高但证据不够充分(可能为烟花/地形因素) */
|
||||
public static ElytraFlyCheckResult suspicious(List<String> reasons) {
|
||||
return new ElytraFlyCheckResult(false, true, reasons);
|
||||
}
|
||||
|
||||
/** 已标记 — 多项规则命中,确认为鞘翅飞行操控 */
|
||||
public static ElytraFlyCheckResult flagged(List<String> reasons) {
|
||||
return new ElytraFlyCheckResult(true, false, reasons);
|
||||
}
|
||||
|
||||
public boolean isFlagged() { return flagged; }
|
||||
public boolean isSuspicious() { return suspicious; }
|
||||
public boolean isClean() { return !flagged && !suspicious; }
|
||||
public List<String> getReasons() { return reasons; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
package com.aluer.anticheat.movement;
|
||||
|
||||
import com.aluer.config.ServerGuardConfig;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* 快速坠落(FastFall)检测服务 — V5.2 反作弊移动模块
|
||||
*
|
||||
* 检测原理:
|
||||
* 1. 超终端速度检测 — Minecraft中正常坠落的最大速度(终端速度)约为3.92方块/tick
|
||||
* (78.4 m/s)。FastFall hack通过修改客户端,使下落速度持续超过5+方块/tick。
|
||||
* 2. 持续超速检测 — 区分偶然的网络延迟导致的瞬间速度尖峰和持续性的FastFall作弊。
|
||||
* 要求连续多tick的下落速度超过终端速度才判定为作弊。
|
||||
* 3. 瞬间坠落检测 — FastFall常与NoFall配合使用,实现"瞬间坠落到地面但不受伤"的效果。
|
||||
* 检测玩家在极短时间内Y坐标大幅下降但又出现到地面且无伤害的情况。
|
||||
* 4. 下落加速度异常检测 — 正常自由落体加速度为-0.08方块/tick²(重力加速度),
|
||||
* FastFall的下落加速度远超此值。检测下落过程中的Y速度变化率。
|
||||
* 5. 下落模式与合法情况区分 — 排除鞘翅俯冲(合法快速下落)、
|
||||
* 水中下沉(不同物理规则)、使用末影珍珠等合法快速下落方式。
|
||||
*
|
||||
* 配置开关:serverguard.security.super-evolution.anti-fast-fall
|
||||
*/
|
||||
@Service
|
||||
public class AntiFastFallService {
|
||||
|
||||
private final ServerGuardConfig config;
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的Y速度历史(playerName -> Y速度历史列表)
|
||||
*/
|
||||
private final Map<String, List<Double>> playerYVelocityHistory = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的下落记录(playerName -> 下落事件列表)
|
||||
*/
|
||||
private final Map<String, List<FallEvent>> playerFallEvents = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的连续超速下落tick数(playerName -> 连续tick数)
|
||||
*/
|
||||
private final Map<String, Integer> playerOverspeedTicks = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家当前下落过程的起始Y坐标(playerName -> 起始Y)
|
||||
*/
|
||||
private final Map<String, Double> playerFallStartY = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家是否处于下落状态(playerName -> Boolean)
|
||||
*/
|
||||
private final Map<String, Boolean> playerInFall = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家下落过程中经过的tick数(playerName -> tick计数)
|
||||
*/
|
||||
private final Map<String, Integer> playerFallTickCount = new ConcurrentHashMap<>();
|
||||
|
||||
private final AtomicLong totalChecks = new AtomicLong(0);
|
||||
private final AtomicLong fastFallViolations = new AtomicLong(0);
|
||||
|
||||
/**
|
||||
* Minecraft正常终端速度(方块/tick)
|
||||
* 自由落体最大速度约3.92 blocks/tick = 78.4 m/s
|
||||
* 这个值是由重力加速度(0.08)和空气阻力(0.98)的平衡决定的
|
||||
*/
|
||||
private static final double TERMINAL_VELOCITY = 3.92;
|
||||
|
||||
/**
|
||||
* FastFall检测阈值 — 明显超出终端速度的速度(方块/tick)
|
||||
* 留出少量余量以容忍浮点误差和合法的短暂速度尖峰
|
||||
*/
|
||||
private static final double FAST_FALL_THRESHOLD = 5.0;
|
||||
|
||||
/**
|
||||
* 连续超速tick阈值 — 需要连续超过此tick数才判定为作弊
|
||||
* 低于此值可能是网络延迟/丢包导致的数据包积压后一次性处理
|
||||
*/
|
||||
private static final int MIN_CONSECUTIVE_OVERSPEED_TICKS = 5;
|
||||
|
||||
/**
|
||||
* 瞬间坠落检测阈值(方块)— 单tick内Y坐标下降超过此值
|
||||
*/
|
||||
private static final double INSTANT_FALL_THRESHOLD = 10.0;
|
||||
|
||||
/**
|
||||
* Y速度正值阈值 — 低于此值的波动忽略
|
||||
*/
|
||||
private static final double ZERO_VELOCITY_THRESHOLD = 0.01;
|
||||
|
||||
/**
|
||||
* 正常重力加速度(方块/tick²)
|
||||
*/
|
||||
private static final double GRAVITY_ACCELERATION = 0.08;
|
||||
|
||||
/**
|
||||
* FastFall加速度阈值 — 下落加速度超过重力的数倍即异常
|
||||
*/
|
||||
private static final double ABNORMAL_ACCELERATION = GRAVITY_ACCELERATION * 3.0;
|
||||
|
||||
/**
|
||||
* 每个玩家保留的最大记录数
|
||||
*/
|
||||
private static final int MAX_RECORDS_PER_PLAYER = 60;
|
||||
|
||||
/**
|
||||
* 无参构造函数 — 测试/默认配置使用
|
||||
*/
|
||||
public AntiFastFallService() {
|
||||
this(new ServerGuardConfig());
|
||||
}
|
||||
|
||||
/**
|
||||
* Spring 注入构造函数
|
||||
* @param config 全局配置,提供模块开关控制
|
||||
*/
|
||||
public AntiFastFallService(ServerGuardConfig config) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测玩家是否使用了加速下落(FastFall hack)
|
||||
*
|
||||
* @param playerName 玩家名称
|
||||
* @param playerUUID 玩家UUID
|
||||
* @param fromY 移动起始Y坐标
|
||||
* @param toY 移动结束Y坐标
|
||||
* @param yVelocity 当前tick Y速度(方块/tick,负值表示下落)
|
||||
* @param prevYVelocity 上一tick Y速度
|
||||
* @param onGround 玩家是否在地面
|
||||
* @param isInWater 玩家是否在水中(排除合法水中下沉)
|
||||
* @param isInLava 玩家是否在岩浆中
|
||||
* @param isElytraFlying 玩家是否鞘翅飞行(排除合法俯冲)
|
||||
* @param isClimbing 玩家是否在攀爬(梯子/藤蔓)
|
||||
* @param timestamp 时间戳
|
||||
* @return 检测结果
|
||||
*/
|
||||
public FastFallCheckResult detect(String playerName, String playerUUID,
|
||||
double fromY, double toY,
|
||||
double yVelocity, double prevYVelocity,
|
||||
boolean onGround, boolean isInWater,
|
||||
boolean isInLava, boolean isElytraFlying,
|
||||
boolean isClimbing, Instant timestamp) {
|
||||
// 模块开关检查 — 关闭时跳过所有检测
|
||||
if (!config.getSecurity().getSuperEvolution().isAntiFastFall()) {
|
||||
return FastFallCheckResult.clean();
|
||||
}
|
||||
|
||||
totalChecks.incrementAndGet();
|
||||
|
||||
// 合法的快速下落情况 — 不需要检测
|
||||
if (isInWater || isInLava || isElytraFlying || isClimbing) {
|
||||
playerOverspeedTicks.remove(playerName);
|
||||
playerInFall.remove(playerName);
|
||||
return FastFallCheckResult.clean();
|
||||
}
|
||||
|
||||
double dy = toY - fromY;
|
||||
double absYVelocity = Math.abs(yVelocity);
|
||||
boolean isFalling = dy < 0 && Math.abs(yVelocity) > ZERO_VELOCITY_THRESHOLD;
|
||||
|
||||
List<Double> yVelHistory = playerYVelocityHistory.computeIfAbsent(
|
||||
playerName, k -> new ArrayList<>());
|
||||
yVelHistory.add(yVelocity);
|
||||
while (yVelHistory.size() > MAX_RECORDS_PER_PLAYER) {
|
||||
yVelHistory.remove(0);
|
||||
}
|
||||
|
||||
List<FallEvent> fallEvents = playerFallEvents.computeIfAbsent(
|
||||
playerName, k -> new ArrayList<>());
|
||||
|
||||
List<String> reasons = new ArrayList<>();
|
||||
|
||||
// ─── 检测1:持续超终端速度下落 ───
|
||||
if (isFalling && absYVelocity > TERMINAL_VELOCITY) {
|
||||
// 下落速度超过终端速度
|
||||
if (absYVelocity > FAST_FALL_THRESHOLD) {
|
||||
int overspeedTicks = playerOverspeedTicks.getOrDefault(playerName, 0) + 1;
|
||||
playerOverspeedTicks.put(playerName, overspeedTicks);
|
||||
|
||||
// 记录下落事件
|
||||
FallEvent event = new FallEvent(timestamp, fromY, toY, absYVelocity,
|
||||
overspeedTicks);
|
||||
fallEvents.add(event);
|
||||
while (fallEvents.size() > MAX_RECORDS_PER_PLAYER) {
|
||||
fallEvents.remove(0);
|
||||
}
|
||||
|
||||
if (overspeedTicks >= MIN_CONSECUTIVE_OVERSPEED_TICKS) {
|
||||
reasons.add("SUSTAINED_OVERSPEED_FALL: " + String.format("%.2f", absYVelocity)
|
||||
+ " blocks/tick downward for " + overspeedTicks
|
||||
+ " consecutive ticks (terminal velocity: "
|
||||
+ String.format("%.2f", TERMINAL_VELOCITY + " blocks/tick)"));
|
||||
fastFallViolations.incrementAndGet();
|
||||
}
|
||||
} else {
|
||||
// 超过终端速度但未达到FastFall阈值 — 轻微超速,重置计数
|
||||
// 但保留最近记录作为可疑模式
|
||||
if (absYVelocity > TERMINAL_VELOCITY * 1.1) {
|
||||
reasons.add("SLIGHT_OVERSPEED: " + String.format("%.2f", absYVelocity)
|
||||
+ " blocks/tick, slightly above terminal "
|
||||
+ String.format("%.2f", TERMINAL_VELOCITY));
|
||||
}
|
||||
playerOverspeedTicks.remove(playerName);
|
||||
}
|
||||
} else if (isFalling) {
|
||||
// 正常下落速度 — 重置超速计数
|
||||
playerOverspeedTicks.remove(playerName);
|
||||
}
|
||||
|
||||
// ─── 检测2:瞬间坠落(Instant Fall)— 配合NoFall使用 ───
|
||||
if (dy < -INSTANT_FALL_THRESHOLD && absYVelocity > FAST_FALL_THRESHOLD * 2) {
|
||||
reasons.add("INSTANT_FALL: dropped " + String.format("%.1f", Math.abs(dy))
|
||||
+ " blocks in one tick at " + String.format("%.2f", absYVelocity)
|
||||
+ " blocks/tick (possibly combined with NoFall)");
|
||||
fastFallViolations.incrementAndGet();
|
||||
}
|
||||
|
||||
// ─── 检测3:下落加速度异常 — 加速度远超正常重力 ───
|
||||
if (isFalling && yVelHistory.size() >= 3) {
|
||||
int size = yVelHistory.size();
|
||||
// 计算最近两个tick之间的加速度(Y速度更负 = 加速下落)
|
||||
double vel1 = yVelHistory.get(size - 2);
|
||||
double vel2 = yVelHistory.get(size - 1);
|
||||
double acceleration = Math.abs(vel2 - vel1); // 每tick的速度变化
|
||||
|
||||
// 正常下落加速度约0.08方块/tick²
|
||||
// FastFall的下落加速度可能远超此值
|
||||
if (acceleration > ABNORMAL_ACCELERATION && vel2 < 0 && vel1 < 0
|
||||
&& Math.abs(vel2) > Math.abs(vel1)) {
|
||||
reasons.add("ABNORMAL_FALL_ACCELERATION: velocity change of "
|
||||
+ String.format("%.3f", acceleration) + " blocks/tick²"
|
||||
+ " (normal gravity: " + String.format("%.2f", GRAVITY_ACCELERATION)
|
||||
+ ", yVel went from " + String.format("%.2f", vel1)
|
||||
+ " to " + String.format("%.2f", vel2) + ")");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 检测4:下落模式分析 — 检查近期下落事件是否有规律性超速 ───
|
||||
if (fallEvents.size() >= 10) {
|
||||
long overspeedEvents = fallEvents.stream()
|
||||
.filter(e -> e.absSpeed > FAST_FALL_THRESHOLD)
|
||||
.count();
|
||||
if (overspeedEvents >= 5) {
|
||||
reasons.add("PATTERN_OVERSPEED_FALLS: " + overspeedEvents
|
||||
+ " of last " + fallEvents.size() + " falls exceeded "
|
||||
+ String.format("%.2f", FAST_FALL_THRESHOLD) + " blocks/tick");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 检测5:下落速度一致性检查 — FastFall通常维持恒定超速 ───
|
||||
if (yVelHistory.size() >= 8 && !onGround) {
|
||||
// 取最近8个tick中有下落速度的tick
|
||||
List<Double> recentFalls = new ArrayList<>();
|
||||
for (int i = Math.max(0, yVelHistory.size() - 8); i < yVelHistory.size(); i++) {
|
||||
double v = yVelHistory.get(i);
|
||||
if (v < -ZERO_VELOCITY_THRESHOLD) {
|
||||
recentFalls.add(Math.abs(v));
|
||||
}
|
||||
}
|
||||
if (recentFalls.size() >= 5) {
|
||||
double avgSpeed = recentFalls.stream().mapToDouble(Double::doubleValue).average().orElse(0);
|
||||
double minSpeed = recentFalls.stream().mapToDouble(Double::doubleValue).min().orElse(0);
|
||||
|
||||
// 如果最小速度都超过终端速度,高度可疑
|
||||
if (minSpeed > TERMINAL_VELOCITY * 1.2 && recentFalls.size() >= 6) {
|
||||
reasons.add("CONSISTENT_OVERSPEED: all " + recentFalls.size()
|
||||
+ " recent fall ticks exceeded terminal velocity"
|
||||
+ " (min=" + String.format("%.2f", minSpeed)
|
||||
+ ", avg=" + String.format("%.2f", avgSpeed) + " blocks/tick)");
|
||||
fastFallViolations.incrementAndGet();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (reasons.size() >= 2) {
|
||||
return FastFallCheckResult.flagged(reasons);
|
||||
} else if (reasons.size() == 1) {
|
||||
return FastFallCheckResult.suspicious(reasons);
|
||||
}
|
||||
|
||||
return FastFallCheckResult.clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* 玩家离线时清理追踪数据
|
||||
* @param playerName 离线玩家名称
|
||||
*/
|
||||
public void clearPlayer(String playerName) {
|
||||
playerYVelocityHistory.remove(playerName);
|
||||
playerFallEvents.remove(playerName);
|
||||
playerOverspeedTicks.remove(playerName);
|
||||
playerFallStartY.remove(playerName);
|
||||
playerInFall.remove(playerName);
|
||||
playerFallTickCount.remove(playerName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模块运行状态
|
||||
* @return 包含计数器和违规数量的Map
|
||||
*/
|
||||
public Map<String, Object> getStatus() {
|
||||
Map<String, Object> status = new LinkedHashMap<>();
|
||||
status.put("totalChecks", totalChecks.get());
|
||||
status.put("fastFallViolations", fastFallViolations.get());
|
||||
status.put("activeTrackedPlayers", playerYVelocityHistory.size());
|
||||
|
||||
// 列出当前超速下落的玩家
|
||||
List<Map<String, Object>> overspeedPlayers = new ArrayList<>();
|
||||
for (Map.Entry<String, Integer> entry : playerOverspeedTicks.entrySet()) {
|
||||
if (entry.getValue() >= 1) {
|
||||
Map<String, Object> info = new LinkedHashMap<>();
|
||||
info.put("player", entry.getKey());
|
||||
info.put("overspeedTicks", entry.getValue());
|
||||
overspeedPlayers.add(info);
|
||||
}
|
||||
}
|
||||
status.put("overspeedPlayers", overspeedPlayers);
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部下落事件记录 — 记录单次超速下落事件
|
||||
*/
|
||||
private static class FallEvent {
|
||||
final Instant timestamp;
|
||||
final double fromY;
|
||||
final double toY;
|
||||
final double absSpeed;
|
||||
final int consecutiveTick;
|
||||
|
||||
FallEvent(Instant timestamp, double fromY, double toY,
|
||||
double absSpeed, int consecutiveTick) {
|
||||
this.timestamp = timestamp;
|
||||
this.fromY = fromY;
|
||||
this.toY = toY;
|
||||
this.absSpeed = absSpeed;
|
||||
this.consecutiveTick = consecutiveTick;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FastFall检测结果 — 不可变结果类
|
||||
*/
|
||||
public static class FastFallCheckResult {
|
||||
private final boolean flagged;
|
||||
private final boolean suspicious;
|
||||
private final List<String> reasons;
|
||||
|
||||
private FastFallCheckResult(boolean flagged, boolean suspicious, List<String> reasons) {
|
||||
this.flagged = flagged;
|
||||
this.suspicious = suspicious;
|
||||
this.reasons = reasons;
|
||||
}
|
||||
|
||||
/** 无异常 — 正常的下落速度 */
|
||||
public static FastFallCheckResult clean() {
|
||||
return new FastFallCheckResult(false, false, List.of());
|
||||
}
|
||||
|
||||
/** 可疑 — 暂时性超速但证据不充分 */
|
||||
public static FastFallCheckResult suspicious(List<String> reasons) {
|
||||
return new FastFallCheckResult(false, true, reasons);
|
||||
}
|
||||
|
||||
/** 已标记 — 确认FastFall hack,持续超终端速度下落 */
|
||||
public static FastFallCheckResult flagged(List<String> reasons) {
|
||||
return new FastFallCheckResult(true, false, reasons);
|
||||
}
|
||||
|
||||
public boolean isFlagged() { return flagged; }
|
||||
public boolean isSuspicious() { return suspicious; }
|
||||
public boolean isClean() { return !flagged && !suspicious; }
|
||||
public List<String> getReasons() { return reasons; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package com.aluer.anticheat.movement;
|
||||
|
||||
import com.aluer.config.ServerGuardConfig;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
@Service
|
||||
public class AntiFlyDetectionService {
|
||||
|
||||
private final ServerGuardConfig config;
|
||||
|
||||
private final Map<String, PlayerMovementData> movementData = new ConcurrentHashMap<>();
|
||||
private final Map<String, Instant> flaggedPlayers = new ConcurrentHashMap<>();
|
||||
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
|
||||
private final AtomicLong totalDetections = new AtomicLong(0);
|
||||
|
||||
private static final double MAX_VERTICAL_SPEED = 0.42;
|
||||
private static final double MAX_HORIZONTAL_SPEED_SURVIVAL = 9.0;
|
||||
private static final double MAX_HORIZONTAL_SPEED_CREATIVE = 15.0;
|
||||
private static final double MAX_VERTICAL_SPEED_ELYTRA = 3.0;
|
||||
private static final int MAX_CONSECUTIVE_AIR_TICKS = 20;
|
||||
private static final double IMPOSSIBLE_Y_CHANGE = 5.0;
|
||||
private static final int MOVEMENT_BUFFER_SIZE = 100;
|
||||
private static final int MIN_MOVEMENTS_FOR_ANALYSIS = 20;
|
||||
private static final long FLAG_DURATION_SECONDS = 3600;
|
||||
|
||||
public AntiFlyDetectionService() {
|
||||
this(new ServerGuardConfig());
|
||||
}
|
||||
|
||||
public AntiFlyDetectionService(ServerGuardConfig config) {
|
||||
this.config = config;
|
||||
scheduler.scheduleAtFixedRate(this::cleanupOldData, 120, 300, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
public FlyCheckResult checkMovement(String playerName, double fromX, double fromY, double fromZ,
|
||||
double toX, double toY, double toZ, boolean onGround, String gameMode) {
|
||||
if (!config.getSecurity().getSuperEvolution().isAntiFly()) {
|
||||
return FlyCheckResult.clean();
|
||||
}
|
||||
|
||||
if (flaggedPlayers.containsKey(playerName)) {
|
||||
if (Instant.now().isAfter(flaggedPlayers.get(playerName))) {
|
||||
flaggedPlayers.remove(playerName);
|
||||
} else {
|
||||
return FlyCheckResult.flagged("Player is flagged for movement hacks");
|
||||
}
|
||||
}
|
||||
|
||||
PlayerMovementData data = movementData.computeIfAbsent(playerName, k -> new PlayerMovementData());
|
||||
data.recordMovement(fromX, fromY, fromZ, toX, toY, toZ, onGround);
|
||||
|
||||
int score = 0;
|
||||
List<String> reasons = new ArrayList<>();
|
||||
|
||||
double dx = toX - fromX;
|
||||
double dy = toY - fromY;
|
||||
double dz = toZ - fromZ;
|
||||
double horizontalDist = Math.sqrt(dx * dx + dz * dz);
|
||||
double verticalSpeed = Math.abs(dy);
|
||||
|
||||
// Rule 1: Impossible vertical movement (no fall + rapid rise, not elytra)
|
||||
boolean isCreative = "creative".equalsIgnoreCase(gameMode) || "spectator".equalsIgnoreCase(gameMode);
|
||||
if (!onGround && !isCreative && verticalSpeed > MAX_VERTICAL_SPEED * 3 && dy > 3.0) {
|
||||
score += 35;
|
||||
reasons.add("IMPOSSIBLE_VERTICAL: " + String.format("%.2f", verticalSpeed) + " blocks/tick (no ground)");
|
||||
}
|
||||
|
||||
// Rule 2: Excessive horizontal speed
|
||||
double gameModeLimit = "creative".equalsIgnoreCase(gameMode) || "spectator".equalsIgnoreCase(gameMode)
|
||||
? MAX_HORIZONTAL_SPEED_CREATIVE : MAX_HORIZONTAL_SPEED_SURVIVAL;
|
||||
if (horizontalDist > gameModeLimit) {
|
||||
score += 35;
|
||||
reasons.add("EXCESSIVE_HORIZONTAL: " + String.format("%.2f", horizontalDist) + " blocks/tick");
|
||||
}
|
||||
|
||||
// Rule 3: Sustained air time (flying without elytra)
|
||||
if (!onGround) {
|
||||
data.consecutiveAirTicks++;
|
||||
if (data.consecutiveAirTicks > MAX_CONSECUTIVE_AIR_TICKS && verticalSpeed < 0.1 && verticalSpeed > -0.5) {
|
||||
score += 25;
|
||||
reasons.add("SUSTAINED_AIR: " + data.consecutiveAirTicks + " ticks");
|
||||
}
|
||||
} else {
|
||||
data.consecutiveAirTicks = 0;
|
||||
}
|
||||
|
||||
// Rule 4: No fall damage (anti-knockback/anti-fall)
|
||||
if (!onGround && dy < -IMPOSSIBLE_Y_CHANGE && !data.prevOnGround) {
|
||||
data.freefallDistance += Math.abs(dy);
|
||||
}
|
||||
if (onGround && data.freefallDistance > 10) {
|
||||
score += 20;
|
||||
reasons.add("NO_FALL_DAMAGE: fell " + String.format("%.1f", data.freefallDistance) + " blocks");
|
||||
data.freefallDistance = 0;
|
||||
}
|
||||
|
||||
// Rule 5: Phase detection (moving through blocks)
|
||||
if (data.totalMovements > MIN_MOVEMENTS_FOR_ANALYSIS) {
|
||||
double avgSpeed = data.totalDistance / Math.max(1, data.totalMovements);
|
||||
if (avgSpeed > MAX_HORIZONTAL_SPEED_SURVIVAL * 1.5 && !"creative".equalsIgnoreCase(gameMode)) {
|
||||
score += 15;
|
||||
reasons.add("HIGH_AVERAGE_SPEED: " + String.format("%.2f", avgSpeed) + " avg");
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 6: Teleport detection
|
||||
if (horizontalDist > 50 && "survival".equalsIgnoreCase(gameMode)) {
|
||||
score += 40;
|
||||
reasons.add("TELEPORT_DETECTED: " + String.format("%.1f", horizontalDist) + " blocks");
|
||||
}
|
||||
|
||||
if (score >= 35) {
|
||||
flaggedPlayers.put(playerName, Instant.now().plusSeconds(FLAG_DURATION_SECONDS));
|
||||
totalDetections.incrementAndGet();
|
||||
return FlyCheckResult.flagged("Movement hack detected: " + String.join("; ", reasons));
|
||||
} else if (score >= 30) {
|
||||
return FlyCheckResult.suspicious(score, reasons);
|
||||
}
|
||||
|
||||
return FlyCheckResult.clean();
|
||||
}
|
||||
|
||||
public void clearPlayer(String playerName) {
|
||||
movementData.remove(playerName);
|
||||
flaggedPlayers.remove(playerName);
|
||||
}
|
||||
|
||||
public Map<String, Object> getStatus() {
|
||||
Map<String, Object> status = new LinkedHashMap<>();
|
||||
status.put("trackedPlayers", movementData.size());
|
||||
status.put("flaggedPlayers", flaggedPlayers.size());
|
||||
status.put("totalDetections", totalDetections.get());
|
||||
|
||||
List<Map<String, Object>> topSuspicious = new ArrayList<>();
|
||||
for (Map.Entry<String, PlayerMovementData> e : movementData.entrySet()) {
|
||||
PlayerMovementData d = e.getValue();
|
||||
double avgSpeed = d.totalMovements > 0 ? d.totalDistance / d.totalMovements : 0;
|
||||
if (avgSpeed > 1.0 || d.consecutiveAirTicks > 10) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("player", e.getKey());
|
||||
m.put("avgSpeed", String.format("%.2f", avgSpeed));
|
||||
m.put("airTicks", d.consecutiveAirTicks);
|
||||
m.put("totalMovements", d.totalMovements);
|
||||
topSuspicious.add(m);
|
||||
}
|
||||
}
|
||||
topSuspicious.sort((a, b) -> {
|
||||
double sa = Double.parseDouble(a.get("avgSpeed").toString());
|
||||
double sb = Double.parseDouble(b.get("avgSpeed").toString());
|
||||
return Double.compare(sb, sa);
|
||||
});
|
||||
status.put("suspiciousPlayers", topSuspicious.subList(0, Math.min(topSuspicious.size(), 20)));
|
||||
return status;
|
||||
}
|
||||
|
||||
public long getTotalDetections() { return totalDetections.get(); }
|
||||
|
||||
private void cleanupOldData() {
|
||||
Instant cutoff = Instant.now().minusSeconds(600);
|
||||
movementData.entrySet().removeIf(e -> e.getValue().lastActivity.isBefore(cutoff));
|
||||
flaggedPlayers.entrySet().removeIf(e -> e.getValue().isBefore(Instant.now()));
|
||||
}
|
||||
|
||||
private static class PlayerMovementData {
|
||||
int totalMovements;
|
||||
double totalDistance;
|
||||
int consecutiveAirTicks;
|
||||
double freefallDistance;
|
||||
boolean prevOnGround;
|
||||
Instant lastActivity = Instant.now();
|
||||
|
||||
void recordMovement(double fx, double fy, double fz, double tx, double ty, double tz, boolean onGround) {
|
||||
totalMovements++;
|
||||
totalDistance += Math.sqrt(Math.pow(tx - fx, 2) + Math.pow(ty - fy, 2) + Math.pow(tz - fz, 2));
|
||||
lastActivity = Instant.now();
|
||||
prevOnGround = onGround;
|
||||
}
|
||||
}
|
||||
|
||||
public static class FlyCheckResult {
|
||||
private final boolean flagged;
|
||||
private final boolean suspicious;
|
||||
private final int score;
|
||||
private final List<String> reasons;
|
||||
|
||||
private FlyCheckResult(boolean flagged, boolean suspicious, int score, List<String> reasons) {
|
||||
this.flagged = flagged;
|
||||
this.suspicious = suspicious;
|
||||
this.score = score;
|
||||
this.reasons = reasons;
|
||||
}
|
||||
|
||||
public static FlyCheckResult clean() { return new FlyCheckResult(false, false, 0, List.of()); }
|
||||
public static FlyCheckResult suspicious(int score, List<String> reasons) { return new FlyCheckResult(false, true, score, reasons); }
|
||||
public static FlyCheckResult flagged(String msg) { return new FlyCheckResult(true, true, 100, List.of(msg)); }
|
||||
|
||||
public boolean isFlagged() { return flagged; }
|
||||
public boolean isSuspicious() { return suspicious; }
|
||||
public int getScore() { return score; }
|
||||
public List<String> getReasons() { return reasons; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package com.aluer.anticheat.movement;
|
||||
|
||||
import com.aluer.config.ServerGuardConfig;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* 水上/岩浆上行走(Jesus)检测服务 — V4.0 反作弊扩展模块
|
||||
*
|
||||
* 检测原理:
|
||||
* 1. 液体表面行走检测 — Minecraft中玩家无法在水面或岩浆面上稳定行走而不下沉。
|
||||
* 正常情况下,玩家在液体上会立即开始下沉。Jesus hack通过在客户端伪造液体为固体方块,
|
||||
* 使玩家可以在液体表面上行走。
|
||||
* 2. 持续水上行走时间检测 — 追踪玩家在液体上方的连续时间,超过1秒则标记。
|
||||
* 正常玩家在接触液体时会立即开始垂直移动(下沉或游泳),而Jesus hack用户Y坐标保持稳定。
|
||||
* 3. 耶稣模式抖动检测 — 典型的hack客户端会表现出"抖动"模式:
|
||||
* 玩家Y坐标在水面附近小幅振荡(尝试维持在水面),而不是自然下沉。
|
||||
* 4. 水面Y坐标估算 — 通过追踪液体表面的Y坐标(通常是整数Y值),
|
||||
* 判断玩家是否站在液体方块的上表面(精确站在水面上是Jesus hack的特征)。
|
||||
*
|
||||
* 配置开关:serverguard.security.super-evolution.anti-jesus
|
||||
*/
|
||||
@Service
|
||||
public class AntiJesusService {
|
||||
|
||||
private final ServerGuardConfig config;
|
||||
|
||||
/**
|
||||
* 追踪每个玩家与液体相关的移动记录(playerName -> 记录列表)
|
||||
*/
|
||||
private final Map<String, List<LiquidRecord>> playerLiquidHistory = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家在液体上方的连续时间(playerName -> 首次检测到水上行走的时间戳)
|
||||
*/
|
||||
private final Map<String, Instant> playerJesusStart = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家最近的Y坐标(用于抖动检测)
|
||||
*/
|
||||
private final Map<String, List<Double>> playerYHistory = new ConcurrentHashMap<>();
|
||||
|
||||
private final AtomicLong totalChecks = new AtomicLong(0);
|
||||
private final AtomicLong jesusViolations = new AtomicLong(0);
|
||||
|
||||
/**
|
||||
* 连续水上行走时间阈值(毫秒)— 超过1秒标记
|
||||
*/
|
||||
private static final long MAX_JESUS_DURATION_MS = 1_000;
|
||||
|
||||
/**
|
||||
* 水面Y坐标误差容差(方块)— 玩家Y坐标在此范围内被视为在水面高度
|
||||
* Minecraft中一个完整方块的高度为1.0,玩家脚部Y值与其所站方块顶面相同
|
||||
*/
|
||||
private static final double LIQUID_SURFACE_TOLERANCE = 0.3;
|
||||
|
||||
/**
|
||||
* 抖动检测 — Y坐标波动阈值,Jesus hack用户会在水面高度附近小幅振动
|
||||
* 正常玩家在水中会稳定下沉或游泳
|
||||
*/
|
||||
private static final double JITTER_AMPLITUDE_MAX = 0.15;
|
||||
|
||||
/**
|
||||
* 抖动检测 — 连续小幅度波动次数超过此值视为Jesus hack的抖动特征
|
||||
*/
|
||||
private static final int MAX_JITTER_WAVES = 5;
|
||||
|
||||
/**
|
||||
* 每个玩家保留的最大记录数
|
||||
*/
|
||||
private static final int MAX_RECORDS_PER_PLAYER = 30;
|
||||
|
||||
/**
|
||||
* 液体表面的典型Y偏移 — 液体方块的顶面Y值是整数+偏移,水在整数高度上方,
|
||||
* 玩家站在水面上的脚Y值约为液体方块Y值(整数)+1.0的位置附近
|
||||
*/
|
||||
|
||||
/**
|
||||
* 无参构造函数 — 测试/默认配置使用
|
||||
*/
|
||||
public AntiJesusService() {
|
||||
this(new ServerGuardConfig());
|
||||
}
|
||||
|
||||
/**
|
||||
* Spring 注入构造函数
|
||||
* @param config 全局配置,提供模块开关控制
|
||||
*/
|
||||
public AntiJesusService(ServerGuardConfig config) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测玩家是否在水或岩浆上非法行走
|
||||
*
|
||||
* @param playerName 玩家名称
|
||||
* @param playerUUID 玩家UUID
|
||||
* @param playerY 玩家脚部Y坐标
|
||||
* @param liquidSurfaceY 液体表面Y坐标(整数,液体方块顶面的Y值)
|
||||
* @param liquidType 液体类型 ("WATER" 或 "LAVA")
|
||||
* @param onGround 玩家是否声称在地面
|
||||
* @param isMoving 玩家是否在移动中
|
||||
* @param timestamp 时间戳
|
||||
* @return 检测结果
|
||||
*/
|
||||
public JesusCheckResult detect(String playerName, String playerUUID,
|
||||
double playerY, double liquidSurfaceY,
|
||||
String liquidType, boolean onGround,
|
||||
boolean isMoving, Instant timestamp) {
|
||||
// 模块开关检查 — 关闭时跳过所有检测
|
||||
if (!config.getSecurity().getSuperEvolution().isAntiJesus()) {
|
||||
return JesusCheckResult.clean();
|
||||
}
|
||||
|
||||
totalChecks.incrementAndGet();
|
||||
|
||||
List<LiquidRecord> history = playerLiquidHistory.computeIfAbsent(
|
||||
playerName, k -> new ArrayList<>());
|
||||
|
||||
List<Double> yHistory = playerYHistory.computeIfAbsent(
|
||||
playerName, k -> new ArrayList<>());
|
||||
|
||||
LiquidRecord record = new LiquidRecord(timestamp, playerY, liquidSurfaceY, liquidType, onGround, isMoving);
|
||||
history.add(record);
|
||||
yHistory.add(playerY);
|
||||
|
||||
while (history.size() > MAX_RECORDS_PER_PLAYER) {
|
||||
history.remove(0);
|
||||
}
|
||||
while (yHistory.size() > 20) {
|
||||
yHistory.remove(0);
|
||||
}
|
||||
|
||||
List<String> reasons = new ArrayList<>();
|
||||
|
||||
// 1. 液体表面行走检测 — 玩家Y坐标在液体表面高度附近,且声称在地面
|
||||
double yDiff = playerY - liquidSurfaceY;
|
||||
|
||||
// 玩家在液体表面时,如果实际站在液体上方,脚Y应该在液体方块表面Y值处
|
||||
// 正常的液体方块表面在Y = liquidSurfaceY(方块整数Y值的顶面)
|
||||
boolean isOnLiquidSurface = Math.abs(yDiff) <= LIQUID_SURFACE_TOLERANCE;
|
||||
|
||||
if (isOnLiquidSurface && onGround) {
|
||||
// 玩家声称站在液体表面上 — Jesus hack特征
|
||||
playerJesusStart.putIfAbsent(playerName, timestamp);
|
||||
|
||||
Instant jesusStart = playerJesusStart.get(playerName);
|
||||
long jesusDurationMs = timestamp.toEpochMilli() - jesusStart.toEpochMilli();
|
||||
|
||||
if (jesusDurationMs >= MAX_JESUS_DURATION_MS) {
|
||||
// 持续在液体表面行走 — 确认Jesus hack
|
||||
jesusViolations.incrementAndGet();
|
||||
reasons.add("SUSTAINED_JESUS: " + (jesusDurationMs / 1000.0)
|
||||
+ "s walking on " + liquidType.toLowerCase()
|
||||
+ " surface (Y=" + String.format("%.2f", playerY)
|
||||
+ ", surface=" + String.format("%.1f", liquidSurfaceY) + ")");
|
||||
}
|
||||
} else {
|
||||
// 不再在液体表面,清除计时器
|
||||
playerJesusStart.remove(playerName);
|
||||
}
|
||||
|
||||
// 2. 抖动检测 — 检测Y坐标在液体表面附近的小幅振荡
|
||||
if (isOnLiquidSurface && yHistory.size() >= 6) {
|
||||
int jitterCount = 0;
|
||||
double prevDiff = yHistory.get(0) - liquidSurfaceY;
|
||||
|
||||
for (int i = 1; i < yHistory.size(); i++) {
|
||||
double currentDiff = yHistory.get(i) - liquidSurfaceY;
|
||||
// 检测相邻两次Y值是否都在阈值内但方向相反(抖动特征)
|
||||
if (Math.abs(prevDiff) <= JITTER_AMPLITUDE_MAX
|
||||
&& Math.abs(currentDiff) <= JITTER_AMPLITUDE_MAX
|
||||
&& prevDiff * currentDiff < 0) {
|
||||
jitterCount++;
|
||||
}
|
||||
prevDiff = currentDiff;
|
||||
}
|
||||
|
||||
// 如果抖动次数过多,这是Jesus hack的典型行为模式
|
||||
if (jitterCount >= MAX_JITTER_WAVES) {
|
||||
reasons.add("JITTER_PATTERN: " + jitterCount
|
||||
+ " oscillations near " + liquidType.toLowerCase()
|
||||
+ " surface (amplitude < " + String.format("%.2f", JITTER_AMPLITUDE_MAX) + ")");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 异常稳定性检测 — 在液体表面完全静止站立(不自然的行为)
|
||||
if (isOnLiquidSurface && !isMoving && onGround) {
|
||||
// 收集足够的静止记录
|
||||
long stationaryCount = history.stream()
|
||||
.filter(r -> r.onLiquidSurface && !r.isMoving && r.onGround)
|
||||
.count();
|
||||
if (stationaryCount >= 8) {
|
||||
reasons.add("STATIONARY_ON_LIQUID: standing still on " + liquidType.toLowerCase()
|
||||
+ " surface (" + stationaryCount + " consecutive checks)");
|
||||
}
|
||||
}
|
||||
|
||||
if (reasons.size() >= 2) {
|
||||
return JesusCheckResult.flagged(reasons);
|
||||
} else if (reasons.size() == 1) {
|
||||
return JesusCheckResult.suspicious(reasons);
|
||||
}
|
||||
|
||||
return JesusCheckResult.clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* 玩家离线时清理追踪数据
|
||||
* @param playerName 离线玩家名称
|
||||
*/
|
||||
public void clearPlayer(String playerName) {
|
||||
playerLiquidHistory.remove(playerName);
|
||||
playerJesusStart.remove(playerName);
|
||||
playerYHistory.remove(playerName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模块运行状态
|
||||
* @return 包含计数器和违规数量的Map
|
||||
*/
|
||||
public Map<String, Object> getStatus() {
|
||||
Map<String, Object> status = new LinkedHashMap<>();
|
||||
status.put("totalChecks", totalChecks.get());
|
||||
status.put("jesusViolations", jesusViolations.get());
|
||||
status.put("activeTrackedPlayers", playerLiquidHistory.size());
|
||||
|
||||
// 列出当前疑似Jesus的玩家
|
||||
List<String> suspectedPlayers = new ArrayList<>(playerJesusStart.keySet());
|
||||
status.put("suspectedJesusPlayers", suspectedPlayers);
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部液体接触记录 — 记录玩家与液体交互的时空信息
|
||||
*/
|
||||
private static class LiquidRecord {
|
||||
final Instant timestamp;
|
||||
final double playerY;
|
||||
final double liquidSurfaceY;
|
||||
final String liquidType;
|
||||
final boolean onGround;
|
||||
final boolean onLiquidSurface;
|
||||
final boolean isMoving;
|
||||
|
||||
LiquidRecord(Instant timestamp, double playerY, double liquidSurfaceY,
|
||||
String liquidType, boolean onGround, boolean isMoving) {
|
||||
this.timestamp = timestamp;
|
||||
this.playerY = playerY;
|
||||
this.liquidSurfaceY = liquidSurfaceY;
|
||||
this.liquidType = liquidType;
|
||||
this.onGround = onGround;
|
||||
this.isMoving = isMoving;
|
||||
// 计算是否在液体表面上
|
||||
this.onLiquidSurface = Math.abs(playerY - liquidSurfaceY) <= LIQUID_SURFACE_TOLERANCE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Jesus检测结果 — 不可变结果类
|
||||
*/
|
||||
public static class JesusCheckResult {
|
||||
private final boolean flagged;
|
||||
private final boolean suspicious;
|
||||
private final List<String> reasons;
|
||||
|
||||
private JesusCheckResult(boolean flagged, boolean suspicious, List<String> reasons) {
|
||||
this.flagged = flagged;
|
||||
this.suspicious = suspicious;
|
||||
this.reasons = reasons;
|
||||
}
|
||||
|
||||
/** 无异常 — 玩家正常与液体交互(游泳/下沉) */
|
||||
public static JesusCheckResult clean() {
|
||||
return new JesusCheckResult(false, false, List.of());
|
||||
}
|
||||
|
||||
/** 可疑 — 短暂在液体表面但不足够确认为Jesus hack */
|
||||
public static JesusCheckResult suspicious(List<String> reasons) {
|
||||
return new JesusCheckResult(false, true, reasons);
|
||||
}
|
||||
|
||||
/** 已标记 — 确认在液体表面非法行走(Jesus hack) */
|
||||
public static JesusCheckResult flagged(List<String> reasons) {
|
||||
return new JesusCheckResult(true, false, reasons);
|
||||
}
|
||||
|
||||
public boolean isFlagged() { return flagged; }
|
||||
public boolean isSuspicious() { return suspicious; }
|
||||
public boolean isClean() { return !flagged && !suspicious; }
|
||||
public List<String> getReasons() { return reasons; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
package com.aluer.anticheat.movement;
|
||||
|
||||
import com.aluer.config.ServerGuardConfig;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* 远跳(LongJump)检测服务 — V5.2 反作弊移动模块
|
||||
*
|
||||
* 检测原理:
|
||||
* 1. 极端水平跳跃距离检测 — Minecraft中正常最大跳跃距离约4方块(冲刺+跳跃),
|
||||
* 而LongJump hack可达到10-30+方块。通过计算单次跳跃的水平位移量来检测。
|
||||
* 2. 跳跃后水平速度衰减检测 — 正常跳跃后水平速度迅速衰减(每tick乘以0.91的空气阻力系数),
|
||||
* LongJump在跳跃后维持高速不衰减。追踪起跳后连续tick的水平速度变化趋势。
|
||||
* 3. 连跳模式检测 — LongJump常配合"bunny hop"模式,通过跳跃链维持高速。
|
||||
* 检测玩家是否通过连续跳跃维持异常的水平速度。
|
||||
* 4. 绝对速度上限检测 — 检查玩家是否达到任何合法组合都无法达到的移动速度。
|
||||
* 合法组合的极限:速度II(+40%)+ 冲刺(+30%)+ 跳跃加成 ≈ 最大约7.8 m/s。
|
||||
*
|
||||
* 配置开关:serverguard.security.super-evolution.anti-long-jump
|
||||
*/
|
||||
@Service
|
||||
public class AntiLongJumpService {
|
||||
|
||||
private final ServerGuardConfig config;
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的移动历史(playerName -> 移动记录列表)
|
||||
*/
|
||||
private final Map<String, List<LongJumpRecord>> playerMoveHistory = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家当前是否处于跳跃后的空中状态(playerName -> 跳跃后tick计数)
|
||||
* 用于追踪起跳后的水平速度衰减模式
|
||||
*/
|
||||
private final Map<String, List<Double>> playerPostJumpSpeeds = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的连续跳跃高速移动计数(playerName -> 连续次数)
|
||||
*/
|
||||
private final Map<String, Integer> playerBunnyHopCount = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的最近一次跳跃起始坐标(playerName -> 跳跃起跳点坐标数组 [x, z])
|
||||
*/
|
||||
private final Map<String, double[]> playerJumpStartPos = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家是否处于跳跃后的空中状态(playerName -> Boolean)
|
||||
*/
|
||||
private final Map<String, Boolean> playerInPostJump = new ConcurrentHashMap<>();
|
||||
|
||||
private final AtomicLong totalChecks = new AtomicLong(0);
|
||||
private final AtomicLong longJumpViolations = new AtomicLong(0);
|
||||
|
||||
/**
|
||||
* 单次跳跃最大合法水平距离(方块)
|
||||
* 冲刺+跳跃在平地的极限约4方块,加上速度II效果略多一些
|
||||
*/
|
||||
private static final double MAX_LEGIT_JUMP_DISTANCE = 4.5;
|
||||
|
||||
/**
|
||||
* LongJump典型最小跳跃距离(方块)— 低于此值不予考虑
|
||||
*/
|
||||
private static final double LONG_JUMP_MIN_DISTANCE = 8.0;
|
||||
|
||||
/**
|
||||
* 正常空气阻力系数 — 每tick水平速度乘以0.91
|
||||
* 预期3 tick后速度衰减为原来的 0.91^3 ≈ 0.754
|
||||
*/
|
||||
private static final double AIR_RESISTANCE_PER_TICK = 0.91;
|
||||
|
||||
/**
|
||||
* 跳跃后坚持tick数后速度应衰减到的预期比例上限
|
||||
* 3 tick后正常玩家速度应降至初始的75%以下
|
||||
*/
|
||||
private static final double EXPECTED_SPEED_DECAY_RATIO = 0.75;
|
||||
|
||||
/**
|
||||
* 绝对最大合法水平速度(m/s)
|
||||
* 速度II(+40%)+ 冲刺(+30%)+ 跳跃初始速度 ≈ 4.317 * 1.4 * 1.3 ≈ 7.85 m/s
|
||||
* LongJump可达到10+ m/s
|
||||
*/
|
||||
private static final double ABSOLUTE_MAX_SPEED = 7.85;
|
||||
|
||||
/**
|
||||
* 连续高速跳跃阈值 — 连续超过此值的跳跃标记为连跳作弊
|
||||
*/
|
||||
private static final int MAX_CONSECUTIVE_BUNNY_HOPS = 3;
|
||||
|
||||
/**
|
||||
* 每个玩家保留的最大移动记录数
|
||||
*/
|
||||
private static final int MAX_RECORDS_PER_PLAYER = 50;
|
||||
|
||||
/**
|
||||
* 无参构造函数 — 测试/默认配置使用
|
||||
*/
|
||||
public AntiLongJumpService() {
|
||||
this(new ServerGuardConfig());
|
||||
}
|
||||
|
||||
/**
|
||||
* Spring 注入构造函数
|
||||
* @param config 全局配置,提供模块开关控制
|
||||
*/
|
||||
public AntiLongJumpService(ServerGuardConfig config) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测玩家是否执行了异常的远跳(LongJump hack)
|
||||
*
|
||||
* @param playerName 玩家名称
|
||||
* @param playerUUID 玩家UUID
|
||||
* @param fromX 移动起始X坐标
|
||||
* @param fromY 移动起始Y坐标
|
||||
* @param fromZ 移动起始Z坐标
|
||||
* @param toX 移动结束X坐标
|
||||
* @param toY 移动结束Y坐标
|
||||
* @param toZ 移动结束Z坐标
|
||||
* @param horizontalSpeed 当前tick的水平速度(m/s)
|
||||
* @param onGround 玩家是否在地面
|
||||
* @param isJumping 玩家是否发送了跳跃数据包(Y速度突变为正)
|
||||
* @param prevOnGround 上一tick是否在地面(用于判断起跳)
|
||||
* @param timestamp 时间戳
|
||||
* @return 检测结果
|
||||
*/
|
||||
public LongJumpCheckResult detect(String playerName, String playerUUID,
|
||||
double fromX, double fromY, double fromZ,
|
||||
double toX, double toY, double toZ,
|
||||
double horizontalSpeed,
|
||||
boolean onGround, boolean isJumping,
|
||||
boolean prevOnGround, Instant timestamp) {
|
||||
// 模块开关检查 — 关闭时跳过所有检测
|
||||
if (!config.getSecurity().getSuperEvolution().isAntiLongJump()) {
|
||||
return LongJumpCheckResult.clean();
|
||||
}
|
||||
|
||||
totalChecks.incrementAndGet();
|
||||
|
||||
// 计算本次移动的水平距离和Y变化
|
||||
double dx = toX - fromX;
|
||||
double dz = toZ - fromZ;
|
||||
double dy = toY - fromY;
|
||||
double horizontalDist = Math.sqrt(dx * dx + dz * dz);
|
||||
|
||||
List<LongJumpRecord> history = playerMoveHistory.computeIfAbsent(
|
||||
playerName, k -> new ArrayList<>());
|
||||
|
||||
LongJumpRecord record = new LongJumpRecord(timestamp, fromX, fromY, fromZ,
|
||||
toX, toY, toZ, horizontalSpeed, horizontalDist, dy,
|
||||
onGround, isJumping, prevOnGround);
|
||||
history.add(record);
|
||||
while (history.size() > MAX_RECORDS_PER_PLAYER) {
|
||||
history.remove(0);
|
||||
}
|
||||
|
||||
List<String> reasons = new ArrayList<>();
|
||||
|
||||
// ─── 检测1:起跳追踪 — 检测从地面起跳后的跳跃距离 ───
|
||||
// 当玩家上一tick在地面且当前tick Y速度为正(起跳信号),记录起跳位置
|
||||
if (prevOnGround && dy > 0) {
|
||||
playerJumpStartPos.put(playerName, new double[]{fromX, fromZ});
|
||||
playerInPostJump.put(playerName, true);
|
||||
// 初始化跳跃后速度追踪列表
|
||||
playerPostJumpSpeeds.computeIfAbsent(playerName, k -> new ArrayList<>()).clear();
|
||||
}
|
||||
|
||||
// 如果玩家处于跳跃后的空中状态,累积速度数据
|
||||
if (Boolean.TRUE.equals(playerInPostJump.get(playerName))) {
|
||||
List<Double> postJumpSpeeds = playerPostJumpSpeeds.computeIfAbsent(
|
||||
playerName, k -> new ArrayList<>());
|
||||
postJumpSpeeds.add(horizontalSpeed);
|
||||
|
||||
// 跳跃后3 tick检测速度衰减
|
||||
if (postJumpSpeeds.size() >= 3) {
|
||||
double initialSpeed = postJumpSpeeds.get(0);
|
||||
double currentSpeed = postJumpSpeeds.get(postJumpSpeeds.size() - 1);
|
||||
|
||||
// 正常衰减:3 tick后速度应 < 初始的75%
|
||||
// LongJump:维持高速,衰减比例远低于预期
|
||||
if (initialSpeed > 3.0 && currentSpeed > 3.0) {
|
||||
double decayRatio = currentSpeed / Math.max(initialSpeed, 0.001);
|
||||
if (decayRatio > EXPECTED_SPEED_DECAY_RATIO) {
|
||||
reasons.add("SPEED_DECAY_FAILURE: post-jump speed "
|
||||
+ String.format("%.2f", currentSpeed) + " m/s after "
|
||||
+ postJumpSpeeds.size() + " ticks, decay ratio "
|
||||
+ String.format("%.2f", decayRatio)
|
||||
+ " exceeds expected " + String.format("%.2f", EXPECTED_SPEED_DECAY_RATIO));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 玩家落地后清理跳跃后追踪状态
|
||||
if (onGround && Boolean.TRUE.equals(playerInPostJump.get(playerName))) {
|
||||
// 计算从起跳到落地的总水平距离
|
||||
double[] startPos = playerJumpStartPos.remove(playerName);
|
||||
List<Double> postJumpSpeeds = playerPostJumpSpeeds.remove(playerName);
|
||||
playerInPostJump.remove(playerName);
|
||||
|
||||
if (startPos != null) {
|
||||
double totalJumpDist = Math.sqrt(
|
||||
Math.pow(toX - startPos[0], 2) + Math.pow(toZ - startPos[1], 2));
|
||||
|
||||
if (totalJumpDist >= LONG_JUMP_MIN_DISTANCE) {
|
||||
// 极端跳跃距离 — 单次跳跃不可能达到此距离
|
||||
reasons.add("EXTREME_JUMP_DISTANCE: " + String.format("%.1f", totalJumpDist)
|
||||
+ " blocks in a single jump"
|
||||
+ " (max legit: " + String.format("%.1f", MAX_LEGIT_JUMP_DISTANCE) + ")");
|
||||
longJumpViolations.incrementAndGet();
|
||||
} else if (totalJumpDist >= MAX_LEGIT_JUMP_DISTANCE + 2.0) {
|
||||
// 超出合法范围2方块以上 — 可疑
|
||||
reasons.add("EXCESSIVE_JUMP_DISTANCE: " + String.format("%.1f", totalJumpDist)
|
||||
+ " blocks jump distance exceeds legit max");
|
||||
}
|
||||
|
||||
// 连跳模式检测 — 检查是否通过连续跳跃维持高速
|
||||
if (totalJumpDist >= MAX_LEGIT_JUMP_DISTANCE && postJumpSpeeds != null
|
||||
&& !postJumpSpeeds.isEmpty()) {
|
||||
double avgSpeed = postJumpSpeeds.stream()
|
||||
.mapToDouble(Double::doubleValue).average().orElse(0);
|
||||
if (avgSpeed > ABSOLUTE_MAX_SPEED) {
|
||||
int bunnyCount = playerBunnyHopCount.getOrDefault(playerName, 0) + 1;
|
||||
playerBunnyHopCount.put(playerName, bunnyCount);
|
||||
reasons.add("BUNNY_HOP_PATTERN: avg post-jump speed "
|
||||
+ String.format("%.2f", avgSpeed) + " m/s exceeds absolute max "
|
||||
+ String.format("%.2f", ABSOLUTE_MAX_SPEED) + " m/s"
|
||||
+ " (#" + bunnyCount + " in chain)");
|
||||
|
||||
if (bunnyCount >= MAX_CONSECUTIVE_BUNNY_HOPS) {
|
||||
reasons.add("SUSTAINED_BUNNY_HOP: " + bunnyCount
|
||||
+ " consecutive extreme jumps detected");
|
||||
longJumpViolations.incrementAndGet();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 重置连跳计数(如果落地且速度正常)
|
||||
if (onGround && horizontalSpeed < MAX_LEGIT_JUMP_DISTANCE) {
|
||||
if (!reasons.toString().contains("BUNNY_HOP")) {
|
||||
playerBunnyHopCount.remove(playerName);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 检测2:绝对速度上限检测 ───
|
||||
// 任何合法组合都无法达到的水平速度
|
||||
if (horizontalSpeed > ABSOLUTE_MAX_SPEED) {
|
||||
reasons.add("ABSOLUTE_SPEED_EXCEEDED: " + String.format("%.2f", horizontalSpeed)
|
||||
+ " m/s exceeds absolute legit max "
|
||||
+ String.format("%.2f", ABSOLUTE_MAX_SPEED) + " m/s"
|
||||
+ " (onGround=" + onGround + ")");
|
||||
|
||||
if (!onGround) {
|
||||
longJumpViolations.incrementAndGet();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 检测3:移动历史模式分析 — 检查是否频繁出现超大水平位移 ───
|
||||
if (history.size() >= 10) {
|
||||
long extremeMoves = history.stream()
|
||||
.filter(r -> r.horizontalDist > LONG_JUMP_MIN_DISTANCE && !r.onGround)
|
||||
.count();
|
||||
if (extremeMoves >= 5) {
|
||||
reasons.add("FREQUENT_EXTREME_HOPS: " + extremeMoves
|
||||
+ " extreme-distance airborne moves in last "
|
||||
+ history.size() + " ticks");
|
||||
}
|
||||
}
|
||||
|
||||
if (reasons.size() >= 2) {
|
||||
return LongJumpCheckResult.flagged(reasons);
|
||||
} else if (reasons.size() == 1) {
|
||||
return LongJumpCheckResult.suspicious(reasons);
|
||||
}
|
||||
|
||||
return LongJumpCheckResult.clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* 玩家离线时清理追踪数据
|
||||
* @param playerName 离线玩家名称
|
||||
*/
|
||||
public void clearPlayer(String playerName) {
|
||||
playerMoveHistory.remove(playerName);
|
||||
playerPostJumpSpeeds.remove(playerName);
|
||||
playerBunnyHopCount.remove(playerName);
|
||||
playerJumpStartPos.remove(playerName);
|
||||
playerInPostJump.remove(playerName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模块运行状态
|
||||
* @return 包含计数器和违规数量的Map
|
||||
*/
|
||||
public Map<String, Object> getStatus() {
|
||||
Map<String, Object> status = new LinkedHashMap<>();
|
||||
status.put("totalChecks", totalChecks.get());
|
||||
status.put("longJumpViolations", longJumpViolations.get());
|
||||
status.put("activeTrackedPlayers", playerMoveHistory.size());
|
||||
status.put("playersInPostJump", playerInPostJump.size());
|
||||
|
||||
// 列出连跳计数异常的玩家
|
||||
List<Map<String, Object>> bunnyHopSuspects = new ArrayList<>();
|
||||
for (Map.Entry<String, Integer> entry : playerBunnyHopCount.entrySet()) {
|
||||
if (entry.getValue() >= 1) {
|
||||
Map<String, Object> info = new LinkedHashMap<>();
|
||||
info.put("player", entry.getKey());
|
||||
info.put("bunnyHopCount", entry.getValue());
|
||||
bunnyHopSuspects.add(info);
|
||||
}
|
||||
}
|
||||
status.put("bunnyHopSuspects", bunnyHopSuspects);
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部远跳移动记录 — 记录玩家单次移动的完整数据
|
||||
*/
|
||||
private static class LongJumpRecord {
|
||||
final Instant timestamp;
|
||||
final double fromX, fromY, fromZ;
|
||||
final double toX, toY, toZ;
|
||||
final double horizontalSpeed;
|
||||
final double horizontalDist;
|
||||
final double dy;
|
||||
final boolean onGround;
|
||||
final boolean isJumping;
|
||||
final boolean prevOnGround;
|
||||
|
||||
LongJumpRecord(Instant timestamp, double fromX, double fromY, double fromZ,
|
||||
double toX, double toY, double toZ, double horizontalSpeed,
|
||||
double horizontalDist, double dy, boolean onGround,
|
||||
boolean isJumping, boolean prevOnGround) {
|
||||
this.timestamp = timestamp;
|
||||
this.fromX = fromX; this.fromY = fromY; this.fromZ = fromZ;
|
||||
this.toX = toX; this.toY = toY; this.toZ = toZ;
|
||||
this.horizontalSpeed = horizontalSpeed;
|
||||
this.horizontalDist = horizontalDist;
|
||||
this.dy = dy;
|
||||
this.onGround = onGround;
|
||||
this.isJumping = isJumping;
|
||||
this.prevOnGround = prevOnGround;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* LongJump检测结果 — 不可变结果类
|
||||
*/
|
||||
public static class LongJumpCheckResult {
|
||||
private final boolean flagged;
|
||||
private final boolean suspicious;
|
||||
private final List<String> reasons;
|
||||
|
||||
private LongJumpCheckResult(boolean flagged, boolean suspicious, List<String> reasons) {
|
||||
this.flagged = flagged;
|
||||
this.suspicious = suspicious;
|
||||
this.reasons = reasons;
|
||||
}
|
||||
|
||||
/** 无异常 — 正常跳跃距离和速度衰减 */
|
||||
public static LongJumpCheckResult clean() {
|
||||
return new LongJumpCheckResult(false, false, List.of());
|
||||
}
|
||||
|
||||
/** 可疑 — 单次跳跃距离偏大但证据不充分 */
|
||||
public static LongJumpCheckResult suspicious(List<String> reasons) {
|
||||
return new LongJumpCheckResult(false, true, reasons);
|
||||
}
|
||||
|
||||
/** 已标记 — 确认LongJump hack,极端跳跃距离或持续连跳模式 */
|
||||
public static LongJumpCheckResult flagged(List<String> reasons) {
|
||||
return new LongJumpCheckResult(true, false, reasons);
|
||||
}
|
||||
|
||||
public boolean isFlagged() { return flagged; }
|
||||
public boolean isSuspicious() { return suspicious; }
|
||||
public boolean isClean() { return !flagged && !suspicious; }
|
||||
public List<String> getReasons() { return reasons; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,868 @@
|
||||
package com.aluer.anticheat.movement;
|
||||
|
||||
import com.aluer.config.ServerGuardConfig;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* 无摔落伤害(NoFall)检测服务 — V4.0 反作弊扩展模块,V5.2 增强
|
||||
*
|
||||
* 检测原理:
|
||||
* 1. 坠落检测 — 追踪玩家从高处坠落的事件。在Minecraft中,从3+方块高度坠落后
|
||||
* 玩家应受到摔落伤害(每超过3方块的每方块造成1点伤害,即0.5心)。
|
||||
* NoFall hack通过修改或伪造摔落距离数据包来避免伤害。
|
||||
* 2. 合法无伤害区分 — 需要排除以下合法的无摔落伤害情况:
|
||||
* - 落入水中(浅水或深水)
|
||||
* - 落在粘液块上
|
||||
* - 落在床上
|
||||
* - 落在干草块上
|
||||
* - 鞘翅飞行
|
||||
* - 缓降效果
|
||||
* - 抗性提升效果
|
||||
* - 创造/旁观模式
|
||||
* 3. 可疑NoFall模式检测 — 如果同一玩家反复经历高空坠落但从未受伤,
|
||||
* 且无法用合法情况解释,则标记为作弊。
|
||||
* 4. 摔落距离与伤害不一致检测 — 比较服务端计算的摔落距离与客户端报告的伤害。
|
||||
*
|
||||
* V5.2 新增检测:
|
||||
* 5. GroundSpoof检测 — 客户端声称onGround=true但服务端Y速度指示应在空中
|
||||
* 6. 数据包级分析 — 逐tick比较客户端声称onGround与服务端计算onGround状态的差异
|
||||
* 7. 坠落距离累积 — 服务端独立计算坠落距离,与客户端报告对比
|
||||
* 8. 传送式NoFall — 玩家瞬间下落5+方块后无伤害(与VClip/TeleportFall配合)
|
||||
*
|
||||
* 配置开关:serverguard.security.super-evolution.anti-no-fall
|
||||
*/
|
||||
@Service
|
||||
public class AntiNoFallService {
|
||||
|
||||
private final ServerGuardConfig config;
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的坠落数据(playerName -> 坠落记录列表)
|
||||
*/
|
||||
private final Map<String, List<FallRecord>> playerFallHistory = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家当前是否处于坠落状态及其起始信息(playerName -> 当前坠落上下文)
|
||||
*/
|
||||
private final Map<String, ActiveFall> activeFalls = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪疑似NoFall的事件(playerName -> NoFall事件列表)
|
||||
*/
|
||||
private final Map<String, List<Map<String, Object>>> noFallEvents = new ConcurrentHashMap<>();
|
||||
|
||||
// ─── V5.2 新增追踪数据结构 ───
|
||||
|
||||
/**
|
||||
* 追踪玩家地面伪造(GroundSpoof)的连续tick数(playerName -> 连续次数)
|
||||
* 用于检测客户端onGround=true但服务端Y速度指示应在空中
|
||||
*/
|
||||
private final Map<String, Integer> playerGroundSpoofTicks = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家服务端计算的onGround状态历史(playerName -> 服务端onGround历史)
|
||||
* 用于与客户端声称的onGround进行交叉对比
|
||||
*/
|
||||
private final Map<String, List<GroundStateRecord>> playerGroundStateHistory = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家服务端累积的坠落距离(playerName -> 累积坠落距离)
|
||||
* 从离开地面开始累积,直到接触地面或受伤重置
|
||||
*/
|
||||
private final Map<String, Double> playerAccumulatedFallDistance = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家最近一次开始累积坠落距离的起始Y坐标
|
||||
*/
|
||||
private final Map<String, Double> playerFallStartY = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家是否处于服务端判断的空中状态
|
||||
*/
|
||||
private final Map<String, Boolean> playerServerSideAirborne = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的最近一次Y坐标,用于检测传送式下落
|
||||
*/
|
||||
private final Map<String, Double> playerLastY = new ConcurrentHashMap<>();
|
||||
|
||||
private final AtomicLong totalFalls = new AtomicLong(0);
|
||||
private final AtomicLong noFallViolations = new AtomicLong(0);
|
||||
private final AtomicLong falsePositives = new AtomicLong(0);
|
||||
|
||||
/**
|
||||
* V5.2 新增计数器
|
||||
*/
|
||||
private final AtomicLong groundSpoofViolations = new AtomicLong(0);
|
||||
private final AtomicLong teleportNoFallViolations = new AtomicLong(0);
|
||||
|
||||
/**
|
||||
* 坠落伤害触发高度(方块)— 从3方块高度坠落开始产生摔落伤害
|
||||
*/
|
||||
private static final double FALL_DAMAGE_HEIGHT = 3.0;
|
||||
|
||||
/**
|
||||
* 危险坠落高度(方块)— 超过此高度几乎肯定产生伤害
|
||||
*/
|
||||
private static final double DANGEROUS_FALL_HEIGHT = 8.0;
|
||||
|
||||
/**
|
||||
* 连续无伤坠落次数阈值 — 超过此值标记为NoFall hack
|
||||
*/
|
||||
private static final int MAX_CONSECUTIVE_NO_DAMAGE_FALLS = 3;
|
||||
|
||||
/**
|
||||
* 每个玩家保留的最大坠落记录数
|
||||
*/
|
||||
private static final int MAX_FALL_RECORDS = 20;
|
||||
|
||||
// ─── V5.2 新增常量 ───
|
||||
|
||||
/**
|
||||
* GroundSpoof连续tick阈值 — 连续超过此tick数的ground伪造判定为NoFall
|
||||
* AntiHunger hack同样使用ground伪造,但此处关注的是坠落伤害规避场景
|
||||
*/
|
||||
private static final int MAX_GROUND_SPOOF_TICKS = 5;
|
||||
|
||||
/**
|
||||
* 传送式下落检测阈值(方块)— 单tick内Y坐标下降超过此值且无伤害
|
||||
*/
|
||||
private static final double TELEPORT_FALL_THRESHOLD = 5.0;
|
||||
|
||||
/**
|
||||
* 服务端累积坠落距离阈值 — 超过此距离且无伤害时标记
|
||||
*/
|
||||
private static final double ACCUMULATED_FALL_THRESHOLD = 3.0;
|
||||
|
||||
/**
|
||||
* Y速度正值阈值 — 低于此值的波动忽略
|
||||
*/
|
||||
private static final double ZERO_VELOCITY_THRESHOLD = 0.01;
|
||||
|
||||
/**
|
||||
* 每个玩家保留的最大地面状态记录数
|
||||
*/
|
||||
private static final int MAX_GROUND_STATE_RECORDS = 30;
|
||||
|
||||
/**
|
||||
* 无参构造函数 — 测试/默认配置使用
|
||||
*/
|
||||
public AntiNoFallService() {
|
||||
this(new ServerGuardConfig());
|
||||
}
|
||||
|
||||
/**
|
||||
* Spring 注入构造函数
|
||||
* @param config 全局配置,提供模块开关控制
|
||||
*/
|
||||
public AntiNoFallService(ServerGuardConfig config) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录玩家开始坠落(空中下落开始)
|
||||
* 应在玩家离开地面且垂直速度向下时调用
|
||||
*
|
||||
* @param playerName 玩家名称
|
||||
* @param playerUUID 玩家UUID
|
||||
* @param highestY 坠落起始的Y坐标(最高点)
|
||||
* @param gameMode 玩家游戏模式
|
||||
* @param timestamp 时间戳
|
||||
*/
|
||||
public void recordFallStart(String playerName, String playerUUID,
|
||||
double highestY, String gameMode, Instant timestamp) {
|
||||
// 模块关闭时不追踪数据
|
||||
if (!config.getSecurity().getSuperEvolution().isAntiNoFall()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 创造/旁观模式不受摔落伤害,不需要追踪
|
||||
if ("creative".equalsIgnoreCase(gameMode) || "spectator".equalsIgnoreCase(gameMode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeFalls.put(playerName, new ActiveFall(highestY, gameMode, timestamp));
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录玩家落地事件
|
||||
* 应在玩家接触地面时调用
|
||||
*
|
||||
* @param playerName 玩家名称
|
||||
* @param playerUUID 玩家UUID
|
||||
* @param landingY 着陆时Y坐标
|
||||
* @param tookDamage 玩家是否受到摔落伤害
|
||||
* @param damageAmount 受到的伤害量(0表示无伤害)
|
||||
* @param isInLiquid 是否在水中(合法无伤害情况)
|
||||
* @param isOnSlimeBlock 是否落在粘液块上(合法无伤害情况)
|
||||
* @param isOnHayBale 是否落在干草块上(减少80%伤害)
|
||||
* @param isOnBed 是否落在床上(减少50%伤害)
|
||||
* @param hasSlowFalling 是否有缓降效果
|
||||
* @param isElytraFlying 是否鞘翅飞行
|
||||
* @param timestamp 时间戳
|
||||
* @return 检测结果
|
||||
*/
|
||||
public NoFallCheckResult recordLanding(String playerName, String playerUUID,
|
||||
double landingY, boolean tookDamage, double damageAmount,
|
||||
boolean isInLiquid, boolean isOnSlimeBlock,
|
||||
boolean isOnHayBale, boolean isOnBed,
|
||||
boolean hasSlowFalling, boolean isElytraFlying,
|
||||
Instant timestamp) {
|
||||
// 模块开关检查
|
||||
if (!config.getSecurity().getSuperEvolution().isAntiNoFall()) {
|
||||
return NoFallCheckResult.clean();
|
||||
}
|
||||
|
||||
totalFalls.incrementAndGet();
|
||||
|
||||
ActiveFall activeFall = activeFalls.remove(playerName);
|
||||
if (activeFall == null) {
|
||||
// 没有记录的坠落起始 — 无法判断
|
||||
return NoFallCheckResult.clean();
|
||||
}
|
||||
|
||||
double fallDistance = activeFall.highestY - landingY;
|
||||
|
||||
// 坠落高度不足,不产生伤害,无需检测
|
||||
if (fallDistance < FALL_DAMAGE_HEIGHT) {
|
||||
return NoFallCheckResult.clean();
|
||||
}
|
||||
|
||||
List<String> reasons = new ArrayList<>();
|
||||
boolean legitimateNoDamage = false;
|
||||
|
||||
// 检查合法无伤害情况
|
||||
if (isInLiquid) {
|
||||
// 落入水中完全免疫摔落伤害
|
||||
legitimateNoDamage = true;
|
||||
falsePositives.incrementAndGet();
|
||||
}
|
||||
if (isOnSlimeBlock) {
|
||||
// 粘液块完全免疫摔落伤害(不反弹则无伤害)
|
||||
legitimateNoDamage = true;
|
||||
falsePositives.incrementAndGet();
|
||||
}
|
||||
// 干草块减少80%摔落伤害
|
||||
if (isOnHayBale && fallDistance < 15.0) {
|
||||
// 极端高度的坠落(>15方块)在干草块上仍可能产生可见伤害
|
||||
// 此处保守地认为15方块以下在干草块上无伤害是合法的
|
||||
if (!tookDamage) {
|
||||
legitimateNoDamage = true;
|
||||
}
|
||||
}
|
||||
if (isOnBed) {
|
||||
// 床减少50%摔落伤害,结合一定高度判断
|
||||
if (fallDistance < 6.0 && !tookDamage) {
|
||||
legitimateNoDamage = true;
|
||||
}
|
||||
}
|
||||
if (hasSlowFalling) {
|
||||
// 缓降效果完全免疫摔落伤害
|
||||
legitimateNoDamage = true;
|
||||
falsePositives.incrementAndGet();
|
||||
}
|
||||
if (isElytraFlying) {
|
||||
// 鞘翅飞行期间不受摔落伤害
|
||||
legitimateNoDamage = true;
|
||||
falsePositives.incrementAndGet();
|
||||
}
|
||||
|
||||
// 记录坠落事件
|
||||
FallRecord fallRecord = new FallRecord(timestamp, fallDistance, tookDamage, damageAmount,
|
||||
legitimateNoDamage);
|
||||
List<FallRecord> history = playerFallHistory.computeIfAbsent(
|
||||
playerName, k -> new ArrayList<>());
|
||||
history.add(fallRecord);
|
||||
while (history.size() > MAX_FALL_RECORDS) {
|
||||
history.remove(0);
|
||||
}
|
||||
|
||||
// 如果坠落高度显著且没有受到伤害,且没有合法理由,则标记
|
||||
if (!tookDamage && !legitimateNoDamage && fallDistance >= FALL_DAMAGE_HEIGHT) {
|
||||
reasons.add("NO_FALL_DAMAGE: fell " + String.format("%.1f", fallDistance)
|
||||
+ " blocks, no damage taken");
|
||||
|
||||
// 记录NoFall事件
|
||||
List<Map<String, Object>> events = noFallEvents.computeIfAbsent(
|
||||
playerName, k -> new ArrayList<>());
|
||||
Map<String, Object> event = new LinkedHashMap<>();
|
||||
event.put("time", timestamp.toString());
|
||||
event.put("fallDistance", String.format("%.1f", fallDistance));
|
||||
event.put("landingY", String.format("%.2f", landingY));
|
||||
event.put("highestY", String.format("%.2f", activeFall.highestY));
|
||||
events.add(event);
|
||||
while (events.size() > 10) {
|
||||
events.remove(0);
|
||||
}
|
||||
|
||||
// 检查连续无伤坠落模式
|
||||
long recentNoDamageCount = history.stream()
|
||||
.filter(r -> !r.tookDamage && !r.legitimateNoDamage)
|
||||
.count();
|
||||
|
||||
if (recentNoDamageCount >= MAX_CONSECUTIVE_NO_DAMAGE_FALLS) {
|
||||
reasons.add("REPEATED_NO_DAMAGE: " + recentNoDamageCount
|
||||
+ " consecutive falls without damage");
|
||||
noFallViolations.incrementAndGet();
|
||||
return NoFallCheckResult.flagged(reasons);
|
||||
}
|
||||
|
||||
if (fallDistance >= DANGEROUS_FALL_HEIGHT) {
|
||||
// 从危险高度坠落无伤害 — 高度可疑
|
||||
reasons.add("DANGEROUS_FALL: " + String.format("%.1f", fallDistance)
|
||||
+ " blocks fall with no damage (extremely unlikely)");
|
||||
noFallViolations.incrementAndGet();
|
||||
return NoFallCheckResult.flagged(reasons);
|
||||
}
|
||||
|
||||
return NoFallCheckResult.suspicious(reasons);
|
||||
}
|
||||
|
||||
return NoFallCheckResult.clean();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// V5.2 新增检测方法
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* V5.2 GroundSpoof检测 — 逐tick调用
|
||||
*
|
||||
* 检测客户端声称onGround=true但服务端Y速度指示玩家应在空中。
|
||||
* NoFall hack常通过伪造onGround=true来跳过服务端的摔落伤害计算,
|
||||
* 因为Minecraft服务端在onGround=true时重置摔落距离。
|
||||
*
|
||||
* @param playerName 玩家名称
|
||||
* @param yVelocity 当前tick的Y速度(负值=下落中)
|
||||
* @param clientOnGround 客户端声称的onGround状态
|
||||
* @param timestamp 时间戳
|
||||
* @return 如果检测到持续的ground伪造则返回flagged,否则返回clean
|
||||
*/
|
||||
public NoFallCheckResult detectGroundSpoof(String playerName,
|
||||
double yVelocity,
|
||||
boolean clientOnGround,
|
||||
Instant timestamp) {
|
||||
if (!config.getSecurity().getSuperEvolution().isAntiNoFall()) {
|
||||
return NoFallCheckResult.clean();
|
||||
}
|
||||
|
||||
// 客户端声称在地面,但Y速度为负(在下落)— 物理上矛盾
|
||||
// 正常在地面时Y速度应接近0(重力被地面法向力抵消)
|
||||
boolean serverSideLikelyAirborne = yVelocity < -ZERO_VELOCITY_THRESHOLD;
|
||||
|
||||
if (clientOnGround && serverSideLikelyAirborne) {
|
||||
int spoofTicks = playerGroundSpoofTicks.getOrDefault(playerName, 0) + 1;
|
||||
playerGroundSpoofTicks.put(playerName, spoofTicks);
|
||||
|
||||
if (spoofTicks >= MAX_GROUND_SPOOF_TICKS) {
|
||||
List<String> reasons = new ArrayList<>();
|
||||
reasons.add("GROUND_SPOOF: client claims onGround=true for "
|
||||
+ spoofTicks + " consecutive ticks while server Y velocity ("
|
||||
+ String.format("%.3f", yVelocity) + ") indicates falling");
|
||||
reasons.add("This is consistent with NoFall: faking onGround to reset fall distance");
|
||||
groundSpoofViolations.incrementAndGet();
|
||||
noFallViolations.incrementAndGet();
|
||||
return NoFallCheckResult.flagged(reasons);
|
||||
}
|
||||
|
||||
if (spoofTicks >= 3) {
|
||||
List<String> reasons = new ArrayList<>();
|
||||
reasons.add("GROUND_SPOOF_SUSPICIOUS: " + spoofTicks
|
||||
+ " ticks of onGround=true while yVel="
|
||||
+ String.format("%.3f", yVelocity));
|
||||
return NoFallCheckResult.suspicious(reasons);
|
||||
}
|
||||
} else {
|
||||
// 状态一致或客户端正确报告了空中状态,重置计数
|
||||
playerGroundSpoofTicks.remove(playerName);
|
||||
}
|
||||
|
||||
return NoFallCheckResult.clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* V5.2 数据包级地面状态分析 — 逐tick调用
|
||||
*
|
||||
* 比较客户端声称的onGround与服务端独立计算的onGround状态。
|
||||
* 服务端根据以下条件自行判断地面状态:
|
||||
* - Y坐标与整数边界的关系(脚部Y坐标接近整数表示在地面)
|
||||
* - Y速度接近0且上一tick在下降
|
||||
* - 碰撞检测(服务端的方块碰撞计算结果)
|
||||
*
|
||||
* 如果客户端与服务端在onGround上持续不一致,说明客户端在伪造数据包。
|
||||
*
|
||||
* @param playerName 玩家名称
|
||||
* @param clientOnGround 客户端声称的onGround
|
||||
* @param serverCalcOnGround 服务端计算的onGround(基于Y坐标/碰撞)
|
||||
* @param playerY 玩家当前Y坐标
|
||||
* @param yVelocity 玩家Y速度
|
||||
* @param timestamp 时间戳
|
||||
* @return 检测结果
|
||||
*/
|
||||
public NoFallCheckResult analyzePacketGroundState(String playerName,
|
||||
boolean clientOnGround,
|
||||
boolean serverCalcOnGround,
|
||||
double playerY,
|
||||
double yVelocity,
|
||||
Instant timestamp) {
|
||||
if (!config.getSecurity().getSuperEvolution().isAntiNoFall()) {
|
||||
return NoFallCheckResult.clean();
|
||||
}
|
||||
|
||||
// 记录地面状态历史
|
||||
List<GroundStateRecord> history = playerGroundStateHistory.computeIfAbsent(
|
||||
playerName, k -> new ArrayList<>());
|
||||
GroundStateRecord record = new GroundStateRecord(timestamp, clientOnGround,
|
||||
serverCalcOnGround, playerY, yVelocity);
|
||||
history.add(record);
|
||||
while (history.size() > MAX_GROUND_STATE_RECORDS) {
|
||||
history.remove(0);
|
||||
}
|
||||
|
||||
List<String> reasons = new ArrayList<>();
|
||||
|
||||
// 逐tick比较:客户端声称在地面但服务端计算显示在空中
|
||||
if (clientOnGround && !serverCalcOnGround && yVelocity < -ZERO_VELOCITY_THRESHOLD) {
|
||||
// 服务端计算在空中 + 客户端说在地面 + Y速度在下落 = 典型的NoFall数据包伪造
|
||||
reasons.add("PACKET_GROUND_MISMATCH: client onGround=true but server "
|
||||
+ "calculates onGround=false (yVel=" + String.format("%.3f", yVelocity)
|
||||
+ ", playerY=" + String.format("%.2f", playerY + ")"));
|
||||
}
|
||||
|
||||
// 长期模式分析:统计客户端/服务端不一致的比例
|
||||
if (history.size() >= 10) {
|
||||
long mismatchCount = history.stream()
|
||||
.filter(r -> r.clientOnGround != r.serverCalcOnGround)
|
||||
.count();
|
||||
double mismatchRatio = (double) mismatchCount / history.size();
|
||||
|
||||
// 超过60%的tick存在onGround不一致 — 客户端在系统性伪造
|
||||
if (mismatchRatio > 0.6) {
|
||||
reasons.add("SYSTEMATIC_GROUND_MISMATCH: "
|
||||
+ String.format("%.0f", mismatchRatio * 100)
|
||||
+ "% ground state mismatch over " + history.size()
|
||||
+ " ticks (" + mismatchCount + "/" + history.size() + " ticks)");
|
||||
noFallViolations.incrementAndGet();
|
||||
}
|
||||
}
|
||||
|
||||
if (reasons.size() >= 2) {
|
||||
return NoFallCheckResult.flagged(reasons);
|
||||
} else if (reasons.size() == 1) {
|
||||
return NoFallCheckResult.suspicious(reasons);
|
||||
}
|
||||
|
||||
return NoFallCheckResult.clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* V5.2 服务端坠落距离累积追踪 — 逐tick调用
|
||||
*
|
||||
* 服务端独立追踪玩家的坠落距离,不受客户端报告的onGround影响。
|
||||
* 从服务端判断的离地瞬间开始累积Y坐标的下降量,直到接触地面或受到伤害。
|
||||
*
|
||||
* 如果服务端累积的坠落距离超过3.0方块但玩家未受到任何伤害,
|
||||
* 说明客户端可能伪造了onGround或摔落距离数据包。
|
||||
*
|
||||
* @param playerName 玩家名称
|
||||
* @param currentY 玩家当前Y坐标
|
||||
* @param yVelocity 玩家Y速度
|
||||
* @param serverOnGround 服务端判断的onGround状态
|
||||
* @param tookDamage 玩家是否受到伤害(本tick)
|
||||
* @param timestamp 时间戳
|
||||
* @return 检测结果
|
||||
*/
|
||||
public NoFallCheckResult checkFallDistanceAccumulation(String playerName,
|
||||
double currentY,
|
||||
double yVelocity,
|
||||
boolean serverOnGround,
|
||||
boolean tookDamage,
|
||||
Instant timestamp) {
|
||||
if (!config.getSecurity().getSuperEvolution().isAntiNoFall()) {
|
||||
return NoFallCheckResult.clean();
|
||||
}
|
||||
|
||||
Double previousY = playerLastY.get(playerName);
|
||||
|
||||
// 在地面上,重置坠落累积
|
||||
if (serverOnGround) {
|
||||
Double accumulated = playerAccumulatedFallDistance.get(playerName);
|
||||
if (accumulated != null && accumulated >= ACCUMULATED_FALL_THRESHOLD && !tookDamage) {
|
||||
// 累积了足够的坠落距离但没有受伤 — NoFall
|
||||
List<String> reasons = new ArrayList<>();
|
||||
reasons.add("ACCUMULATED_FALL_NO_DAMAGE: server-side fall distance of "
|
||||
+ String.format("%.1f", accumulated) + " blocks accumulated,"
|
||||
+ " landed without taking damage");
|
||||
reasons.add("Client likely faked onGround or fall distance packet");
|
||||
noFallViolations.incrementAndGet();
|
||||
|
||||
// 重置累积
|
||||
playerAccumulatedFallDistance.remove(playerName);
|
||||
playerFallStartY.remove(playerName);
|
||||
|
||||
return NoFallCheckResult.flagged(reasons);
|
||||
}
|
||||
|
||||
// 正常落地,重置
|
||||
playerAccumulatedFallDistance.remove(playerName);
|
||||
playerFallStartY.remove(playerName);
|
||||
playerServerSideAirborne.put(playerName, false);
|
||||
} else {
|
||||
// 在空中 — 累积坠落距离
|
||||
playerServerSideAirborne.put(playerName, true);
|
||||
|
||||
if (previousY != null && yVelocity < -ZERO_VELOCITY_THRESHOLD) {
|
||||
double fallDelta = previousY - currentY;
|
||||
if (fallDelta > 0) {
|
||||
double accumulated = playerAccumulatedFallDistance.getOrDefault(playerName, 0.0);
|
||||
accumulated += fallDelta;
|
||||
playerAccumulatedFallDistance.put(playerName, accumulated);
|
||||
|
||||
// 记录坠落起始Y
|
||||
playerFallStartY.putIfAbsent(playerName, previousY);
|
||||
|
||||
// 如果累积超过阈值但玩家未受伤且仍在空中 — 记录可疑状态
|
||||
if (accumulated >= ACCUMULATED_FALL_THRESHOLD + 3.0) {
|
||||
// 积累了大量坠落距离但还未落地 — 可能是Blink或持续空中状态
|
||||
// 暂时仅记录,等落地时再判断
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果玩家受到伤害,重置累积(伤害已正确应用,非NoFall)
|
||||
if (tookDamage) {
|
||||
playerAccumulatedFallDistance.remove(playerName);
|
||||
playerFallStartY.remove(playerName);
|
||||
}
|
||||
|
||||
// 更新最近Y坐标
|
||||
if (previousY == null || !serverOnGround) {
|
||||
playerLastY.put(playerName, currentY);
|
||||
}
|
||||
|
||||
return NoFallCheckResult.clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* V5.2 传送式NoFall检测 — 逐tick调用
|
||||
*
|
||||
* 检测玩家在单tick内Y坐标突然下降5+方块且没有受到伤害。
|
||||
* 这通常意味着hack结合了VClip(垂直传送)和NoFall(免疫伤害)功能。
|
||||
*
|
||||
* 合法情况排除:
|
||||
* - 末影珍珠传送(有投掷音效+伤害/粒子)
|
||||
* - /tp命令(有日志)
|
||||
* - 紫颂果传送(有粒子效果)
|
||||
* - 鞘翅俯冲然后突然拉起(有速度渐变+无伤害是因为鞘翅免疫)
|
||||
*
|
||||
* @param playerName 玩家名称
|
||||
* @param fromY 上一tick Y坐标
|
||||
* @param toY 当前tick Y坐标
|
||||
* @param tookDamage 本tick是否受到伤害
|
||||
* @param isElytraFlying 是否鞘翅飞行
|
||||
* @param isTeleportCommand 是否为/tp命令触发
|
||||
* @param timestamp 时间戳
|
||||
* @return 检测结果
|
||||
*/
|
||||
public NoFallCheckResult detectTeleportNoFall(String playerName,
|
||||
double fromY, double toY,
|
||||
boolean tookDamage,
|
||||
boolean isElytraFlying,
|
||||
boolean isTeleportCommand,
|
||||
Instant timestamp) {
|
||||
if (!config.getSecurity().getSuperEvolution().isAntiNoFall()) {
|
||||
return NoFallCheckResult.clean();
|
||||
}
|
||||
|
||||
double dy = toY - fromY;
|
||||
double fallAmount = Math.abs(dy);
|
||||
|
||||
// 没有显著下落,跳过
|
||||
if (dy >= 0 || fallAmount < TELEPORT_FALL_THRESHOLD) {
|
||||
return NoFallCheckResult.clean();
|
||||
}
|
||||
|
||||
// 排除合法情况
|
||||
if (isElytraFlying || isTeleportCommand) {
|
||||
return NoFallCheckResult.clean();
|
||||
}
|
||||
|
||||
// 大幅下落但无伤害 — 典型的Teleport+NoFall组合
|
||||
if (!tookDamage && fallAmount >= TELEPORT_FALL_THRESHOLD) {
|
||||
List<String> reasons = new ArrayList<>();
|
||||
reasons.add("TELEPORT_NOFALL: instant drop of "
|
||||
+ String.format("%.1f", fallAmount) + " blocks (Y "
|
||||
+ String.format("%.1f", fromY) + " -> " + String.format("%.1f", toY)
|
||||
+ ") with zero damage taken");
|
||||
reasons.add("Consistent with VClip + NoFall combination cheat");
|
||||
|
||||
if (fallAmount >= DANGEROUS_FALL_HEIGHT) {
|
||||
reasons.add("CRITICAL_TELEPORT_NOFALL: drop distance ("
|
||||
+ String.format("%.1f", fallAmount)
|
||||
+ " blocks) would cause " + String.format("%.0f", fallAmount - FALL_DAMAGE_HEIGHT)
|
||||
+ " damage points normally");
|
||||
teleportNoFallViolations.incrementAndGet();
|
||||
noFallViolations.incrementAndGet();
|
||||
return NoFallCheckResult.flagged(reasons);
|
||||
}
|
||||
|
||||
// 小于危险高度但超过阈值
|
||||
teleportNoFallViolations.incrementAndGet();
|
||||
return NoFallCheckResult.flagged(reasons);
|
||||
}
|
||||
|
||||
return NoFallCheckResult.clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* V5.2 逐tick综合检测入口 — 在一个方法调用中执行所有tick级检测
|
||||
*
|
||||
* 调用方应在每个玩家移动tick中调用此方法,传入完整的移动和状态数据。
|
||||
* 此方法内部协调调用所有V5.2新增的子检测方法。
|
||||
*
|
||||
* @param playerName 玩家名称
|
||||
* @param currentY 玩家当前Y坐标
|
||||
* @param previousY 玩家上一tick Y坐标
|
||||
* @param yVelocity 玩家Y速度
|
||||
* @param clientOnGround 客户端声称的onGround
|
||||
* @param serverCalcOnGround 服务端计算的onGround
|
||||
* @param tookDamage 本tick是否受到伤害
|
||||
* @param isElytraFlying 是否鞘翅飞行
|
||||
* @param isTeleportCommand 是否为/tp命令传送
|
||||
* @param timestamp 时间戳
|
||||
* @return 综合检测结果(合并所有子检测的发现)
|
||||
*/
|
||||
public NoFallCheckResult detect(String playerName,
|
||||
double currentY, double previousY,
|
||||
double yVelocity,
|
||||
boolean clientOnGround, boolean serverCalcOnGround,
|
||||
boolean tookDamage,
|
||||
boolean isElytraFlying, boolean isTeleportCommand,
|
||||
Instant timestamp) {
|
||||
if (!config.getSecurity().getSuperEvolution().isAntiNoFall()) {
|
||||
return NoFallCheckResult.clean();
|
||||
}
|
||||
|
||||
List<String> allReasons = new ArrayList<>();
|
||||
boolean anyFlagged = false;
|
||||
boolean anySuspicious = false;
|
||||
|
||||
// 1. GroundSpoof检测
|
||||
NoFallCheckResult groundSpoofResult = detectGroundSpoof(
|
||||
playerName, yVelocity, clientOnGround, timestamp);
|
||||
if (groundSpoofResult.isFlagged()) {
|
||||
anyFlagged = true;
|
||||
allReasons.addAll(groundSpoofResult.getReasons());
|
||||
} else if (groundSpoofResult.isSuspicious()) {
|
||||
anySuspicious = true;
|
||||
allReasons.addAll(groundSpoofResult.getReasons());
|
||||
}
|
||||
|
||||
// 2. 数据包级地面状态分析
|
||||
NoFallCheckResult packetResult = analyzePacketGroundState(
|
||||
playerName, clientOnGround, serverCalcOnGround,
|
||||
currentY, yVelocity, timestamp);
|
||||
if (packetResult.isFlagged()) {
|
||||
anyFlagged = true;
|
||||
allReasons.addAll(packetResult.getReasons());
|
||||
} else if (packetResult.isSuspicious()) {
|
||||
anySuspicious = true;
|
||||
allReasons.addAll(packetResult.getReasons());
|
||||
}
|
||||
|
||||
// 3. 服务端坠落距离累积
|
||||
NoFallCheckResult accumulationResult = checkFallDistanceAccumulation(
|
||||
playerName, currentY, yVelocity, serverCalcOnGround,
|
||||
tookDamage, timestamp);
|
||||
if (accumulationResult.isFlagged()) {
|
||||
anyFlagged = true;
|
||||
allReasons.addAll(accumulationResult.getReasons());
|
||||
} else if (accumulationResult.isSuspicious()) {
|
||||
anySuspicious = true;
|
||||
allReasons.addAll(accumulationResult.getReasons());
|
||||
}
|
||||
|
||||
// 4. 传送式NoFall检测
|
||||
NoFallCheckResult teleportResult = detectTeleportNoFall(
|
||||
playerName, previousY, currentY, tookDamage,
|
||||
isElytraFlying, isTeleportCommand, timestamp);
|
||||
if (teleportResult.isFlagged()) {
|
||||
anyFlagged = true;
|
||||
allReasons.addAll(teleportResult.getReasons());
|
||||
} else if (teleportResult.isSuspicious()) {
|
||||
anySuspicious = true;
|
||||
allReasons.addAll(teleportResult.getReasons());
|
||||
}
|
||||
|
||||
if (anyFlagged) {
|
||||
return NoFallCheckResult.flagged(allReasons);
|
||||
} else if (anySuspicious) {
|
||||
return NoFallCheckResult.suspicious(allReasons);
|
||||
}
|
||||
|
||||
return NoFallCheckResult.clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* 玩家离线时清理追踪数据
|
||||
* @param playerName 离线玩家名称
|
||||
*/
|
||||
public void clearPlayer(String playerName) {
|
||||
playerFallHistory.remove(playerName);
|
||||
activeFalls.remove(playerName);
|
||||
noFallEvents.remove(playerName);
|
||||
// V5.2 新增数据结构清理
|
||||
playerGroundSpoofTicks.remove(playerName);
|
||||
playerGroundStateHistory.remove(playerName);
|
||||
playerAccumulatedFallDistance.remove(playerName);
|
||||
playerFallStartY.remove(playerName);
|
||||
playerServerSideAirborne.remove(playerName);
|
||||
playerLastY.remove(playerName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模块运行状态
|
||||
* @return 包含计数器、违规数量和误报数量的Map
|
||||
*/
|
||||
public Map<String, Object> getStatus() {
|
||||
Map<String, Object> status = new LinkedHashMap<>();
|
||||
status.put("totalFalls", totalFalls.get());
|
||||
status.put("noFallViolations", noFallViolations.get());
|
||||
status.put("falsePositives", falsePositives.get());
|
||||
status.put("groundSpoofViolations", groundSpoofViolations.get());
|
||||
status.put("teleportNoFallViolations", teleportNoFallViolations.get());
|
||||
status.put("activeTrackedPlayers", playerFallHistory.size());
|
||||
status.put("playersInFall", activeFalls.size());
|
||||
status.put("playersInGroundSpoof", playerGroundSpoofTicks.size());
|
||||
status.put("playersAccumulatingFall", playerAccumulatedFallDistance.size());
|
||||
|
||||
// 列出可疑NoFall玩家
|
||||
List<Map<String, Object>> suspiciousPlayers = new ArrayList<>();
|
||||
for (Map.Entry<String, List<FallRecord>> entry : playerFallHistory.entrySet()) {
|
||||
long noDamageCount = entry.getValue().stream()
|
||||
.filter(r -> !r.tookDamage && !r.legitimateNoDamage).count();
|
||||
if (noDamageCount > 0) {
|
||||
Map<String, Object> info = new LinkedHashMap<>();
|
||||
info.put("player", entry.getKey());
|
||||
info.put("noDamageFalls", noDamageCount);
|
||||
info.put("totalFalls", entry.getValue().size());
|
||||
suspiciousPlayers.add(info);
|
||||
}
|
||||
}
|
||||
suspiciousPlayers.sort((a, b) ->
|
||||
Long.compare((Long) b.get("noDamageFalls"), (Long) a.get("noDamageFalls")));
|
||||
status.put("suspiciousPlayers", suspiciousPlayers.subList(0, Math.min(suspiciousPlayers.size(), 10)));
|
||||
|
||||
// V5.2: 列出正在伪造ground的玩家
|
||||
List<Map<String, Object>> groundSpoofPlayers = new ArrayList<>();
|
||||
for (Map.Entry<String, Integer> entry : playerGroundSpoofTicks.entrySet()) {
|
||||
if (entry.getValue() >= 1) {
|
||||
Map<String, Object> info = new LinkedHashMap<>();
|
||||
info.put("player", entry.getKey());
|
||||
info.put("spoofTicks", entry.getValue());
|
||||
groundSpoofPlayers.add(info);
|
||||
}
|
||||
}
|
||||
status.put("groundSpoofPlayers", groundSpoofPlayers);
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 追踪玩家当前活跃的坠落状态
|
||||
*/
|
||||
private static class ActiveFall {
|
||||
final double highestY; // 坠落起始时最高点的Y坐标
|
||||
final String gameMode; // 坠落开始时的游戏模式
|
||||
final Instant startTime; // 坠落开始时间
|
||||
|
||||
ActiveFall(double highestY, String gameMode, Instant startTime) {
|
||||
this.highestY = highestY;
|
||||
this.gameMode = gameMode;
|
||||
this.startTime = startTime;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部坠落记录 — 记录单次坠落的详细信息
|
||||
*/
|
||||
private static class FallRecord {
|
||||
final Instant timestamp;
|
||||
final double fallDistance;
|
||||
final boolean tookDamage;
|
||||
final double damageAmount;
|
||||
final boolean legitimateNoDamage; // 是否为合法的无伤害情况
|
||||
|
||||
FallRecord(Instant timestamp, double fallDistance, boolean tookDamage,
|
||||
double damageAmount, boolean legitimateNoDamage) {
|
||||
this.timestamp = timestamp;
|
||||
this.fallDistance = fallDistance;
|
||||
this.tookDamage = tookDamage;
|
||||
this.damageAmount = damageAmount;
|
||||
this.legitimateNoDamage = legitimateNoDamage;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* V5.2 地面状态记录 — 存储每次地面状态数据包分析的结果
|
||||
* 用于逐tick的客户端/服务端onGround交叉对比
|
||||
*/
|
||||
private static class GroundStateRecord {
|
||||
final Instant timestamp;
|
||||
final boolean clientOnGround;
|
||||
final boolean serverCalcOnGround;
|
||||
final double playerY;
|
||||
final double yVelocity;
|
||||
|
||||
GroundStateRecord(Instant timestamp, boolean clientOnGround,
|
||||
boolean serverCalcOnGround, double playerY,
|
||||
double yVelocity) {
|
||||
this.timestamp = timestamp;
|
||||
this.clientOnGround = clientOnGround;
|
||||
this.serverCalcOnGround = serverCalcOnGround;
|
||||
this.playerY = playerY;
|
||||
this.yVelocity = yVelocity;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NoFall检测结果 — 不可变结果类
|
||||
*/
|
||||
public static class NoFallCheckResult {
|
||||
private final boolean flagged;
|
||||
private final boolean suspicious;
|
||||
private final List<String> reasons;
|
||||
|
||||
private NoFallCheckResult(boolean flagged, boolean suspicious, List<String> reasons) {
|
||||
this.flagged = flagged;
|
||||
this.suspicious = suspicious;
|
||||
this.reasons = reasons;
|
||||
}
|
||||
|
||||
/** 无异常 — 正常坠落并受到伤害,或有合法无伤害理由 */
|
||||
public static NoFallCheckResult clean() {
|
||||
return new NoFallCheckResult(false, false, List.of());
|
||||
}
|
||||
|
||||
/** 可疑 — 未受伤害但坠落高度不够极端,或存在未知因素 */
|
||||
public static NoFallCheckResult suspicious(List<String> reasons) {
|
||||
return new NoFallCheckResult(false, true, reasons);
|
||||
}
|
||||
|
||||
/** 已标记 — 确认NoFall hack,从危险高度坠落未受伤害且无合法理由 */
|
||||
public static NoFallCheckResult flagged(List<String> reasons) {
|
||||
return new NoFallCheckResult(true, false, reasons);
|
||||
}
|
||||
|
||||
public boolean isFlagged() { return flagged; }
|
||||
public boolean isSuspicious() { return suspicious; }
|
||||
public boolean isClean() { return !flagged && !suspicious; }
|
||||
public List<String> getReasons() { return reasons; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
package com.aluer.anticheat.movement;
|
||||
|
||||
import com.aluer.config.ServerGuardConfig;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* 无减速(NoSlow)检测服务 — V5.2 反作弊移动模块
|
||||
*
|
||||
* 检测原理:
|
||||
* 1. 物品使用时移速检测 — Minecraft中玩家在使用物品(吃食物、拉弓、举盾、喝药水)时
|
||||
* 会强制减速至潜行速度(约1.31 m/s)。NoSlow hack通过取消物品使用减速封包,
|
||||
* 使玩家在使用物品时仍能全速移动。
|
||||
* 2. 按物品类型设定速度阈值 — 不同类型的物品使用有不同的预期速度上限:
|
||||
* 吃食物(1.5 m/s)、拉弓(1.2 m/s)、举盾(0.8 m/s)、喝药水(1.5 m/s)、
|
||||
* 吃东西(1.5 m/s)。超过对应阈值即标记。
|
||||
* 3. 连续tick检测 — 区分偶发的网络延迟导致的瞬间速度尖峰和持续性的作弊行为,
|
||||
* 要求连续多tick同时"使用物品+高速移动"才判定为作弊。
|
||||
* 4. 使用状态交叉验证 — 结合玩家"正在使用物品"状态与Agent上报的移动速度数据,
|
||||
* 精准判断玩家是否在使用物品的同时快速移动。
|
||||
*
|
||||
* 配置开关:serverguard.security.super-evolution.anti-no-slow
|
||||
*/
|
||||
@Service
|
||||
public class AntiNoSlowService {
|
||||
|
||||
private final ServerGuardConfig config;
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的物品使用历史记录(playerName -> 使用记录列表)
|
||||
*/
|
||||
private final Map<String, List<ItemUseRecord>> playerUseHistory = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家连续违规tick计数(playerName -> 连续违规次数)
|
||||
* 用于区分瞬时延迟与持续作弊
|
||||
*/
|
||||
private final Map<String, Integer> playerViolationTicks = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家当前正在使用的物品类型(playerName -> 物品类型描述)
|
||||
*/
|
||||
private final Map<String, String> playerActiveItem = new ConcurrentHashMap<>();
|
||||
|
||||
private final AtomicLong totalChecks = new AtomicLong(0);
|
||||
private final AtomicLong noSlowViolations = new AtomicLong(0);
|
||||
|
||||
/**
|
||||
* 各物品类型在使用时的最大合法水平速度(m/s)
|
||||
* 正常使用物品时玩家被强制减速至潜行速度约1.31 m/s
|
||||
*/
|
||||
private static final double MAX_EATING_SPEED = 1.5;
|
||||
private static final double MAX_BOW_DRAW_SPEED = 1.2;
|
||||
private static final double MAX_SHIELD_BLOCK_SPEED = 0.8;
|
||||
private static final double MAX_POTION_DRINK_SPEED = 1.5;
|
||||
private static final double MAX_FOOD_EAT_SPEED = 1.5;
|
||||
private static final double MAX_DEFAULT_ITEM_USE_SPEED = 1.3;
|
||||
|
||||
/**
|
||||
* 正常步行速度的60%阈值 — 用于快速判断
|
||||
* Minecraft步行速度约4.317 m/s,60%约2.59 m/s
|
||||
*/
|
||||
private static final double WALK_SPEED_60_PERCENT = 2.59;
|
||||
|
||||
/**
|
||||
* 连续违规tick阈值 — 需要连续超过此tick数才判定为作弊
|
||||
* 低于此值可能是网络延迟导致的数据包积压释放
|
||||
*/
|
||||
private static final int MIN_CONSECUTIVE_VIOLATION_TICKS = 5;
|
||||
|
||||
/**
|
||||
* 每个玩家保留的最大记录数
|
||||
*/
|
||||
private static final int MAX_RECORDS_PER_PLAYER = 40;
|
||||
|
||||
/**
|
||||
* 无参构造函数 — 测试/默认配置使用
|
||||
*/
|
||||
public AntiNoSlowService() {
|
||||
this(new ServerGuardConfig());
|
||||
}
|
||||
|
||||
/**
|
||||
* Spring 注入构造函数
|
||||
* @param config 全局配置,提供模块开关控制
|
||||
*/
|
||||
public AntiNoSlowService(ServerGuardConfig config) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测玩家是否在使用物品时维持异常高速(NoSlow hack)
|
||||
*
|
||||
* @param playerName 玩家名称
|
||||
* @param playerUUID 玩家UUID
|
||||
* @param isUsingItem 玩家是否正在使用物品(右键按住)
|
||||
* @param itemType 正在使用的物品类型(EATING, BOW, SHIELD, POTION, FOOD, OTHER)
|
||||
* @param fromX 移动起始X坐标
|
||||
* @param fromZ 移动起始Z坐标
|
||||
* @param toX 移动结束X坐标
|
||||
* @param toZ 移动结束Z坐标
|
||||
* @param deltaSeconds 本次移动时间间隔(秒)
|
||||
* @param timestamp 时间戳
|
||||
* @return 检测结果
|
||||
*/
|
||||
public NoSlowCheckResult detect(String playerName, String playerUUID,
|
||||
boolean isUsingItem, String itemType,
|
||||
double fromX, double fromZ,
|
||||
double toX, double toZ,
|
||||
double deltaSeconds, Instant timestamp) {
|
||||
// 模块开关检查 — 关闭时跳过所有检测
|
||||
if (!config.getSecurity().getSuperEvolution().isAntiNoSlow()) {
|
||||
return NoSlowCheckResult.clean();
|
||||
}
|
||||
|
||||
totalChecks.incrementAndGet();
|
||||
|
||||
// 玩家当前未使用物品,无需检测,重置违规计数
|
||||
if (!isUsingItem) {
|
||||
playerViolationTicks.remove(playerName);
|
||||
playerActiveItem.remove(playerName);
|
||||
return NoSlowCheckResult.clean();
|
||||
}
|
||||
|
||||
// 更新当前使用的物品类型
|
||||
playerActiveItem.put(playerName, itemType != null ? itemType : "OTHER");
|
||||
|
||||
List<ItemUseRecord> history = playerUseHistory.computeIfAbsent(
|
||||
playerName, k -> new ArrayList<>());
|
||||
|
||||
// 避免除零 — 默认50ms(1 tick)
|
||||
if (deltaSeconds <= 0) {
|
||||
deltaSeconds = 0.05;
|
||||
}
|
||||
|
||||
// 计算XZ平面水平速度
|
||||
double dx = toX - fromX;
|
||||
double dz = toZ - fromZ;
|
||||
double horizontalDistance = Math.sqrt(dx * dx + dz * dz);
|
||||
double horizontalSpeed = horizontalDistance / deltaSeconds;
|
||||
|
||||
// 根据物品类型确定速度阈值
|
||||
double speedThreshold = getItemSpeedThreshold(itemType);
|
||||
|
||||
ItemUseRecord record = new ItemUseRecord(timestamp, itemType,
|
||||
horizontalSpeed, horizontalDistance, deltaSeconds);
|
||||
history.add(record);
|
||||
while (history.size() > MAX_RECORDS_PER_PLAYER) {
|
||||
history.remove(0);
|
||||
}
|
||||
|
||||
List<String> reasons = new ArrayList<>();
|
||||
|
||||
// 1. 核心检测:使用物品时水平速度超过对应物品类型阈值
|
||||
if (horizontalSpeed > speedThreshold) {
|
||||
// 累加违规tick计数
|
||||
int violationTicks = playerViolationTicks.getOrDefault(playerName, 0) + 1;
|
||||
playerViolationTicks.put(playerName, violationTicks);
|
||||
|
||||
// 超过连续阈值才判定为作弊
|
||||
if (violationTicks >= MIN_CONSECUTIVE_VIOLATION_TICKS) {
|
||||
noSlowViolations.incrementAndGet();
|
||||
reasons.add("SUSTAINED_NOSLOW: " + String.format("%.2f", horizontalSpeed)
|
||||
+ " m/s while using " + itemType.toLowerCase()
|
||||
+ " for " + violationTicks + " consecutive ticks"
|
||||
+ " (threshold: " + String.format("%.2f", speedThreshold) + " m/s)");
|
||||
}
|
||||
} else {
|
||||
// 速度正常,重置违规计数
|
||||
playerViolationTicks.remove(playerName);
|
||||
}
|
||||
|
||||
// 2. 快速筛查:使用物品时速度超过步行速度60%
|
||||
if (horizontalSpeed > WALK_SPEED_60_PERCENT) {
|
||||
int violationTicks = playerViolationTicks.getOrDefault(playerName, 0);
|
||||
if (violationTicks >= MIN_CONSECUTIVE_VIOLATION_TICKS) {
|
||||
reasons.add("HIGH_SPEED_ITEM_USE: " + String.format("%.2f", horizontalSpeed)
|
||||
+ " m/s (" + String.format("%.0f", (horizontalSpeed / 4.317) * 100)
|
||||
+ "% of walk speed) while using " + itemType.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 使用模式分析 — 检测高频使用+高速移动的组合模式
|
||||
if (history.size() >= 10) {
|
||||
long highSpeedWhileUsing = history.stream()
|
||||
.filter(r -> r.horizontalSpeed > speedThreshold)
|
||||
.count();
|
||||
double highSpeedRatio = (double) highSpeedWhileUsing / history.size();
|
||||
|
||||
// 如果超过70%的使用物品时间都处于高速移动,高度可疑
|
||||
if (highSpeedRatio > 0.7 && history.size() >= 10) {
|
||||
reasons.add("PATTERN_HIGH_SPEED_USE: "
|
||||
+ String.format("%.0f", highSpeedRatio * 100)
|
||||
+ "% of item use ticks at high speed ("
|
||||
+ highSpeedWhileUsing + "/" + history.size() + " ticks)");
|
||||
}
|
||||
}
|
||||
|
||||
if (reasons.size() >= 2) {
|
||||
return NoSlowCheckResult.flagged(reasons);
|
||||
} else if (reasons.size() == 1) {
|
||||
return NoSlowCheckResult.suspicious(reasons);
|
||||
}
|
||||
|
||||
return NoSlowCheckResult.clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据物品类型返回对应的速度阈值
|
||||
*
|
||||
* @param itemType 物品类型字符串
|
||||
* @return 该物品类型允许的最大水平速度(m/s)
|
||||
*/
|
||||
private double getItemSpeedThreshold(String itemType) {
|
||||
if (itemType == null) return MAX_DEFAULT_ITEM_USE_SPEED;
|
||||
return switch (itemType.toUpperCase()) {
|
||||
case "EATING" -> MAX_EATING_SPEED;
|
||||
case "BOW" -> MAX_BOW_DRAW_SPEED;
|
||||
case "SHIELD" -> MAX_SHIELD_BLOCK_SPEED;
|
||||
case "POTION" -> MAX_POTION_DRINK_SPEED;
|
||||
case "FOOD" -> MAX_FOOD_EAT_SPEED;
|
||||
default -> MAX_DEFAULT_ITEM_USE_SPEED;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 玩家离线时清理追踪数据
|
||||
* @param playerName 离线玩家名称
|
||||
*/
|
||||
public void clearPlayer(String playerName) {
|
||||
playerUseHistory.remove(playerName);
|
||||
playerViolationTicks.remove(playerName);
|
||||
playerActiveItem.remove(playerName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模块运行状态
|
||||
* @return 包含计数器和违规数量的Map
|
||||
*/
|
||||
public Map<String, Object> getStatus() {
|
||||
Map<String, Object> status = new LinkedHashMap<>();
|
||||
status.put("totalChecks", totalChecks.get());
|
||||
status.put("noSlowViolations", noSlowViolations.get());
|
||||
status.put("activeTrackedPlayers", playerUseHistory.size());
|
||||
status.put("playersInViolation", playerViolationTicks.size());
|
||||
|
||||
// 列出当前正在使用物品的玩家
|
||||
List<Map<String, String>> usingPlayers = new ArrayList<>();
|
||||
for (Map.Entry<String, String> entry : playerActiveItem.entrySet()) {
|
||||
Map<String, String> info = new LinkedHashMap<>();
|
||||
info.put("player", entry.getKey());
|
||||
info.put("item", entry.getValue());
|
||||
info.put("violationTicks", String.valueOf(
|
||||
playerViolationTicks.getOrDefault(entry.getKey(), 0)));
|
||||
usingPlayers.add(info);
|
||||
}
|
||||
status.put("activeItemUsers", usingPlayers);
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部物品使用记录 — 记录玩家使用物品时的移动信息
|
||||
*/
|
||||
private static class ItemUseRecord {
|
||||
final Instant timestamp;
|
||||
final String itemType;
|
||||
final double horizontalSpeed;
|
||||
final double horizontalDistance;
|
||||
final double deltaSeconds;
|
||||
|
||||
ItemUseRecord(Instant timestamp, String itemType,
|
||||
double horizontalSpeed, double horizontalDistance,
|
||||
double deltaSeconds) {
|
||||
this.timestamp = timestamp;
|
||||
this.itemType = itemType;
|
||||
this.horizontalSpeed = horizontalSpeed;
|
||||
this.horizontalDistance = horizontalDistance;
|
||||
this.deltaSeconds = deltaSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NoSlow检测结果 — 不可变结果类
|
||||
*/
|
||||
public static class NoSlowCheckResult {
|
||||
private final boolean flagged;
|
||||
private final boolean suspicious;
|
||||
private final List<String> reasons;
|
||||
|
||||
private NoSlowCheckResult(boolean flagged, boolean suspicious, List<String> reasons) {
|
||||
this.flagged = flagged;
|
||||
this.suspicious = suspicious;
|
||||
this.reasons = reasons;
|
||||
}
|
||||
|
||||
/** 无异常 — 正常使用物品并减速 */
|
||||
public static NoSlowCheckResult clean() {
|
||||
return new NoSlowCheckResult(false, false, List.of());
|
||||
}
|
||||
|
||||
/** 可疑 — 短暂超速但不足够确认为NoSlow hack */
|
||||
public static NoSlowCheckResult suspicious(List<String> reasons) {
|
||||
return new NoSlowCheckResult(false, true, reasons);
|
||||
}
|
||||
|
||||
/** 已标记 — 确认NoSlow hack,持续在使用物品时高速移动 */
|
||||
public static NoSlowCheckResult flagged(List<String> reasons) {
|
||||
return new NoSlowCheckResult(true, false, reasons);
|
||||
}
|
||||
|
||||
public boolean isFlagged() { return flagged; }
|
||||
public boolean isSuspicious() { return suspicious; }
|
||||
public boolean isClean() { return !flagged && !suspicious; }
|
||||
public List<String> getReasons() { return reasons; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
package com.aluer.anticheat.movement;
|
||||
|
||||
import com.aluer.config.ServerGuardConfig;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* 数据包飞行(PacketFly)检测服务 — V5.2 反作弊移动模块
|
||||
*
|
||||
* 检测原理:
|
||||
* 1. 无辅助持续上升检测 — PacketFly是最先进的飞行hack之一,通过操控移动数据包序列
|
||||
* 欺骗服务端,实现无鞘翅、无缓降、无创造模式的持续飞行。
|
||||
* 检测玩家在生存模式下没有任何飞行辅助物品(鞘翅/烟花/浮空效果)的持续垂直上升。
|
||||
* 2. 震荡模式检测 — PacketFly最典型的特征是"上下震荡"模式:
|
||||
* 快速的上下来回移动(约±0.5-2.0方块/tick的振荡),目的是重置服务端的摔落距离计算,
|
||||
* 从而避免被反飞行检测。正常玩家不会产生这种高频的垂直振荡模式。
|
||||
* 3. 永不落地检测 — PacketFly用户可以无限期保持在空中不接触地面,
|
||||
* 远超正常玩家通过跳跃、鞘翅滑翔等方式能维持的最大空中时间。
|
||||
* 4. 总垂直位移异常检测 — 在生存模式下,累计的总上升高度不应超过物理极限。
|
||||
* PacketFly用户可以在几分钟内上升数百方块而没有任何合法的上升手段。
|
||||
*
|
||||
* 配置开关:serverguard.security.super-evolution.anti-packet-fly
|
||||
*/
|
||||
@Service
|
||||
public class AntiPacketFlyService {
|
||||
|
||||
private final ServerGuardConfig config;
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的移动历史(playerName -> 移动记录列表)
|
||||
*/
|
||||
private final Map<String, List<PacketFlyRecord>> playerMoveHistory = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的连续空中tick(playerName -> 连续不落地tick计数)
|
||||
*/
|
||||
private final Map<String, Integer> playerAirTicks = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的累计上升高度(playerName -> 累计上升方块数)
|
||||
*/
|
||||
private final Map<String, Double> playerTotalRise = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家最近一次接触地面的时间(playerName -> 时间戳)
|
||||
*/
|
||||
private final Map<String, Instant> playerLastGroundContact = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的振荡检测数据(playerName -> Y方向变化列表)
|
||||
*/
|
||||
private final Map<String, List<Double>> playerDyHistory = new ConcurrentHashMap<>();
|
||||
|
||||
private final AtomicLong totalChecks = new AtomicLong(0);
|
||||
private final AtomicLong packetFlyViolations = new AtomicLong(0);
|
||||
|
||||
/**
|
||||
* 单tick最大合法垂直上升(生存模式无飞行辅助)— 约为0.42 blocks/tick(跳跃峰值)
|
||||
*/
|
||||
private static final double MAX_SURVIVAL_VERTICAL_RISE = 0.5;
|
||||
|
||||
/**
|
||||
* 持续空中tick阈值 — 超过此值且持续上升则标记
|
||||
* 正常玩家从最高处跳下也最多在空中停留约12 ticks
|
||||
*/
|
||||
private static final int MAX_AIR_TICKS_NORMAL = 20;
|
||||
|
||||
/**
|
||||
* 振荡检测窗口大小 — 在此窗口内检测方向反转次数
|
||||
*/
|
||||
private static final int OSCILLATION_WINDOW = 10;
|
||||
|
||||
/**
|
||||
* 振荡检测:方向反转次数阈值 — 在窗口内反转超过此值即为振荡模式
|
||||
*/
|
||||
private static final int MAX_DIRECTION_REVERSALS = 4;
|
||||
|
||||
/**
|
||||
* 累计上升高度异常阈值 — 生存模式无辅助累计上升超过此值可疑
|
||||
*/
|
||||
private static final double MAX_CUMULATIVE_RISE = 50.0;
|
||||
|
||||
/**
|
||||
* 永不落地时间阈值(毫秒)— 超过30秒不落地即为异常
|
||||
*/
|
||||
private static final long MAX_AIR_TIME_MS = 30_000;
|
||||
|
||||
/**
|
||||
* 每个玩家保留的最大记录数
|
||||
*/
|
||||
private static final int MAX_RECORDS_PER_PLAYER = 60;
|
||||
|
||||
/**
|
||||
* 无参构造函数 — 测试/默认配置使用
|
||||
*/
|
||||
public AntiPacketFlyService() {
|
||||
this(new ServerGuardConfig());
|
||||
}
|
||||
|
||||
/**
|
||||
* Spring 注入构造函数
|
||||
* @param config 全局配置,提供模块开关控制
|
||||
*/
|
||||
public AntiPacketFlyService(ServerGuardConfig config) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测玩家是否使用PacketFly数据包操控飞行
|
||||
*
|
||||
* @param playerName 玩家名称
|
||||
* @param playerUUID 玩家UUID
|
||||
* @param fromX 移动起始X坐标
|
||||
* @param fromY 移动起始Y坐标
|
||||
* @param fromZ 移动起始Z坐标
|
||||
* @param toX 移动结束X坐标
|
||||
* @param toY 移动结束Y坐标
|
||||
* @param toZ 移动结束Z坐标
|
||||
* @param onGround 玩家是否在地面
|
||||
* @param gameMode 游戏模式(survival/creative/spectator)
|
||||
* @param hasElytra 是否装备鞘翅
|
||||
* @param hasLevitation 是否有浮空效果
|
||||
* @param isUsingFirework 是否正在使用烟花(鞘翅助推)
|
||||
* @param timestamp 时间戳
|
||||
* @return 检测结果
|
||||
*/
|
||||
public PacketFlyCheckResult detect(String playerName, String playerUUID,
|
||||
double fromX, double fromY, double fromZ,
|
||||
double toX, double toY, double toZ,
|
||||
boolean onGround, String gameMode,
|
||||
boolean hasElytra, boolean hasLevitation,
|
||||
boolean isUsingFirework, Instant timestamp) {
|
||||
// 模块开关检查 — 关闭时跳过所有检测
|
||||
if (!config.getSecurity().getSuperEvolution().isAntiPacketFly()) {
|
||||
return PacketFlyCheckResult.clean();
|
||||
}
|
||||
|
||||
totalChecks.incrementAndGet();
|
||||
|
||||
// 创造/旁观模式允许飞行,跳过
|
||||
if ("creative".equalsIgnoreCase(gameMode) || "spectator".equalsIgnoreCase(gameMode)) {
|
||||
return PacketFlyCheckResult.clean();
|
||||
}
|
||||
|
||||
double dy = toY - fromY;
|
||||
double dx = toX - fromX;
|
||||
double dz = toZ - fromZ;
|
||||
double horizontalDist = Math.sqrt(dx * dx + dz * dz);
|
||||
|
||||
List<PacketFlyRecord> history = playerMoveHistory.computeIfAbsent(
|
||||
playerName, k -> new ArrayList<>());
|
||||
List<Double> dyHistory = playerDyHistory.computeIfAbsent(
|
||||
playerName, k -> new ArrayList<>());
|
||||
|
||||
PacketFlyRecord record = new PacketFlyRecord(timestamp, fromX, fromY, fromZ,
|
||||
toX, toY, toZ, dy, horizontalDist, onGround, hasElytra,
|
||||
hasLevitation, isUsingFirework);
|
||||
history.add(record);
|
||||
dyHistory.add(dy);
|
||||
|
||||
while (history.size() > MAX_RECORDS_PER_PLAYER) {
|
||||
history.remove(0);
|
||||
}
|
||||
while (dyHistory.size() > 30) {
|
||||
dyHistory.remove(0);
|
||||
}
|
||||
|
||||
// 更新空中tick计数
|
||||
if (!onGround) {
|
||||
int airTicks = playerAirTicks.getOrDefault(playerName, 0) + 1;
|
||||
playerAirTicks.put(playerName, airTicks);
|
||||
} else {
|
||||
playerAirTicks.remove(playerName);
|
||||
playerLastGroundContact.put(playerName, timestamp);
|
||||
// 落地时重置累计上升
|
||||
playerTotalRise.remove(playerName);
|
||||
}
|
||||
|
||||
// 累计上升高度(只计正方向)
|
||||
if (dy > 0) {
|
||||
double totalRise = playerTotalRise.getOrDefault(playerName, 0.0) + dy;
|
||||
playerTotalRise.put(playerName, totalRise);
|
||||
}
|
||||
|
||||
// 确定玩家是否有合法飞行手段
|
||||
boolean canFlyLegitimately = hasElytra || hasLevitation || isUsingFirework;
|
||||
|
||||
List<String> reasons = new ArrayList<>();
|
||||
|
||||
// 1. 核心检测:无合法手段的持续上升
|
||||
if (!onGround && dy > MAX_SURVIVAL_VERTICAL_RISE && !canFlyLegitimately) {
|
||||
reasons.add("IMPOSSIBLE_RISE: dy=" + String.format("%.3f", dy)
|
||||
+ " blocks/tick in survival with no flight items"
|
||||
+ " (max normal: " + String.format("%.2f", MAX_SURVIVAL_VERTICAL_RISE) + ")");
|
||||
}
|
||||
|
||||
// 2. 振荡模式检测 — PacketFly的典型特征
|
||||
if (dyHistory.size() >= OSCILLATION_WINDOW) {
|
||||
int reversals = 0;
|
||||
double prevSign = Math.signum(dyHistory.get(dyHistory.size() - OSCILLATION_WINDOW));
|
||||
|
||||
for (int i = dyHistory.size() - OSCILLATION_WINDOW + 1; i < dyHistory.size(); i++) {
|
||||
double currentSign = Math.signum(dyHistory.get(i));
|
||||
if (currentSign != 0 && prevSign != 0 && currentSign != prevSign) {
|
||||
reversals++;
|
||||
}
|
||||
if (currentSign != 0) {
|
||||
prevSign = currentSign;
|
||||
}
|
||||
}
|
||||
|
||||
// 高频振荡且振幅明显 — PacketFly特征
|
||||
if (reversals >= MAX_DIRECTION_REVERSALS) {
|
||||
// 检查振荡幅度是否显著
|
||||
double maxDy = 0;
|
||||
int start = Math.max(0, dyHistory.size() - OSCILLATION_WINDOW);
|
||||
for (int i = start; i < dyHistory.size(); i++) {
|
||||
maxDy = Math.max(maxDy, Math.abs(dyHistory.get(i)));
|
||||
}
|
||||
if (maxDy > 0.3) {
|
||||
reasons.add("PACKET_FLY_OSCILLATION: " + reversals
|
||||
+ " direction reversals in " + OSCILLATION_WINDOW
|
||||
+ " ticks (amplitude: " + String.format("%.2f", maxDy)
|
||||
+ ", typical PacketFly bounce pattern)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 永不落地检测
|
||||
int airTicks = playerAirTicks.getOrDefault(playerName, 0);
|
||||
if (airTicks >= MAX_AIR_TICKS_NORMAL && !canFlyLegitimately) {
|
||||
// 检查是否持续上升(而非下落)
|
||||
long risingTicks = history.stream()
|
||||
.filter(r -> r.dy > 0.05)
|
||||
.count();
|
||||
if (risingTicks >= MAX_AIR_TICKS_NORMAL / 2) {
|
||||
reasons.add("NEVER_LANDS: " + airTicks
|
||||
+ " ticks in air without touching ground"
|
||||
+ " (" + risingTicks + " ticks rising, no flight items)");
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 累计上升高度异常
|
||||
double totalRise = playerTotalRise.getOrDefault(playerName, 0.0);
|
||||
if (totalRise > MAX_CUMULATIVE_RISE && !canFlyLegitimately) {
|
||||
packetFlyViolations.incrementAndGet();
|
||||
reasons.add("EXCESSIVE_CUMULATIVE_RISE: " + String.format("%.1f", totalRise)
|
||||
+ " blocks total rise without flight items"
|
||||
+ " (threshold: " + String.format("%.0f", MAX_CUMULATIVE_RISE) + ")");
|
||||
}
|
||||
|
||||
// 5. 空中持续时间过长(基于时间戳)
|
||||
Instant lastGround = playerLastGroundContact.get(playerName);
|
||||
if (lastGround != null && !canFlyLegitimately) {
|
||||
long airTimeMs = timestamp.toEpochMilli() - lastGround.toEpochMilli();
|
||||
if (airTimeMs > MAX_AIR_TIME_MS) {
|
||||
reasons.add("PROLONGED_AIR_TIME: " + (airTimeMs / 1000)
|
||||
+ " seconds without ground contact (no flight items)");
|
||||
}
|
||||
}
|
||||
|
||||
if (reasons.size() >= 2) {
|
||||
return PacketFlyCheckResult.flagged(reasons);
|
||||
} else if (reasons.size() == 1) {
|
||||
return PacketFlyCheckResult.suspicious(reasons);
|
||||
}
|
||||
|
||||
return PacketFlyCheckResult.clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* 玩家离线时清理追踪数据
|
||||
* @param playerName 离线玩家名称
|
||||
*/
|
||||
public void clearPlayer(String playerName) {
|
||||
playerMoveHistory.remove(playerName);
|
||||
playerAirTicks.remove(playerName);
|
||||
playerTotalRise.remove(playerName);
|
||||
playerLastGroundContact.remove(playerName);
|
||||
playerDyHistory.remove(playerName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模块运行状态
|
||||
* @return 包含计数器和违规数量的Map
|
||||
*/
|
||||
public Map<String, Object> getStatus() {
|
||||
Map<String, Object> status = new LinkedHashMap<>();
|
||||
status.put("totalChecks", totalChecks.get());
|
||||
status.put("packetFlyViolations", packetFlyViolations.get());
|
||||
status.put("activeTrackedPlayers", playerMoveHistory.size());
|
||||
|
||||
// 列出空中玩家
|
||||
List<Map<String, Object>> airbornePlayers = new ArrayList<>();
|
||||
for (Map.Entry<String, Integer> entry : playerAirTicks.entrySet()) {
|
||||
if (entry.getValue() > MAX_AIR_TICKS_NORMAL) {
|
||||
Map<String, Object> info = new LinkedHashMap<>();
|
||||
info.put("player", entry.getKey());
|
||||
info.put("airTicks", entry.getValue());
|
||||
info.put("totalRise", String.format("%.1f",
|
||||
playerTotalRise.getOrDefault(entry.getKey(), 0.0)));
|
||||
airbornePlayers.add(info);
|
||||
}
|
||||
}
|
||||
status.put("extendedAirbornePlayers", airbornePlayers);
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部PacketFly移动记录 — 记录玩家单次移动的状态数据
|
||||
*/
|
||||
private static class PacketFlyRecord {
|
||||
final Instant timestamp;
|
||||
final double fromX, fromY, fromZ;
|
||||
final double toX, toY, toZ;
|
||||
final double dy;
|
||||
final double horizontalDist;
|
||||
final boolean onGround;
|
||||
final boolean hasElytra;
|
||||
final boolean hasLevitation;
|
||||
final boolean isUsingFirework;
|
||||
|
||||
PacketFlyRecord(Instant timestamp, double fromX, double fromY, double fromZ,
|
||||
double toX, double toY, double toZ, double dy,
|
||||
double horizontalDist, boolean onGround,
|
||||
boolean hasElytra, boolean hasLevitation,
|
||||
boolean isUsingFirework) {
|
||||
this.timestamp = timestamp;
|
||||
this.fromX = fromX; this.fromY = fromY; this.fromZ = fromZ;
|
||||
this.toX = toX; this.toY = toY; this.toZ = toZ;
|
||||
this.dy = dy;
|
||||
this.horizontalDist = horizontalDist;
|
||||
this.onGround = onGround;
|
||||
this.hasElytra = hasElytra;
|
||||
this.hasLevitation = hasLevitation;
|
||||
this.isUsingFirework = isUsingFirework;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PacketFly检测结果 — 不可变结果类
|
||||
*/
|
||||
public static class PacketFlyCheckResult {
|
||||
private final boolean flagged;
|
||||
private final boolean suspicious;
|
||||
private final List<String> reasons;
|
||||
|
||||
private PacketFlyCheckResult(boolean flagged, boolean suspicious, List<String> reasons) {
|
||||
this.flagged = flagged;
|
||||
this.suspicious = suspicious;
|
||||
this.reasons = reasons;
|
||||
}
|
||||
|
||||
/** 无异常 — 正常的地面/合法飞行移动 */
|
||||
public static PacketFlyCheckResult clean() {
|
||||
return new PacketFlyCheckResult(false, false, List.of());
|
||||
}
|
||||
|
||||
/** 可疑 — 短暂的异常但不满足所有检测条件 */
|
||||
public static PacketFlyCheckResult suspicious(List<String> reasons) {
|
||||
return new PacketFlyCheckResult(false, true, reasons);
|
||||
}
|
||||
|
||||
/** 已标记 — 确认PacketFly hack,持续无合法手段的飞行 */
|
||||
public static PacketFlyCheckResult flagged(List<String> reasons) {
|
||||
return new PacketFlyCheckResult(true, false, reasons);
|
||||
}
|
||||
|
||||
public boolean isFlagged() { return flagged; }
|
||||
public boolean isSuspicious() { return suspicious; }
|
||||
public boolean isClean() { return !flagged && !suspicious; }
|
||||
public List<String> getReasons() { return reasons; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
package com.aluer.anticheat.movement;
|
||||
|
||||
import com.aluer.config.ServerGuardConfig;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* 穿墙/方块剪切(Phase)检测服务 — V5.0 反作弊扩展模块
|
||||
*
|
||||
* 检测原理:
|
||||
* 1. 固体方块穿越检测 — 玩家两次连续移动位置之间如果存在固体方块,
|
||||
* 则玩家穿过了方块,这是Phase hack的典型特征。通过步进采样两点间
|
||||
* 路径上的方块状态进行检测。
|
||||
* 2. 移动距离异常检测 — 正常玩家无法在单tick内移动超过一定距离。
|
||||
* 如果玩家单次移动穿越了多个固体方块,几乎可以确定为Phase hack。
|
||||
* 3. 地狱门Phase检测 — Phase hack的一个常见变种是利用地狱门传送瞬间
|
||||
* 将玩家位置修改到门的另一端(通常是固体方块内部)。
|
||||
* 检测玩家在地狱门附近出现的不可能位置。
|
||||
* 4. 方块内停留检测 — 正常游戏机制会阻止玩家停留在固体方块内部。
|
||||
* 如果检测到玩家在固体方块内部持续存在,说明存在Phase hack。
|
||||
*
|
||||
* 注意:此模块接收来自Agent监听器的方块状态数据,
|
||||
* 由于Paper API为provided scope,实际的isSolid/isOccluding检查
|
||||
* 由Agent在Minecraft进程内部完成并传递结果。
|
||||
*
|
||||
* 配置开关:serverguard.security.super-evolution.anti-phase
|
||||
*/
|
||||
@Service
|
||||
public class AntiPhaseService {
|
||||
|
||||
private final ServerGuardConfig config;
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的移动轨迹(playerName -> 最近的位置记录)
|
||||
* 用于检测两点间是否穿越了固体方块
|
||||
*/
|
||||
private final Map<String, PlayerPosition> playerLastPosition = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的可疑穿墙事件(playerName -> 事件列表)
|
||||
* 用于累积检测模式和判定作弊行为
|
||||
*/
|
||||
private final Map<String, List<PhaseEvent>> playerPhaseEvents = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪在地狱门附近的玩家及其进入时间(playerName -> 最近门户交互时间)
|
||||
* 用于检测地狱门Phase exploit
|
||||
*/
|
||||
private final Map<String, Instant> playerPortalInteraction = new ConcurrentHashMap<>();
|
||||
|
||||
private final AtomicLong totalChecks = new AtomicLong(0);
|
||||
private final AtomicLong flaggedCount = new AtomicLong(0);
|
||||
|
||||
/**
|
||||
* 单次移动允许的最大距离(方块)— 生存模式正常移动不会超过此值
|
||||
* 超过此值表明可能穿过了固体方块
|
||||
*/
|
||||
private static final double MAX_NORMAL_MOVE_DISTANCE = 10.0;
|
||||
|
||||
/**
|
||||
* 路径采样步长(方块)— 在两点间进行路径采样时的步长
|
||||
* 较小值提供更精细的检测,较大值提高性能
|
||||
*/
|
||||
private static final double PATH_SAMPLE_STEP = 0.5;
|
||||
|
||||
/**
|
||||
* 触发标记所需的最小穿墙事件数
|
||||
*/
|
||||
private static final int MIN_PHASE_EVENTS = 2;
|
||||
|
||||
/**
|
||||
* 穿墙事件时间窗口(毫秒)— 在此窗口内的事件视为同一作弊会话
|
||||
*/
|
||||
private static final long PHASE_WINDOW_MS = 30_000;
|
||||
|
||||
/**
|
||||
* 地狱门Phase检测窗口(秒)— 门户交互后此时间内出现的不可能位置
|
||||
*/
|
||||
private static final long PORTAL_PHASE_WINDOW_MS = 3_000;
|
||||
|
||||
/**
|
||||
* 方块内部检测所需的连续检查次数
|
||||
*/
|
||||
private static final int MIN_INSIDE_BLOCK_CHECKS = 3;
|
||||
|
||||
/**
|
||||
* 每个玩家保留的最大穿墙事件记录数
|
||||
*/
|
||||
private static final int MAX_PHASE_EVENTS = 20;
|
||||
|
||||
/**
|
||||
* 无参构造函数 — 测试/默认配置使用
|
||||
*/
|
||||
public AntiPhaseService() {
|
||||
this(new ServerGuardConfig());
|
||||
}
|
||||
|
||||
/**
|
||||
* Spring 注入构造函数
|
||||
* @param config 全局配置,提供模块开关控制
|
||||
*/
|
||||
public AntiPhaseService(ServerGuardConfig config) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测玩家移动是否穿越了固体方块(Phase hack)
|
||||
*
|
||||
* @param playerName 玩家名称
|
||||
* @param playerUUID 玩家UUID
|
||||
* @param fromX 移动起始X坐标
|
||||
* @param fromY 移动起始Y坐标
|
||||
* @param fromZ 移动起始Z坐标
|
||||
* @param toX 移动结束X坐标
|
||||
* @param toY 移动结束Y坐标
|
||||
* @param toZ 移动结束Z坐标
|
||||
* @param solidBlocksBetween 两点间固体方块数量(由Agent在Minecraft进程中统计)
|
||||
* @param isPlayerInsideBlock 移动后玩家是否在固体方块内部(由Agent检测)
|
||||
* @param isNearPortal 玩家是否在地狱门附近
|
||||
* @param gameMode 玩家游戏模式
|
||||
* @param timestamp 时间戳
|
||||
* @return 检测结果
|
||||
*/
|
||||
public PhaseCheckResult detect(String playerName, String playerUUID,
|
||||
double fromX, double fromY, double fromZ,
|
||||
double toX, double toY, double toZ,
|
||||
int solidBlocksBetween, boolean isPlayerInsideBlock,
|
||||
boolean isNearPortal, String gameMode,
|
||||
Instant timestamp) {
|
||||
// 模块开关检查 — 关闭时跳过所有检测直接返回安全结果
|
||||
if (!config.getSecurity().getSuperEvolution().isAntiPhase()) {
|
||||
return PhaseCheckResult.clean();
|
||||
}
|
||||
|
||||
// 创造和观察者模式不受方块限制,直接跳过
|
||||
if ("creative".equalsIgnoreCase(gameMode) || "spectator".equalsIgnoreCase(gameMode)) {
|
||||
return PhaseCheckResult.clean();
|
||||
}
|
||||
|
||||
totalChecks.incrementAndGet();
|
||||
List<String> reasons = new ArrayList<>();
|
||||
|
||||
// 获取或创建玩家事件历史
|
||||
List<PhaseEvent> events = playerPhaseEvents.computeIfAbsent(
|
||||
playerName, k -> new ArrayList<>());
|
||||
|
||||
// 计算移动距离
|
||||
double dx = toX - fromX;
|
||||
double dy = toY - fromY;
|
||||
double dz = toZ - fromZ;
|
||||
double distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||
|
||||
// 1. 固体方块穿越检测 — 两点间存在固体方块
|
||||
if (solidBlocksBetween > 0) {
|
||||
PhaseEvent event = new PhaseEvent(timestamp, fromX, fromY, fromZ, toX, toY, toZ,
|
||||
solidBlocksBetween, isPlayerInsideBlock, isNearPortal, distance);
|
||||
events.add(event);
|
||||
// 控制列表大小
|
||||
while (events.size() > MAX_PHASE_EVENTS) {
|
||||
events.remove(0);
|
||||
}
|
||||
|
||||
// 检查是否为地狱门Phase exploit
|
||||
Instant portalTime = playerPortalInteraction.get(playerName);
|
||||
boolean isPortalPhase = portalTime != null
|
||||
&& timestamp.toEpochMilli() - portalTime.toEpochMilli() < PORTAL_PHASE_WINDOW_MS;
|
||||
|
||||
if (isPortalPhase && isNearPortal) {
|
||||
reasons.add("PORTAL_PHASE: moved through " + solidBlocksBetween
|
||||
+ " solid block(s) within " + PORTAL_PHASE_WINDOW_MS / 1000
|
||||
+ "s of portal interaction (distance: " + String.format("%.2f", distance) + " blocks)");
|
||||
} else {
|
||||
reasons.add("SOLID_BLOCK_PHASE: moved through " + solidBlocksBetween
|
||||
+ " solid block(s) over " + String.format("%.2f", distance) + " blocks");
|
||||
}
|
||||
|
||||
// 统计时间窗口内的穿墙事件数
|
||||
Instant windowStart = timestamp.minusMillis(PHASE_WINDOW_MS);
|
||||
long recentPhases = events.stream()
|
||||
.filter(e -> e.timestamp.isAfter(windowStart) && e.solidBlocksCrossed > 0)
|
||||
.count();
|
||||
|
||||
if (recentPhases >= MIN_PHASE_EVENTS) {
|
||||
reasons.add("REPEATED_PHASE: " + recentPhases + " phase events in "
|
||||
+ PHASE_WINDOW_MS / 1000 + "s");
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 方块内部停留检测 — 玩家在固体方块内部持续存在
|
||||
if (isPlayerInsideBlock) {
|
||||
reasons.add("INSIDE_BLOCK: player detected inside solid block at ("
|
||||
+ String.format("%.1f", toX) + ", "
|
||||
+ String.format("%.1f", toY) + ", "
|
||||
+ String.format("%.1f", toZ) + ")");
|
||||
}
|
||||
|
||||
// 3. 移动距离异常检测 — 单次移动距离过大
|
||||
if (distance > MAX_NORMAL_MOVE_DISTANCE && solidBlocksBetween > 0) {
|
||||
reasons.add("IMPOSSIBLE_DISTANCE: moved " + String.format("%.2f", distance)
|
||||
+ " blocks through " + solidBlocksBetween + " solid blocks (max normal: "
|
||||
+ String.format("%.1f", MAX_NORMAL_MOVE_DISTANCE) + ")");
|
||||
}
|
||||
|
||||
// 更新玩家最后位置
|
||||
playerLastPosition.put(playerName, new PlayerPosition(toX, toY, toZ, timestamp));
|
||||
|
||||
// 更新地狱门交互追踪
|
||||
if (isNearPortal) {
|
||||
playerPortalInteraction.put(playerName, timestamp);
|
||||
}
|
||||
|
||||
// 汇总结果
|
||||
if (reasons.size() >= 2) {
|
||||
flaggedCount.incrementAndGet();
|
||||
return PhaseCheckResult.flagged(reasons);
|
||||
} else if (reasons.size() == 1) {
|
||||
return PhaseCheckResult.suspicious(reasons);
|
||||
}
|
||||
|
||||
return PhaseCheckResult.clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录玩家与地狱门的交互(Agent在检测到门户事件时调用)
|
||||
* @param playerName 玩家名称
|
||||
* @param timestamp 交互时间戳
|
||||
*/
|
||||
public void recordPortalInteraction(String playerName, Instant timestamp) {
|
||||
playerPortalInteraction.put(playerName, timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* 玩家离线时清理其追踪数据,释放内存
|
||||
* @param playerName 离线玩家名称
|
||||
*/
|
||||
public void clearPlayer(String playerName) {
|
||||
playerLastPosition.remove(playerName);
|
||||
playerPhaseEvents.remove(playerName);
|
||||
playerPortalInteraction.remove(playerName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模块运行状态
|
||||
* @return 包含计数器和活跃追踪玩家数的Map
|
||||
*/
|
||||
public Map<String, Object> getStatus() {
|
||||
Map<String, Object> status = new LinkedHashMap<>();
|
||||
status.put("totalChecks", totalChecks.get());
|
||||
status.put("flaggedCount", flaggedCount.get());
|
||||
status.put("activeTrackedPlayers", playerLastPosition.size());
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部玩家位置记录
|
||||
*/
|
||||
private static class PlayerPosition {
|
||||
final double x, y, z;
|
||||
final Instant timestamp;
|
||||
|
||||
PlayerPosition(double x, double y, double z, Instant timestamp) {
|
||||
this.x = x; this.y = y; this.z = z;
|
||||
this.timestamp = timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部穿墙事件记录
|
||||
*/
|
||||
private static class PhaseEvent {
|
||||
final Instant timestamp;
|
||||
final double fromX, fromY, fromZ;
|
||||
final double toX, toY, toZ;
|
||||
final int solidBlocksCrossed;
|
||||
final boolean insideBlock;
|
||||
final boolean nearPortal;
|
||||
final double distance;
|
||||
|
||||
PhaseEvent(Instant timestamp, double fromX, double fromY, double fromZ,
|
||||
double toX, double toY, double toZ, int solidBlocksCrossed,
|
||||
boolean insideBlock, boolean nearPortal, double distance) {
|
||||
this.timestamp = timestamp;
|
||||
this.fromX = fromX; this.fromY = fromY; this.fromZ = fromZ;
|
||||
this.toX = toX; this.toY = toY; this.toZ = toZ;
|
||||
this.solidBlocksCrossed = solidBlocksCrossed;
|
||||
this.insideBlock = insideBlock;
|
||||
this.nearPortal = nearPortal;
|
||||
this.distance = distance;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase检测结果 — 不可变结果类
|
||||
*/
|
||||
public static class PhaseCheckResult {
|
||||
private final boolean flagged;
|
||||
private final boolean suspicious;
|
||||
private final List<String> reasons;
|
||||
|
||||
private PhaseCheckResult(boolean flagged, boolean suspicious, List<String> reasons) {
|
||||
this.flagged = flagged;
|
||||
this.suspicious = suspicious;
|
||||
this.reasons = reasons;
|
||||
}
|
||||
|
||||
/** 无异常 — 正常移动路径 */
|
||||
public static PhaseCheckResult clean() {
|
||||
return new PhaseCheckResult(false, false, List.of());
|
||||
}
|
||||
|
||||
/** 可疑 — 检测到穿墙但证据不够充分(可能为网络延迟/边界情况) */
|
||||
public static PhaseCheckResult suspicious(List<String> reasons) {
|
||||
return new PhaseCheckResult(false, true, reasons);
|
||||
}
|
||||
|
||||
/** 已标记 — 多项规则命中,确认为穿墙作弊 */
|
||||
public static PhaseCheckResult flagged(List<String> reasons) {
|
||||
return new PhaseCheckResult(true, false, reasons);
|
||||
}
|
||||
|
||||
public boolean isFlagged() { return flagged; }
|
||||
public boolean isSuspicious() { return suspicious; }
|
||||
public boolean isClean() { return !flagged && !suspicious; }
|
||||
public List<String> getReasons() { return reasons; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
package com.aluer.anticheat.movement;
|
||||
|
||||
import com.aluer.config.ServerGuardConfig;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* 自动搭路(Scaffold)检测服务 — V4.0 反作弊扩展模块
|
||||
*
|
||||
* 检测原理:
|
||||
* 1. 方块放置速率检测 — Scaffold hack的特征是快速连续放置方块用于搭路,
|
||||
* 正常玩家的方块放置间隔不可能短到1秒内放置5+方块且持续3秒以上。
|
||||
* 2. 放置角度检测 — Scaffold hack通常要求玩家向下看(pitch接近90度),
|
||||
* 而正常玩家在放置方块时会自然地改变视角。检测放置时俯仰角的机械一致性。
|
||||
* 3. Safewalk检测 — Scaffold hack的一个常见附加功能是Safewalk,
|
||||
* 防止玩家在方块边缘掉落或移位(通过自动潜行或调整位置实现)。
|
||||
* 4. 放置位置模式检测 — 检测方块是否精确放置在脚下/前方(Scaffold模式),
|
||||
* 以及放置面是否为空气面(空中搭路)。
|
||||
*
|
||||
* 配置开关:serverguard.security.super-evolution.anti-scaffold
|
||||
*/
|
||||
@Service
|
||||
public class AntiScaffoldService {
|
||||
|
||||
private final ServerGuardConfig config;
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的方块放置记录(playerName -> 放置记录列表)
|
||||
*/
|
||||
private final Map<String, List<PlacementRecord>> playerPlacementHistory = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的持续高速放置计时(playerName -> 高速放置开始时间)
|
||||
*/
|
||||
private final Map<String, Instant> playerRapidPlacingSince = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的放置角度记录(playerName -> 角度列表)
|
||||
*/
|
||||
private final Map<String, List<Double>> playerPitchHistory = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的方块边缘安全状态(playerName -> 是否处于安全行走区域)
|
||||
*/
|
||||
private final Map<String, SafewalkData> playerSafewalkData = new ConcurrentHashMap<>();
|
||||
|
||||
private final AtomicLong totalPlacements = new AtomicLong(0);
|
||||
private final AtomicLong scaffoldViolations = new AtomicLong(0);
|
||||
|
||||
/**
|
||||
* 最大正常放置速率(方块/秒)— 超过此速率视为异常快速放置
|
||||
* 正常玩家在有预谋的情况下也很难超过3-4方块/秒
|
||||
*/
|
||||
private static final double MAX_PLACEMENT_RATE = 5.0;
|
||||
|
||||
/**
|
||||
* 持续高速放置时间阈值(毫秒)— 需要高速放置持续3秒以上才标记
|
||||
*/
|
||||
private static final long MIN_RAPID_DURATION_MS = 3_000;
|
||||
|
||||
/**
|
||||
* Scaffold俯仰角阈值(度数)— pitch接近-90或90时表示玩家在看正下或正上方
|
||||
* Scaffold hack用户典型地向下看(pitch接近-90)
|
||||
*/
|
||||
private static final double SCAFFOLD_PITCH_THRESHOLD = 60.0;
|
||||
|
||||
/**
|
||||
* 放置角度一致性阈值 — 连续放置时pitch变化小于此值表示机械性的固定视角
|
||||
*/
|
||||
private static final double PITCH_CONSISTENCY_THRESHOLD = 3.0;
|
||||
|
||||
/**
|
||||
* 最小放置次数 — 需要足够的放置样本才能进行分析
|
||||
*/
|
||||
private static final int MIN_PLACEMENTS_FOR_ANALYSIS = 10;
|
||||
|
||||
/**
|
||||
* 每个玩家保留的最大放置记录数
|
||||
*/
|
||||
private static final int MAX_PLACEMENT_RECORDS = 50;
|
||||
|
||||
/**
|
||||
* 无参构造函数 — 测试/默认配置使用
|
||||
*/
|
||||
public AntiScaffoldService() {
|
||||
this(new ServerGuardConfig());
|
||||
}
|
||||
|
||||
/**
|
||||
* Spring 注入构造函数
|
||||
* @param config 全局配置,提供模块开关控制
|
||||
*/
|
||||
public AntiScaffoldService(ServerGuardConfig config) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测玩家的方块放置行为是否存在Scaffold hack特征
|
||||
*
|
||||
* @param playerName 玩家名称
|
||||
* @param playerUUID 玩家UUID
|
||||
* @param placedX 放置方块X坐标
|
||||
* @param placedY 放置方块Y坐标
|
||||
* @param placedZ 放置方块Z坐标
|
||||
* @param playerYaw 玩家水平视角(度数)
|
||||
* @param playerPitch 玩家俯仰视角(度数)
|
||||
* @param isPlaceBelowPlayer 放置位置是否在玩家脚下
|
||||
* @param isAdjacentToPlayer 放置位置是否紧邻玩家
|
||||
* @param blockType 放置的方块类型
|
||||
* @param timestamp 时间戳
|
||||
* @return 检测结果
|
||||
*/
|
||||
public ScaffoldCheckResult detect(String playerName, String playerUUID,
|
||||
double placedX, double placedY, double placedZ,
|
||||
double playerYaw, double playerPitch,
|
||||
boolean isPlaceBelowPlayer, boolean isAdjacentToPlayer,
|
||||
String blockType, Instant timestamp) {
|
||||
// 模块开关检查 — 关闭时跳过所有检测
|
||||
if (!config.getSecurity().getSuperEvolution().isAntiScaffold()) {
|
||||
return ScaffoldCheckResult.clean();
|
||||
}
|
||||
|
||||
totalPlacements.incrementAndGet();
|
||||
|
||||
List<PlacementRecord> history = playerPlacementHistory.computeIfAbsent(
|
||||
playerName, k -> new ArrayList<>());
|
||||
List<Double> pitchHistory = playerPitchHistory.computeIfAbsent(
|
||||
playerName, k -> new ArrayList<>());
|
||||
|
||||
PlacementRecord record = new PlacementRecord(timestamp, placedX, placedY, placedZ,
|
||||
playerYaw, playerPitch, isPlaceBelowPlayer, isAdjacentToPlayer, blockType);
|
||||
history.add(record);
|
||||
pitchHistory.add(playerPitch);
|
||||
|
||||
while (history.size() > MAX_PLACEMENT_RECORDS) {
|
||||
history.remove(0);
|
||||
}
|
||||
while (pitchHistory.size() > 20) {
|
||||
pitchHistory.remove(0);
|
||||
}
|
||||
|
||||
List<String> reasons = new ArrayList<>();
|
||||
|
||||
// 1. 方块放置速率检测
|
||||
// 计算最近3秒内的放置数量
|
||||
Instant threeSecondsAgo = timestamp.minusMillis(3_000);
|
||||
long recentPlacements = history.stream()
|
||||
.filter(r -> !r.timestamp.isBefore(threeSecondsAgo))
|
||||
.count();
|
||||
double placementRate = recentPlacements / 3.0; // 每秒放置数
|
||||
|
||||
if (placementRate > MAX_PLACEMENT_RATE) {
|
||||
// 高速放置
|
||||
playerRapidPlacingSince.putIfAbsent(playerName, timestamp);
|
||||
|
||||
Instant rapidStartTime = playerRapidPlacingSince.get(playerName);
|
||||
long rapidDurationMs = timestamp.toEpochMilli() - rapidStartTime.toEpochMilli();
|
||||
|
||||
if (rapidDurationMs >= MIN_RAPID_DURATION_MS) {
|
||||
reasons.add("RAPID_PLACEMENT: " + String.format("%.1f", placementRate)
|
||||
+ " blocks/s for " + (rapidDurationMs / 1000.0)
|
||||
+ "s (threshold: " + String.format("%.1f", MAX_PLACEMENT_RATE) + " blocks/s)");
|
||||
}
|
||||
} else {
|
||||
// 速率恢复正常
|
||||
playerRapidPlacingSince.remove(playerName);
|
||||
}
|
||||
|
||||
// 2. 放置角度检测 — Scaffold hack用户看下方放置
|
||||
double absPitch = Math.abs(playerPitch);
|
||||
if (absPitch > SCAFFOLD_PITCH_THRESHOLD && (isPlaceBelowPlayer || isAdjacentToPlayer)) {
|
||||
// 玩家在看下方且方块放在脚下或旁边 — 典型Scaffold姿势
|
||||
|
||||
// 检查角度一致性
|
||||
if (pitchHistory.size() >= MIN_PLACEMENTS_FOR_ANALYSIS) {
|
||||
double maxDeviation = 0;
|
||||
double[] pitches = new double[pitchHistory.size()];
|
||||
for (int i = 0; i < pitchHistory.size(); i++) {
|
||||
pitches[i] = pitchHistory.get(i);
|
||||
}
|
||||
for (int i = 1; i < pitches.length; i++) {
|
||||
double diff = Math.abs(pitches[i] - pitches[i - 1]);
|
||||
if (diff > maxDeviation) maxDeviation = diff;
|
||||
}
|
||||
|
||||
if (maxDeviation < PITCH_CONSISTENCY_THRESHOLD && absPitch > SCAFFOLD_PITCH_THRESHOLD) {
|
||||
reasons.add("SCAFFOLD_ANGLE: pitch " + String.format("%.1f", absPitch)
|
||||
+ " degrees (looking down), max deviation "
|
||||
+ String.format("%.2f", PITCH_CONSISTENCY_THRESHOLD) + " degrees");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 放置位置模式检测 — 连续在脚下/前方放置
|
||||
if (history.size() >= MIN_PLACEMENTS_FOR_ANALYSIS) {
|
||||
long belowCount = history.stream().filter(r -> r.isPlaceBelowPlayer).count();
|
||||
long adjacentCount = history.stream().filter(r -> r.isAdjacentToPlayer).count();
|
||||
long scaffoldPlaceCount = belowCount + adjacentCount;
|
||||
|
||||
// 超过90%的放置都在脚下或相邻位置 — 典型的Scaffold模式
|
||||
if ((double) scaffoldPlaceCount / history.size() > 0.9
|
||||
&& scaffoldPlaceCount >= MIN_PLACEMENTS_FOR_ANALYSIS) {
|
||||
reasons.add("SCAFFOLD_PLACEMENT_PATTERN: " + scaffoldPlaceCount + "/"
|
||||
+ history.size() + " placements are below/adjacent to player");
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Safewalk检测 — 追踪玩家在方块边缘的行为
|
||||
if (isPlaceBelowPlayer) {
|
||||
// 玩家在边缘放置方块防止掉落 — 更新Safewalk追踪
|
||||
SafewalkData swd = playerSafewalkData.computeIfAbsent(
|
||||
playerName, k -> new SafewalkData());
|
||||
swd.edgePlacements++;
|
||||
swd.lastEdgePlacement = timestamp;
|
||||
|
||||
if (swd.edgePlacements > 15) {
|
||||
reasons.add("SAFEWALK: " + swd.edgePlacements
|
||||
+ " edge placements (possible Safewalk + Scaffold combination)");
|
||||
}
|
||||
}
|
||||
|
||||
if (reasons.size() >= 2) {
|
||||
scaffoldViolations.incrementAndGet();
|
||||
return ScaffoldCheckResult.flagged(reasons);
|
||||
} else if (reasons.size() == 1) {
|
||||
return ScaffoldCheckResult.suspicious(reasons);
|
||||
}
|
||||
|
||||
return ScaffoldCheckResult.clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* 玩家离线时清理追踪数据
|
||||
* @param playerName 离线玩家名称
|
||||
*/
|
||||
public void clearPlayer(String playerName) {
|
||||
playerPlacementHistory.remove(playerName);
|
||||
playerRapidPlacingSince.remove(playerName);
|
||||
playerPitchHistory.remove(playerName);
|
||||
playerSafewalkData.remove(playerName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模块运行状态
|
||||
* @return 包含计数器和活跃追踪玩家的Map
|
||||
*/
|
||||
public Map<String, Object> getStatus() {
|
||||
Map<String, Object> status = new LinkedHashMap<>();
|
||||
status.put("totalPlacements", totalPlacements.get());
|
||||
status.put("scaffoldViolations", scaffoldViolations.get());
|
||||
status.put("trackedBuilders", playerPlacementHistory.size());
|
||||
|
||||
// 列出当前活跃的建造玩家
|
||||
List<Map<String, Object>> activeBuilders = new ArrayList<>();
|
||||
for (Map.Entry<String, List<PlacementRecord>> entry : playerPlacementHistory.entrySet()) {
|
||||
if (!entry.getValue().isEmpty()) {
|
||||
Map<String, Object> info = new LinkedHashMap<>();
|
||||
info.put("player", entry.getKey());
|
||||
info.put("recentPlacements", entry.getValue().size());
|
||||
// 计算最近放置速率
|
||||
if (entry.getValue().size() >= 2) {
|
||||
PlacementRecord first = entry.getValue().get(0);
|
||||
PlacementRecord last = entry.getValue().get(entry.getValue().size() - 1);
|
||||
long durationMs = last.timestamp.toEpochMilli() - first.timestamp.toEpochMilli();
|
||||
if (durationMs > 0) {
|
||||
double rate = entry.getValue().size() / (durationMs / 1000.0);
|
||||
info.put("placementRate", String.format("%.1f", rate));
|
||||
}
|
||||
}
|
||||
activeBuilders.add(info);
|
||||
}
|
||||
}
|
||||
activeBuilders.sort((a, b) -> Integer.compare(
|
||||
(Integer) b.get("recentPlacements"), (Integer) a.get("recentPlacements")));
|
||||
status.put("activeBuilders", activeBuilders.subList(0, Math.min(activeBuilders.size(), 10)));
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部方块放置记录 — 记录单次放置的空间、角度和位置信息
|
||||
*/
|
||||
private static class PlacementRecord {
|
||||
final Instant timestamp;
|
||||
final double placedX, placedY, placedZ;
|
||||
final double playerYaw, playerPitch;
|
||||
final boolean isPlaceBelowPlayer;
|
||||
final boolean isAdjacentToPlayer;
|
||||
final String blockType;
|
||||
|
||||
PlacementRecord(Instant timestamp, double placedX, double placedY, double placedZ,
|
||||
double playerYaw, double playerPitch, boolean isPlaceBelowPlayer,
|
||||
boolean isAdjacentToPlayer, String blockType) {
|
||||
this.timestamp = timestamp;
|
||||
this.placedX = placedX;
|
||||
this.placedY = placedY;
|
||||
this.placedZ = placedZ;
|
||||
this.playerYaw = playerYaw;
|
||||
this.playerPitch = playerPitch;
|
||||
this.isPlaceBelowPlayer = isPlaceBelowPlayer;
|
||||
this.isAdjacentToPlayer = isAdjacentToPlayer;
|
||||
this.blockType = blockType;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safewalk追踪数据 — 记录玩家在方块边缘放置方块的频率
|
||||
*/
|
||||
private static class SafewalkData {
|
||||
int edgePlacements = 0;
|
||||
Instant lastEdgePlacement = Instant.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Scaffold检测结果 — 不可变结果类
|
||||
*/
|
||||
public static class ScaffoldCheckResult {
|
||||
private final boolean flagged;
|
||||
private final boolean suspicious;
|
||||
private final List<String> reasons;
|
||||
|
||||
private ScaffoldCheckResult(boolean flagged, boolean suspicious, List<String> reasons) {
|
||||
this.flagged = flagged;
|
||||
this.suspicious = suspicious;
|
||||
this.reasons = reasons;
|
||||
}
|
||||
|
||||
/** 无异常 — 正常方块放置行为 */
|
||||
public static ScaffoldCheckResult clean() {
|
||||
return new ScaffoldCheckResult(false, false, List.of());
|
||||
}
|
||||
|
||||
/** 可疑 — 放置行为略有异常但不完全符合Scaffold模式 */
|
||||
public static ScaffoldCheckResult suspicious(List<String> reasons) {
|
||||
return new ScaffoldCheckResult(false, true, reasons);
|
||||
}
|
||||
|
||||
/** 已标记 — 确认Scaffold hack(高速搭路+固定视角+脚下放置模式) */
|
||||
public static ScaffoldCheckResult flagged(List<String> reasons) {
|
||||
return new ScaffoldCheckResult(true, false, reasons);
|
||||
}
|
||||
|
||||
public boolean isFlagged() { return flagged; }
|
||||
public boolean isSuspicious() { return suspicious; }
|
||||
public boolean isClean() { return !flagged && !suspicious; }
|
||||
public List<String> getReasons() { return reasons; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package com.aluer.anticheat.movement;
|
||||
|
||||
import com.aluer.config.ServerGuardConfig;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* 水平速度(Speed)检测服务 — V4.0 反作弊扩展模块
|
||||
*
|
||||
* 检测原理:
|
||||
* 1. XZ平面水平速度检测 — Minecraft生存模式默认步行速度约4.317 m/s,疾跑约5.612 m/s
|
||||
* 检测水平速度是否超过7.0 m/s,超过则标记为异常
|
||||
* 2. 持续超速检测 — 区分短时速度尖峰(网络延迟可能导致的速度跳跃)和持续超速(真实作弊行为)
|
||||
* 需要速度异常持续超过1秒才标记
|
||||
* 3. GroundSpoof检测 — 检测玩家是否伪造地面状态:玩家声称在地面但垂直速度异常
|
||||
* (正常玩家在地面时Y轴变化极小,而Speed hack的BHop模式会有振荡)
|
||||
* 4. 速度模式分析 — 检测机械性的恒速运动(正常玩家速度会有自然波动)
|
||||
*
|
||||
* 注意:此模块专注于水平速度(XZ平面)检测,与AntiFly检测的垂直速度检测互补
|
||||
*
|
||||
* 配置开关:serverguard.security.super-evolution.anti-speed
|
||||
*/
|
||||
@Service
|
||||
public class AntiSpeedService {
|
||||
|
||||
private final ServerGuardConfig config;
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的移动轨迹(playerName -> 移动记录列表)
|
||||
* 用于计算时间窗口内的速度和加速度
|
||||
*/
|
||||
private final Map<String, List<MovementRecord>> playerMovementHistory = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的持续超速计时(playerName -> 超速开始的时间戳)
|
||||
* 非空表示该玩家当前处于超速状态
|
||||
*/
|
||||
private final Map<String, Instant> playerSpeedingSince = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的速度统计(playerName -> 速度统计)
|
||||
*/
|
||||
private final Map<String, SpeedStats> playerSpeedStats = new ConcurrentHashMap<>();
|
||||
|
||||
private final AtomicLong totalSamples = new AtomicLong(0);
|
||||
private final AtomicLong speedViolations = new AtomicLong(0);
|
||||
|
||||
/**
|
||||
* 水平速度阈值(m/s)— Minecraft生存模式疾跑约5.612 m/s,设置7.0留出延迟余量
|
||||
*/
|
||||
private static final double MAX_HORIZONTAL_SPEED = 7.0;
|
||||
|
||||
/**
|
||||
* 持续超速最小时间(毫秒)— 需要超速状态至少持续1秒才判定为作弊
|
||||
* 短时速度尖峰可能是网络延迟造成的数据包积压释放
|
||||
*/
|
||||
private static final long MIN_OVERSPEED_DURATION_MS = 1_000;
|
||||
|
||||
/**
|
||||
* 地面垂直速度阈值 — 正常玩家在地面时Y轴变化极小(<0.1),
|
||||
* 超过此值的Y轴变化说明可能存在GroundSpoof(BHop等speed hack的辅助技术)
|
||||
*/
|
||||
private static final double GROUND_VERTICAL_THRESHOLD = 0.15;
|
||||
|
||||
/**
|
||||
* 每个玩家保留的最大移动记录数
|
||||
*/
|
||||
private static final int MAX_MOVEMENT_RECORDS = 50;
|
||||
|
||||
/**
|
||||
* 无参构造函数 — 测试/默认配置使用
|
||||
*/
|
||||
public AntiSpeedService() {
|
||||
this(new ServerGuardConfig());
|
||||
}
|
||||
|
||||
/**
|
||||
* Spring 注入构造函数
|
||||
* @param config 全局配置,提供模块开关控制
|
||||
*/
|
||||
public AntiSpeedService(ServerGuardConfig config) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测玩家水平移动速度是否异常
|
||||
*
|
||||
* @param playerName 玩家名称
|
||||
* @param playerUUID 玩家UUID
|
||||
* @param fromX 移动起始X坐标
|
||||
* @param fromZ 移动起始Z坐标
|
||||
* @param fromY 移动起始Y坐标(用于GroundSpoof检测)
|
||||
* @param toX 移动结束X坐标
|
||||
* @param toZ 移动结束Z坐标
|
||||
* @param toY 移动结束Y坐标(用于GroundSpoof检测)
|
||||
* @param onGround 玩家是否声称在地面
|
||||
* @param deltaSeconds 本次移动的时间间隔(秒),用于计算速度
|
||||
* @param timestamp 时间戳
|
||||
* @return 检测结果
|
||||
*/
|
||||
public SpeedCheckResult detect(String playerName, String playerUUID,
|
||||
double fromX, double fromY, double fromZ,
|
||||
double toX, double toY, double toZ,
|
||||
boolean onGround, double deltaSeconds, Instant timestamp) {
|
||||
// 模块开关检查 — 关闭时跳过所有检测
|
||||
if (!config.getSecurity().getSuperEvolution().isAntiSpeed()) {
|
||||
return SpeedCheckResult.clean();
|
||||
}
|
||||
|
||||
totalSamples.incrementAndGet();
|
||||
|
||||
List<MovementRecord> history = playerMovementHistory.computeIfAbsent(
|
||||
playerName, k -> new ArrayList<>());
|
||||
SpeedStats stats = playerSpeedStats.computeIfAbsent(
|
||||
playerName, k -> new SpeedStats());
|
||||
|
||||
List<String> reasons = new ArrayList<>();
|
||||
|
||||
// 避免除零
|
||||
if (deltaSeconds <= 0) {
|
||||
deltaSeconds = 0.05; // 默认50ms(1 tick)
|
||||
}
|
||||
|
||||
// 计算XZ平面水平速度
|
||||
double dx = toX - fromX;
|
||||
double dz = toZ - fromZ;
|
||||
double horizontalDistance = Math.sqrt(dx * dx + dz * dz);
|
||||
double horizontalSpeed = horizontalDistance / deltaSeconds;
|
||||
|
||||
// 更新速度统计
|
||||
stats.totalSpeed += horizontalSpeed;
|
||||
stats.totalSamples++;
|
||||
stats.maxSpeed = Math.max(stats.maxSpeed, horizontalSpeed);
|
||||
|
||||
MovementRecord record = new MovementRecord(timestamp, fromX, fromY, fromZ, toX, toY, toZ,
|
||||
onGround, horizontalSpeed, deltaSeconds);
|
||||
history.add(record);
|
||||
while (history.size() > MAX_MOVEMENT_RECORDS) {
|
||||
history.remove(0);
|
||||
}
|
||||
|
||||
// 1. 水平速度检测
|
||||
if (horizontalSpeed > MAX_HORIZONTAL_SPEED) {
|
||||
// 记录超速开始时间
|
||||
playerSpeedingSince.putIfAbsent(playerName, timestamp);
|
||||
|
||||
// 检查超速持续时间是否超过阈值
|
||||
Instant speedStart = playerSpeedingSince.get(playerName);
|
||||
long overspeedDurationMs = timestamp.toEpochMilli() - speedStart.toEpochMilli();
|
||||
|
||||
if (overspeedDurationMs >= MIN_OVERSPEED_DURATION_MS) {
|
||||
// 持续超速 — 这是作弊行为而非网络延迟
|
||||
speedViolations.incrementAndGet();
|
||||
reasons.add("SUSTAINED_OVERSPEED: " + String.format("%.2f", horizontalSpeed)
|
||||
+ " m/s for " + (overspeedDurationMs / 1000) + "s (threshold: "
|
||||
+ String.format("%.1f", MAX_HORIZONTAL_SPEED) + " m/s)");
|
||||
} else {
|
||||
// 速度尖峰 — 可能是延迟,仅记录可疑
|
||||
reasons.add("SPEED_SPIKE: " + String.format("%.2f", horizontalSpeed)
|
||||
+ " m/s (duration: " + overspeedDurationMs + "ms, threshold: " + MIN_OVERSPEED_DURATION_MS + "ms)");
|
||||
}
|
||||
} else {
|
||||
// 速度恢复正常,清除超速计时器
|
||||
playerSpeedingSince.remove(playerName);
|
||||
}
|
||||
|
||||
// 2. GroundSpoof检测 — 玩家声称在地面但Y轴速度异常
|
||||
if (onGround) {
|
||||
double dy = toY - fromY;
|
||||
double verticalSpeed = Math.abs(dy) / deltaSeconds;
|
||||
|
||||
if (verticalSpeed > GROUND_VERTICAL_THRESHOLD) {
|
||||
reasons.add("GROUND_SPOOF: claims on ground but vertical speed is "
|
||||
+ String.format("%.2f", verticalSpeed) + " m/s (max ground: "
|
||||
+ String.format("%.2f", GROUND_VERTICAL_THRESHOLD) + " m/s)");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 速度模式分析 — 检测机械性恒速运动
|
||||
if (history.size() >= 10) {
|
||||
double speedSum = 0;
|
||||
double speedSumSq = 0;
|
||||
int count = Math.min(history.size(), 10);
|
||||
List<MovementRecord> recentRecords = history.subList(history.size() - count, history.size());
|
||||
|
||||
for (MovementRecord r : recentRecords) {
|
||||
speedSum += r.horizontalSpeed;
|
||||
speedSumSq += r.horizontalSpeed * r.horizontalSpeed;
|
||||
}
|
||||
|
||||
double meanSpeed = speedSum / count;
|
||||
double variance = (speedSumSq / count) - (meanSpeed * meanSpeed);
|
||||
double stdDev = Math.sqrt(Math.max(0, variance));
|
||||
|
||||
// 速度非常恒定(标准差极小)且速度较高 — 典型的机械性Speed hack
|
||||
if (stdDev < 0.1 && meanSpeed > 5.0) {
|
||||
reasons.add("MECHANICAL_SPEED: constant speed " + String.format("%.2f", meanSpeed)
|
||||
+ " m/s with stdDev " + String.format("%.3f", stdDev));
|
||||
}
|
||||
}
|
||||
|
||||
if (reasons.size() >= 2) {
|
||||
return SpeedCheckResult.flagged(reasons);
|
||||
} else if (reasons.size() == 1) {
|
||||
return SpeedCheckResult.suspicious(reasons);
|
||||
}
|
||||
|
||||
return SpeedCheckResult.clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* 玩家离线时清理追踪数据
|
||||
* @param playerName 离线玩家名称
|
||||
*/
|
||||
public void clearPlayer(String playerName) {
|
||||
playerMovementHistory.remove(playerName);
|
||||
playerSpeedingSince.remove(playerName);
|
||||
playerSpeedStats.remove(playerName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模块运行状态
|
||||
* @return 包含计数器、平均速度、最高速度的Map
|
||||
*/
|
||||
public Map<String, Object> getStatus() {
|
||||
Map<String, Object> status = new LinkedHashMap<>();
|
||||
status.put("totalSamples", totalSamples.get());
|
||||
status.put("speedViolations", speedViolations.get());
|
||||
status.put("activeTrackedPlayers", playerMovementHistory.size());
|
||||
|
||||
// 计算全局平均速度和最高速度
|
||||
double globalAvgSpeed = 0;
|
||||
double globalMaxSpeed = 0;
|
||||
int totalSamples = 0;
|
||||
|
||||
for (SpeedStats stats : playerSpeedStats.values()) {
|
||||
totalSamples += stats.totalSamples;
|
||||
globalMaxSpeed = Math.max(globalMaxSpeed, stats.maxSpeed);
|
||||
}
|
||||
if (totalSamples > 0) {
|
||||
double totalSpeedSum = 0;
|
||||
for (SpeedStats stats : playerSpeedStats.values()) {
|
||||
totalSpeedSum += stats.totalSpeed;
|
||||
}
|
||||
globalAvgSpeed = totalSpeedSum / totalSamples;
|
||||
}
|
||||
|
||||
status.put("averageSpeed", String.format("%.2f", globalAvgSpeed));
|
||||
status.put("maxSpeed", String.format("%.2f", globalMaxSpeed));
|
||||
|
||||
// 列出当前超速的玩家
|
||||
List<String> overspeedingPlayers = new ArrayList<>(playerSpeedingSince.keySet());
|
||||
status.put("overspeedingPlayers", overspeedingPlayers);
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部移动记录 — 记录单次移动的空间和时间信息
|
||||
*/
|
||||
private static class MovementRecord {
|
||||
final Instant timestamp;
|
||||
final double fromX, fromY, fromZ;
|
||||
final double toX, toY, toZ;
|
||||
final boolean onGround;
|
||||
final double horizontalSpeed;
|
||||
final double deltaSeconds;
|
||||
|
||||
MovementRecord(Instant timestamp, double fromX, double fromY, double fromZ,
|
||||
double toX, double toY, double toZ, boolean onGround,
|
||||
double horizontalSpeed, double deltaSeconds) {
|
||||
this.timestamp = timestamp;
|
||||
this.fromX = fromX; this.fromY = fromY; this.fromZ = fromZ;
|
||||
this.toX = toX; this.toY = toY; this.toZ = toZ;
|
||||
this.onGround = onGround;
|
||||
this.horizontalSpeed = horizontalSpeed;
|
||||
this.deltaSeconds = deltaSeconds;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 速度统计内部类 — 追踪单个玩家的累加速度数据
|
||||
*/
|
||||
private static class SpeedStats {
|
||||
double totalSpeed = 0;
|
||||
int totalSamples = 0;
|
||||
double maxSpeed = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 速度检测结果 — 不可变结果类
|
||||
*/
|
||||
public static class SpeedCheckResult {
|
||||
private final boolean flagged;
|
||||
private final boolean suspicious;
|
||||
private final List<String> reasons;
|
||||
|
||||
private SpeedCheckResult(boolean flagged, boolean suspicious, List<String> reasons) {
|
||||
this.flagged = flagged;
|
||||
this.suspicious = suspicious;
|
||||
this.reasons = reasons;
|
||||
}
|
||||
|
||||
/** 无异常 — 正常移动速度 */
|
||||
public static SpeedCheckResult clean() {
|
||||
return new SpeedCheckResult(false, false, List.of());
|
||||
}
|
||||
|
||||
/** 可疑 — 速度略超阈值但可能是延迟导致 */
|
||||
public static SpeedCheckResult suspicious(List<String> reasons) {
|
||||
return new SpeedCheckResult(false, true, reasons);
|
||||
}
|
||||
|
||||
/** 已标记 — 确认超速移动(持续超速或GroundSpoof) */
|
||||
public static SpeedCheckResult flagged(List<String> reasons) {
|
||||
return new SpeedCheckResult(true, false, reasons);
|
||||
}
|
||||
|
||||
public boolean isFlagged() { return flagged; }
|
||||
public boolean isSuspicious() { return suspicious; }
|
||||
public boolean isClean() { return !flagged && !suspicious; }
|
||||
public List<String> getReasons() { return reasons; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
package com.aluer.anticheat.movement;
|
||||
|
||||
import com.aluer.config.ServerGuardConfig;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* 爬墙(Spider)检测服务 — V5.2 反作弊移动模块
|
||||
*
|
||||
* 检测原理:
|
||||
* 1. 贴墙垂直上升检测 — Minecraft中玩家无法在不使用梯子、藤蔓、脚手架等攀爬方块
|
||||
* 的情况下贴墙垂直上升。Spider hack修改客户端与墙壁碰撞逻辑,
|
||||
* 使玩家可以向普通固体墙壁移动并以类似爬梯方式上升。
|
||||
* 2. 相邻方块类型检查 — 在玩家上升过程中检查其头部/肩部相邻方块是否为固体方块。
|
||||
* 如果是固体方块且不是可攀爬方块(梯子/藤蔓/脚手架/垂泪藤/缠怨藤),则为异常。
|
||||
* 3. 持续贴墙上升时间追踪 — 追踪玩家连续贴墙并上升的tick数。正常玩家无法维持
|
||||
* 贴墙上升状态超过1tick(除非使用攀爬方块),Spider hack用户可持续上升。
|
||||
* 4. 水平接触+垂直运动综合分析 — 同时检测玩家水平方向是否紧贴墙壁且
|
||||
* 垂直方向持续向上移动,排除跳跃、飞行、鞘翅等合法上升方式。
|
||||
*
|
||||
* 配置开关:serverguard.security.super-evolution.anti-spider
|
||||
*/
|
||||
@Service
|
||||
public class AntiSpiderService {
|
||||
|
||||
private final ServerGuardConfig config;
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的移动历史(playerName -> 移动记录列表)
|
||||
*/
|
||||
private final Map<String, List<WallMoveRecord>> playerMoveHistory = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家连续贴墙上升的tick计数(playerName -> 连续tick数)
|
||||
*/
|
||||
private final Map<String, Integer> playerWallClimbTicks = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家最近的Y坐标(playerName -> Y坐标列表,用于趋势分析)
|
||||
*/
|
||||
private final Map<String, List<Double>> playerYHistory = new ConcurrentHashMap<>();
|
||||
|
||||
private final AtomicLong totalChecks = new AtomicLong(0);
|
||||
private final AtomicLong spiderViolations = new AtomicLong(0);
|
||||
|
||||
/**
|
||||
* 正常单tick最大垂直上升量(无跳跃)— 正常爬梯约0.2 blocks/tick
|
||||
* 超过0.6 blocks/tick的值表明可能存在异常加速上升
|
||||
*/
|
||||
private static final double MAX_NORMAL_CLIMB_PER_TICK = 0.6;
|
||||
|
||||
/**
|
||||
* 连续贴墙上升tick阈值 — 超过此值判定为Spider hack
|
||||
* 正常玩家即使因bug短暂贴墙,也无法维持4+ ticks
|
||||
*/
|
||||
private static final int MAX_CONSECUTIVE_WALL_CLIMB_TICKS = 4;
|
||||
|
||||
/**
|
||||
* 贴墙水平距离阈值(方块)— 玩家XZ距离墙壁边缘在此范围内视为"贴墙"
|
||||
*/
|
||||
private static final double WALL_PROXIMITY_THRESHOLD = 0.4;
|
||||
|
||||
/**
|
||||
* 每个玩家保留的最大记录数
|
||||
*/
|
||||
private static final int MAX_RECORDS_PER_PLAYER = 50;
|
||||
|
||||
/**
|
||||
* 无参构造函数 — 测试/默认配置使用
|
||||
*/
|
||||
public AntiSpiderService() {
|
||||
this(new ServerGuardConfig());
|
||||
}
|
||||
|
||||
/**
|
||||
* Spring 注入构造函数
|
||||
* @param config 全局配置,提供模块开关控制
|
||||
*/
|
||||
public AntiSpiderService(ServerGuardConfig config) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测玩家是否在无攀爬方块的情况下贴墙上升(Spider hack)
|
||||
*
|
||||
* @param playerName 玩家名称
|
||||
* @param playerUUID 玩家UUID
|
||||
* @param fromX 移动起始X坐标
|
||||
* @param fromY 移动起始Y坐标
|
||||
* @param fromZ 移动起始Z坐标
|
||||
* @param toX 移动结束X坐标
|
||||
* @param toY 移动结束Y坐标
|
||||
* @param toZ 移动结束Z坐标
|
||||
* @param adjacentBlockType 玩家相邻方块类型(SOLID, CLIMBABLE, AIR, LIQUID)
|
||||
* @param isClimbableBlock 相邻方块是否为可攀爬方块(梯子/藤蔓/脚手架等)
|
||||
* @param isAgainstWall 玩家是否紧贴垂直固体墙壁
|
||||
* @param onGround 玩家是否在地面
|
||||
* @param isJumping 玩家是否在跳跃中
|
||||
* @param timestamp 时间戳
|
||||
* @return 检测结果
|
||||
*/
|
||||
public SpiderCheckResult detect(String playerName, String playerUUID,
|
||||
double fromX, double fromY, double fromZ,
|
||||
double toX, double toY, double toZ,
|
||||
String adjacentBlockType, boolean isClimbableBlock,
|
||||
boolean isAgainstWall, boolean onGround,
|
||||
boolean isJumping, Instant timestamp) {
|
||||
// 模块开关检查 — 关闭时跳过所有检测
|
||||
if (!config.getSecurity().getSuperEvolution().isAntiSpider()) {
|
||||
return SpiderCheckResult.clean();
|
||||
}
|
||||
|
||||
totalChecks.incrementAndGet();
|
||||
|
||||
double dy = toY - fromY;
|
||||
double dx = toX - fromX;
|
||||
double dz = toZ - fromZ;
|
||||
double horizontalDist = Math.sqrt(dx * dx + dz * dz);
|
||||
|
||||
List<WallMoveRecord> history = playerMoveHistory.computeIfAbsent(
|
||||
playerName, k -> new ArrayList<>());
|
||||
List<Double> yHistory = playerYHistory.computeIfAbsent(
|
||||
playerName, k -> new ArrayList<>());
|
||||
|
||||
WallMoveRecord record = new WallMoveRecord(timestamp, fromX, fromY, fromZ,
|
||||
toX, toY, toZ, dy, horizontalDist, adjacentBlockType,
|
||||
isClimbableBlock, isAgainstWall, onGround, isJumping);
|
||||
history.add(record);
|
||||
yHistory.add(toY);
|
||||
|
||||
while (history.size() > MAX_RECORDS_PER_PLAYER) {
|
||||
history.remove(0);
|
||||
}
|
||||
while (yHistory.size() > 30) {
|
||||
yHistory.remove(0);
|
||||
}
|
||||
|
||||
List<String> reasons = new ArrayList<>();
|
||||
|
||||
// 1. 核心检测:贴墙上升且无攀爬方块
|
||||
if (isAgainstWall && dy > 0 && !onGround && !isJumping && !isClimbableBlock) {
|
||||
int climbTicks = playerWallClimbTicks.getOrDefault(playerName, 0) + 1;
|
||||
playerWallClimbTicks.put(playerName, climbTicks);
|
||||
|
||||
if (climbTicks >= MAX_CONSECUTIVE_WALL_CLIMB_TICKS) {
|
||||
spiderViolations.incrementAndGet();
|
||||
reasons.add("SUSTAINED_WALL_CLIMB: " + climbTicks
|
||||
+ " consecutive ticks climbing solid wall without climbable block"
|
||||
+ " (adjacent: " + adjacentBlockType + ", dy="
|
||||
+ String.format("%.3f", dy) + ")");
|
||||
}
|
||||
} else {
|
||||
// 不再贴墙上升,重置计数
|
||||
playerWallClimbTicks.remove(playerName);
|
||||
}
|
||||
|
||||
// 2. 单tick异常上升量检测 — 即使贴墙,单tick上升量也不应超过正常爬梯
|
||||
if (isAgainstWall && dy > MAX_NORMAL_CLIMB_PER_TICK && !isJumping) {
|
||||
if (!isClimbableBlock) {
|
||||
reasons.add("RAPID_WALL_CLIMB: dy=" + String.format("%.3f", dy)
|
||||
+ " per tick against " + adjacentBlockType.toLowerCase()
|
||||
+ " wall (max normal: "
|
||||
+ String.format("%.2f", MAX_NORMAL_CLIMB_PER_TICK) + ")");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 贴墙+持续上升趋势检测 — 分析Y坐标的整体趋势
|
||||
if (isAgainstWall && !isClimbableBlock && yHistory.size() >= 10) {
|
||||
double totalDy = yHistory.get(yHistory.size() - 1) - yHistory.get(0);
|
||||
// 如果整体上升超过2方块且一直贴墙,非常可疑
|
||||
if (totalDy > 2.0 && !onGround) {
|
||||
long wallContactTicks = history.stream()
|
||||
.filter(r -> r.isAgainstWall && !r.isClimbableBlock)
|
||||
.count();
|
||||
if (wallContactTicks >= 8) {
|
||||
reasons.add("VERTICAL_WALL_ADVANCEMENT: rose "
|
||||
+ String.format("%.1f", totalDy) + " blocks while against"
|
||||
+ " solid wall (" + wallContactTicks + " wall-contact ticks)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 水平贴墙+垂直上升模式分析 — Spider的典型移动模式
|
||||
// 水平移动极小(紧贴墙壁),垂直持续上升
|
||||
if (isAgainstWall && !isClimbableBlock && dy > 0.05 && horizontalDist < 0.2) {
|
||||
long pureClimbTicks = history.stream()
|
||||
.filter(r -> r.isAgainstWall && !r.isClimbableBlock
|
||||
&& r.dy > 0.05 && r.horizontalDist < 0.3)
|
||||
.count();
|
||||
if (pureClimbTicks >= 6) {
|
||||
reasons.add("SPIDER_PATTERN: " + pureClimbTicks
|
||||
+ " ticks of vertical-only movement against solid wall");
|
||||
}
|
||||
}
|
||||
|
||||
if (reasons.size() >= 2) {
|
||||
return SpiderCheckResult.flagged(reasons);
|
||||
} else if (reasons.size() == 1) {
|
||||
return SpiderCheckResult.suspicious(reasons);
|
||||
}
|
||||
|
||||
return SpiderCheckResult.clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* 玩家离线时清理追踪数据
|
||||
* @param playerName 离线玩家名称
|
||||
*/
|
||||
public void clearPlayer(String playerName) {
|
||||
playerMoveHistory.remove(playerName);
|
||||
playerWallClimbTicks.remove(playerName);
|
||||
playerYHistory.remove(playerName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模块运行状态
|
||||
* @return 包含计数器和违规数量的Map
|
||||
*/
|
||||
public Map<String, Object> getStatus() {
|
||||
Map<String, Object> status = new LinkedHashMap<>();
|
||||
status.put("totalChecks", totalChecks.get());
|
||||
status.put("spiderViolations", spiderViolations.get());
|
||||
status.put("activeTrackedPlayers", playerMoveHistory.size());
|
||||
|
||||
// 列出当前贴墙的玩家
|
||||
List<String> wallClimbers = new ArrayList<>(playerWallClimbTicks.keySet());
|
||||
status.put("wallClimbingPlayers", wallClimbers);
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部贴墙移动记录 — 记录玩家贴墙时的移动数据
|
||||
*/
|
||||
private static class WallMoveRecord {
|
||||
final Instant timestamp;
|
||||
final double fromX, fromY, fromZ;
|
||||
final double toX, toY, toZ;
|
||||
final double dy;
|
||||
final double horizontalDist;
|
||||
final String adjacentBlockType;
|
||||
final boolean isClimbableBlock;
|
||||
final boolean isAgainstWall;
|
||||
final boolean onGround;
|
||||
final boolean isJumping;
|
||||
|
||||
WallMoveRecord(Instant timestamp, double fromX, double fromY, double fromZ,
|
||||
double toX, double toY, double toZ, double dy,
|
||||
double horizontalDist, String adjacentBlockType,
|
||||
boolean isClimbableBlock, boolean isAgainstWall,
|
||||
boolean onGround, boolean isJumping) {
|
||||
this.timestamp = timestamp;
|
||||
this.fromX = fromX; this.fromY = fromY; this.fromZ = fromZ;
|
||||
this.toX = toX; this.toY = toY; this.toZ = toZ;
|
||||
this.dy = dy;
|
||||
this.horizontalDist = horizontalDist;
|
||||
this.adjacentBlockType = adjacentBlockType;
|
||||
this.isClimbableBlock = isClimbableBlock;
|
||||
this.isAgainstWall = isAgainstWall;
|
||||
this.onGround = onGround;
|
||||
this.isJumping = isJumping;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Spider检测结果 — 不可变结果类
|
||||
*/
|
||||
public static class SpiderCheckResult {
|
||||
private final boolean flagged;
|
||||
private final boolean suspicious;
|
||||
private final List<String> reasons;
|
||||
|
||||
private SpiderCheckResult(boolean flagged, boolean suspicious, List<String> reasons) {
|
||||
this.flagged = flagged;
|
||||
this.suspicious = suspicious;
|
||||
this.reasons = reasons;
|
||||
}
|
||||
|
||||
/** 无异常 — 正常使用可攀爬方块或在地面移动 */
|
||||
public static SpiderCheckResult clean() {
|
||||
return new SpiderCheckResult(false, false, List.of());
|
||||
}
|
||||
|
||||
/** 可疑 — 短暂贴墙但不足够确认为Spider hack */
|
||||
public static SpiderCheckResult suspicious(List<String> reasons) {
|
||||
return new SpiderCheckResult(false, true, reasons);
|
||||
}
|
||||
|
||||
/** 已标记 — 确认Spider hack,持续贴固体墙壁上升 */
|
||||
public static SpiderCheckResult flagged(List<String> reasons) {
|
||||
return new SpiderCheckResult(true, false, reasons);
|
||||
}
|
||||
|
||||
public boolean isFlagged() { return flagged; }
|
||||
public boolean isSuspicious() { return suspicious; }
|
||||
public boolean isClean() { return !flagged && !suspicious; }
|
||||
public List<String> getReasons() { return reasons; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
package com.aluer.anticheat.movement;
|
||||
|
||||
import com.aluer.config.ServerGuardConfig;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* 自动跨步(Step)检测服务 — V5.2 反作弊移动模块
|
||||
*
|
||||
* 检测原理:
|
||||
* 1. Y坐标突增检测 — Minecraft中玩家自动行走时可以跨越的最高方块是半砖(0.5方块),
|
||||
* 需要跳跃才能跨上完整方块(1.0方块)。Step hack自动将1.0方块高度的障碍物
|
||||
* 当作可跨过的半砖,无需跳跃即上升1完整方块。
|
||||
* 2. 跳跃冲量缺失检测 — 正常跳跃有抛物线速度曲线,Y速度从正到负渐变。
|
||||
* Step hack通过直接修改Y坐标(瞬间传送),缺少跳跃的初始冲量和后续自由落体。
|
||||
* 检测单tick内Y上升超过0.9方块且无跳跃速度分量。
|
||||
* 3. 连续step模式检测 — Step hack在遇到连续台阶时会逐级上升,
|
||||
* 形成"楼梯式"的上升模式(每次精准上升1.0方块),与正常跳跃的抛物线模式截然不同。
|
||||
* 4. 水平移动同时上升 — 正常玩家在移动中遇到方块需要停止、跳跃、再继续移动。
|
||||
* Step hack用户在水平移动的同时无缝上升,水平速度没有丝毫中断。
|
||||
*
|
||||
* 配置开关:serverguard.security.super-evolution.anti-step
|
||||
*/
|
||||
@Service
|
||||
public class AntiStepService {
|
||||
|
||||
private final ServerGuardConfig config;
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的移动历史(playerName -> 移动记录列表)
|
||||
*/
|
||||
private final Map<String, List<StepMoveRecord>> playerMoveHistory = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家连续step上升的次数(playerName -> 连续step次数)
|
||||
*/
|
||||
private final Map<String, Integer> playerStepCount = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的Y坐标历史(playerName -> Y坐标列表)
|
||||
*/
|
||||
private final Map<String, List<Double>> playerYHistory = new ConcurrentHashMap<>();
|
||||
|
||||
private final AtomicLong totalChecks = new AtomicLong(0);
|
||||
private final AtomicLong stepViolations = new AtomicLong(0);
|
||||
|
||||
/**
|
||||
* 单tick内最大正常Y上升(无跳跃)— 正常步行可以跨越0.5方块(半砖)
|
||||
* 超过0.9方块必定需要跳跃或存在异常
|
||||
*/
|
||||
private static final double MAX_NORMAL_STEP_UP = 0.9;
|
||||
|
||||
/**
|
||||
* 完整方块高度 — Step hack典型上升高度
|
||||
*/
|
||||
private static final double FULL_BLOCK_HEIGHT = 1.0;
|
||||
|
||||
/**
|
||||
* Step高度误差容差(方块)— Y变化在此范围内可视为step上升
|
||||
*/
|
||||
private static final double STEP_HEIGHT_TOLERANCE = 0.15;
|
||||
|
||||
/**
|
||||
* 正常跳跃的Y速度分量阈值 — 跳跃时Y速度至少为正
|
||||
* Step hack缺少Y速度分量(ΔY大但速度为零或负)
|
||||
*/
|
||||
private static final double JUMP_VELOCITY_MIN = 0.1;
|
||||
|
||||
/**
|
||||
* 连续step检测阈值 — 连续检测到超过此值的step-like上升判定为作弊
|
||||
*/
|
||||
private static final int MAX_CONSECUTIVE_STEPS = 3;
|
||||
|
||||
/**
|
||||
* 每个玩家保留的最大记录数
|
||||
*/
|
||||
private static final int MAX_RECORDS_PER_PLAYER = 40;
|
||||
|
||||
/**
|
||||
* 无参构造函数 — 测试/默认配置使用
|
||||
*/
|
||||
public AntiStepService() {
|
||||
this(new ServerGuardConfig());
|
||||
}
|
||||
|
||||
/**
|
||||
* Spring 注入构造函数
|
||||
* @param config 全局配置,提供模块开关控制
|
||||
*/
|
||||
public AntiStepService(ServerGuardConfig config) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测玩家是否使用Step hack自动跨上完整方块
|
||||
*
|
||||
* @param playerName 玩家名称
|
||||
* @param playerUUID 玩家UUID
|
||||
* @param fromX 移动起始X坐标
|
||||
* @param fromY 移动起始Y坐标
|
||||
* @param fromZ 移动起始Z坐标
|
||||
* @param toX 移动结束X坐标
|
||||
* @param toY 移动结束Y坐标
|
||||
* @param toZ 移动结束Z坐标
|
||||
* @param yVelocity 玩家Y轴速度分量(正=向上,负=向下)
|
||||
* @param isJumping 玩家是否发送了跳跃数据包
|
||||
* @param onGround 玩家移动前是否在地面
|
||||
* @param timestamp 时间戳
|
||||
* @return 检测结果
|
||||
*/
|
||||
public StepCheckResult detect(String playerName, String playerUUID,
|
||||
double fromX, double fromY, double fromZ,
|
||||
double toX, double toY, double toZ,
|
||||
double yVelocity, boolean isJumping,
|
||||
boolean onGround, Instant timestamp) {
|
||||
// 模块开关检查 — 关闭时跳过所有检测
|
||||
if (!config.getSecurity().getSuperEvolution().isAntiStep()) {
|
||||
return StepCheckResult.clean();
|
||||
}
|
||||
|
||||
totalChecks.incrementAndGet();
|
||||
|
||||
double dy = toY - fromY;
|
||||
double dx = toX - fromX;
|
||||
double dz = toZ - fromZ;
|
||||
double horizontalDist = Math.sqrt(dx * dx + dz * dz);
|
||||
|
||||
List<StepMoveRecord> history = playerMoveHistory.computeIfAbsent(
|
||||
playerName, k -> new ArrayList<>());
|
||||
List<Double> yHistory = playerYHistory.computeIfAbsent(
|
||||
playerName, k -> new ArrayList<>());
|
||||
|
||||
StepMoveRecord record = new StepMoveRecord(timestamp, fromX, fromY, fromZ,
|
||||
toX, toY, toZ, dy, yVelocity, isJumping, onGround, horizontalDist);
|
||||
history.add(record);
|
||||
yHistory.add(toY);
|
||||
|
||||
while (history.size() > MAX_RECORDS_PER_PLAYER) {
|
||||
history.remove(0);
|
||||
}
|
||||
while (yHistory.size() > 30) {
|
||||
yHistory.remove(0);
|
||||
}
|
||||
|
||||
List<String> reasons = new ArrayList<>();
|
||||
|
||||
// 1. 核心检测:单tick上升超过0.9方块,且无跳跃冲量
|
||||
if (dy >= MAX_NORMAL_STEP_UP && !isJumping && Math.abs(yVelocity) < JUMP_VELOCITY_MIN) {
|
||||
int stepCount = playerStepCount.getOrDefault(playerName, 0) + 1;
|
||||
playerStepCount.put(playerName, stepCount);
|
||||
|
||||
if (stepCount >= MAX_CONSECUTIVE_STEPS) {
|
||||
stepViolations.incrementAndGet();
|
||||
reasons.add("SUSTAINED_STEP: " + stepCount
|
||||
+ " consecutive step-ups (dy=" + String.format("%.2f", dy)
|
||||
+ ", no jump impulse, yVel=" + String.format("%.3f", yVelocity) + ")");
|
||||
} else {
|
||||
reasons.add("STEP_DETECT: dy=" + String.format("%.2f", dy)
|
||||
+ " without jump (yVel=" + String.format("%.3f", yVelocity)
|
||||
+ ", count=" + stepCount + "/" + MAX_CONSECUTIVE_STEPS + ")");
|
||||
}
|
||||
} else {
|
||||
// 不是step移动,重置计数
|
||||
playerStepCount.remove(playerName);
|
||||
}
|
||||
|
||||
// 2. 精确step高度检测 — dy恰好在1.0方块附近(完整方块高度)
|
||||
if (Math.abs(dy - FULL_BLOCK_HEIGHT) <= STEP_HEIGHT_TOLERANCE && !isJumping) {
|
||||
// Step hack典型特征:精准跨上1.0方块,无跳跃
|
||||
if (yVelocity <= 0 || Math.abs(yVelocity) < JUMP_VELOCITY_MIN) {
|
||||
reasons.add("EXACT_BLOCK_STEP: dy=" + String.format("%.2f", dy)
|
||||
+ " (1.0 block) without jump impulse (yVel="
|
||||
+ String.format("%.3f", yVelocity) + ")");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 水平移动+上升无缝检测 — 正常玩家跨越方块时水平速度会短暂降低
|
||||
if (dy >= MAX_NORMAL_STEP_UP && !isJumping && horizontalDist > 0.3) {
|
||||
// 检查玩家的水平速度是否在step时保持不变(正常玩家会减速)
|
||||
if (history.size() >= 3) {
|
||||
double prevAvgSpeed = 0;
|
||||
int count = 0;
|
||||
for (int i = Math.max(0, history.size() - 5); i < history.size() - 1; i++) {
|
||||
StepMoveRecord r = history.get(i);
|
||||
if (!r.isJumping && r.dy < 0.3) {
|
||||
prevAvgSpeed += r.horizontalDist;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
if (count > 0) {
|
||||
prevAvgSpeed /= count;
|
||||
// 如果step时的水平速度和平时无异,说明没有因障碍物减速
|
||||
if (horizontalDist >= prevAvgSpeed * 0.8) {
|
||||
reasons.add("NO_SPEED_LOSS_ON_STEP: maintained horizontal speed "
|
||||
+ String.format("%.2f", horizontalDist) + " while stepping "
|
||||
+ String.format("%.2f", dy) + " blocks (avg: "
|
||||
+ String.format("%.2f", prevAvgSpeed) + ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 楼梯式上升模式分析 — 一系列精确的1.0方块上升
|
||||
if (history.size() >= 8 && !isJumping) {
|
||||
long exactBlockSteps = history.stream()
|
||||
.filter(r -> Math.abs(r.dy - FULL_BLOCK_HEIGHT) <= STEP_HEIGHT_TOLERANCE
|
||||
&& !r.isJumping)
|
||||
.count();
|
||||
if (exactBlockSteps >= 4) {
|
||||
reasons.add("STAIRCASE_PATTERN: " + exactBlockSteps
|
||||
+ " exact 1.0-block steps in " + history.size()
|
||||
+ " ticks (step hack staircase pattern)");
|
||||
}
|
||||
}
|
||||
|
||||
if (reasons.size() >= 2) {
|
||||
return StepCheckResult.flagged(reasons);
|
||||
} else if (reasons.size() == 1) {
|
||||
return StepCheckResult.suspicious(reasons);
|
||||
}
|
||||
|
||||
return StepCheckResult.clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* 玩家离线时清理追踪数据
|
||||
* @param playerName 离线玩家名称
|
||||
*/
|
||||
public void clearPlayer(String playerName) {
|
||||
playerMoveHistory.remove(playerName);
|
||||
playerStepCount.remove(playerName);
|
||||
playerYHistory.remove(playerName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模块运行状态
|
||||
* @return 包含计数器和违规数量的Map
|
||||
*/
|
||||
public Map<String, Object> getStatus() {
|
||||
Map<String, Object> status = new LinkedHashMap<>();
|
||||
status.put("totalChecks", totalChecks.get());
|
||||
status.put("stepViolations", stepViolations.get());
|
||||
status.put("activeTrackedPlayers", playerMoveHistory.size());
|
||||
|
||||
// 列出当前疑似step的玩家
|
||||
List<Map<String, Object>> stepSuspects = new ArrayList<>();
|
||||
for (Map.Entry<String, Integer> entry : playerStepCount.entrySet()) {
|
||||
Map<String, Object> info = new LinkedHashMap<>();
|
||||
info.put("player", entry.getKey());
|
||||
info.put("consecutiveSteps", entry.getValue());
|
||||
stepSuspects.add(info);
|
||||
}
|
||||
status.put("stepSuspects", stepSuspects);
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部step移动记录 — 记录玩家单次移动的详细数据
|
||||
*/
|
||||
private static class StepMoveRecord {
|
||||
final Instant timestamp;
|
||||
final double fromX, fromY, fromZ;
|
||||
final double toX, toY, toZ;
|
||||
final double dy;
|
||||
final double yVelocity;
|
||||
final boolean isJumping;
|
||||
final boolean onGround;
|
||||
final double horizontalDist;
|
||||
|
||||
StepMoveRecord(Instant timestamp, double fromX, double fromY, double fromZ,
|
||||
double toX, double toY, double toZ, double dy,
|
||||
double yVelocity, boolean isJumping, boolean onGround,
|
||||
double horizontalDist) {
|
||||
this.timestamp = timestamp;
|
||||
this.fromX = fromX; this.fromY = fromY; this.fromZ = fromZ;
|
||||
this.toX = toX; this.toY = toY; this.toZ = toZ;
|
||||
this.dy = dy;
|
||||
this.yVelocity = yVelocity;
|
||||
this.isJumping = isJumping;
|
||||
this.onGround = onGround;
|
||||
this.horizontalDist = horizontalDist;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step检测结果 — 不可变结果类
|
||||
*/
|
||||
public static class StepCheckResult {
|
||||
private final boolean flagged;
|
||||
private final boolean suspicious;
|
||||
private final List<String> reasons;
|
||||
|
||||
private StepCheckResult(boolean flagged, boolean suspicious, List<String> reasons) {
|
||||
this.flagged = flagged;
|
||||
this.suspicious = suspicious;
|
||||
this.reasons = reasons;
|
||||
}
|
||||
|
||||
/** 无异常 — 正常行走或跳跃跨越方块 */
|
||||
public static StepCheckResult clean() {
|
||||
return new StepCheckResult(false, false, List.of());
|
||||
}
|
||||
|
||||
/** 可疑 — 单次step但不足够确认为Step hack */
|
||||
public static StepCheckResult suspicious(List<String> reasons) {
|
||||
return new StepCheckResult(false, true, reasons);
|
||||
}
|
||||
|
||||
/** 已标记 — 确认Step hack,连续无跳跃跨越完整方块 */
|
||||
public static StepCheckResult flagged(List<String> reasons) {
|
||||
return new StepCheckResult(true, false, reasons);
|
||||
}
|
||||
|
||||
public boolean isFlagged() { return flagged; }
|
||||
public boolean isSuspicious() { return suspicious; }
|
||||
public boolean isClean() { return !flagged && !suspicious; }
|
||||
public List<String> getReasons() { return reasons; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package com.aluer.anticheat.movement;
|
||||
|
||||
import com.aluer.config.ServerGuardConfig;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* 游戏加速(Timer)检测服务 — V5.0 反作弊扩展模块
|
||||
*
|
||||
* 检测原理:
|
||||
* 1. 移动Tick间隔检测 — Minecraft标准时钟频率为20 ticks/秒(50ms/tick)。
|
||||
* Timer hack通过修改游戏客户端时钟加速游戏速度,使客户端以>20 ticks/秒
|
||||
* 的频率发送移动数据包。本模块追踪每个玩家相邻两次移动的时间间隔,
|
||||
* 如果间隔持续低于45ms(对应约22.2 ticks/秒),表明存在Timer加速。
|
||||
* 2. 连续异常计数 — 单次短间隔可能由网络抖动造成,需要连续检测到多次
|
||||
* 异常短间隔才标记,防止误报。
|
||||
* 3. 间隔模式分析 — 正常玩家的移动间隔有自然波动(网络延迟、TPS波动),
|
||||
* 而Timer hack会产生高度一致的加速间隔,通过标准差分析检测机械性加速。
|
||||
* 4. 时间戳跳跃检测 — 检测客户端时间戳是否快于服务器时间,
|
||||
* 这是Timer hack的典型特征(客户端时间比服务器时间推进更快)。
|
||||
*
|
||||
* 配置开关:serverguard.security.super-evolution.anti-timer
|
||||
*/
|
||||
@Service
|
||||
public class AntiTimerService {
|
||||
|
||||
private final ServerGuardConfig config;
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的移动时间间隔历史(playerName -> 间隔记录列表)
|
||||
* 用于计算滑动窗口内的平均tick间隔
|
||||
*/
|
||||
private final Map<String, List<MovementInterval>> playerIntervalHistory = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家连续异常短间隔的次数(playerName -> 连续异常计数)
|
||||
* 用于判断是偶发延迟还是持续加速
|
||||
*/
|
||||
private final Map<String, Integer> playerConsecutiveAnomalies = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家最近的绝对时间戳(playerName -> 上次移动的Instant)
|
||||
* 用于计算两次移动之间的真实时间间隔
|
||||
*/
|
||||
private final Map<String, Instant> playerLastMoveTime = new ConcurrentHashMap<>();
|
||||
|
||||
private final AtomicLong totalChecks = new AtomicLong(0);
|
||||
private final AtomicLong flaggedCount = new AtomicLong(0);
|
||||
|
||||
/**
|
||||
* 正常tick间隔(毫秒)— Minecraft标准为50ms/tick
|
||||
*/
|
||||
private static final double NORMAL_TICK_INTERVAL_MS = 50.0;
|
||||
|
||||
/**
|
||||
* 异常短间隔阈值(毫秒)— 间隔低于此值表明加速
|
||||
* 45ms对应约22.2 ticks/秒,留有一定检测余量
|
||||
*/
|
||||
private static final double MIN_TICK_INTERVAL_MS = 45.0;
|
||||
|
||||
/**
|
||||
* 触发标记所需的最小连续异常次数
|
||||
* 至少需要3次连续异常才能排除网络抖动
|
||||
*/
|
||||
private static final int MIN_CONSECUTIVE_ANOMALIES = 3;
|
||||
|
||||
/**
|
||||
* 间隔分析窗口大小 — 取最近N次移动进行分析
|
||||
*/
|
||||
private static final int ANALYSIS_WINDOW_SIZE = 20;
|
||||
|
||||
/**
|
||||
* 标准差阈值 — 间隔标准差低于此值表明机械性加速
|
||||
* 正常玩家由于网络波动,间隔标准差通常在5ms以上
|
||||
*/
|
||||
private static final double STD_DEV_THRESHOLD = 3.0;
|
||||
|
||||
/**
|
||||
* 每个玩家保留的最大间隔记录数
|
||||
*/
|
||||
private static final int MAX_INTERVAL_RECORDS = 50;
|
||||
|
||||
/**
|
||||
* 时间戳跳跃检测阈值(毫秒)— 客户端时间快于服务器时间的容差
|
||||
* 如果累计超前超过此值,表明存在Timer加速
|
||||
*/
|
||||
private static final long TIMESTAMP_JUMP_THRESHOLD_MS = 500;
|
||||
|
||||
/**
|
||||
* 无参构造函数 — 测试/默认配置使用
|
||||
*/
|
||||
public AntiTimerService() {
|
||||
this(new ServerGuardConfig());
|
||||
}
|
||||
|
||||
/**
|
||||
* Spring 注入构造函数
|
||||
* @param config 全局配置,提供模块开关控制
|
||||
*/
|
||||
public AntiTimerService(ServerGuardConfig config) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测玩家移动tick间隔是否异常(Timer加速检测)
|
||||
*
|
||||
* @param playerName 玩家名称
|
||||
* @param playerUUID 玩家UUID
|
||||
* @param clientTimestamp 客户端报告的时间戳(用于检测时间跳跃)
|
||||
* @param serverTimestamp 服务器接收时间戳
|
||||
* @return 检测结果
|
||||
*/
|
||||
public TimerCheckResult detect(String playerName, String playerUUID,
|
||||
Instant clientTimestamp, Instant serverTimestamp) {
|
||||
// 模块开关检查 — 关闭时跳过所有检测直接返回安全结果
|
||||
if (!config.getSecurity().getSuperEvolution().isAntiTimer()) {
|
||||
return TimerCheckResult.clean();
|
||||
}
|
||||
|
||||
totalChecks.incrementAndGet();
|
||||
List<String> reasons = new ArrayList<>();
|
||||
|
||||
// 获取或创建玩家间隔历史
|
||||
List<MovementInterval> intervalHistory = playerIntervalHistory.computeIfAbsent(
|
||||
playerName, k -> new ArrayList<>());
|
||||
|
||||
// 计算与上次移动的时间间隔
|
||||
Instant lastMove = playerLastMoveTime.get(playerName);
|
||||
if (lastMove != null) {
|
||||
long intervalMs = serverTimestamp.toEpochMilli() - lastMove.toEpochMilli();
|
||||
|
||||
// 记录合理的间隔(跳过异常大的间隔,如玩家长时间不动)
|
||||
if (intervalMs > 0 && intervalMs < 5000) {
|
||||
MovementInterval mi = new MovementInterval(serverTimestamp, intervalMs, clientTimestamp);
|
||||
intervalHistory.add(mi);
|
||||
// 控制列表大小
|
||||
while (intervalHistory.size() > MAX_INTERVAL_RECORDS) {
|
||||
intervalHistory.remove(0);
|
||||
}
|
||||
|
||||
// 1. Tick间隔检测 — 检查当前间隔是否过短
|
||||
if (intervalMs < MIN_TICK_INTERVAL_MS) {
|
||||
// 连续异常计数递增
|
||||
int consecutive = playerConsecutiveAnomalies.merge(playerName, 1, Integer::sum);
|
||||
|
||||
if (consecutive >= MIN_CONSECUTIVE_ANOMALIES) {
|
||||
// 连续多次短间隔 — 确认为Timer加速
|
||||
flaggedCount.incrementAndGet();
|
||||
reasons.add("SUSTAINED_TIMER: " + consecutive + " consecutive moves at "
|
||||
+ intervalMs + "ms intervals (threshold: "
|
||||
+ String.format("%.1f", MIN_TICK_INTERVAL_MS) + "ms)");
|
||||
} else {
|
||||
// 短间隔但次数不够 — 仅标记可疑
|
||||
reasons.add("FAST_TICK: interval " + intervalMs + "ms (consecutive: "
|
||||
+ consecutive + "/" + MIN_CONSECUTIVE_ANOMALIES + ")");
|
||||
}
|
||||
} else {
|
||||
// 间隔正常,重置连续异常计数
|
||||
playerConsecutiveAnomalies.put(playerName, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 更新最后移动时间
|
||||
playerLastMoveTime.put(playerName, serverTimestamp);
|
||||
|
||||
// 2. 间隔模式分析 — 检测机械性加速
|
||||
if (intervalHistory.size() >= ANALYSIS_WINDOW_SIZE) {
|
||||
List<MovementInterval> recentIntervals = intervalHistory.subList(
|
||||
intervalHistory.size() - ANALYSIS_WINDOW_SIZE, intervalHistory.size());
|
||||
|
||||
double sum = 0;
|
||||
double sumSq = 0;
|
||||
int count = 0;
|
||||
|
||||
for (MovementInterval mi : recentIntervals) {
|
||||
double ms = mi.intervalMs;
|
||||
sum += ms;
|
||||
sumSq += ms * ms;
|
||||
count++;
|
||||
}
|
||||
|
||||
double mean = sum / count;
|
||||
double variance = (sumSq / count) - (mean * mean);
|
||||
double stdDev = Math.sqrt(Math.max(0, variance));
|
||||
|
||||
// 平均间隔显著低于正常值且标准差极小 — 机械性Timer加速
|
||||
if (mean < MIN_TICK_INTERVAL_MS && stdDev < STD_DEV_THRESHOLD) {
|
||||
reasons.add("MECHANICAL_TIMER: avg interval " + String.format("%.2f", mean)
|
||||
+ "ms, stdDev " + String.format("%.3f", stdDev) + "ms (normal: "
|
||||
+ String.format("%.1f", NORMAL_TICK_INTERVAL_MS) + "ms)");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 时间戳跳跃检测 — 检测客户端时间是否超前服务器时间
|
||||
// 累积计算客户端时间戳与服务器时间戳的偏差
|
||||
long clientAheadMs = clientTimestamp.toEpochMilli() - serverTimestamp.toEpochMilli();
|
||||
if (clientAheadMs > TIMESTAMP_JUMP_THRESHOLD_MS) {
|
||||
reasons.add("TIMESTAMP_JUMP: client ahead of server by " + clientAheadMs
|
||||
+ "ms (threshold: " + TIMESTAMP_JUMP_THRESHOLD_MS + "ms)");
|
||||
}
|
||||
|
||||
// 汇总结果
|
||||
if (reasons.size() >= 2) {
|
||||
flaggedCount.incrementAndGet();
|
||||
return TimerCheckResult.flagged(reasons);
|
||||
} else if (reasons.size() == 1) {
|
||||
return TimerCheckResult.suspicious(reasons);
|
||||
}
|
||||
|
||||
return TimerCheckResult.clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* 玩家离线时清理其追踪数据,释放内存
|
||||
* @param playerName 离线玩家名称
|
||||
*/
|
||||
public void clearPlayer(String playerName) {
|
||||
playerIntervalHistory.remove(playerName);
|
||||
playerConsecutiveAnomalies.remove(playerName);
|
||||
playerLastMoveTime.remove(playerName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模块运行状态
|
||||
* @return 包含计数器和活跃追踪玩家数的Map
|
||||
*/
|
||||
public Map<String, Object> getStatus() {
|
||||
Map<String, Object> status = new LinkedHashMap<>();
|
||||
status.put("totalChecks", totalChecks.get());
|
||||
status.put("flaggedCount", flaggedCount.get());
|
||||
status.put("activeTrackedPlayers", playerIntervalHistory.size());
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部移动间隔记录 — 记录单次移动间隔的时间和客户端信息
|
||||
*/
|
||||
private static class MovementInterval {
|
||||
final Instant serverTimestamp;
|
||||
final long intervalMs;
|
||||
final Instant clientTimestamp;
|
||||
|
||||
MovementInterval(Instant serverTimestamp, long intervalMs, Instant clientTimestamp) {
|
||||
this.serverTimestamp = serverTimestamp;
|
||||
this.intervalMs = intervalMs;
|
||||
this.clientTimestamp = clientTimestamp;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Timer检测结果 — 不可变结果类
|
||||
*/
|
||||
public static class TimerCheckResult {
|
||||
private final boolean flagged;
|
||||
private final boolean suspicious;
|
||||
private final List<String> reasons;
|
||||
|
||||
private TimerCheckResult(boolean flagged, boolean suspicious, List<String> reasons) {
|
||||
this.flagged = flagged;
|
||||
this.suspicious = suspicious;
|
||||
this.reasons = reasons;
|
||||
}
|
||||
|
||||
/** 无异常 — 正常tick速度 */
|
||||
public static TimerCheckResult clean() {
|
||||
return new TimerCheckResult(false, false, List.of());
|
||||
}
|
||||
|
||||
/** 可疑 — 间隔较短但证据不足(次数不够或可能为网络延迟) */
|
||||
public static TimerCheckResult suspicious(List<String> reasons) {
|
||||
return new TimerCheckResult(false, true, reasons);
|
||||
}
|
||||
|
||||
/** 已标记 — 多项规则命中,高度可能使用Timer加速 */
|
||||
public static TimerCheckResult flagged(List<String> reasons) {
|
||||
return new TimerCheckResult(true, false, reasons);
|
||||
}
|
||||
|
||||
public boolean isFlagged() { return flagged; }
|
||||
public boolean isSuspicious() { return suspicious; }
|
||||
public boolean isClean() { return !flagged && !suspicious; }
|
||||
public List<String> getReasons() { return reasons; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
package com.aluer.anticheat.movement;
|
||||
|
||||
import com.aluer.config.ServerGuardConfig;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* 垂直穿墙(VClip)检测服务 — V5.2 反作弊移动模块
|
||||
*
|
||||
* 检测原理:
|
||||
* 1. 瞬间垂直位移检测 — VClip hack使玩家在单tick内Y坐标变化超过3方块,
|
||||
* 中间无任何移动数据包。正常情况下玩家无法在单tick内垂直穿越多个完整方块。
|
||||
* 2. 固体方块穿透验证 — 检查玩家起始位置与结束位置之间是否存在固体方块。
|
||||
* 如果起始和结束Y坐标之间的所有位置都包含不可穿越的固体方块,则确认为VClip。
|
||||
* 3. 合法传送方式区分 — 需要排除以下合法的快速垂直位移:
|
||||
* - 末影珍珠(需要投掷动画+落地伤害)
|
||||
* - 紫颂果(随机8方块以内,有传送粒子效果)
|
||||
* - /tp命令(有日志记录)
|
||||
* - 船/矿车等载具(无中间方块穿透)
|
||||
* - 鞘翅俯冲(有速度渐变过程)
|
||||
* - 水中快速上浮(有水环境)
|
||||
* 4. 连续VClip模式检测 — 某些hack允许连续多次VClip实现快速垂直穿梭。
|
||||
* 检测短时间内的多次大Y位移事件。
|
||||
* 5. 斜向VClip检测 — 某些变种hack同时改变X/Z坐标以规避纯Y检测,
|
||||
* 但仍然呈现不合理的垂直穿越模式。
|
||||
*
|
||||
* 配置开关:serverguard.security.super-evolution.anti-vclip
|
||||
*/
|
||||
@Service
|
||||
public class AntiVClipService {
|
||||
|
||||
private final ServerGuardConfig config;
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的移动历史(playerName -> 移动记录列表)
|
||||
*/
|
||||
private final Map<String, List<VClipRecord>> playerMoveHistory = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的VClip事件列表(playerName -> 事件列表)
|
||||
*/
|
||||
private final Map<String, List<Map<String, Object>>> playerVClipEvents = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家的连续VClip次数(playerName -> 连续次数)
|
||||
*/
|
||||
private final Map<String, Integer> playerConsecutiveVClips = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家最近一次大Y位移的时间戳(playerName -> 时间戳)
|
||||
*/
|
||||
private final Map<String, Instant> playerLastLargeYChange = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家最近一次末影珍珠使用时间(playerName -> 时间戳)
|
||||
*/
|
||||
private final Map<String, Instant> playerLastEnderPearl = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 追踪每个玩家最近一次紫颂果使用时间(playerName -> 时间戳)
|
||||
*/
|
||||
private final Map<String, Instant> playerLastChorusFruit = new ConcurrentHashMap<>();
|
||||
|
||||
private final AtomicLong totalChecks = new AtomicLong(0);
|
||||
private final AtomicLong vclipViolations = new AtomicLong(0);
|
||||
|
||||
/**
|
||||
* VClip最小检测阈值(方块)— 单tick Y变化超过此值
|
||||
* 正常跳跃约1.25方块,加上台阶/楼梯等情况,3方块是合理的检测起点
|
||||
*/
|
||||
private static final double VCLIP_MIN_Y_CHANGE = 3.0;
|
||||
|
||||
/**
|
||||
* 严重VClip阈值(方块)— Y变化超过此值几乎肯定是VClip
|
||||
*/
|
||||
private static final double SEVERE_VCLIP_Y_CHANGE = 10.0;
|
||||
|
||||
/**
|
||||
* 紫颂果最大传送范围(方块)— 合法紫颂果传送在8方块以内
|
||||
*/
|
||||
private static final double CHORUS_FRUIT_MAX_RANGE = 8.0;
|
||||
|
||||
/**
|
||||
* 末影珍珠使用后的合法传送窗口(毫秒)
|
||||
* 末影珍珠落地后约1-2 tick产生传送
|
||||
*/
|
||||
private static final long ENDER_PEARL_WINDOW_MS = 1000;
|
||||
|
||||
/**
|
||||
* 紫颂果使用后的合法传送窗口(毫秒)
|
||||
*/
|
||||
private static final long CHORUS_FRUIT_WINDOW_MS = 500;
|
||||
|
||||
/**
|
||||
* 连续VClip事件间隔(毫秒)— 在此间隔内再次VClip视为连续事件
|
||||
*/
|
||||
private static final long CONSECUTIVE_VCLIP_WINDOW_MS = 2000;
|
||||
|
||||
/**
|
||||
* 连续VClip违规阈值 — 连续超过此次数标记为系统化VClip使用
|
||||
*/
|
||||
private static final int MAX_CONSECUTIVE_VCLIPS = 2;
|
||||
|
||||
/**
|
||||
* 每个玩家保留的最大记录数
|
||||
*/
|
||||
private static final int MAX_RECORDS_PER_PLAYER = 50;
|
||||
|
||||
/**
|
||||
* 无参构造函数 — 测试/默认配置使用
|
||||
*/
|
||||
public AntiVClipService() {
|
||||
this(new ServerGuardConfig());
|
||||
}
|
||||
|
||||
/**
|
||||
* Spring 注入构造函数
|
||||
* @param config 全局配置,提供模块开关控制
|
||||
*/
|
||||
public AntiVClipService(ServerGuardConfig config) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测玩家是否使用了垂直穿墙(VClip hack)
|
||||
*
|
||||
* @param playerName 玩家名称
|
||||
* @param playerUUID 玩家UUID
|
||||
* @param fromX 移动起始X坐标
|
||||
* @param fromY 移动起始Y坐标
|
||||
* @param fromZ 移动起始Z坐标
|
||||
* @param toX 移动结束X坐标
|
||||
* @param toY 移动结束Y坐标
|
||||
* @param toZ 移动结束Z坐标
|
||||
* @param blockBetweenSolid 起始和结束位置之间是否存在固体方块
|
||||
* 调用方需要通过服务端方块查询来确定
|
||||
* @param solidBlocksBetween 起始和结束之间的固体方块数量(-1表示未知)
|
||||
* @param isUsingEnderPearl 玩家是否刚使用了末影珍珠
|
||||
* @param isUsingChorusFruit 玩家是否刚使用了紫颂果
|
||||
* @param isTeleportCommand 是否为/tp命令触发的传送(检查权限日志)
|
||||
* @param isInVehicle 玩家是否在载具中
|
||||
* @param isInWater 玩家是否在水中(排除快速上浮)
|
||||
* @param timestamp 时间戳
|
||||
* @return 检测结果
|
||||
*/
|
||||
public VClipCheckResult detect(String playerName, String playerUUID,
|
||||
double fromX, double fromY, double fromZ,
|
||||
double toX, double toY, double toZ,
|
||||
boolean blockBetweenSolid,
|
||||
int solidBlocksBetween,
|
||||
boolean isUsingEnderPearl,
|
||||
boolean isUsingChorusFruit,
|
||||
boolean isTeleportCommand,
|
||||
boolean isInVehicle,
|
||||
boolean isInWater,
|
||||
Instant timestamp) {
|
||||
// 模块开关检查 — 关闭时跳过所有检测
|
||||
if (!config.getSecurity().getSuperEvolution().isAntiVClip()) {
|
||||
return VClipCheckResult.clean();
|
||||
}
|
||||
|
||||
totalChecks.incrementAndGet();
|
||||
|
||||
double dy = toY - fromY;
|
||||
double absDy = Math.abs(dy);
|
||||
double dx = toX - fromX;
|
||||
double dz = toZ - fromZ;
|
||||
double horizontalDist = Math.sqrt(dx * dx + dz * dz);
|
||||
|
||||
// 更新末影珍珠/紫颂果使用时间
|
||||
if (isUsingEnderPearl) {
|
||||
playerLastEnderPearl.put(playerName, timestamp);
|
||||
}
|
||||
if (isUsingChorusFruit) {
|
||||
playerLastChorusFruit.put(playerName, timestamp);
|
||||
}
|
||||
|
||||
List<VClipRecord> history = playerMoveHistory.computeIfAbsent(
|
||||
playerName, k -> new ArrayList<>());
|
||||
|
||||
VClipRecord record = new VClipRecord(timestamp, fromX, fromY, fromZ,
|
||||
toX, toY, toZ, dy, absDy, horizontalDist, blockBetweenSolid,
|
||||
solidBlocksBetween);
|
||||
history.add(record);
|
||||
while (history.size() > MAX_RECORDS_PER_PLAYER) {
|
||||
history.remove(0);
|
||||
}
|
||||
|
||||
// Y变化不够大 — 不可能是VClip
|
||||
if (absDy < VCLIP_MIN_Y_CHANGE) {
|
||||
// 检查是否值得重置连续VClip计数
|
||||
if (horizontalDist < 1.0 && absDy < 1.0) {
|
||||
// 正常微小移动,可重置计数
|
||||
Integer consecutive = playerConsecutiveVClips.get(playerName);
|
||||
if (consecutive != null && consecutive > 0) {
|
||||
long lastLargeChange = playerLastLargeYChange.getOrDefault(
|
||||
playerName, Instant.EPOCH).toEpochMilli();
|
||||
if (timestamp.toEpochMilli() - lastLargeChange > CONSECUTIVE_VCLIP_WINDOW_MS) {
|
||||
playerConsecutiveVClips.remove(playerName);
|
||||
}
|
||||
}
|
||||
}
|
||||
return VClipCheckResult.clean();
|
||||
}
|
||||
|
||||
// 记录大Y位移时间
|
||||
playerLastLargeYChange.put(playerName, timestamp);
|
||||
|
||||
List<String> reasons = new ArrayList<>();
|
||||
|
||||
// ─── 合法传送方式的排除检查 ───
|
||||
boolean legitimateVerticalMove = false;
|
||||
|
||||
// 末影珍珠检查
|
||||
Instant lastPearl = playerLastEnderPearl.get(playerName);
|
||||
if (lastPearl != null) {
|
||||
long msSincePearl = timestamp.toEpochMilli() - lastPearl.toEpochMilli();
|
||||
if (msSincePearl <= ENDER_PEARL_WINDOW_MS) {
|
||||
// 末影珍珠传送距离可以很大,且是合法的
|
||||
if (absDy <= 300) { // Minecraft世界中末影珍珠最大射程
|
||||
legitimateVerticalMove = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 紫颂果检查
|
||||
Instant lastChorus = playerLastChorusFruit.get(playerName);
|
||||
if (lastChorus != null) {
|
||||
long msSinceChorus = timestamp.toEpochMilli() - lastChorus.toEpochMilli();
|
||||
if (msSinceChorus <= CHORUS_FRUIT_WINDOW_MS) {
|
||||
// 紫颂果传送范围在8方块以内
|
||||
if (absDy <= CHORUS_FRUIT_MAX_RANGE) {
|
||||
legitimateVerticalMove = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// /tp命令检查
|
||||
if (isTeleportCommand) {
|
||||
legitimateVerticalMove = true;
|
||||
}
|
||||
|
||||
// 载具中的垂直移动(如船掉落/气泡柱上浮)
|
||||
if (isInVehicle) {
|
||||
// 载具移动是服务端控制的,玩家无法直接操控VClip
|
||||
legitimateVerticalMove = true;
|
||||
}
|
||||
|
||||
// 水中上浮 — 灵魂沙气泡柱可快速上浮
|
||||
if (isInWater && dy > 0 && absDy < 20) {
|
||||
// 气泡柱上浮速度约11方块/秒,单tick约0.55方块
|
||||
// 但多个tick累积可能一次性处理,保守处理
|
||||
if (absDy < 15) {
|
||||
legitimateVerticalMove = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 核心VClip检测 ───
|
||||
|
||||
// 检测1:固体方块穿越
|
||||
if (blockBetweenSolid && !legitimateVerticalMove) {
|
||||
reasons.add("SOLID_BLOCK_PENETRATION: moved " + String.format("%.1f", absDy)
|
||||
+ " blocks vertically (from Y=" + String.format("%.1f", fromY)
|
||||
+ " to Y=" + String.format("%.1f", toY) + ") through "
|
||||
+ (solidBlocksBetween >= 0 ? solidBlocksBetween + " solid blocks" : "solid blocks"));
|
||||
vclipViolations.incrementAndGet();
|
||||
}
|
||||
|
||||
// 检测2:极端垂直位移 — 超过严重阈值且无合法理由
|
||||
if (absDy >= SEVERE_VCLIP_Y_CHANGE && !legitimateVerticalMove) {
|
||||
if (!blockBetweenSolid) {
|
||||
// 即使中间没有固体方块(可能是空气柱),如此大的单tick位移也不正常
|
||||
// 需要检查水平位移是否也很小(纯垂直穿越)
|
||||
if (horizontalDist < 2.0) {
|
||||
reasons.add("EXTREME_VERTICAL_SHIFT: " + String.format("%.1f", absDy)
|
||||
+ " blocks pure vertical movement in one tick"
|
||||
+ " (horizontal: " + String.format("%.2f", horizontalDist) + ")");
|
||||
vclipViolations.incrementAndGet();
|
||||
} else if (horizontalDist < absDy * 0.5) {
|
||||
// 斜向但以垂直为主,也高度可疑
|
||||
reasons.add("DIAGONAL_VCLIP: " + String.format("%.1f", absDy)
|
||||
+ " blocks vertical with only " + String.format("%.1f", horizontalDist)
|
||||
+ " blocks horizontal displacement");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检测3:无合法理由的大Y位移 + 无中间过渡
|
||||
if (!legitimateVerticalMove && absDy >= VCLIP_MIN_Y_CHANGE) {
|
||||
// 检查是否水平位移极小(纯垂直穿越的典型特征)
|
||||
if (horizontalDist < 0.5 && absDy >= 5.0) {
|
||||
reasons.add("PURE_VERTICAL_PHASE: " + String.format("%.1f", absDy)
|
||||
+ " blocks vertical with negligible horizontal movement");
|
||||
vclipViolations.incrementAndGet();
|
||||
}
|
||||
|
||||
// 检查位移大小与合法方式不匹配
|
||||
if (absDy > CHORUS_FRUIT_MAX_RANGE && !legitimateVerticalMove && !blockBetweenSolid) {
|
||||
// 超过紫颂果范围且无固体方块穿透证据 — 添加可疑标记
|
||||
reasons.add("UNEXPLAINED_LARGE_Y_CHANGE: " + String.format("%.1f", absDy)
|
||||
+ " blocks Y change with no legitimate source identified");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 检测4:连续VClip模式 ───
|
||||
if (!legitimateVerticalMove && absDy >= VCLIP_MIN_Y_CHANGE) {
|
||||
int consecutiveVClips = playerConsecutiveVClips.getOrDefault(playerName, 0) + 1;
|
||||
playerConsecutiveVClips.put(playerName, consecutiveVClips);
|
||||
|
||||
// 记录事件
|
||||
List<Map<String, Object>> events = playerVClipEvents.computeIfAbsent(
|
||||
playerName, k -> new ArrayList<>());
|
||||
Map<String, Object> event = new LinkedHashMap<>();
|
||||
event.put("time", timestamp.toString());
|
||||
event.put("dy", String.format("%.1f", absDy));
|
||||
event.put("fromY", String.format("%.2f", fromY));
|
||||
event.put("toY", String.format("%.2f", toY));
|
||||
event.put("solidBlocks", solidBlocksBetween);
|
||||
events.add(event);
|
||||
while (events.size() > 20) {
|
||||
events.remove(0);
|
||||
}
|
||||
|
||||
if (consecutiveVClips >= MAX_CONSECUTIVE_VCLIPS) {
|
||||
reasons.add("CONSECUTIVE_VCLIPS: " + consecutiveVClips
|
||||
+ " consecutive large Y movements detected");
|
||||
vclipViolations.incrementAndGet();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 检测5:历史模式分析 ───
|
||||
if (history.size() >= 10) {
|
||||
long largeYChanges = history.stream()
|
||||
.filter(r -> r.absDy >= VCLIP_MIN_Y_CHANGE)
|
||||
.count();
|
||||
if (largeYChanges >= 4 && history.size() <= 30) {
|
||||
reasons.add("FREQUENT_LARGE_Y_CHANGES: " + largeYChanges
|
||||
+ " large Y changes in last " + history.size()
|
||||
+ " moves (possible VClip pattern)");
|
||||
}
|
||||
}
|
||||
|
||||
if (reasons.size() >= 2) {
|
||||
return VClipCheckResult.flagged(reasons);
|
||||
} else if (reasons.size() == 1) {
|
||||
return VClipCheckResult.suspicious(reasons);
|
||||
}
|
||||
|
||||
return VClipCheckResult.clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* 玩家离线时清理追踪数据
|
||||
* @param playerName 离线玩家名称
|
||||
*/
|
||||
public void clearPlayer(String playerName) {
|
||||
playerMoveHistory.remove(playerName);
|
||||
playerVClipEvents.remove(playerName);
|
||||
playerConsecutiveVClips.remove(playerName);
|
||||
playerLastLargeYChange.remove(playerName);
|
||||
playerLastEnderPearl.remove(playerName);
|
||||
playerLastChorusFruit.remove(playerName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取模块运行状态
|
||||
* @return 包含计数器和违规数量的Map
|
||||
*/
|
||||
public Map<String, Object> getStatus() {
|
||||
Map<String, Object> status = new LinkedHashMap<>();
|
||||
status.put("totalChecks", totalChecks.get());
|
||||
status.put("vclipViolations", vclipViolations.get());
|
||||
status.put("activeTrackedPlayers", playerMoveHistory.size());
|
||||
|
||||
// 列出VClip事件较多的玩家
|
||||
List<Map<String, Object>> vclipSuspects = new ArrayList<>();
|
||||
for (Map.Entry<String, List<Map<String, Object>>> entry : playerVClipEvents.entrySet()) {
|
||||
int eventCount = entry.getValue().size();
|
||||
if (eventCount >= 1) {
|
||||
Map<String, Object> info = new LinkedHashMap<>();
|
||||
info.put("player", entry.getKey());
|
||||
info.put("vclipEvents", eventCount);
|
||||
info.put("consecutiveVClips",
|
||||
playerConsecutiveVClips.getOrDefault(entry.getKey(), 0));
|
||||
vclipSuspects.add(info);
|
||||
}
|
||||
}
|
||||
vclipSuspects.sort((a, b) ->
|
||||
Integer.compare((Integer) b.get("vclipEvents"), (Integer) a.get("vclipEvents")));
|
||||
status.put("vclipSuspects", vclipSuspects);
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部VClip移动记录 — 记录玩家单次移动的完整数据
|
||||
*/
|
||||
private static class VClipRecord {
|
||||
final Instant timestamp;
|
||||
final double fromX, fromY, fromZ;
|
||||
final double toX, toY, toZ;
|
||||
final double dy;
|
||||
final double absDy;
|
||||
final double horizontalDist;
|
||||
final boolean blockBetweenSolid;
|
||||
final int solidBlocksBetween;
|
||||
|
||||
VClipRecord(Instant timestamp, double fromX, double fromY, double fromZ,
|
||||
double toX, double toY, double toZ, double dy, double absDy,
|
||||
double horizontalDist, boolean blockBetweenSolid,
|
||||
int solidBlocksBetween) {
|
||||
this.timestamp = timestamp;
|
||||
this.fromX = fromX; this.fromY = fromY; this.fromZ = fromZ;
|
||||
this.toX = toX; this.toY = toY; this.toZ = toZ;
|
||||
this.dy = dy;
|
||||
this.absDy = absDy;
|
||||
this.horizontalDist = horizontalDist;
|
||||
this.blockBetweenSolid = blockBetweenSolid;
|
||||
this.solidBlocksBetween = solidBlocksBetween;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* VClip检测结果 — 不可变结果类
|
||||
*/
|
||||
public static class VClipCheckResult {
|
||||
private final boolean flagged;
|
||||
private final boolean suspicious;
|
||||
private final List<String> reasons;
|
||||
|
||||
private VClipCheckResult(boolean flagged, boolean suspicious, List<String> reasons) {
|
||||
this.flagged = flagged;
|
||||
this.suspicious = suspicious;
|
||||
this.reasons = reasons;
|
||||
}
|
||||
|
||||
/** 无异常 — 正常的垂直移动或合法的传送方式 */
|
||||
public static VClipCheckResult clean() {
|
||||
return new VClipCheckResult(false, false, List.of());
|
||||
}
|
||||
|
||||
/** 可疑 — Y位移偏大但有合法可能或证据不充分 */
|
||||
public static VClipCheckResult suspicious(List<String> reasons) {
|
||||
return new VClipCheckResult(false, true, reasons);
|
||||
}
|
||||
|
||||
/** 已标记 — 确认VClip hack,穿越固体方块或持续性垂直相位 */
|
||||
public static VClipCheckResult flagged(List<String> reasons) {
|
||||
return new VClipCheckResult(true, false, reasons);
|
||||
}
|
||||
|
||||
public boolean isFlagged() { return flagged; }
|
||||
public boolean isSuspicious() { return suspicious; }
|
||||
public boolean isClean() { return !flagged && !suspicious; }
|
||||
public List<String> getReasons() { return reasons; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user