Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
867b8bbaed | ||
|
|
e15ff6549f |
No files matched your search
@@ -2,7 +2,7 @@
|
||||
|
||||
Passive, explainable movement-pattern observation for Purpur servers.
|
||||
|
||||
Sentinel gives permitted operators bounded evidence of repeated suspicious on-foot horizontal movement. It does not cancel movement, punish players, persist player evidence, or export telemetry.
|
||||
Sentinel gives permitted operators bounded evidence of repeated suspicious on-foot horizontal movement and sustained unauthorized unsupported flight. It does not cancel movement, change flight permissions, punish players, persist player evidence, or export telemetry.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -2,5 +2,22 @@ package games.dmg.purpursentinel;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record AlertDecision(UUID playerId, int breachCount, double peakSpeed, double capturedLimit) {
|
||||
public record AlertDecision(
|
||||
UUID playerId,
|
||||
DetectionSignal signal,
|
||||
int breachCount,
|
||||
double peakSpeed,
|
||||
double capturedLimit,
|
||||
int eligibleTicks,
|
||||
double horizontalDistance,
|
||||
double verticalDisplacement) {
|
||||
|
||||
public AlertDecision(UUID playerId, int breachCount, double peakSpeed, double capturedLimit) {
|
||||
this(playerId, DetectionSignal.ABSOLUTE_GROUND_SPEED, breachCount, peakSpeed, capturedLimit, 0, 0, 0);
|
||||
}
|
||||
|
||||
public static AlertDecision flight(UUID playerId, FlightEvidence evidence) {
|
||||
return new AlertDecision(playerId, evidence.signal(), 0, 0, 0, evidence.eligibleTicks(),
|
||||
evidence.horizontalDistance(), evidence.verticalDisplacement());
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,10 @@ public final class AtomicYamlSettingsStore implements SettingsPersistence {
|
||||
alert-cooldown-seconds: 60
|
||||
minimum-tps: 18.0
|
||||
exemption-grace-ticks: 20
|
||||
unsupported-flight-ticks: 40
|
||||
unsupported-hover-ticks: 30
|
||||
unsupported-ascent-blocks: 2.0
|
||||
unsupported-cruise-blocks: 10.0
|
||||
""", StandardOpenOption.CREATE_NEW);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package games.dmg.purpursentinel;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.BiPredicate;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.LongSupplier;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.GameMode;
|
||||
@@ -28,19 +32,29 @@ public final class BukkitMovementListener implements Listener {
|
||||
private final Supplier<SentinelSettings> settings;
|
||||
private final LongSupplier nanoTime;
|
||||
private final BiPredicate<Location, Location> ordinaryEnvironment;
|
||||
private final Function<Location, SupportState> supportState;
|
||||
private final Map<UUID, Location> lastFlightSamples = new HashMap<>();
|
||||
|
||||
public BukkitMovementListener(Server server, MovementMonitor monitor, Supplier<SentinelSettings> settings, LongSupplier nanoTime) {
|
||||
this(server, monitor, settings, nanoTime,
|
||||
(from, to) -> MovementEnvironment.isOrdinary(from) && MovementEnvironment.isOrdinary(to));
|
||||
(from, to) -> MovementEnvironment.isOrdinary(from) && MovementEnvironment.isOrdinary(to),
|
||||
MovementEnvironment::supportState);
|
||||
}
|
||||
|
||||
BukkitMovementListener(Server server, MovementMonitor monitor, Supplier<SentinelSettings> settings,
|
||||
LongSupplier nanoTime, BiPredicate<Location, Location> ordinaryEnvironment) {
|
||||
this(server, monitor, settings, nanoTime, ordinaryEnvironment, MovementEnvironment::supportState);
|
||||
}
|
||||
|
||||
BukkitMovementListener(Server server, MovementMonitor monitor, Supplier<SentinelSettings> settings,
|
||||
LongSupplier nanoTime, BiPredicate<Location, Location> ordinaryEnvironment,
|
||||
Function<Location, SupportState> supportState) {
|
||||
this.server = server;
|
||||
this.monitor = monitor;
|
||||
this.settings = settings;
|
||||
this.nanoTime = nanoTime;
|
||||
this.ordinaryEnvironment = ordinaryEnvironment;
|
||||
this.supportState = supportState;
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
|
||||
@@ -84,9 +98,36 @@ public final class BukkitMovementListener implements Listener {
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR)
|
||||
public void onQuit(PlayerQuitEvent event) { monitor.forget(event.getPlayer().getUniqueId()); }
|
||||
public void onQuit(PlayerQuitEvent event) {
|
||||
lastFlightSamples.remove(event.getPlayer().getUniqueId());
|
||||
monitor.forget(event.getPlayer().getUniqueId());
|
||||
}
|
||||
|
||||
public void sampleFlight() {
|
||||
int tick = server.getCurrentTick();
|
||||
long observedAtNanos = nanoTime.getAsLong();
|
||||
for (Player player : server.getOnlinePlayers()) {
|
||||
Location current = player.getLocation();
|
||||
if (current == null || current.getWorld() == null) continue;
|
||||
Location previous = lastFlightSamples.put(player.getUniqueId(), current.clone());
|
||||
if (previous == null || previous.getWorld() == null || !previous.getWorld().equals(current.getWorld())) {
|
||||
previous = current;
|
||||
}
|
||||
ExclusionReason reason = classify(player, previous, current, false);
|
||||
monitor.record(player.getUniqueId(), new FlightFrame(
|
||||
tick, observedAtNanos, current.getWorld().getUID(),
|
||||
previous.getX(), previous.getY(), previous.getZ(),
|
||||
current.getX(), current.getY(), current.getZ(),
|
||||
authorizedFlight(player), player.isFlying(), supportState.apply(current), reason), settings.get());
|
||||
}
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
lastFlightSamples.clear();
|
||||
}
|
||||
|
||||
private void exempt(Player player, ExclusionReason reason) {
|
||||
lastFlightSamples.remove(player.getUniqueId());
|
||||
monitor.exempt(player.getUniqueId(), server.getCurrentTick(), nanoTime.getAsLong(), reason, settings.get());
|
||||
}
|
||||
|
||||
@@ -97,20 +138,27 @@ public final class BukkitMovementListener implements Listener {
|
||||
if (server.getTPS()[0] < settings.get().minimumTps()) return ExclusionReason.LOW_TPS;
|
||||
if (player.getGameMode() != GameMode.SURVIVAL && player.getGameMode() != GameMode.ADVENTURE) return ExclusionReason.GAME_MODE;
|
||||
if (player.isInsideVehicle()) return ExclusionReason.VEHICLE;
|
||||
if (player.isFlying()) return ExclusionReason.FLYING;
|
||||
if (player.getAllowFlight()) return ExclusionReason.FLYING;
|
||||
if (player.isGliding() || player.getPose() == Pose.FALL_FLYING) return ExclusionReason.GLIDING;
|
||||
if (player.isSwimming() || player.getPose() == Pose.SWIMMING || player.isInWater()
|
||||
|| player.isInLava() || player.isInBubbleColumn()) return ExclusionReason.SWIMMING;
|
||||
if (player.isClimbing()) return ExclusionReason.CLIMBING;
|
||||
if (player.isRiptiding() || player.getPose() == Pose.SPIN_ATTACK) return ExclusionReason.RIPTIDE;
|
||||
if (player.getActivePotionEffects().stream().map(effect -> effect.getType().getKey().getKey())
|
||||
.anyMatch(key -> key.equals("speed") || key.equals("slowness"))) {
|
||||
.anyMatch(key -> key.equals("speed") || key.equals("slowness")
|
||||
|| key.equals("jump_boost") || key.equals("levitation") || key.equals("slow_falling"))) {
|
||||
return ExclusionReason.POTION_EFFECT;
|
||||
}
|
||||
if (!ordinaryEnvironment.test(from, to)) return ExclusionReason.ENVIRONMENT;
|
||||
return ExclusionReason.NONE;
|
||||
}
|
||||
|
||||
private static boolean authorizedFlight(Player player) {
|
||||
return player.getGameMode() == GameMode.CREATIVE
|
||||
|| player.getGameMode() == GameMode.SPECTATOR
|
||||
|| player.getAllowFlight();
|
||||
}
|
||||
|
||||
private static boolean finite(double... values) {
|
||||
for (double value : values) if (!Double.isFinite(value)) return false;
|
||||
return true;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package games.dmg.purpursentinel;
|
||||
|
||||
public enum DetectionSignal {
|
||||
ABSOLUTE_GROUND_SPEED,
|
||||
UNAUTHORIZED_FLIGHT_STATE,
|
||||
UNSUPPORTED_ASCENT,
|
||||
UNSUPPORTED_HOVER,
|
||||
UNSUPPORTED_CRUISE
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package games.dmg.purpursentinel;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
final class FlightDetector {
|
||||
private final Map<UUID, State> players = new HashMap<>();
|
||||
|
||||
Optional<AlertDecision> observe(UUID playerId, FlightFrame frame, SentinelSettings settings) {
|
||||
var state = players.computeIfAbsent(playerId, ignored -> new State());
|
||||
state.expire(frame.observedAtNanos());
|
||||
if (ineligible(frame)) {
|
||||
state.resetSequence();
|
||||
state.authorized = frame.authorized();
|
||||
state.supported = frame.supportState() == SupportState.SUPPORTED;
|
||||
state.currentExclusion = frame.exclusionReason() != ExclusionReason.NONE
|
||||
? frame.exclusionReason() : frame.authorized() ? ExclusionReason.FLYING : ExclusionReason.ENVIRONMENT;
|
||||
state.graceThroughTick = Math.max(state.graceThroughTick,
|
||||
frame.tick() + settings.exemptionGraceTicks());
|
||||
return Optional.empty();
|
||||
}
|
||||
if (frame.tick() <= state.graceThroughTick) return Optional.empty();
|
||||
if (!state.connected(frame)) state.startSequence(frame);
|
||||
|
||||
state.lastTick = frame.tick();
|
||||
state.authorized = false;
|
||||
state.supported = false;
|
||||
state.currentExclusion = ExclusionReason.NONE;
|
||||
state.eligibleTicks++;
|
||||
double horizontal = Math.hypot(frame.toX() - frame.fromX(), frame.toZ() - frame.fromZ());
|
||||
double vertical = frame.toY() - frame.fromY();
|
||||
state.horizontalDistance += horizontal;
|
||||
state.verticalDisplacement = frame.toY() - state.startY;
|
||||
if (frame.flying()) state.unauthorizedFlightTicks++;
|
||||
if (vertical > 0.01) state.ascentTicks++;
|
||||
if (Math.abs(vertical) <= 0.01 && horizontal <= 0.03) state.hoverTicks++;
|
||||
if (Math.abs(vertical) <= 0.01 && horizontal > 0.03) state.cruiseTicks++;
|
||||
state.signal = signal(state, frame, settings);
|
||||
if (!thresholdReached(state, settings)) return Optional.empty();
|
||||
state.evidenceExpiresAtNanos = frame.observedAtNanos() + seconds(settings.evidenceWindowSeconds());
|
||||
state.lastEvidence = state.evidence(frame.observedAtNanos());
|
||||
state.evidenceFirstObservedAtNanos = state.firstObservedAtNanos;
|
||||
if (frame.observedAtNanos() < state.cooldownUntilNanos) return Optional.empty();
|
||||
state.cooldownUntilNanos = frame.observedAtNanos() + seconds(settings.alertCooldownSeconds());
|
||||
state.lastEvidence = state.evidence(frame.observedAtNanos());
|
||||
return Optional.of(AlertDecision.flight(playerId, state.lastEvidence));
|
||||
}
|
||||
|
||||
void exempt(UUID playerId, int currentTick, long nowNanos, ExclusionReason reason,
|
||||
SentinelSettings settings) {
|
||||
var state = players.computeIfAbsent(playerId, ignored -> new State());
|
||||
state.expire(nowNanos);
|
||||
state.resetSequence();
|
||||
state.currentExclusion = reason;
|
||||
state.graceThroughTick = Math.max(state.graceThroughTick,
|
||||
currentTick + settings.exemptionGraceTicks());
|
||||
}
|
||||
|
||||
Optional<FlightEvidence> inspect(UUID playerId, long nowNanos) {
|
||||
var state = players.get(playerId);
|
||||
if (state == null) return Optional.empty();
|
||||
state.expire(nowNanos);
|
||||
if (state.lastEvidence == null) return Optional.empty();
|
||||
return Optional.of(state.evidence(nowNanos));
|
||||
}
|
||||
|
||||
void cleanup(int currentTick, long nowNanos) {
|
||||
players.entrySet().removeIf(entry -> {
|
||||
var state = entry.getValue();
|
||||
state.expire(nowNanos);
|
||||
if (state.world != null && currentTick > state.lastTick + 1) state.resetSequence();
|
||||
return state.world == null && state.lastEvidence == null
|
||||
&& state.cooldownUntilNanos <= nowNanos && currentTick > state.graceThroughTick;
|
||||
});
|
||||
}
|
||||
|
||||
void forget(UUID playerId) {
|
||||
players.remove(playerId);
|
||||
}
|
||||
|
||||
void clear() {
|
||||
players.clear();
|
||||
}
|
||||
|
||||
private static boolean ineligible(FlightFrame frame) {
|
||||
return frame.authorized() || frame.supportState() != SupportState.UNSUPPORTED
|
||||
|| frame.exclusionReason() != ExclusionReason.NONE
|
||||
|| !finite(frame.fromX(), frame.fromY(), frame.fromZ(), frame.toX(), frame.toY(), frame.toZ());
|
||||
}
|
||||
|
||||
private static DetectionSignal signal(State state, FlightFrame frame, SentinelSettings settings) {
|
||||
if (frame.flying()) return DetectionSignal.UNAUTHORIZED_FLIGHT_STATE;
|
||||
if (state.verticalDisplacement >= settings.unsupportedAscentBlocks()) {
|
||||
return DetectionSignal.UNSUPPORTED_ASCENT;
|
||||
}
|
||||
if (state.hoverTicks >= settings.unsupportedHoverTicks()) return DetectionSignal.UNSUPPORTED_HOVER;
|
||||
return DetectionSignal.UNSUPPORTED_CRUISE;
|
||||
}
|
||||
|
||||
private static boolean thresholdReached(State state, SentinelSettings settings) {
|
||||
return switch (state.signal) {
|
||||
case UNAUTHORIZED_FLIGHT_STATE -> state.unauthorizedFlightTicks >= settings.unsupportedFlightTicks();
|
||||
case UNSUPPORTED_ASCENT -> state.ascentTicks >= 10;
|
||||
case UNSUPPORTED_HOVER -> state.hoverTicks >= settings.unsupportedHoverTicks();
|
||||
case UNSUPPORTED_CRUISE -> state.cruiseTicks >= settings.unsupportedFlightTicks()
|
||||
&& state.horizontalDistance >= settings.unsupportedCruiseBlocks();
|
||||
case ABSOLUTE_GROUND_SPEED -> false;
|
||||
};
|
||||
}
|
||||
|
||||
private static long seconds(int value) {
|
||||
return value * 1_000_000_000L;
|
||||
}
|
||||
|
||||
private static boolean finite(double... values) {
|
||||
for (double value : values) {
|
||||
if (!Double.isFinite(value)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static final class State {
|
||||
private UUID world;
|
||||
private int lastTick = Integer.MIN_VALUE;
|
||||
private long firstObservedAtNanos;
|
||||
private long evidenceExpiresAtNanos;
|
||||
private long cooldownUntilNanos;
|
||||
private double startY;
|
||||
private int eligibleTicks;
|
||||
private int unauthorizedFlightTicks;
|
||||
private int ascentTicks;
|
||||
private int hoverTicks;
|
||||
private int cruiseTicks;
|
||||
private int graceThroughTick = Integer.MIN_VALUE;
|
||||
private double horizontalDistance;
|
||||
private double verticalDisplacement;
|
||||
private DetectionSignal signal = DetectionSignal.UNSUPPORTED_CRUISE;
|
||||
private boolean authorized;
|
||||
private boolean supported;
|
||||
private ExclusionReason currentExclusion = ExclusionReason.NONE;
|
||||
private FlightEvidence lastEvidence;
|
||||
private long evidenceFirstObservedAtNanos;
|
||||
|
||||
private boolean connected(FlightFrame frame) {
|
||||
return world != null && world.equals(frame.world()) && lastTick + 1 == frame.tick();
|
||||
}
|
||||
|
||||
private void startSequence(FlightFrame frame) {
|
||||
resetSequence();
|
||||
world = frame.world();
|
||||
lastTick = frame.tick() - 1;
|
||||
firstObservedAtNanos = frame.observedAtNanos();
|
||||
startY = frame.fromY();
|
||||
}
|
||||
|
||||
private void resetSequence() {
|
||||
world = null;
|
||||
lastTick = Integer.MIN_VALUE;
|
||||
firstObservedAtNanos = 0;
|
||||
startY = 0;
|
||||
eligibleTicks = 0;
|
||||
unauthorizedFlightTicks = 0;
|
||||
ascentTicks = 0;
|
||||
hoverTicks = 0;
|
||||
cruiseTicks = 0;
|
||||
horizontalDistance = 0;
|
||||
verticalDisplacement = 0;
|
||||
signal = DetectionSignal.UNSUPPORTED_CRUISE;
|
||||
}
|
||||
|
||||
private void expire(long nowNanos) {
|
||||
if (lastEvidence != null && evidenceExpiresAtNanos <= nowNanos) lastEvidence = null;
|
||||
}
|
||||
|
||||
private FlightEvidence evidence(long nowNanos) {
|
||||
FlightEvidence source = lastEvidence;
|
||||
int ticks = eligibleTicks;
|
||||
int flyingTicks = unauthorizedFlightTicks;
|
||||
int climbingTicks = ascentTicks;
|
||||
int levelTicks = hoverTicks;
|
||||
int travelTicks = cruiseTicks;
|
||||
double horizontal = horizontalDistance;
|
||||
double vertical = verticalDisplacement;
|
||||
DetectionSignal currentSignal = signal;
|
||||
long first = firstObservedAtNanos;
|
||||
if (eligibleTicks == 0 && source != null) {
|
||||
ticks = source.eligibleTicks();
|
||||
flyingTicks = source.unauthorizedFlightTicks();
|
||||
climbingTicks = source.ascentTicks();
|
||||
levelTicks = source.hoverTicks();
|
||||
travelTicks = source.cruiseTicks();
|
||||
horizontal = source.horizontalDistance();
|
||||
vertical = source.verticalDisplacement();
|
||||
currentSignal = source.signal();
|
||||
first = evidenceFirstObservedAtNanos;
|
||||
}
|
||||
return new FlightEvidence(currentSignal, authorized, supported, ticks,
|
||||
flyingTicks, climbingTicks, levelTicks, travelTicks, horizontal, vertical,
|
||||
Math.max(0, nowNanos - first) / 1_000_000_000.0,
|
||||
Math.max(0, cooldownUntilNanos - nowNanos) / 1_000_000_000.0,
|
||||
currentExclusion);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package games.dmg.purpursentinel;
|
||||
|
||||
public record FlightEvidence(
|
||||
DetectionSignal signal,
|
||||
boolean authorized,
|
||||
boolean supported,
|
||||
int eligibleTicks,
|
||||
int unauthorizedFlightTicks,
|
||||
int ascentTicks,
|
||||
int hoverTicks,
|
||||
int cruiseTicks,
|
||||
double horizontalDistance,
|
||||
double verticalDisplacement,
|
||||
double evidenceAgeSeconds,
|
||||
double cooldownRemainingSeconds,
|
||||
ExclusionReason currentExclusion) {
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package games.dmg.purpursentinel;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record FlightFrame(
|
||||
int tick,
|
||||
long observedAtNanos,
|
||||
UUID world,
|
||||
double fromX,
|
||||
double fromY,
|
||||
double fromZ,
|
||||
double toX,
|
||||
double toY,
|
||||
double toZ,
|
||||
boolean authorized,
|
||||
boolean flying,
|
||||
SupportState supportState,
|
||||
ExclusionReason exclusionReason) {
|
||||
|
||||
public static FlightFrame unsupported(int tick, long observedAtNanos, UUID world,
|
||||
double fromX, double fromY, double fromZ, double toX, double toY, double toZ,
|
||||
boolean authorized, boolean flying) {
|
||||
return new FlightFrame(tick, observedAtNanos, world,
|
||||
fromX, fromY, fromZ, toX, toY, toZ,
|
||||
authorized, flying, SupportState.UNSUPPORTED, ExclusionReason.NONE);
|
||||
}
|
||||
}
|
||||
@@ -6,5 +6,12 @@ public record InspectionSnapshot(
|
||||
double peakSpeed,
|
||||
double capturedLimit,
|
||||
ExclusionReason currentExclusion,
|
||||
double cooldownRemainingSeconds) {
|
||||
double cooldownRemainingSeconds,
|
||||
FlightEvidence flightEvidence) {
|
||||
|
||||
public InspectionSnapshot(int breachCount, double evidenceAgeSeconds, double peakSpeed, double capturedLimit,
|
||||
ExclusionReason currentExclusion, double cooldownRemainingSeconds) {
|
||||
this(breachCount, evidenceAgeSeconds, peakSpeed, capturedLimit,
|
||||
currentExclusion, cooldownRemainingSeconds, null);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,27 @@ final class MovementEnvironment {
|
||||
private MovementEnvironment() {
|
||||
}
|
||||
|
||||
static SupportState supportState(Location location) {
|
||||
var world = location.getWorld();
|
||||
if (world == null || !world.isChunkLoaded(location.getBlockX() >> 4, location.getBlockZ() >> 4)) {
|
||||
return SupportState.UNKNOWN;
|
||||
}
|
||||
double minX = location.getX() - 0.3;
|
||||
double maxX = location.getX() + 0.3;
|
||||
double minZ = location.getZ() - 0.3;
|
||||
double maxZ = location.getZ() + 0.3;
|
||||
int y = (int) Math.floor(location.getY() - 0.01);
|
||||
for (int x = (int) Math.floor(minX); x <= (int) Math.floor(maxX); x++) {
|
||||
for (int z = (int) Math.floor(minZ); z <= (int) Math.floor(maxZ); z++) {
|
||||
var block = world.getBlockAt(x, y, z);
|
||||
if (block.getType() != Material.AIR && !block.isLiquid() && !block.isPassable()) {
|
||||
return SupportState.SUPPORTED;
|
||||
}
|
||||
}
|
||||
}
|
||||
return SupportState.UNSUPPORTED;
|
||||
}
|
||||
|
||||
static boolean isOrdinary(Location location) {
|
||||
var world = location.getWorld();
|
||||
if (world == null || !world.isChunkLoaded(location.getBlockX() >> 4, location.getBlockZ() >> 4)) {
|
||||
|
||||
@@ -10,12 +10,18 @@ import java.util.UUID;
|
||||
public final class MovementMonitor {
|
||||
private static final double CHAIN_TOLERANCE = 1.0e-6;
|
||||
private final MovementDetector detector;
|
||||
private final FlightDetector flightDetector = new FlightDetector();
|
||||
private final Map<UUID, TickBucket> pending = new HashMap<>();
|
||||
private final List<AlertDecision> flightAlerts = new ArrayList<>();
|
||||
|
||||
public MovementMonitor(MovementDetector detector) {
|
||||
this.detector = detector;
|
||||
}
|
||||
|
||||
public void record(UUID playerId, FlightFrame frame, SentinelSettings settings) {
|
||||
flightDetector.observe(playerId, frame, settings).ifPresent(flightAlerts::add);
|
||||
}
|
||||
|
||||
public void record(UUID playerId, MovementFrame frame, SentinelSettings settings) {
|
||||
var existing = pending.get(playerId);
|
||||
if (existing != null && existing.tick != frame.tick()) {
|
||||
@@ -31,7 +37,8 @@ public final class MovementMonitor {
|
||||
}
|
||||
|
||||
public List<AlertDecision> finishBefore(int currentTick, long nowNanos) {
|
||||
var alerts = new ArrayList<AlertDecision>();
|
||||
var alerts = new ArrayList<AlertDecision>(flightAlerts);
|
||||
flightAlerts.clear();
|
||||
var iterator = pending.entrySet().iterator();
|
||||
while (iterator.hasNext()) {
|
||||
var entry = iterator.next();
|
||||
@@ -41,26 +48,41 @@ public final class MovementMonitor {
|
||||
}
|
||||
}
|
||||
detector.cleanup(currentTick, nowNanos);
|
||||
flightDetector.cleanup(currentTick, nowNanos);
|
||||
return List.copyOf(alerts);
|
||||
}
|
||||
|
||||
public void exempt(UUID playerId, int tick, long nowNanos, ExclusionReason reason, SentinelSettings settings) {
|
||||
pending.remove(playerId);
|
||||
detector.exempt(playerId, tick, nowNanos, reason, settings);
|
||||
flightDetector.exempt(playerId, tick, nowNanos, reason, settings);
|
||||
}
|
||||
|
||||
public Optional<InspectionSnapshot> inspect(UUID playerId, long nowNanos) {
|
||||
return detector.inspect(playerId, nowNanos);
|
||||
var movement = detector.inspect(playerId, nowNanos);
|
||||
var flight = flightDetector.inspect(playerId, nowNanos);
|
||||
if (flight.isEmpty()) return movement;
|
||||
if (movement.isEmpty()) {
|
||||
return Optional.of(new InspectionSnapshot(0, 0, 0, 0,
|
||||
ExclusionReason.NONE, 0, flight.get()));
|
||||
}
|
||||
var evidence = movement.get();
|
||||
return Optional.of(new InspectionSnapshot(evidence.breachCount(), evidence.evidenceAgeSeconds(),
|
||||
evidence.peakSpeed(), evidence.capturedLimit(), evidence.currentExclusion(),
|
||||
evidence.cooldownRemainingSeconds(), flight.get()));
|
||||
}
|
||||
|
||||
public void forget(UUID playerId) {
|
||||
pending.remove(playerId);
|
||||
detector.forget(playerId);
|
||||
flightDetector.forget(playerId);
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
pending.clear();
|
||||
detector.clear();
|
||||
flightDetector.clear();
|
||||
flightAlerts.clear();
|
||||
}
|
||||
|
||||
int trackedPlayerCount() {
|
||||
|
||||
@@ -26,8 +26,10 @@ public final class PurpurSentinelPlugin extends JavaPlugin {
|
||||
command.setTabCompleter(handler);
|
||||
getServer().getPluginManager().registerEvents(listener, this);
|
||||
var alerts = new SentinelAlerts(getServer());
|
||||
tickTask = getServer().getScheduler().runTaskTimer(this, () -> monitor
|
||||
.finishBefore(getServer().getCurrentTick(), System.nanoTime()).forEach(alerts::send), 1L, 1L);
|
||||
tickTask = getServer().getScheduler().runTaskTimer(this, () -> {
|
||||
listener.sampleFlight();
|
||||
monitor.finishBefore(getServer().getCurrentTick(), System.nanoTime()).forEach(alerts::send);
|
||||
}, 1L, 1L);
|
||||
} catch (IOException | IllegalArgumentException exception) {
|
||||
getLogger().severe("Sentinel configuration is invalid or unavailable; observation was not activated: " + exception.getMessage());
|
||||
getServer().getPluginManager().disablePlugin(this);
|
||||
@@ -40,6 +42,7 @@ public final class PurpurSentinelPlugin extends JavaPlugin {
|
||||
tickTask = null;
|
||||
}
|
||||
if (listener != null) {
|
||||
listener.clear();
|
||||
HandlerList.unregisterAll(listener);
|
||||
listener = null;
|
||||
}
|
||||
|
||||
@@ -13,9 +13,17 @@ final class SentinelAlerts {
|
||||
void send(AlertDecision alert) {
|
||||
var subject = server.getPlayer(alert.playerId());
|
||||
if (subject == null || !subject.isOnline()) return;
|
||||
String message = String.format(Locale.ROOT,
|
||||
"[Sentinel] %s: %d movement breaches; peak %.2f blocks/tick vs captured limit %.2f. Use /sentinel inspect %s.",
|
||||
subject.getName(), alert.breachCount(), alert.peakSpeed(), alert.capturedLimit(), subject.getName());
|
||||
String message;
|
||||
if (alert.signal() == DetectionSignal.ABSOLUTE_GROUND_SPEED) {
|
||||
message = String.format(Locale.ROOT,
|
||||
"[Sentinel] %s: %d movement breaches; peak %.2f blocks/tick vs captured limit %.2f. Use /sentinel inspect %s.",
|
||||
subject.getName(), alert.breachCount(), alert.peakSpeed(), alert.capturedLimit(), subject.getName());
|
||||
} else {
|
||||
message = String.format(Locale.ROOT,
|
||||
"[Sentinel] %s: %s; %d unsupported ticks; %.2f horizontal blocks; %.2f vertical blocks. Use /sentinel inspect %s.",
|
||||
subject.getName(), alert.signal(), alert.eligibleTicks(), alert.horizontalDistance(),
|
||||
alert.verticalDisplacement(), subject.getName());
|
||||
}
|
||||
server.getOnlinePlayers().stream()
|
||||
.filter(recipient -> recipient.hasPermission("sentinel.alerts"))
|
||||
.forEach(recipient -> recipient.sendMessage(message));
|
||||
|
||||
@@ -93,10 +93,21 @@ public final class SentinelCommand implements CommandExecutor, TabCompleter {
|
||||
return true;
|
||||
}
|
||||
var evidence = snapshot.get();
|
||||
sender.sendMessage(String.format(Locale.ROOT,
|
||||
"%s: %d breaches; age %.1fs; peak %.2f blocks/tick vs captured limit %.2f; exclusion %s; cooldown %.1fs.",
|
||||
player.get().getName(), evidence.breachCount(), evidence.evidenceAgeSeconds(), evidence.peakSpeed(),
|
||||
evidence.capturedLimit(), evidence.currentExclusion().description(), evidence.cooldownRemainingSeconds()));
|
||||
if (evidence.flightEvidence() != null) {
|
||||
var flight = evidence.flightEvidence();
|
||||
sender.sendMessage(String.format(Locale.ROOT,
|
||||
"%s: %s; authorized %s; supported %s; %d eligible ticks; signal counts flying %d, ascent %d, hover %d, cruise %d; horizontal %.2f blocks; ascent %.2f blocks; age %.1fs; exclusion %s; cooldown %.1fs.",
|
||||
player.get().getName(), flight.signal(), yesNo(flight.authorized()), yesNo(flight.supported()),
|
||||
flight.eligibleTicks(), flight.unauthorizedFlightTicks(), flight.ascentTicks(), flight.hoverTicks(),
|
||||
flight.cruiseTicks(), flight.horizontalDistance(), flight.verticalDisplacement(),
|
||||
flight.evidenceAgeSeconds(), flight.currentExclusion().description(),
|
||||
flight.cooldownRemainingSeconds()));
|
||||
} else {
|
||||
sender.sendMessage(String.format(Locale.ROOT,
|
||||
"%s: %d breaches; age %.1fs; peak %.2f blocks/tick vs captured limit %.2f; exclusion %s; cooldown %.1fs.",
|
||||
player.get().getName(), evidence.breachCount(), evidence.evidenceAgeSeconds(), evidence.peakSpeed(),
|
||||
evidence.capturedLimit(), evidence.currentExclusion().description(), evidence.cooldownRemainingSeconds()));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -129,6 +140,10 @@ public final class SentinelCommand implements CommandExecutor, TabCompleter {
|
||||
return true;
|
||||
}
|
||||
|
||||
private static String yesNo(boolean value) {
|
||||
return value ? "yes" : "no";
|
||||
}
|
||||
|
||||
private static List<String> filter(List<String> options, String prefix) {
|
||||
String normalized = prefix.toLowerCase(Locale.ROOT);
|
||||
return options.stream().filter(value -> value.toLowerCase(Locale.ROOT).startsWith(normalized))
|
||||
|
||||
@@ -6,7 +6,18 @@ public record SentinelSettings(
|
||||
int evidenceWindowSeconds,
|
||||
int alertCooldownSeconds,
|
||||
double minimumTps,
|
||||
int exemptionGraceTicks) {
|
||||
int exemptionGraceTicks,
|
||||
int unsupportedFlightTicks,
|
||||
int unsupportedHoverTicks,
|
||||
double unsupportedAscentBlocks,
|
||||
double unsupportedCruiseBlocks) {
|
||||
|
||||
public SentinelSettings(double horizontalSpeedLimit, int breachesToAlert, int evidenceWindowSeconds,
|
||||
int alertCooldownSeconds, double minimumTps, int exemptionGraceTicks) {
|
||||
this(horizontalSpeedLimit, breachesToAlert, evidenceWindowSeconds, alertCooldownSeconds,
|
||||
minimumTps, exemptionGraceTicks, 40, 30, 2.0, 10.0);
|
||||
}
|
||||
|
||||
public SentinelSettings {
|
||||
requireFiniteRange(horizontalSpeedLimit, 0.40, 5.00, "horizontal-speed-limit");
|
||||
requireRange(breachesToAlert, 3, 40, "breaches-to-alert");
|
||||
@@ -14,10 +25,14 @@ public record SentinelSettings(
|
||||
requireRange(alertCooldownSeconds, 5, 3600, "alert-cooldown-seconds");
|
||||
requireFiniteRange(minimumTps, 10.0, 20.0, "minimum-tps");
|
||||
requireRange(exemptionGraceTicks, 1, 200, "exemption-grace-ticks");
|
||||
requireRange(unsupportedFlightTicks, 10, 200, "unsupported-flight-ticks");
|
||||
requireRange(unsupportedHoverTicks, 10, 200, "unsupported-hover-ticks");
|
||||
requireFiniteRange(unsupportedAscentBlocks, 0.5, 20.0, "unsupported-ascent-blocks");
|
||||
requireFiniteRange(unsupportedCruiseBlocks, 2.0, 100.0, "unsupported-cruise-blocks");
|
||||
}
|
||||
|
||||
public static SentinelSettings defaults() {
|
||||
return new SentinelSettings(0.80, 6, 10, 60, 18.0, 20);
|
||||
return new SentinelSettings(0.80, 6, 10, 60, 18.0, 20, 40, 30, 2.0, 10.0);
|
||||
}
|
||||
|
||||
private static void requireRange(int value, int minimum, int maximum, String key) {
|
||||
|
||||
@@ -10,7 +10,11 @@ public enum SettingKey {
|
||||
EVIDENCE_WINDOW_SECONDS("evidence-window-seconds", "2", "60", true),
|
||||
ALERT_COOLDOWN_SECONDS("alert-cooldown-seconds", "5", "3600", true),
|
||||
MINIMUM_TPS("minimum-tps", "10.0", "20.0", false),
|
||||
EXEMPTION_GRACE_TICKS("exemption-grace-ticks", "1", "200", true);
|
||||
EXEMPTION_GRACE_TICKS("exemption-grace-ticks", "1", "200", true),
|
||||
UNSUPPORTED_FLIGHT_TICKS("unsupported-flight-ticks", "10", "200", true),
|
||||
UNSUPPORTED_HOVER_TICKS("unsupported-hover-ticks", "10", "200", true),
|
||||
UNSUPPORTED_ASCENT_BLOCKS("unsupported-ascent-blocks", "0.5", "20.0", false),
|
||||
UNSUPPORTED_CRUISE_BLOCKS("unsupported-cruise-blocks", "2.0", "100.0", false);
|
||||
|
||||
private final String key;
|
||||
private final String minimum;
|
||||
|
||||
@@ -43,7 +43,11 @@ public final class SettingsService {
|
||||
integer(values, SettingKey.EVIDENCE_WINDOW_SECONDS),
|
||||
integer(values, SettingKey.ALERT_COOLDOWN_SECONDS),
|
||||
decimal(values, SettingKey.MINIMUM_TPS),
|
||||
integer(values, SettingKey.EXEMPTION_GRACE_TICKS));
|
||||
integer(values, SettingKey.EXEMPTION_GRACE_TICKS),
|
||||
optionalInteger(values, SettingKey.UNSUPPORTED_FLIGHT_TICKS, 40),
|
||||
optionalInteger(values, SettingKey.UNSUPPORTED_HOVER_TICKS, 30),
|
||||
optionalDecimal(values, SettingKey.UNSUPPORTED_ASCENT_BLOCKS, 2.0),
|
||||
optionalDecimal(values, SettingKey.UNSUPPORTED_CRUISE_BLOCKS, 10.0));
|
||||
}
|
||||
|
||||
private static Object parseValue(SettingKey key, String value) {
|
||||
@@ -60,6 +64,14 @@ public final class SettingsService {
|
||||
}
|
||||
}
|
||||
|
||||
private static int optionalInteger(Map<String, Object> values, SettingKey key, int defaultValue) {
|
||||
return values.containsKey(key.key()) ? integer(values, key) : defaultValue;
|
||||
}
|
||||
|
||||
private static double optionalDecimal(Map<String, Object> values, SettingKey key, double defaultValue) {
|
||||
return values.containsKey(key.key()) ? decimal(values, key) : defaultValue;
|
||||
}
|
||||
|
||||
private static int integer(Map<String, Object> values, SettingKey key) {
|
||||
Object value = required(values, key);
|
||||
if (!(value instanceof Number number)) throw new IllegalArgumentException(key.key() + " must be an integer.");
|
||||
@@ -92,6 +104,10 @@ public final class SettingsService {
|
||||
case ALERT_COOLDOWN_SECONDS -> Integer.toString(settings.alertCooldownSeconds());
|
||||
case MINIMUM_TPS -> decimal(settings.minimumTps(), 1);
|
||||
case EXEMPTION_GRACE_TICKS -> Integer.toString(settings.exemptionGraceTicks());
|
||||
case UNSUPPORTED_FLIGHT_TICKS -> Integer.toString(settings.unsupportedFlightTicks());
|
||||
case UNSUPPORTED_HOVER_TICKS -> Integer.toString(settings.unsupportedHoverTicks());
|
||||
case UNSUPPORTED_ASCENT_BLOCKS -> decimal(settings.unsupportedAscentBlocks(), 1);
|
||||
case UNSUPPORTED_CRUISE_BLOCKS -> decimal(settings.unsupportedCruiseBlocks(), 1);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package games.dmg.purpursentinel;
|
||||
|
||||
public enum SupportState {
|
||||
SUPPORTED,
|
||||
UNSUPPORTED,
|
||||
UNKNOWN
|
||||
}
|
||||
@@ -4,3 +4,7 @@ evidence-window-seconds: 10
|
||||
alert-cooldown-seconds: 60
|
||||
minimum-tps: 18.0
|
||||
exemption-grace-ticks: 20
|
||||
unsupported-flight-ticks: 40
|
||||
unsupported-hover-ticks: 30
|
||||
unsupported-ascent-blocks: 2.0
|
||||
unsupported-cruise-blocks: 10.0
|
||||
@@ -90,8 +90,10 @@ class PluginLifecycleTest {
|
||||
when(subject.isOnline()).thenReturn(true);
|
||||
when(subject.getGameMode()).thenReturn(GameMode.SURVIVAL);
|
||||
when(subject.getPose()).thenReturn(Pose.STANDING);
|
||||
when(subject.getLocation()).thenReturn(new Location(world, 0, 64, 0));
|
||||
when(operator.hasPermission("sentinel.alerts")).thenReturn(true);
|
||||
when(operator.hasPermission("sentinel.inspect")).thenReturn(true);
|
||||
when(operator.hasPermission("sentinel.config")).thenReturn(true);
|
||||
when(server.getPlayer(subject.getUniqueId())).thenReturn(subject);
|
||||
doReturn((Collection) List.of(subject, operator)).when(server).getOnlinePlayers();
|
||||
|
||||
@@ -114,6 +116,8 @@ class PluginLifecycleTest {
|
||||
assertTrue(command.execute(operator, "sentinel", new String[]{"inspect", "Alex"}));
|
||||
verify(operator).sendMessage(argThat((String message) -> message.startsWith("Alex: 6 breaches")));
|
||||
assertEquals(List.of("inspect"), command.tabComplete(operator, "sentinel", new String[]{"in"}));
|
||||
assertEquals(List.of("unsupported-flight-ticks"),
|
||||
command.tabComplete(operator, "sentinel", new String[]{"config", "unsupported-f"}));
|
||||
|
||||
Field monitorField = PurpurSentinelPlugin.class.getDeclaredField("monitor");
|
||||
monitorField.setAccessible(true);
|
||||
|
||||
@@ -40,6 +40,58 @@ class BukkitMovementListenerTest {
|
||||
assertTrue(fixture.monitor.inspect(fixture.player.getUniqueId(), 100_000_000L).isEmpty(), name);
|
||||
}
|
||||
|
||||
@Test void unsupportedSurvivalMovementFeedsFlightEvidenceWithoutTrustingFlyingAsAuthorization() {
|
||||
var fixture = fixture();
|
||||
when(fixture.player.isFlying()).thenReturn(true);
|
||||
when(fixture.player.getAllowFlight()).thenReturn(false);
|
||||
doReturn(java.util.List.of(fixture.player)).when(fixture.server).getOnlinePlayers();
|
||||
for (int tick = 1; tick <= 40; tick++) {
|
||||
when(fixture.server.getCurrentTick()).thenReturn(tick);
|
||||
when(fixture.player.getLocation()).thenReturn(new Location(fixture.world, tick * 0.1, 80, 0));
|
||||
fixture.listener.sampleFlight();
|
||||
}
|
||||
var alerts = fixture.monitor.finishBefore(41, 2_050_000_000L);
|
||||
assertEquals(1, alerts.size());
|
||||
assertEquals(DetectionSignal.UNAUTHORIZED_FLIGHT_STATE, alerts.getFirst().signal());
|
||||
}
|
||||
|
||||
@Test void serverGrantedSurvivalFlightIsAuthorized() {
|
||||
var fixture = fixture();
|
||||
when(fixture.player.isFlying()).thenReturn(true);
|
||||
when(fixture.player.getAllowFlight()).thenReturn(true);
|
||||
when(fixture.player.getLocation()).thenReturn(new Location(fixture.world, 2, 80, 0));
|
||||
doReturn(java.util.List.of(fixture.player)).when(fixture.server).getOnlinePlayers();
|
||||
fixture.listener.sampleFlight();
|
||||
assertTrue(fixture.monitor.finishBefore(2, 100_000_000L).isEmpty());
|
||||
assertTrue(fixture.monitor.inspect(fixture.player.getUniqueId(), 100_000_000L).isEmpty());
|
||||
}
|
||||
|
||||
@Test void solidServerGeometryStopsUnsupportedFlightEvidence() {
|
||||
var fixture = fixture(SupportState.SUPPORTED);
|
||||
when(fixture.player.isFlying()).thenReturn(true);
|
||||
when(fixture.player.getLocation()).thenReturn(new Location(fixture.world, 0, 80, 0));
|
||||
doReturn(java.util.List.of(fixture.player)).when(fixture.server).getOnlinePlayers();
|
||||
for (int tick = 1; tick <= 50; tick++) {
|
||||
when(fixture.server.getCurrentTick()).thenReturn(tick);
|
||||
fixture.listener.sampleFlight();
|
||||
}
|
||||
assertTrue(fixture.monitor.finishBefore(51, 2_550_000_000L).isEmpty());
|
||||
assertTrue(fixture.monitor.inspect(fixture.player.getUniqueId(), 2_550_000_000L).isEmpty());
|
||||
}
|
||||
|
||||
@Test void unknownGeometryIsExcludedInsteadOfTreatedAsUnsupported() {
|
||||
var fixture = fixture(SupportState.UNKNOWN);
|
||||
when(fixture.player.isFlying()).thenReturn(true);
|
||||
when(fixture.player.getLocation()).thenReturn(new Location(fixture.world, 0, 80, 0));
|
||||
doReturn(java.util.List.of(fixture.player)).when(fixture.server).getOnlinePlayers();
|
||||
for (int tick = 1; tick <= 50; tick++) {
|
||||
when(fixture.server.getCurrentTick()).thenReturn(tick);
|
||||
fixture.listener.sampleFlight();
|
||||
}
|
||||
assertTrue(fixture.monitor.finishBefore(51, 2_550_000_000L).isEmpty());
|
||||
assertTrue(fixture.monitor.inspect(fixture.player.getUniqueId(), 2_550_000_000L).isEmpty());
|
||||
}
|
||||
|
||||
@Test void lifecycleVelocityQuitAndCancelledMovementArePassiveExemptions() {
|
||||
var fixture = fixture();
|
||||
fixture.listener.onMove(move(fixture.player, fixture.world, 0, 2, true));
|
||||
@@ -55,7 +107,7 @@ class BukkitMovementListenerTest {
|
||||
return Stream.of(
|
||||
Arguments.of("creative", (java.util.function.Consumer<Fixture>) f -> when(f.player.getGameMode()).thenReturn(GameMode.CREATIVE)),
|
||||
Arguments.of("vehicle", (java.util.function.Consumer<Fixture>) f -> when(f.player.isInsideVehicle()).thenReturn(true)),
|
||||
Arguments.of("flying", (java.util.function.Consumer<Fixture>) f -> when(f.player.isFlying()).thenReturn(true)),
|
||||
Arguments.of("authorized flight", (java.util.function.Consumer<Fixture>) f -> when(f.player.getAllowFlight()).thenReturn(true)),
|
||||
Arguments.of("gliding", (java.util.function.Consumer<Fixture>) f -> when(f.player.isGliding()).thenReturn(true)),
|
||||
Arguments.of("swimming", (java.util.function.Consumer<Fixture>) f -> when(f.player.isSwimming()).thenReturn(true)),
|
||||
Arguments.of("crawling", (java.util.function.Consumer<Fixture>) f -> when(f.player.getPose()).thenReturn(Pose.SWIMMING)),
|
||||
@@ -65,6 +117,10 @@ class BukkitMovementListenerTest {
|
||||
}
|
||||
|
||||
private static Fixture fixture() {
|
||||
return fixture(SupportState.UNSUPPORTED);
|
||||
}
|
||||
|
||||
private static Fixture fixture(SupportState support) {
|
||||
var server = mock(Server.class);
|
||||
when(server.getCurrentTick()).thenReturn(1);
|
||||
when(server.getTPS()).thenReturn(new double[]{20, 20, 20});
|
||||
@@ -74,6 +130,7 @@ class BukkitMovementListenerTest {
|
||||
when(world.getBlockAt(anyInt(), anyInt(), anyInt())).thenReturn(mock(org.bukkit.block.Block.class, invocation -> {
|
||||
if (invocation.getMethod().getName().equals("getType")) return org.bukkit.Material.AIR;
|
||||
if (invocation.getMethod().getName().equals("isLiquid")) return false;
|
||||
if (invocation.getMethod().getName().equals("isPassable")) return true;
|
||||
return RETURNS_DEFAULTS.answer(invocation);
|
||||
}));
|
||||
var player = mock(Player.class);
|
||||
@@ -82,7 +139,8 @@ class BukkitMovementListenerTest {
|
||||
when(player.getPose()).thenReturn(Pose.STANDING);
|
||||
var monitor = new MovementMonitor(new MovementDetector());
|
||||
var clock = new AtomicLong(50_000_000L);
|
||||
var listener = new BukkitMovementListener(server, monitor, () -> SETTINGS, clock::get, (from, to) -> true);
|
||||
var listener = new BukkitMovementListener(server, monitor, () -> SETTINGS, clock::get,
|
||||
(from, to) -> true, location -> support);
|
||||
return new Fixture(server, world, player, monitor, listener);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,8 +27,12 @@ class DistributionTest {
|
||||
assertNull(metadata.get("depend"));
|
||||
assertNull(metadata.get("softdepend"));
|
||||
assertNotNull(jar.getEntry("config.yml"));
|
||||
assertTrue(new String(jar.getInputStream(jar.getEntry("config.yml")).readAllBytes())
|
||||
.contains("horizontal-speed-limit: 0.80"));
|
||||
String config = new String(jar.getInputStream(jar.getEntry("config.yml")).readAllBytes());
|
||||
assertTrue(config.contains("horizontal-speed-limit: 0.80"));
|
||||
assertTrue(config.contains("unsupported-flight-ticks: 40"));
|
||||
assertTrue(config.contains("unsupported-hover-ticks: 30"));
|
||||
assertTrue(config.contains("unsupported-ascent-blocks: 2.0"));
|
||||
assertTrue(config.contains("unsupported-cruise-blocks: 10.0"));
|
||||
var entry = jar.getEntry("games/dmg/purpursentinel/PurpurSentinelPlugin.class");
|
||||
assertNotNull(entry);
|
||||
try (var bytes = new DataInputStream(jar.getInputStream(entry))) {
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
package games.dmg.purpursentinel;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class FlightMonitorTest {
|
||||
private static final SentinelSettings SETTINGS = SentinelSettings.defaults();
|
||||
|
||||
@Test void activeFlightSequenceSurvivesPerTickCleanupUntilItAlerts() {
|
||||
var monitor = new MovementMonitor(new MovementDetector());
|
||||
var player = UUID.randomUUID();
|
||||
var world = UUID.randomUUID();
|
||||
java.util.List<AlertDecision> alerts = java.util.List.of();
|
||||
for (int tick = 1; tick <= 40; tick++) {
|
||||
monitor.record(player, FlightFrame.unsupported(tick, tick * 50_000_000L, world,
|
||||
0, 80, 0, 0.3, 80, 0, false, true), SETTINGS);
|
||||
alerts = monitor.finishBefore(tick + 1, tick * 50_000_000L + 1);
|
||||
}
|
||||
assertEquals(1, alerts.size());
|
||||
assertEquals(DetectionSignal.UNAUTHORIZED_FLIGHT_STATE, alerts.getFirst().signal());
|
||||
}
|
||||
|
||||
@Test void sustainedUnauthorizedFlyingStateAlertsWithExplainableEvidence() {
|
||||
var monitor = new MovementMonitor(new MovementDetector());
|
||||
var player = UUID.randomUUID();
|
||||
var world = UUID.randomUUID();
|
||||
|
||||
for (int tick = 1; tick <= 40; tick++) {
|
||||
monitor.record(player, FlightFrame.unsupported(
|
||||
tick, tick * 50_000_000L, world,
|
||||
0, 80, 0, 0.1, 80, 0,
|
||||
false, true), SETTINGS);
|
||||
}
|
||||
|
||||
var alerts = monitor.finishBefore(41, 2_050_000_000L);
|
||||
assertEquals(1, alerts.size());
|
||||
assertEquals(DetectionSignal.UNAUTHORIZED_FLIGHT_STATE, alerts.getFirst().signal());
|
||||
assertEquals(40, alerts.getFirst().eligibleTicks());
|
||||
assertEquals(4.0, alerts.getFirst().horizontalDistance(), 1e-9);
|
||||
assertEquals(0.0, alerts.getFirst().verticalDisplacement(), 1e-9);
|
||||
|
||||
var evidence = monitor.inspect(player, 2_050_000_000L).orElseThrow().flightEvidence();
|
||||
assertFalse(evidence.authorized());
|
||||
assertFalse(evidence.supported());
|
||||
assertEquals(40, evidence.eligibleTicks());
|
||||
assertEquals(DetectionSignal.UNAUTHORIZED_FLIGHT_STATE, evidence.signal());
|
||||
}
|
||||
|
||||
@Test void sustainedAscentHoverAndCruiseProduceDistinctSignals() {
|
||||
assertEquals(DetectionSignal.UNSUPPORTED_ASCENT,
|
||||
alertFor(40, 0, 0.06, false, SETTINGS).signal());
|
||||
assertEquals(DetectionSignal.UNSUPPORTED_HOVER,
|
||||
alertFor(30, 0.01, 0, false, SETTINGS).signal());
|
||||
assertEquals(DetectionSignal.UNSUPPORTED_CRUISE,
|
||||
alertFor(40, 0.3, 0, false, SETTINGS).signal());
|
||||
}
|
||||
|
||||
@Test void ordinaryJumpAndFallTrajectoryNeverAlerts() {
|
||||
var monitor = new MovementMonitor(new MovementDetector());
|
||||
var player = UUID.randomUUID();
|
||||
var world = UUID.randomUUID();
|
||||
double y = 64;
|
||||
double verticalVelocity = 0.42;
|
||||
for (int tick = 1; tick <= 18; tick++) {
|
||||
double nextY = y + verticalVelocity;
|
||||
monitor.record(player, FlightFrame.unsupported(tick, tick * 50_000_000L, world,
|
||||
0, y, 0, 0.1, nextY, 0, false, false), SETTINGS);
|
||||
y = nextY;
|
||||
verticalVelocity = (verticalVelocity - 0.08) * 0.98;
|
||||
}
|
||||
assertTrue(monitor.finishBefore(19, 950_000_000L).isEmpty());
|
||||
}
|
||||
|
||||
@Test void exclusionStartsGraceAndEvidenceExpiresWithoutMoreMovement() {
|
||||
var monitor = new MovementMonitor(new MovementDetector());
|
||||
var player = UUID.randomUUID();
|
||||
var world = UUID.randomUUID();
|
||||
monitor.record(player, new FlightFrame(1, 50_000_000L, world,
|
||||
0, 80, 0, 0, 80, 0, false, false,
|
||||
SupportState.UNKNOWN, ExclusionReason.ENVIRONMENT), SETTINGS);
|
||||
for (int tick = 2; tick <= 21; tick++) {
|
||||
monitor.record(player, FlightFrame.unsupported(tick, tick * 50_000_000L, world,
|
||||
0, 80, 0, 0.4, 80, 0, false, false), SETTINGS);
|
||||
}
|
||||
assertTrue(monitor.finishBefore(22, 1_100_000_000L).isEmpty());
|
||||
assertTrue(monitor.inspect(player, 1_100_000_000L).isEmpty());
|
||||
|
||||
for (int tick = 22; tick <= 61; tick++) {
|
||||
monitor.record(player, FlightFrame.unsupported(tick, tick * 50_000_000L, world,
|
||||
0, 80, 0, 0.4, 80, 0, false, false), SETTINGS);
|
||||
}
|
||||
assertEquals(DetectionSignal.UNSUPPORTED_CRUISE,
|
||||
monitor.finishBefore(62, 3_100_000_000L).getFirst().signal());
|
||||
assertTrue(monitor.inspect(player, 3_100_000_000L).isPresent());
|
||||
monitor.finishBefore(300, 20_000_000_000L);
|
||||
assertTrue(monitor.inspect(player, 20_000_000_000L).isEmpty());
|
||||
}
|
||||
|
||||
@Test void disconnectAndDisableClearFlightEvidence() {
|
||||
var monitor = new MovementMonitor(new MovementDetector());
|
||||
var player = UUID.randomUUID();
|
||||
var world = UUID.randomUUID();
|
||||
for (int tick = 1; tick <= 40; tick++) {
|
||||
monitor.record(player, FlightFrame.unsupported(tick, tick * 50_000_000L, world,
|
||||
0, 80, 0, 0.5, 80, 0, false, true), SETTINGS);
|
||||
}
|
||||
assertTrue(monitor.inspect(player, 2_000_000_000L).isPresent());
|
||||
monitor.forget(player);
|
||||
assertTrue(monitor.inspect(player, 50_000_000L).isEmpty());
|
||||
for (int tick = 41; tick <= 80; tick++) {
|
||||
monitor.record(player, FlightFrame.unsupported(tick, tick * 50_000_000L, world,
|
||||
0, 80, 0, 0.5, 80, 0, false, true), SETTINGS);
|
||||
}
|
||||
monitor.clear();
|
||||
assertTrue(monitor.inspect(player, 4_000_000_000L).isEmpty());
|
||||
}
|
||||
|
||||
@Test void externalLifecycleExemptionStartsFlightGrace() {
|
||||
var monitor = new MovementMonitor(new MovementDetector());
|
||||
var player = UUID.randomUUID();
|
||||
var world = UUID.randomUUID();
|
||||
monitor.exempt(player, 1, 50_000_000L, ExclusionReason.VELOCITY, SETTINGS);
|
||||
for (int tick = 2; tick <= 21; tick++) {
|
||||
monitor.record(player, FlightFrame.unsupported(tick, tick * 50_000_000L, world,
|
||||
0, 80, 0, 1, 80, 0, false, true), SETTINGS);
|
||||
}
|
||||
assertTrue(monitor.finishBefore(22, 1_100_000_000L).isEmpty());
|
||||
assertTrue(monitor.inspect(player, 1_100_000_000L).isEmpty());
|
||||
}
|
||||
|
||||
@Test void everyApprovedExclusionAndUnknownSupportResetsFlightEvidenceWithGrace() {
|
||||
for (ExclusionReason reason : java.util.List.of(
|
||||
ExclusionReason.GAME_MODE, ExclusionReason.VEHICLE, ExclusionReason.FLYING,
|
||||
ExclusionReason.GLIDING, ExclusionReason.SWIMMING, ExclusionReason.CLIMBING,
|
||||
ExclusionReason.RIPTIDE, ExclusionReason.POTION_EFFECT, ExclusionReason.ENVIRONMENT,
|
||||
ExclusionReason.LOW_TPS, ExclusionReason.LIFECYCLE, ExclusionReason.VELOCITY,
|
||||
ExclusionReason.DAMAGE)) {
|
||||
var monitor = new MovementMonitor(new MovementDetector());
|
||||
var player = UUID.randomUUID();
|
||||
var world = UUID.randomUUID();
|
||||
monitor.record(player, new FlightFrame(1, 50_000_000L, world,
|
||||
0, 80, 0, 1, 80, 0, false, false,
|
||||
SupportState.UNSUPPORTED, reason), SETTINGS);
|
||||
for (int tick = 2; tick <= 21; tick++) {
|
||||
monitor.record(player, FlightFrame.unsupported(tick, tick * 50_000_000L, world,
|
||||
0, 80, 0, 1, 80, 0, false, true), SETTINGS);
|
||||
}
|
||||
assertTrue(monitor.finishBefore(22, 1_100_000_000L).isEmpty(), reason.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void duplicateAndDisconnectedTicksCannotInflateOrPreserveFlightSequence() {
|
||||
var monitor = new MovementMonitor(new MovementDetector());
|
||||
var player = UUID.randomUUID();
|
||||
var world = UUID.randomUUID();
|
||||
for (int tick = 1; tick <= 39; tick++) {
|
||||
var frame = FlightFrame.unsupported(tick, tick * 50_000_000L, world,
|
||||
0, 80, 0, 0.5, 80, 0, false, true);
|
||||
monitor.record(player, frame, SETTINGS);
|
||||
monitor.record(player, frame, SETTINGS);
|
||||
}
|
||||
assertTrue(monitor.finishBefore(40, 2_000_000_000L).isEmpty());
|
||||
monitor.record(player, FlightFrame.unsupported(41, 2_050_000_000L, world,
|
||||
0, 80, 0, 0.5, 80, 0, false, true), SETTINGS);
|
||||
assertTrue(monitor.finishBefore(42, 2_100_000_000L).isEmpty());
|
||||
}
|
||||
|
||||
@Test void changingAuthorizationClearsPriorUnsupportedSequence() {
|
||||
var monitor = new MovementMonitor(new MovementDetector());
|
||||
var player = UUID.randomUUID();
|
||||
var world = UUID.randomUUID();
|
||||
for (int tick = 1; tick <= 39; tick++) {
|
||||
monitor.record(player, FlightFrame.unsupported(tick, tick * 50_000_000L, world,
|
||||
0, 80, 0, 0.5, 80, 0, false, true), SETTINGS);
|
||||
}
|
||||
monitor.record(player, FlightFrame.unsupported(40, 2_000_000_000L, world,
|
||||
0, 80, 0, 0.5, 80, 0, true, true), SETTINGS);
|
||||
for (int tick = 41; tick <= 79; tick++) {
|
||||
monitor.record(player, FlightFrame.unsupported(tick, tick * 50_000_000L, world,
|
||||
0, 80, 0, 0.5, 80, 0, false, true), SETTINGS);
|
||||
}
|
||||
assertTrue(monitor.finishBefore(80, 4_000_000_000L).isEmpty());
|
||||
}
|
||||
|
||||
@Test void survivalFlightGrantedByTheServerIsAuthorizedAndNeverAlerts() {
|
||||
var monitor = new MovementMonitor(new MovementDetector());
|
||||
var player = UUID.randomUUID();
|
||||
var world = UUID.randomUUID();
|
||||
|
||||
for (int tick = 1; tick <= 50; tick++) {
|
||||
monitor.record(player, FlightFrame.unsupported(
|
||||
tick, tick * 50_000_000L, world,
|
||||
0, 80, 0, 0.2, 80.1, 0,
|
||||
true, true), SETTINGS);
|
||||
}
|
||||
|
||||
assertTrue(monitor.finishBefore(51, 2_550_000_000L).isEmpty());
|
||||
assertTrue(monitor.inspect(player, 2_550_000_000L).isEmpty());
|
||||
}
|
||||
|
||||
private static AlertDecision alertFor(int ticks, double horizontalPerTick, double verticalPerTick,
|
||||
boolean flying, SentinelSettings settings) {
|
||||
var monitor = new MovementMonitor(new MovementDetector());
|
||||
var player = UUID.randomUUID();
|
||||
var world = UUID.randomUUID();
|
||||
double x = 0;
|
||||
double y = 80;
|
||||
for (int tick = 1; tick <= ticks; tick++) {
|
||||
monitor.record(player, FlightFrame.unsupported(tick, tick * 50_000_000L, world,
|
||||
x, y, 0, x + horizontalPerTick, y + verticalPerTick, 0, false, flying), settings);
|
||||
x += horizontalPerTick;
|
||||
y += verticalPerTick;
|
||||
}
|
||||
return monitor.finishBefore(ticks + 1, (ticks + 1) * 50_000_000L).getFirst();
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,13 @@ class PrivacyBoundaryTest {
|
||||
monitor.finishBefore(tick + 1, tick * 50_000_000L + 1);
|
||||
}
|
||||
assertTrue(monitor.inspect(player, 400_000_000L).isPresent());
|
||||
var flightWorld = UUID.randomUUID();
|
||||
for (int tick = 10; tick < 50; tick++) {
|
||||
monitor.record(player, FlightFrame.unsupported(tick, tick * 50_000_000L, flightWorld,
|
||||
120.25, 90, -45.5, 120.55, 90, -45.5, false, true), service.current());
|
||||
}
|
||||
monitor.finishBefore(50, 2_500_000_000L);
|
||||
assertNotNull(monitor.inspect(player, 2_500_000_000L).orElseThrow().flightEvidence());
|
||||
assertArrayEquals(before, Files.readAllBytes(file));
|
||||
assertEquals(java.util.Set.of("config.yml"), Files.list(directory)
|
||||
.map(path -> path.getFileName().toString()).collect(java.util.stream.Collectors.toSet()));
|
||||
@@ -32,5 +39,6 @@ class PrivacyBoundaryTest {
|
||||
assertFalse(disk.contains(player.toString()));
|
||||
assertFalse(disk.contains("120.25"));
|
||||
assertFalse(disk.contains("-45.5"));
|
||||
assertFalse(disk.contains("unsupported ticks"));
|
||||
}
|
||||
}
|
||||
@@ -29,4 +29,25 @@ class SentinelAlertsTest {
|
||||
verify(denied, never()).sendMessage(anyString());
|
||||
verify(server, never()).getConsoleSender();
|
||||
}
|
||||
|
||||
@Test @SuppressWarnings({"unchecked", "rawtypes"})
|
||||
void flightAlertNamesSignalAndBoundedDisplacementWithoutAccusingOrReportingCoordinates() {
|
||||
var server = mock(Server.class);
|
||||
var subject = mock(Player.class);
|
||||
var operator = mock(Player.class);
|
||||
var id = UUID.randomUUID();
|
||||
when(server.getPlayer(id)).thenReturn(subject);
|
||||
when(subject.isOnline()).thenReturn(true);
|
||||
when(subject.getName()).thenReturn("Alex");
|
||||
when(operator.hasPermission("sentinel.alerts")).thenReturn(true);
|
||||
doReturn((java.util.Collection) List.of(operator)).when(server).getOnlinePlayers();
|
||||
|
||||
new SentinelAlerts(server).send(new AlertDecision(id, DetectionSignal.UNSUPPORTED_ASCENT,
|
||||
0, 0, 0, 40, 8.5, 2.4));
|
||||
|
||||
verify(operator).sendMessage(argThat((String message) -> message.contains("UNSUPPORTED_ASCENT")
|
||||
&& message.contains("40 unsupported ticks") && message.contains("8.50 horizontal")
|
||||
&& message.contains("2.40 vertical") && !message.toLowerCase().contains("cheat")
|
||||
&& !message.contains("x=") && !message.contains("z=")));
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,30 @@ class SentinelCommandTest {
|
||||
verify(online, never()).teleport(any(org.bukkit.Location.class));
|
||||
}
|
||||
|
||||
@Test void inspectReportsBoundedFlightStateSignalsAgeAndCooldown() throws Exception {
|
||||
var fixture = fixture();
|
||||
var target = online(fixture.server, "Flyer");
|
||||
var sender = permitted("sentinel.inspect");
|
||||
var world = UUID.randomUUID();
|
||||
double y = 80;
|
||||
for (int tick = 1; tick <= 40; tick++) {
|
||||
fixture.monitor.record(target.getUniqueId(), FlightFrame.unsupported(
|
||||
tick, tick * 50_000_000L, world,
|
||||
0, y, 0, 0, y + 0.06, 0, false, false), fixture.settings.current());
|
||||
y += 0.06;
|
||||
}
|
||||
fixture.monitor.finishBefore(41, 2_050_000_000L);
|
||||
|
||||
assertTrue(fixture.command.onCommand(sender, mock(Command.class), "sentinel",
|
||||
new String[]{"inspect", "Flyer"}));
|
||||
verify(sender).sendMessage(argThat((String message) -> message.contains("UNSUPPORTED_ASCENT")
|
||||
&& message.contains("authorized no") && message.contains("supported no")
|
||||
&& message.contains("40 eligible ticks")
|
||||
&& message.contains("signal counts flying 0, ascent 40, hover 0, cruise 0")
|
||||
&& message.contains("ascent 2.40") && message.contains("cooldown")
|
||||
&& !message.contains("80")));
|
||||
}
|
||||
|
||||
@Test void configReadsAndDurablyUpdatesBeforeFutureDetection() throws Exception {
|
||||
var fixture = fixture();
|
||||
var sender = permitted("sentinel.config");
|
||||
|
||||
@@ -14,7 +14,8 @@ class SettingsServiceTest {
|
||||
|
||||
@Test void validUpdatePersistsBeforePublicationAndPreservesUnrelatedYaml() throws Exception {
|
||||
var file = directory.resolve("config.yml");
|
||||
Files.writeString(file, "horizontal-speed-limit: 0.8\nbreaches-to-alert: 6\nevidence-window-seconds: 10\nalert-cooldown-seconds: 60\nminimum-tps: 18.0\nexemption-grace-ticks: 20\nunrelated: keep-me\n");
|
||||
AtomicYamlSettingsStore.writeDefaults(file);
|
||||
Files.writeString(file, Files.readString(file) + "unrelated: keep-me\n");
|
||||
var service = SettingsService.load(new AtomicYamlSettingsStore(file));
|
||||
|
||||
assertEquals("0.60", service.update(SettingKey.HORIZONTAL_SPEED_LIMIT, "0.60"));
|
||||
@@ -56,6 +57,40 @@ class SettingsServiceTest {
|
||||
assertEquals(service.current(), loaded.current());
|
||||
}
|
||||
|
||||
@Test void existingVersionOneConfigurationReceivesFlightDefaultsWithoutRewrite() throws Exception {
|
||||
var file = directory.resolve("config.yml");
|
||||
Files.writeString(file, "horizontal-speed-limit: 0.8\nbreaches-to-alert: 6\n"
|
||||
+ "evidence-window-seconds: 10\nalert-cooldown-seconds: 60\n"
|
||||
+ "minimum-tps: 18.0\nexemption-grace-ticks: 20\nunrelated: keep-me\n");
|
||||
byte[] before = Files.readAllBytes(file);
|
||||
var service = SettingsService.load(new AtomicYamlSettingsStore(file));
|
||||
assertEquals(40, service.current().unsupportedFlightTicks());
|
||||
assertEquals(30, service.current().unsupportedHoverTicks());
|
||||
assertEquals(2.0, service.current().unsupportedAscentBlocks(), 1e-9);
|
||||
assertEquals(10.0, service.current().unsupportedCruiseBlocks(), 1e-9);
|
||||
assertArrayEquals(before, Files.readAllBytes(file));
|
||||
}
|
||||
|
||||
@Test void flightThresholdsLoadPersistAndRejectOutOfRangeValues() throws Exception {
|
||||
var file = directory.resolve("config.yml");
|
||||
AtomicYamlSettingsStore.writeDefaults(file);
|
||||
var service = SettingsService.load(new AtomicYamlSettingsStore(file));
|
||||
assertEquals(40, service.current().unsupportedFlightTicks());
|
||||
assertEquals(30, service.current().unsupportedHoverTicks());
|
||||
assertEquals(2.0, service.current().unsupportedAscentBlocks(), 1e-9);
|
||||
assertEquals(10.0, service.current().unsupportedCruiseBlocks(), 1e-9);
|
||||
|
||||
assertEquals("50", service.update(SettingKey.UNSUPPORTED_FLIGHT_TICKS, "50"));
|
||||
assertEquals("3.5", service.update(SettingKey.UNSUPPORTED_ASCENT_BLOCKS, "3.5"));
|
||||
var loaded = SettingsService.load(new AtomicYamlSettingsStore(file));
|
||||
assertEquals(50, loaded.current().unsupportedFlightTicks());
|
||||
assertEquals(3.5, loaded.current().unsupportedAscentBlocks(), 1e-9);
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> service.update(SettingKey.UNSUPPORTED_HOVER_TICKS, "9"));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> service.update(SettingKey.UNSUPPORTED_CRUISE_BLOCKS, "101"));
|
||||
}
|
||||
|
||||
@Test void malformedStartupConfigurationFailsBeforeUse() throws Exception {
|
||||
var file = directory.resolve("config.yml");
|
||||
AtomicYamlSettingsStore.writeDefaults(file);
|
||||
|
||||
Reference in new issue
Block a user