安全模块大规模重构: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:
Peijun Zhao
2026-05-22 19:29:45 +08:00
parent 5adbbd9592
commit 055de65e88
181 changed files with 682 additions and 303 deletions
@@ -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; }
}
}