diff --git a/README.md b/README.md index 5c64e2f..a4a2bc6 100644 --- a/README.md +++ b/README.md @@ -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, contextually impossible ground speed, sustained unauthorized unsupported flight, and occupied boats or rafts that sustain unsupported ascent, hovering, or level travel. Contextual ground-speed evidence uses server-authoritative movement attributes, locomotion state, and Speed/Slowness effects while vehicle and flight evidence uses bounded loaded-geometry checks and conservative lifecycle exclusions. It does not cancel movement, alter vehicles, change flight permissions, punish players, persist player evidence, or export telemetry. +Sentinel gives permitted operators bounded evidence of suspicious on-foot speed, unauthorized unsupported flight, unsupported boat travel, source-less elytra propulsion, and statistically implausible elytra wear. Elytra analysis correlates server-recognized gliding, equipped-item identity, observed rocket use, external propulsion, altitude conversion, wear opportunities, Unbreaking, Mending/XP repair, and unbreakable items; high speed alone is never evidence. Sentinel does not cancel movement, alter vehicles or items, consume rockets, change flight permissions, punish players, persist player evidence, or export telemetry. ## Development diff --git a/src/main/java/games/dmg/purpursentinel/AlertDecision.java b/src/main/java/games/dmg/purpursentinel/AlertDecision.java index 0b02ae4..c6a2589 100644 --- a/src/main/java/games/dmg/purpursentinel/AlertDecision.java +++ b/src/main/java/games/dmg/purpursentinel/AlertDecision.java @@ -48,4 +48,10 @@ public record AlertDecision( return new AlertDecision(playerId, evidence.signal(), 0, 0, 0, 0, 0, evidence.eligibleTicks(), evidence.horizontalDistance(), evidence.verticalDisplacement(), evidence.vehicleType()); } + + public static AlertDecision elytra(UUID playerId, ElytraEvidence evidence) { + return new AlertDecision(playerId, evidence.signal(), evidence.wearOpportunities(), + evidence.peakAcceleration(), evidence.unbreakingLevel(), 0, evidence.observedBoosts(), + evidence.eligibleTicks(), 0, evidence.ascent(), null); + } } diff --git a/src/main/java/games/dmg/purpursentinel/BukkitElytraObserver.java b/src/main/java/games/dmg/purpursentinel/BukkitElytraObserver.java new file mode 100644 index 0000000..5206693 --- /dev/null +++ b/src/main/java/games/dmg/purpursentinel/BukkitElytraObserver.java @@ -0,0 +1,175 @@ +package games.dmg.purpursentinel; + +import com.destroystokyo.paper.event.player.PlayerArmorChangeEvent; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.function.Function; +import java.util.function.LongSupplier; +import java.util.function.Supplier; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.Server; +import org.bukkit.enchantments.Enchantment; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityDamageEvent; +import org.bukkit.event.entity.EntityToggleGlideEvent; +import org.bukkit.event.player.PlayerExpChangeEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.event.player.PlayerItemDamageEvent; +import org.bukkit.event.player.PlayerItemMendEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.event.player.PlayerRiptideEvent; +import org.bukkit.event.player.PlayerVelocityEvent; +import org.bukkit.inventory.ItemStack; + +final class BukkitElytraObserver implements Listener { + private final Server server; + private final MovementMonitor monitor; + private final Supplier settings; + private final LongSupplier nanoTime; + private final Function itemContext; + private final Map players = new HashMap<>(); + + BukkitElytraObserver(Server server, MovementMonitor monitor, Supplier settings, + LongSupplier nanoTime) { + this(server, monitor, settings, nanoTime, BukkitElytraObserver::itemContext); + } + + BukkitElytraObserver(Server server, MovementMonitor monitor, Supplier settings, + LongSupplier nanoTime, Function itemContext) { + this.server = server; + this.monitor = monitor; + this.settings = settings; + this.nanoTime = nanoTime; + this.itemContext = itemContext; + } + + void sample() { + int tick = server.getCurrentTick(); + long observedAtNanos = nanoTime.getAsLong(); + for (Player player : server.getOnlinePlayers()) { + var state = players.computeIfAbsent(player.getUniqueId(), ignored -> new State()); + if (player.getInventory() == null) { + reset(player.getUniqueId()); + continue; + } + ItemStack chest = player.getInventory().getChestplate(); + if (!player.isGliding() || chest == null || chest.getType() != Material.ELYTRA) { + reset(player.getUniqueId()); + continue; + } + Location current = player.getLocation(); + if (current == null || current.getWorld() == null) { + reset(player.getUniqueId()); + continue; + } + if (state.elytraId == null) state.elytraId = UUID.randomUUID(); + Location previous = state.location; + ExclusionReason reason = ExclusionReason.NONE; + if (previous == null || previous.getWorld() == null || !previous.getWorld().equals(current.getWorld())) { + previous = current; + reason = ExclusionReason.LIFECYCLE; + } + state.glideTicks++; + boolean opportunity = state.glideTicks % 20 == 0; + ItemContext item = itemContext.apply(chest); + monitor.record(player.getUniqueId(), new ElytraFrame(tick, observedAtNanos, + current.getWorld().getUID(), state.elytraId, true, + previous.getX(), previous.getY(), previous.getZ(), current.getX(), current.getY(), current.getZ(), + state.boost, state.riptide, state.velocity, state.explosion, opportunity, + state.wear, state.repair || state.experience, item.unbreaking(), item.unbreakable(), reason), settings.get()); + state.location = current.clone(); + state.clearSignals(); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onInteract(PlayerInteractEvent event) { + if (event.getItem() != null && event.getItem().getType() == Material.FIREWORK_ROCKET + && event.getPlayer().isGliding()) state(event.getPlayer()).boost = true; + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onRiptide(PlayerRiptideEvent event) { state(event.getPlayer()).riptide = true; } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onVelocity(PlayerVelocityEvent event) { state(event.getPlayer()).velocity = true; } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onDamage(EntityDamageEvent event) { + if (event.getEntity() instanceof Player player + && (event.getCause() == EntityDamageEvent.DamageCause.BLOCK_EXPLOSION + || event.getCause() == EntityDamageEvent.DamageCause.ENTITY_EXPLOSION)) { + state(player).explosion = true; + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onWear(PlayerItemDamageEvent event) { + if (event.getItem().getType() == Material.ELYTRA && event.getDamage() > 0) state(event.getPlayer()).wear = true; + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onMend(PlayerItemMendEvent event) { + if (event.getItem().getType() == Material.ELYTRA) state(event.getPlayer()).repair = true; + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onExperience(PlayerExpChangeEvent event) { + if (event.getAmount() > 0) state(event.getPlayer()).experience = true; + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onArmorChange(PlayerArmorChangeEvent event) { + if (event.getSlotType() == PlayerArmorChangeEvent.SlotType.CHEST) reset(event.getPlayer().getUniqueId()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onToggleGlide(EntityToggleGlideEvent event) { + if (event.getEntity() instanceof Player player) reset(player.getUniqueId()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onQuit(PlayerQuitEvent event) { reset(event.getPlayer().getUniqueId()); } + + void clear() { players.clear(); } + int trackedPlayerCount() { return players.size(); } + + private static ItemContext itemContext(ItemStack item) { + int unbreaking = item.getEnchantmentLevel(Enchantment.UNBREAKING); + boolean unbreakable = item.getItemMeta() != null && item.getItemMeta().isUnbreakable(); + return new ItemContext(unbreaking, unbreakable); + } + + record ItemContext(int unbreaking, boolean unbreakable) {} + + private State state(Player player) { + return players.computeIfAbsent(player.getUniqueId(), ignored -> new State()); + } + + private void reset(UUID playerId) { + players.remove(playerId); + monitor.forgetElytra(playerId); + } + + private static final class State { + private UUID elytraId; + private Location location; + private int glideTicks; + private boolean boost; + private boolean riptide; + private boolean velocity; + private boolean explosion; + private boolean wear; + private boolean repair; + private boolean experience; + + private void clearSignals() { + boost = riptide = velocity = explosion = wear = repair = experience = false; + } + } +} diff --git a/src/main/java/games/dmg/purpursentinel/DetectionSignal.java b/src/main/java/games/dmg/purpursentinel/DetectionSignal.java index 9aaeec8..c3cc5f2 100644 --- a/src/main/java/games/dmg/purpursentinel/DetectionSignal.java +++ b/src/main/java/games/dmg/purpursentinel/DetectionSignal.java @@ -9,5 +9,7 @@ public enum DetectionSignal { UNSUPPORTED_CRUISE, UNSUPPORTED_BOAT_ASCENT, UNSUPPORTED_BOAT_HOVER, - UNSUPPORTED_BOAT_CRUISE + UNSUPPORTED_BOAT_CRUISE, + ELYTRA_SOURCELESS_PROPULSION, + ELYTRA_IMPLAUSIBLE_WEAR } diff --git a/src/main/java/games/dmg/purpursentinel/ElytraDetector.java b/src/main/java/games/dmg/purpursentinel/ElytraDetector.java new file mode 100644 index 0000000..41d2f58 --- /dev/null +++ b/src/main/java/games/dmg/purpursentinel/ElytraDetector.java @@ -0,0 +1,136 @@ +package games.dmg.purpursentinel; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +final class ElytraDetector { + private static final int SOURCELESS_TICKS_TO_ALERT = 6; + private static final double ASCENT_PER_TICK = 0.10; + private static final double ACCELERATION_PER_TICK = 0.12; + private final Map players = new HashMap<>(); + + Optional observe(UUID playerId, ElytraFrame frame, SentinelSettings settings) { + var state = players.computeIfAbsent(playerId, ignored -> new State()); + state.expire(frame.observedAtNanos()); + if (!eligible(frame) || !frame.elytraId().equals(state.elytraId) && state.elytraId != null) { + state.reset(frame.elytraId()); + state.currentExclusion = frame.exclusionReason() == ExclusionReason.NONE + ? ExclusionReason.LIFECYCLE : frame.exclusionReason(); + return Optional.empty(); + } + if (state.elytraId == null) state.reset(frame.elytraId()); + if (state.world != null && (!state.world.equals(frame.world()) || state.lastTick + 1 != frame.tick())) { + state.reset(frame.elytraId()); + } + state.world = frame.world(); + state.lastTick = frame.tick(); + state.currentExclusion = ExclusionReason.NONE; + if (state.firstObservedAtNanos == 0) state.firstObservedAtNanos = frame.observedAtNanos(); + state.eligibleTicks++; + + double horizontalSpeed = Math.hypot(frame.toX() - frame.fromX(), frame.toZ() - frame.fromZ()); + double acceleration = horizontalSpeed - state.lastHorizontalSpeed; + double vertical = frame.toY() - frame.fromY(); + state.ascent += Math.max(0, vertical); + state.peakAcceleration = Math.max(state.peakAcceleration, acceleration); + boolean source = frame.observedBoost() || frame.riptide() || frame.externalVelocity() || frame.explosion(); + if (source) state.propulsionGraceThroughTick = frame.tick() + 20; + if (frame.observedBoost()) state.observedBoosts++; + if (frame.tick() <= state.propulsionGraceThroughTick || vertical < -0.05) { + state.sourcelessTicks = 0; + } else if (vertical > ASCENT_PER_TICK || acceleration > ACCELERATION_PER_TICK) { + state.sourcelessTicks++; + } + state.lastHorizontalSpeed = horizontalSpeed; + + if (frame.unbreakable() || frame.repairObserved() || frame.wearObserved()) { + state.wearOpportunities = 0; + } else if (frame.wearOpportunity()) { + state.wearOpportunities++; + } + state.unbreakingLevel = frame.unbreakingLevel(); + + DetectionSignal signal = null; + if (state.sourcelessTicks >= SOURCELESS_TICKS_TO_ALERT) { + signal = DetectionSignal.ELYTRA_SOURCELESS_PROPULSION; + } else if (!frame.unbreakable() && state.wearOpportunities >= wearThreshold(frame.unbreakingLevel())) { + signal = DetectionSignal.ELYTRA_IMPLAUSIBLE_WEAR; + } + if (signal == null) return Optional.empty(); + state.signal = signal; + state.evidenceExpiresAtNanos = frame.observedAtNanos() + seconds(settings.evidenceWindowSeconds()); + state.lastEvidence = state.evidence(frame.observedAtNanos()); + 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.elytra(playerId, state.lastEvidence)); + } + + Optional inspect(UUID playerId, long nowNanos) { + var state = players.get(playerId); + if (state == null) return Optional.empty(); + state.expire(nowNanos); + return state.lastEvidence == null ? Optional.empty() : Optional.of(state.evidence(nowNanos)); + } + + void forget(UUID playerId) { players.remove(playerId); } + void clear() { players.clear(); } + void cleanup(long nowNanos) { + players.entrySet().removeIf(entry -> { + var state = entry.getValue(); + state.expire(nowNanos); + return state.lastEvidence == null && state.cooldownUntilNanos <= nowNanos && !state.active(); + }); + } + + private static boolean eligible(ElytraFrame frame) { + return frame.gliding() && frame.exclusionReason() == ExclusionReason.NONE + && finite(frame.fromX(), frame.fromY(), frame.fromZ(), frame.toX(), frame.toY(), frame.toZ()); + } + private static int wearThreshold(int unbreakingLevel) { return 60 * Math.max(1, unbreakingLevel + 1); } + 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 UUID elytraId; + private int lastTick = Integer.MIN_VALUE; + private long firstObservedAtNanos; + private long evidenceExpiresAtNanos; + private long cooldownUntilNanos; + private int eligibleTicks; + private int sourcelessTicks; + private int observedBoosts; + private int wearOpportunities; + private int unbreakingLevel; + private int propulsionGraceThroughTick = Integer.MIN_VALUE; + private double ascent; + private double peakAcceleration; + private double lastHorizontalSpeed; + private DetectionSignal signal = DetectionSignal.ELYTRA_SOURCELESS_PROPULSION; + private ExclusionReason currentExclusion = ExclusionReason.NONE; + private ElytraEvidence lastEvidence; + + private boolean active() { return world != null && eligibleTicks > 0; } + private void reset(UUID replacement) { + world = null; elytraId = replacement; lastTick = Integer.MIN_VALUE; firstObservedAtNanos = 0; + eligibleTicks = sourcelessTicks = observedBoosts = wearOpportunities = unbreakingLevel = 0; + propulsionGraceThroughTick = Integer.MIN_VALUE; + ascent = peakAcceleration = lastHorizontalSpeed = 0; + } + private void expire(long nowNanos) { + if (lastEvidence != null && evidenceExpiresAtNanos <= nowNanos) lastEvidence = null; + } + private ElytraEvidence evidence(long nowNanos) { + return new ElytraEvidence(signal, eligibleTicks, sourcelessTicks, observedBoosts, wearOpportunities, + unbreakingLevel, ascent, peakAcceleration, + Math.max(0, nowNanos - firstObservedAtNanos) / 1_000_000_000.0, + Math.max(0, cooldownUntilNanos - nowNanos) / 1_000_000_000.0, currentExclusion); + } + } +} diff --git a/src/main/java/games/dmg/purpursentinel/ElytraEvidence.java b/src/main/java/games/dmg/purpursentinel/ElytraEvidence.java new file mode 100644 index 0000000..2d3a8f8 --- /dev/null +++ b/src/main/java/games/dmg/purpursentinel/ElytraEvidence.java @@ -0,0 +1,15 @@ +package games.dmg.purpursentinel; + +public record ElytraEvidence( + DetectionSignal signal, + int eligibleTicks, + int sourcelessTicks, + int observedBoosts, + int wearOpportunities, + int unbreakingLevel, + double ascent, + double peakAcceleration, + double evidenceAgeSeconds, + double cooldownRemainingSeconds, + ExclusionReason currentExclusion) { +} diff --git a/src/main/java/games/dmg/purpursentinel/ElytraFrame.java b/src/main/java/games/dmg/purpursentinel/ElytraFrame.java new file mode 100644 index 0000000..bebc608 --- /dev/null +++ b/src/main/java/games/dmg/purpursentinel/ElytraFrame.java @@ -0,0 +1,27 @@ +package games.dmg.purpursentinel; + +import java.util.UUID; + +public record ElytraFrame( + int tick, + long observedAtNanos, + UUID world, + UUID elytraId, + boolean gliding, + double fromX, + double fromY, + double fromZ, + double toX, + double toY, + double toZ, + boolean observedBoost, + boolean riptide, + boolean externalVelocity, + boolean explosion, + boolean wearOpportunity, + boolean wearObserved, + boolean repairObserved, + int unbreakingLevel, + boolean unbreakable, + ExclusionReason exclusionReason) { +} diff --git a/src/main/java/games/dmg/purpursentinel/FlightDetector.java b/src/main/java/games/dmg/purpursentinel/FlightDetector.java index 87eb8de..18f19d5 100644 --- a/src/main/java/games/dmg/purpursentinel/FlightDetector.java +++ b/src/main/java/games/dmg/purpursentinel/FlightDetector.java @@ -107,7 +107,8 @@ final class FlightDetector { case UNSUPPORTED_CRUISE -> state.cruiseTicks >= settings.unsupportedFlightTicks() && state.horizontalDistance >= settings.unsupportedCruiseBlocks(); case ABSOLUTE_GROUND_SPEED, CONTEXTUAL_GROUND_SPEED, UNSUPPORTED_BOAT_ASCENT, - UNSUPPORTED_BOAT_HOVER, UNSUPPORTED_BOAT_CRUISE -> false; + UNSUPPORTED_BOAT_HOVER, UNSUPPORTED_BOAT_CRUISE, ELYTRA_SOURCELESS_PROPULSION, + ELYTRA_IMPLAUSIBLE_WEAR -> false; }; } diff --git a/src/main/java/games/dmg/purpursentinel/InspectionSnapshot.java b/src/main/java/games/dmg/purpursentinel/InspectionSnapshot.java index 01f7460..194fc9f 100644 --- a/src/main/java/games/dmg/purpursentinel/InspectionSnapshot.java +++ b/src/main/java/games/dmg/purpursentinel/InspectionSnapshot.java @@ -9,24 +9,25 @@ public record InspectionSnapshot( double cooldownRemainingSeconds, FlightEvidence flightEvidence, GroundSpeedEvidence contextualEvidence, - BoatEvidence boatEvidence) { + BoatEvidence boatEvidence, + ElytraEvidence elytraEvidence) { public InspectionSnapshot(int breachCount, double evidenceAgeSeconds, double peakSpeed, double capturedLimit, ExclusionReason currentExclusion, double cooldownRemainingSeconds) { this(breachCount, evidenceAgeSeconds, peakSpeed, capturedLimit, - currentExclusion, cooldownRemainingSeconds, null, null, null); + currentExclusion, cooldownRemainingSeconds, null, null, null, null); } public InspectionSnapshot(int breachCount, double evidenceAgeSeconds, double peakSpeed, double capturedLimit, ExclusionReason currentExclusion, double cooldownRemainingSeconds, FlightEvidence flightEvidence) { this(breachCount, evidenceAgeSeconds, peakSpeed, capturedLimit, - currentExclusion, cooldownRemainingSeconds, flightEvidence, null, null); + currentExclusion, cooldownRemainingSeconds, flightEvidence, null, null, null); } public InspectionSnapshot(int breachCount, double evidenceAgeSeconds, double peakSpeed, double capturedLimit, ExclusionReason currentExclusion, double cooldownRemainingSeconds, FlightEvidence flightEvidence, GroundSpeedEvidence contextualEvidence) { this(breachCount, evidenceAgeSeconds, peakSpeed, capturedLimit, - currentExclusion, cooldownRemainingSeconds, flightEvidence, contextualEvidence, null); + currentExclusion, cooldownRemainingSeconds, flightEvidence, contextualEvidence, null, null); } } diff --git a/src/main/java/games/dmg/purpursentinel/MovementMonitor.java b/src/main/java/games/dmg/purpursentinel/MovementMonitor.java index 065a50f..cefdad2 100644 --- a/src/main/java/games/dmg/purpursentinel/MovementMonitor.java +++ b/src/main/java/games/dmg/purpursentinel/MovementMonitor.java @@ -12,6 +12,7 @@ public final class MovementMonitor { private final MovementDetector detector; private final FlightDetector flightDetector = new FlightDetector(); private final BoatDetector boatDetector = new BoatDetector(); + private final ElytraDetector elytraDetector = new ElytraDetector(); private final Map pending = new HashMap<>(); private final List flightAlerts = new ArrayList<>(); @@ -23,6 +24,10 @@ public final class MovementMonitor { flightDetector.observe(playerId, frame, settings).ifPresent(flightAlerts::add); } + public void record(UUID playerId, ElytraFrame frame, SentinelSettings settings) { + elytraDetector.observe(playerId, frame, settings).ifPresent(flightAlerts::add); + } + public void record(BoatFrame frame, SentinelSettings settings) { boatDetector.observe(frame, settings).ifPresent(flightAlerts::add); } @@ -55,6 +60,7 @@ public final class MovementMonitor { detector.cleanup(currentTick, nowNanos); flightDetector.cleanup(currentTick, nowNanos); boatDetector.cleanup(currentTick, nowNanos); + elytraDetector.cleanup(nowNanos); return List.copyOf(alerts); } @@ -68,16 +74,17 @@ public final class MovementMonitor { var movement = detector.inspect(playerId, nowNanos); var flight = flightDetector.inspect(playerId, nowNanos); var boat = boatDetector.inspect(playerId, nowNanos); - if (flight.isEmpty() && boat.isEmpty()) return movement; + var elytra = elytraDetector.inspect(playerId, nowNanos); + if (flight.isEmpty() && boat.isEmpty() && elytra.isEmpty()) return movement; if (movement.isEmpty()) { return Optional.of(new InspectionSnapshot(0, 0, 0, 0, - ExclusionReason.NONE, 0, flight.orElse(null), null, boat.orElse(null))); + ExclusionReason.NONE, 0, flight.orElse(null), null, boat.orElse(null), elytra.orElse(null))); } var evidence = movement.get(); return Optional.of(new InspectionSnapshot(evidence.breachCount(), evidence.evidenceAgeSeconds(), evidence.peakSpeed(), evidence.capturedLimit(), evidence.currentExclusion(), evidence.cooldownRemainingSeconds(), flight.orElse(null), evidence.contextualEvidence(), - boat.orElse(null))); + boat.orElse(null), elytra.orElse(null))); } public void forget(UUID playerId) { @@ -85,6 +92,7 @@ public final class MovementMonitor { detector.forget(playerId); flightDetector.forget(playerId); boatDetector.forget(playerId); + elytraDetector.forget(playerId); } public void clear() { @@ -92,9 +100,14 @@ public final class MovementMonitor { detector.clear(); flightDetector.clear(); boatDetector.clear(); + elytraDetector.clear(); flightAlerts.clear(); } + public void forgetElytra(UUID playerId) { + elytraDetector.forget(playerId); + } + public void releaseBoat(UUID boatId) { boatDetector.release(boatId); } diff --git a/src/main/java/games/dmg/purpursentinel/PurpurSentinelPlugin.java b/src/main/java/games/dmg/purpursentinel/PurpurSentinelPlugin.java index 2a83bfc..b2b234f 100644 --- a/src/main/java/games/dmg/purpursentinel/PurpurSentinelPlugin.java +++ b/src/main/java/games/dmg/purpursentinel/PurpurSentinelPlugin.java @@ -11,6 +11,7 @@ public final class PurpurSentinelPlugin extends JavaPlugin { private MovementMonitor monitor; private BukkitMovementListener listener; private BukkitBoatObserver boatObserver; + private BukkitElytraObserver elytraObserver; private BukkitTask tickTask; @Override public void onEnable() { @@ -22,16 +23,19 @@ public final class PurpurSentinelPlugin extends JavaPlugin { monitor = new MovementMonitor(new MovementDetector()); listener = new BukkitMovementListener(getServer(), monitor, settings::current, System::nanoTime); boatObserver = new BukkitBoatObserver(getServer(), monitor, settings::current, System::nanoTime); + elytraObserver = new BukkitElytraObserver(getServer(), monitor, settings::current, System::nanoTime); var command = Objects.requireNonNull(getCommand("sentinel"), "Missing sentinel command"); var handler = new SentinelCommand(getServer(), monitor, settings, System::nanoTime); command.setExecutor(handler); command.setTabCompleter(handler); getServer().getPluginManager().registerEvents(listener, this); getServer().getPluginManager().registerEvents(boatObserver, this); + getServer().getPluginManager().registerEvents(elytraObserver, this); var alerts = new SentinelAlerts(getServer()); tickTask = getServer().getScheduler().runTaskTimer(this, () -> { listener.sampleFlight(); boatObserver.sample(); + elytraObserver.sample(); monitor.finishBefore(getServer().getCurrentTick(), System.nanoTime()).forEach(alerts::send); }, 1L, 1L); } catch (IOException | IllegalArgumentException exception) { @@ -55,6 +59,11 @@ public final class PurpurSentinelPlugin extends JavaPlugin { HandlerList.unregisterAll(boatObserver); boatObserver = null; } + if (elytraObserver != null) { + elytraObserver.clear(); + HandlerList.unregisterAll(elytraObserver); + elytraObserver = null; + } if (monitor != null) { monitor.clear(); monitor = null; diff --git a/src/main/java/games/dmg/purpursentinel/SentinelAlerts.java b/src/main/java/games/dmg/purpursentinel/SentinelAlerts.java index 4e63aa7..20bb479 100644 --- a/src/main/java/games/dmg/purpursentinel/SentinelAlerts.java +++ b/src/main/java/games/dmg/purpursentinel/SentinelAlerts.java @@ -24,6 +24,13 @@ final class SentinelAlerts { "[Sentinel] %s: CONTEXTUAL_GROUND_SPEED; %d breaches in %ds window; observed %.2f blocks/tick vs expected %.2f; ratio %.2f. Use /sentinel inspect %s.", subject.getName(), alert.breachCount(), alert.evidenceWindowSeconds(), alert.peakSpeed(), alert.capturedLimit(), alert.ratio(), subject.getName()); + } else if (alert.signal() == DetectionSignal.ELYTRA_SOURCELESS_PROPULSION + || alert.signal() == DetectionSignal.ELYTRA_IMPLAUSIBLE_WEAR) { + message = String.format(Locale.ROOT, + "[Sentinel] %s: %s; %d eligible ticks; ascent %.2f blocks; peak acceleration %.2f; observed boosts %d; wear opportunities %d; Unbreaking %.0f. Use /sentinel inspect %s.", + subject.getName(), alert.signal(), alert.eligibleTicks(), alert.verticalDisplacement(), + alert.peakSpeed(), alert.evidenceWindowSeconds(), alert.breachCount(), alert.capturedLimit(), + subject.getName()); } else if (alert.vehicleType() != null) { message = String.format(Locale.ROOT, "[Sentinel] %s: %s; boat %s; %d unsupported ticks; %.2f horizontal blocks; %.2f ascent blocks. Use /sentinel inspect %s.", diff --git a/src/main/java/games/dmg/purpursentinel/SentinelCommand.java b/src/main/java/games/dmg/purpursentinel/SentinelCommand.java index 88bcc22..2cb2614 100644 --- a/src/main/java/games/dmg/purpursentinel/SentinelCommand.java +++ b/src/main/java/games/dmg/purpursentinel/SentinelCommand.java @@ -93,7 +93,15 @@ public final class SentinelCommand implements CommandExecutor, TabCompleter { return true; } var evidence = snapshot.get(); - if (evidence.boatEvidence() != null) { + if (evidence.elytraEvidence() != null) { + var elytra = evidence.elytraEvidence(); + sender.sendMessage(String.format(Locale.ROOT, + "%s: %s; %d eligible ticks; %d sourceless ticks; ascent %.2f blocks; peak acceleration %.2f; observed boosts %d; wear opportunities %d; Unbreaking %d; age %.1fs; exclusion %s; cooldown %.1fs.", + player.get().getName(), elytra.signal(), elytra.eligibleTicks(), elytra.sourcelessTicks(), + elytra.ascent(), elytra.peakAcceleration(), elytra.observedBoosts(), + elytra.wearOpportunities(), elytra.unbreakingLevel(), elytra.evidenceAgeSeconds(), + elytra.currentExclusion().description(), elytra.cooldownRemainingSeconds())); + } else if (evidence.boatEvidence() != null) { var boat = evidence.boatEvidence(); sender.sendMessage(String.format(Locale.ROOT, "%s: %s; boat %s; supported %s; %d eligible ticks; signal counts ascent %d, hover %d, cruise %d; horizontal %.2f blocks; ascent %.2f blocks; age %.1fs; exclusion %s; cooldown %.1fs.", diff --git a/src/nativeTest/java/games/dmg/purpursentinel/PluginLifecycleTest.java b/src/nativeTest/java/games/dmg/purpursentinel/PluginLifecycleTest.java index e002572..fcaf8d6 100644 --- a/src/nativeTest/java/games/dmg/purpursentinel/PluginLifecycleTest.java +++ b/src/nativeTest/java/games/dmg/purpursentinel/PluginLifecycleTest.java @@ -116,6 +116,7 @@ class PluginLifecycleTest { assertTrue(config.contains("breaches-to-alert: 6")); assertTrue(config.contains("unsupported-boat-ticks: 40")); verify(manager).registerEvents(isA(BukkitBoatObserver.class), eq(plugin)); + verify(manager).registerEvents(isA(BukkitElytraObserver.class), eq(plugin)); for (int sample = 1; sample <= 6; sample++) { currentTick.set(sample); events.call(new PlayerMoveEvent(subject, new Location(world, 0, 64, 0), new Location(world, 1, 64, 0))); diff --git a/src/test/java/games/dmg/purpursentinel/BukkitElytraObserverTest.java b/src/test/java/games/dmg/purpursentinel/BukkitElytraObserverTest.java new file mode 100644 index 0000000..ae11380 --- /dev/null +++ b/src/test/java/games/dmg/purpursentinel/BukkitElytraObserverTest.java @@ -0,0 +1,72 @@ +package games.dmg.purpursentinel; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.Server; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.event.block.Action; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.bukkit.inventory.meta.ItemMeta; +import org.junit.jupiter.api.Test; + +class BukkitElytraObserverTest { + @Test void samplesEquippedGlidingElytraAndCorrelatesObservedRocketUse() { + var server = mock(Server.class); + var tick = new AtomicInteger(); + when(server.getCurrentTick()).thenAnswer(call -> tick.incrementAndGet()); + var world = mock(World.class); + when(world.getUID()).thenReturn(UUID.randomUUID()); + var player = mock(Player.class); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + when(player.isGliding()).thenReturn(true); + when(player.getLocation()).thenReturn(new Location(world, 0, 80, 0), new Location(world, 1, 80.2, 0)); + var inventory = mock(PlayerInventory.class); + when(player.getInventory()).thenReturn(inventory); + var elytra = mock(ItemStack.class); + when(elytra.getType()).thenReturn(Material.ELYTRA); + var meta = mock(ItemMeta.class); + when(elytra.getItemMeta()).thenReturn(meta); + when(inventory.getChestplate()).thenReturn(elytra); + doReturn(List.of(player)).when(server).getOnlinePlayers(); + var monitor = new MovementMonitor(new MovementDetector()); + var observer = new BukkitElytraObserver(server, monitor, SentinelSettings::defaults, System::nanoTime, + ignored -> new BukkitElytraObserver.ItemContext(0, false)); + var rocket = mock(ItemStack.class); + when(rocket.getType()).thenReturn(Material.FIREWORK_ROCKET); + var event = new PlayerInteractEvent(player, Action.RIGHT_CLICK_AIR, rocket, null, null); + + observer.onInteract(event); + observer.sample(); + observer.sample(); + + assertEquals(1, observer.trackedPlayerCount()); + assertTrue(monitor.finishBefore(3, System.nanoTime()).isEmpty()); + verify(player, never()).setVelocity(any()); + verify(player, never()).setGliding(anyBoolean()); + verify(rocket, never()).setAmount(anyInt()); + } + + @Test void glideInterruptionAndArmorSwapClearObserverAndDetectorState() { + var server = mock(Server.class); + var player = mock(Player.class); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + var monitor = new MovementMonitor(new MovementDetector()); + var observer = new BukkitElytraObserver(server, monitor, SentinelSettings::defaults, System::nanoTime, + ignored -> new BukkitElytraObserver.ItemContext(0, false)); + observer.onToggleGlide(new org.bukkit.event.entity.EntityToggleGlideEvent(player, false)); + observer.onArmorChange(new com.destroystokyo.paper.event.player.PlayerArmorChangeEvent(player, + com.destroystokyo.paper.event.player.PlayerArmorChangeEvent.SlotType.CHEST, + mock(ItemStack.class), mock(ItemStack.class))); + assertEquals(0, observer.trackedPlayerCount()); + assertTrue(monitor.inspect(player.getUniqueId(), System.nanoTime()).isEmpty()); + } +} diff --git a/src/test/java/games/dmg/purpursentinel/ElytraMonitorTest.java b/src/test/java/games/dmg/purpursentinel/ElytraMonitorTest.java new file mode 100644 index 0000000..eee6f09 --- /dev/null +++ b/src/test/java/games/dmg/purpursentinel/ElytraMonitorTest.java @@ -0,0 +1,161 @@ +package games.dmg.purpursentinel; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class ElytraMonitorTest { + private static final UUID PLAYER = UUID.randomUUID(); + private static final UUID WORLD = UUID.randomUUID(); + private static final UUID ELYTRA = UUID.randomUUID(); + private static final SentinelSettings SETTINGS = SentinelSettings.defaults(); + + @Test void highHorizontalSpeedAndOrdinaryDescendingGlideNeverCreateEvidence() { + var monitor = new MovementMonitor(new MovementDetector()); + double x = 0; + double y = 120; + for (int tick = 1; tick <= 100; tick++) { + monitor.record(PLAYER, frame(tick, x, y, x + 2.5, y - 0.2, false, false, + false, false, false, 0, tick % 20 == 0), SETTINGS); + x += 2.5; + y -= 0.2; + } + assertTrue(monitor.finishBefore(101, nanos(101)).isEmpty()); + assertTrue(monitor.inspect(PLAYER, nanos(101)).isEmpty()); + } + + @Test void repeatedSourcelessAscentAlertsWithExplainableEvidence() { + var monitor = new MovementMonitor(new MovementDetector()); + double x = 0; + double y = 80; + for (int tick = 1; tick <= 6; tick++) { + monitor.record(PLAYER, frame(tick, x, y, x + 0.4 + tick * 0.2, y + 0.2, + false, false, false, false, false, 0, false), SETTINGS); + x += 0.4 + tick * 0.2; + y += 0.2; + } + var alert = monitor.finishBefore(7, nanos(7)).getFirst(); + assertEquals(DetectionSignal.ELYTRA_SOURCELESS_PROPULSION, alert.signal()); + var evidence = monitor.inspect(PLAYER, nanos(7)).orElseThrow().elytraEvidence(); + assertEquals(6, evidence.sourcelessTicks()); + assertTrue(evidence.ascent() >= 1.2 - 1e-9); + assertTrue(evidence.peakAcceleration() > 0.1); + assertEquals(0, evidence.observedBoosts()); + } + + @Test void rocketRiptideVelocityExplosionAndAltitudeConversionExcludePropulsion() { + var contexts = new boolean[][] { + {true, false, false, false}, + {false, true, false, false}, + {false, false, true, false}, + {false, false, false, true} + }; + for (boolean[] context : contexts) { + var monitor = new MovementMonitor(new MovementDetector()); + double x = 0; + double y = 80; + for (int tick = 1; tick <= 20; tick++) { + monitor.record(PLAYER, frame(tick, x, y, x + tick * 0.3, y + 0.2, + context[0], context[1], context[2], context[3], false, 0, false), SETTINGS); + x += tick * 0.3; + y += 0.2; + } + assertTrue(monitor.finishBefore(21, nanos(21)).isEmpty()); + assertTrue(monitor.inspect(PLAYER, nanos(21)).isEmpty()); + } + } + + @Test void observedRocketCreatesCorrelationGraceWithoutTrustingCarriedInventory() { + var monitor = new MovementMonitor(new MovementDetector()); + double x = 0; + double y = 80; + for (int tick = 1; tick <= 20; tick++) { + monitor.record(PLAYER, frame(tick, x, y, x + tick * .3, y + .2, + tick == 1, false, false, false, false, 0, false), SETTINGS); + x += tick * .3; + y += .2; + } + assertTrue(monitor.finishBefore(21, nanos(21)).isEmpty()); + } + + @Test void wearRequiresSixtyEligibleOpportunitiesAndAccountsForUnbreaking() { + var plain = new MovementMonitor(new MovementDetector()); + for (int opportunity = 1; opportunity <= 59; opportunity++) { + plain.record(PLAYER, wearFrame(opportunity, 0, false, false, false), SETTINGS); + } + assertTrue(plain.finishBefore(60, nanos(60)).isEmpty()); + plain.record(PLAYER, wearFrame(60, 0, false, false, false), SETTINGS); + assertEquals(DetectionSignal.ELYTRA_IMPLAUSIBLE_WEAR, + plain.finishBefore(61, nanos(61)).getFirst().signal()); + + var unbreaking = new MovementMonitor(new MovementDetector()); + for (int opportunity = 1; opportunity <= 60; opportunity++) { + unbreaking.record(PLAYER, wearFrame(opportunity, 3, false, false, false), SETTINGS); + } + assertTrue(unbreaking.finishBefore(61, nanos(61)).isEmpty()); + for (int opportunity = 61; opportunity <= 240; opportunity++) { + unbreaking.record(PLAYER, wearFrame(opportunity, 3, false, false, false), SETTINGS); + } + assertEquals(DetectionSignal.ELYTRA_IMPLAUSIBLE_WEAR, + unbreaking.finishBefore(241, nanos(241)).getFirst().signal()); + } + + @Test void observedWearMendingXpUnbreakableSwapAndInterruptionResetWearEvidence() { + var monitor = new MovementMonitor(new MovementDetector()); + for (int opportunity = 1; opportunity <= 59; opportunity++) { + monitor.record(PLAYER, wearFrame(opportunity, 0, false, false, false), SETTINGS); + } + monitor.record(PLAYER, wearFrame(60, 0, true, false, false), SETTINGS); + for (int opportunity = 61; opportunity <= 119; opportunity++) { + monitor.record(PLAYER, wearFrame(opportunity, 0, false, false, false), SETTINGS); + } + monitor.record(PLAYER, wearFrame(120, 0, false, true, false), SETTINGS); + for (int opportunity = 121; opportunity <= 179; opportunity++) { + monitor.record(PLAYER, wearFrame(opportunity, 0, false, false, false), SETTINGS); + } + monitor.record(PLAYER, wearFrame(180, 0, false, false, true), SETTINGS); + assertTrue(monitor.finishBefore(181, nanos(181)).isEmpty()); + + monitor.record(PLAYER, new ElytraFrame(181, nanos(181), WORLD, UUID.randomUUID(), false, + 0, 80, 0, 0, 80, 0, false, false, false, false, + false, false, false, 0, false, ExclusionReason.NONE), SETTINGS); + assertTrue(monitor.inspect(PLAYER, nanos(181)).isEmpty()); + } + + @Test void disconnectAndDisableClearAllElytraState() { + var monitor = new MovementMonitor(new MovementDetector()); + for (int opportunity = 1; opportunity <= 59; opportunity++) { + monitor.record(PLAYER, wearFrame(opportunity, 0, false, false, false), SETTINGS); + } + monitor.forget(PLAYER); + assertTrue(monitor.inspect(PLAYER, nanos(60)).isEmpty()); + monitor.record(PLAYER, wearFrame(61, 0, false, false, false), SETTINGS); + monitor.clear(); + assertTrue(monitor.inspect(PLAYER, nanos(62)).isEmpty()); + } + + private static ElytraFrame wearFrame(int opportunity, int unbreaking, boolean wearObserved, + boolean repairObserved, boolean unbreakable) { + return frame(opportunity, 0, 80, 0.2, 79.9, false, false, false, false, + unbreakable, unbreaking, true, wearObserved, repairObserved); + } + + private static ElytraFrame frame(int tick, double fromX, double fromY, double toX, double toY, + boolean boost, boolean riptide, boolean velocity, boolean explosion, boolean unbreakable, + int unbreaking, boolean wearOpportunity) { + return frame(tick, fromX, fromY, toX, toY, boost, riptide, velocity, explosion, + unbreakable, unbreaking, wearOpportunity, false, false); + } + + private static ElytraFrame frame(int tick, double fromX, double fromY, double toX, double toY, + boolean boost, boolean riptide, boolean velocity, boolean explosion, boolean unbreakable, + int unbreaking, boolean wearOpportunity, boolean wearObserved, boolean repairObserved) { + return new ElytraFrame(tick, nanos(tick), WORLD, ELYTRA, true, + fromX, fromY, 0, toX, toY, 0, boost, riptide, velocity, explosion, + wearOpportunity, wearObserved, repairObserved, unbreaking, unbreakable, + ExclusionReason.NONE); + } + + private static long nanos(int tick) { return tick * 50_000_000L; } +} diff --git a/src/test/java/games/dmg/purpursentinel/SentinelAlertsTest.java b/src/test/java/games/dmg/purpursentinel/SentinelAlertsTest.java index 22b2a8c..c58ba3a 100644 --- a/src/test/java/games/dmg/purpursentinel/SentinelAlertsTest.java +++ b/src/test/java/games/dmg/purpursentinel/SentinelAlertsTest.java @@ -51,6 +51,27 @@ class SentinelAlertsTest { && !message.toLowerCase().contains("cheat"))); } + @Test @SuppressWarnings({"unchecked", "rawtypes"}) + void elytraAlertDistinguishesSignalAndReportsOnlyBoundedContext() { + 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(); + var evidence = new ElytraEvidence(DetectionSignal.ELYTRA_SOURCELESS_PROPULSION, + 20, 6, 1, 0, 3, 2.4, 0.35, 1, 60, ExclusionReason.NONE); + new SentinelAlerts(server).send(AlertDecision.elytra(id, evidence)); + verify(operator).sendMessage(argThat((String message) -> message.contains("ELYTRA_SOURCELESS_PROPULSION") + && message.contains("ascent 2.40") && message.contains("peak acceleration 0.35") + && message.contains("observed boosts 1") && message.contains("Unbreaking 3") + && !message.contains("x=") && !message.toLowerCase().contains("inventory") + && !message.toLowerCase().contains("cheat"))); + } + @Test @SuppressWarnings({"unchecked", "rawtypes"}) void boatAlertNamesControllerVehicleSignalAndBoundedEvidenceWithoutCoordinates() { var server = mock(Server.class); diff --git a/src/test/java/games/dmg/purpursentinel/SentinelCommandTest.java b/src/test/java/games/dmg/purpursentinel/SentinelCommandTest.java index 62b7f34..c2069b0 100644 --- a/src/test/java/games/dmg/purpursentinel/SentinelCommandTest.java +++ b/src/test/java/games/dmg/purpursentinel/SentinelCommandTest.java @@ -71,6 +71,30 @@ class SentinelCommandTest { verify(online, never()).teleport(any(org.bukkit.Location.class)); } + @Test void inspectReportsBoundedElytraEvidenceAndExclusions() throws Exception { + var fixture = fixture(); + var target = online(fixture.server, "Glider"); + var sender = permitted("sentinel.inspect"); + var world = UUID.randomUUID(); + var elytra = UUID.randomUUID(); + double x = 0; + double y = 80; + for (int tick = 1; tick <= 6; tick++) { + fixture.monitor.record(target.getUniqueId(), new ElytraFrame(tick, tick * 50_000_000L, + world, elytra, true, x, y, 0, x + tick * .2, y + .2, 0, + false, false, false, false, false, false, false, 2, false, + ExclusionReason.NONE), fixture.settings.current()); + x += tick * .2; + y += .2; + } + fixture.monitor.finishBefore(7, 350_000_000L); + fixture.command.onCommand(sender, mock(Command.class), "sentinel", new String[]{"inspect", "Glider"}); + verify(sender).sendMessage(argThat((String message) -> message.contains("ELYTRA_SOURCELESS_PROPULSION") + && message.contains("6 sourceless ticks") && message.contains("ascent 1.20") + && message.contains("Unbreaking 2") && message.contains("exclusion none") + && message.contains("cooldown") && !message.contains("80"))); + } + @Test void inspectReportsBoundedBoatEvidenceSupportExclusionAndCooldown() throws Exception { var fixture = fixture(); var target = online(fixture.server, "Boater");