feat(sentinel): detect elytra anomalies
Merge PR #5 for Kaneo SM-348 after exact-candidate checks 3825 and 3826 passed.
This commit was merged in pull request #5.
This commit is contained in:
commit
e506cb79f0
18 files changed
+690
-11
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, 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");
|
||||
|
||||
Reference in new issue
Block a user