feat(sentinel): detect elytra anomalies
CI / build (push) Successful in 1m12s
CI / build (pull_request) Successful in 1m16s

This commit is contained in:
dmg committed 2026-10-29 17:09:00 -04:00
1 parent a31966d148
commit 28e72e7349
18 files changed
+690 -11

No files matched your search

+1 -1
View File
@@ -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
@@ -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);
}
}
@@ -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<SentinelSettings> settings;
private final LongSupplier nanoTime;
private final Function<ItemStack, ItemContext> itemContext;
private final Map<UUID, State> players = new HashMap<>();
BukkitElytraObserver(Server server, MovementMonitor monitor, Supplier<SentinelSettings> settings,
LongSupplier nanoTime) {
this(server, monitor, settings, nanoTime, BukkitElytraObserver::itemContext);
}
BukkitElytraObserver(Server server, MovementMonitor monitor, Supplier<SentinelSettings> settings,
LongSupplier nanoTime, Function<ItemStack, ItemContext> 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;
}
}
}
@@ -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
}
@@ -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<UUID, State> players = new HashMap<>();
Optional<AlertDecision> 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<ElytraEvidence> 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);
}
}
}
@@ -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) {
}
@@ -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) {
}
@@ -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;
};
}
@@ -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);
}
}
@@ -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<UUID, TickBucket> pending = new HashMap<>();
private final List<AlertDecision> 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);
}
@@ -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;
@@ -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.",
@@ -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.",
@@ -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)));
@@ -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());
}
}
@@ -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; }
}
@@ -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);
@@ -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");