Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8731548d5a | ||
|
|
4cebd19250 | ||
|
|
e506cb79f0 | ||
|
|
28e72e7349 |
No files matched your search
@@ -2,7 +2,9 @@
|
||||
|
||||
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, or punish players.
|
||||
|
||||
Each generated alert is also appended asynchronously as a CloudEvents 1.0 JSON object to `plugins/PurpurSentinel/events/YYYY-MM-DD.jsonl`. Dates and timestamps use UTC, files retain 30 UTC days, and durable events identify the subject by UUID without display names, coordinates, raw trajectories, inventory contents, IP addresses, or permission state. Queue or disk failures never interrupt detection or gameplay.
|
||||
|
||||
## 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,8 @@
|
||||
package games.dmg.purpursentinel;
|
||||
|
||||
@FunctionalInterface
|
||||
interface AlertEventSink {
|
||||
AlertEventSink NONE = alert -> {};
|
||||
|
||||
void offer(AlertDecision alert);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package games.dmg.purpursentinel;
|
||||
|
||||
import java.io.BufferedWriter;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.DirectoryStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
final class CloudEventLog implements AlertEventSink, AutoCloseable {
|
||||
static final int DEFAULT_QUEUE_CAPACITY = 1_024;
|
||||
static final int RETENTION_DAYS = 30;
|
||||
private static final Duration WARNING_INTERVAL = Duration.ofMinutes(1);
|
||||
private static final Duration SHUTDOWN_TIMEOUT = Duration.ofSeconds(5);
|
||||
private static final DateTimeFormatter FILE_DATE = DateTimeFormatter.ISO_LOCAL_DATE;
|
||||
private final Clock clock;
|
||||
private final Consumer<String> warning;
|
||||
private final ArrayBlockingQueue<Event> queue;
|
||||
private final AtomicBoolean accepting = new AtomicBoolean(true);
|
||||
private final Object lifecycleLock = new Object();
|
||||
private final Duration shutdownTimeout;
|
||||
private final EventStore store;
|
||||
private final Thread worker;
|
||||
private long nextWarningAtMillis;
|
||||
|
||||
CloudEventLog(Path directory, Clock clock, Consumer<String> warning) {
|
||||
this(clock, warning, DEFAULT_QUEUE_CAPACITY, SHUTDOWN_TIMEOUT, new DailyEventStore(directory));
|
||||
}
|
||||
|
||||
CloudEventLog(Clock clock, Consumer<String> warning, int capacity, Duration shutdownTimeout, EventStore store) {
|
||||
if (capacity < 1) throw new IllegalArgumentException("capacity must be positive");
|
||||
this.clock = clock;
|
||||
this.warning = warning;
|
||||
this.shutdownTimeout = shutdownTimeout;
|
||||
this.store = store;
|
||||
queue = new ArrayBlockingQueue<>(capacity);
|
||||
worker = Thread.ofPlatform().name("purpur-sentinel-event-log").daemon(true).start(this::run);
|
||||
}
|
||||
|
||||
@Override public void offer(AlertDecision alert) {
|
||||
boolean dropped;
|
||||
synchronized (lifecycleLock) {
|
||||
if (!accepting.get()) return;
|
||||
dropped = !queue.offer(new Event(clock.instant(), alert));
|
||||
}
|
||||
if (dropped) warn("Sentinel event log queue is full; one durable alert was dropped.");
|
||||
}
|
||||
|
||||
@Override public void close() {
|
||||
synchronized (lifecycleLock) {
|
||||
if (!accepting.getAndSet(false)) return;
|
||||
}
|
||||
worker.interrupt();
|
||||
try {
|
||||
worker.join(shutdownTimeout.toMillis());
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
if (worker.isAlive()) warn("Sentinel event log did not finish its bounded shutdown drain.");
|
||||
queue.clear();
|
||||
}
|
||||
|
||||
int queuedEventCount() { return queue.size(); }
|
||||
|
||||
private void run() {
|
||||
try {
|
||||
while (accepting.get() || !queue.isEmpty()) {
|
||||
Event event;
|
||||
try {
|
||||
event = queue.poll(250, TimeUnit.MILLISECONDS);
|
||||
} catch (InterruptedException exception) {
|
||||
continue;
|
||||
}
|
||||
if (event == null) continue;
|
||||
try {
|
||||
store.append(event.observedAt, event.alert);
|
||||
} catch (RetentionException exception) {
|
||||
warn("Sentinel could not apply event log retention; detection remains active.");
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
warn("Sentinel could not write a durable alert; detection remains active.");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
store.close();
|
||||
} catch (IOException exception) {
|
||||
warn("Sentinel could not close its event log cleanly.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Writer open(Path directory, LocalDate date) throws IOException {
|
||||
Files.createDirectories(directory);
|
||||
Path file = directory.resolve(FILE_DATE.format(date) + ".jsonl");
|
||||
if (Files.exists(file)) truncatePartialTail(file);
|
||||
return new Writer(date, Files.newBufferedWriter(file, StandardCharsets.UTF_8,
|
||||
StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.APPEND));
|
||||
}
|
||||
|
||||
private static void truncatePartialTail(Path file) throws IOException {
|
||||
try (var channel = java.nio.channels.FileChannel.open(file, StandardOpenOption.READ, StandardOpenOption.WRITE)) {
|
||||
long size = channel.size();
|
||||
if (size == 0) return;
|
||||
var buffer = java.nio.ByteBuffer.allocate(8_192);
|
||||
long end = size;
|
||||
while (end > 0) {
|
||||
int length = (int) Math.min(buffer.capacity(), end);
|
||||
long start = end - length;
|
||||
buffer.clear().limit(length);
|
||||
channel.position(start);
|
||||
while (buffer.hasRemaining() && channel.read(buffer) >= 0) { }
|
||||
byte[] bytes = buffer.array();
|
||||
for (int index = length - 1; index >= 0; index--) {
|
||||
if (bytes[index] == '\n' || bytes[index] == '\r') {
|
||||
if (start + index + 1 < size) channel.truncate(start + index + 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
end = start;
|
||||
}
|
||||
channel.truncate(0);
|
||||
}
|
||||
}
|
||||
|
||||
private static void retain(Path directory, LocalDate currentDate) throws IOException {
|
||||
LocalDate oldest = currentDate.minusDays(RETENTION_DAYS - 1L);
|
||||
try (DirectoryStream<Path> files = Files.newDirectoryStream(directory, "*.jsonl")) {
|
||||
for (Path file : files) {
|
||||
String name = file.getFileName().toString();
|
||||
if (!name.matches("\\d{4}-\\d{2}-\\d{2}\\.jsonl")) continue;
|
||||
try {
|
||||
LocalDate date = LocalDate.parse(name.substring(0, 10), FILE_DATE);
|
||||
if (date.isBefore(oldest)) Files.deleteIfExists(file);
|
||||
} catch (DateTimeParseException ignored) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String toJson(Instant observedAt, AlertDecision alert) {
|
||||
String type = alert.signal().name().toLowerCase(Locale.ROOT).replace('_', '-');
|
||||
StringBuilder data = new StringBuilder(256);
|
||||
data.append("{\"signal\":\"").append(alert.signal()).append('\"');
|
||||
switch (alert.signal()) {
|
||||
case ABSOLUTE_GROUND_SPEED, CONTEXTUAL_GROUND_SPEED -> data
|
||||
.append(",\"breachCount\":").append(alert.breachCount())
|
||||
.append(",\"observedSpeed\":").append(number(alert.peakSpeed()))
|
||||
.append(",\"capturedLimit\":").append(number(alert.capturedLimit()))
|
||||
.append(",\"ratio\":").append(number(alert.ratio()))
|
||||
.append(",\"evidenceWindowSeconds\":").append(alert.evidenceWindowSeconds());
|
||||
case UNAUTHORIZED_FLIGHT_STATE, UNSUPPORTED_ASCENT, UNSUPPORTED_HOVER,
|
||||
UNSUPPORTED_CRUISE -> appendTravel(data, alert);
|
||||
case UNSUPPORTED_BOAT_ASCENT, UNSUPPORTED_BOAT_HOVER, UNSUPPORTED_BOAT_CRUISE -> {
|
||||
appendTravel(data, alert);
|
||||
String vehicleType = alert.vehicleType() == null ? "UNKNOWN" : alert.vehicleType();
|
||||
data.append(",\"vehicleType\":\"").append(escape(vehicleType)).append('\"');
|
||||
}
|
||||
case ELYTRA_SOURCELESS_PROPULSION, ELYTRA_IMPLAUSIBLE_WEAR -> data
|
||||
.append(",\"eligibleTicks\":").append(alert.eligibleTicks())
|
||||
.append(",\"ascent\":").append(number(alert.verticalDisplacement()))
|
||||
.append(",\"peakAcceleration\":").append(number(alert.peakSpeed()))
|
||||
.append(",\"observedBoosts\":").append(alert.evidenceWindowSeconds())
|
||||
.append(",\"wearOpportunities\":").append(alert.breachCount())
|
||||
.append(",\"unbreakingLevel\":").append((int) alert.capturedLimit());
|
||||
}
|
||||
data.append('}');
|
||||
return "{\"specversion\":\"1.0\",\"id\":\"" + UUID.randomUUID()
|
||||
+ "\",\"source\":\"urn:somc:purpur-sentinel\",\"type\":\"games.dmg.purpursentinel.alert."
|
||||
+ type + "\",\"subject\":\"" + alert.playerId() + "\",\"time\":\""
|
||||
+ DateTimeFormatter.ISO_INSTANT.format(observedAt.truncatedTo(ChronoUnit.MILLIS))
|
||||
+ "\",\"datacontenttype\":\"application/json\",\"data\":" + data + '}';
|
||||
}
|
||||
|
||||
private static void appendTravel(StringBuilder data, AlertDecision alert) {
|
||||
data.append(",\"eligibleTicks\":").append(alert.eligibleTicks())
|
||||
.append(",\"horizontalDistance\":").append(number(alert.horizontalDistance()))
|
||||
.append(",\"verticalDisplacement\":").append(number(alert.verticalDisplacement()));
|
||||
}
|
||||
|
||||
private static String number(double value) {
|
||||
return Double.isFinite(value) ? Double.toString(value) : "0.0";
|
||||
}
|
||||
|
||||
private static String escape(String value) {
|
||||
StringBuilder escaped = new StringBuilder(value.length() + 8);
|
||||
for (int index = 0; index < value.length(); index++) {
|
||||
char character = value.charAt(index);
|
||||
switch (character) {
|
||||
case '\"' -> escaped.append("\\\"");
|
||||
case '\\' -> escaped.append("\\\\");
|
||||
case '\b' -> escaped.append("\\b");
|
||||
case '\f' -> escaped.append("\\f");
|
||||
case '\n' -> escaped.append("\\n");
|
||||
case '\r' -> escaped.append("\\r");
|
||||
case '\t' -> escaped.append("\\t");
|
||||
default -> {
|
||||
if (character < 0x20) escaped.append(String.format(Locale.ROOT, "\\u%04x", (int) character));
|
||||
else escaped.append(character);
|
||||
}
|
||||
}
|
||||
}
|
||||
return escaped.toString();
|
||||
}
|
||||
|
||||
private synchronized void warn(String message) {
|
||||
long now = clock.millis();
|
||||
if (now < nextWarningAtMillis) return;
|
||||
nextWarningAtMillis = now + WARNING_INTERVAL.toMillis();
|
||||
try {
|
||||
warning.accept(message);
|
||||
} catch (RuntimeException ignored) {
|
||||
// A logging backend failure must not stop durable event processing.
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface EventStore extends AutoCloseable {
|
||||
void append(Instant observedAt, AlertDecision alert) throws IOException;
|
||||
@Override default void close() throws IOException { }
|
||||
}
|
||||
|
||||
private static final class DailyEventStore implements EventStore {
|
||||
private final Path directory;
|
||||
private Writer writer;
|
||||
|
||||
private DailyEventStore(Path directory) { this.directory = directory; }
|
||||
|
||||
@Override public void append(Instant observedAt, AlertDecision alert) throws IOException {
|
||||
LocalDate date = observedAt.atZone(ZoneOffset.UTC).toLocalDate();
|
||||
IOException retentionFailure = null;
|
||||
try {
|
||||
if (writer == null || !writer.date.equals(date)) {
|
||||
close();
|
||||
writer = open(directory, date);
|
||||
try {
|
||||
retain(directory, date);
|
||||
} catch (IOException exception) {
|
||||
retentionFailure = exception;
|
||||
}
|
||||
}
|
||||
writer.output.write(toJson(observedAt, alert));
|
||||
writer.output.newLine();
|
||||
writer.output.flush();
|
||||
} catch (IOException exception) {
|
||||
try { close(); } catch (IOException closeFailure) { exception.addSuppressed(closeFailure); }
|
||||
throw exception;
|
||||
}
|
||||
if (retentionFailure != null) warnRetention(retentionFailure);
|
||||
}
|
||||
|
||||
private static void warnRetention(IOException failure) throws RetentionException {
|
||||
throw new RetentionException(failure);
|
||||
}
|
||||
|
||||
@Override public void close() throws IOException {
|
||||
if (writer == null) return;
|
||||
try { writer.close(); } finally { writer = null; }
|
||||
}
|
||||
}
|
||||
|
||||
static final class RetentionException extends IOException {
|
||||
private static final long serialVersionUID = 1L;
|
||||
RetentionException(IOException cause) { super(cause); }
|
||||
}
|
||||
|
||||
private record Event(Instant observedAt, AlertDecision alert) { }
|
||||
private record Writer(LocalDate date, BufferedWriter output) implements AutoCloseable {
|
||||
@Override public void close() throws IOException { output.close(); }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package games.dmg.purpursentinel;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.time.Clock;
|
||||
import java.util.Objects;
|
||||
import org.bukkit.event.HandlerList;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
@@ -11,6 +12,8 @@ public final class PurpurSentinelPlugin extends JavaPlugin {
|
||||
private MovementMonitor monitor;
|
||||
private BukkitMovementListener listener;
|
||||
private BukkitBoatObserver boatObserver;
|
||||
private BukkitElytraObserver elytraObserver;
|
||||
private CloudEventLog eventLog;
|
||||
private BukkitTask tickTask;
|
||||
|
||||
@Override public void onEnable() {
|
||||
@@ -22,21 +25,29 @@ 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);
|
||||
eventLog = new CloudEventLog(getDataFolder().toPath().resolve("events"), Clock.systemUTC(),
|
||||
message -> getLogger().warning(message));
|
||||
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);
|
||||
var alerts = new SentinelAlerts(getServer());
|
||||
getServer().getPluginManager().registerEvents(elytraObserver, this);
|
||||
var alerts = new SentinelAlerts(getServer(), eventLog);
|
||||
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) {
|
||||
getLogger().severe("Sentinel configuration is invalid or unavailable; observation was not activated: " + exception.getMessage());
|
||||
getServer().getPluginManager().disablePlugin(this);
|
||||
} catch (RuntimeException exception) {
|
||||
getLogger().severe("Sentinel could not activate observation.");
|
||||
getServer().getPluginManager().disablePlugin(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,9 +66,18 @@ 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;
|
||||
}
|
||||
if (eventLog != null) {
|
||||
eventLog.close();
|
||||
eventLog = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,19 @@ import org.bukkit.Server;
|
||||
|
||||
final class SentinelAlerts {
|
||||
private final Server server;
|
||||
private final AlertEventSink events;
|
||||
|
||||
SentinelAlerts(Server server) {
|
||||
this(server, AlertEventSink.NONE);
|
||||
}
|
||||
|
||||
SentinelAlerts(Server server, AlertEventSink events) {
|
||||
this.server = server;
|
||||
this.events = events;
|
||||
}
|
||||
|
||||
void send(AlertDecision alert) {
|
||||
events.offer(alert);
|
||||
var subject = server.getPlayer(alert.playerId());
|
||||
if (subject == null || !subject.isOnline()) return;
|
||||
String message;
|
||||
@@ -24,6 +31,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.",
|
||||
|
||||
@@ -7,6 +7,7 @@ import io.papermc.paper.plugin.manager.NativeEvents;
|
||||
import java.io.File;
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
@@ -116,6 +117,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)));
|
||||
@@ -135,6 +137,13 @@ class PluginLifecycleTest {
|
||||
var monitor = (MovementMonitor) monitorField.get(plugin);
|
||||
assertEquals(1, monitor.trackedPlayerCount());
|
||||
plugin.onDisable();
|
||||
Path eventFile = directory.toPath().resolve("events/" + java.time.LocalDate.now(java.time.ZoneOffset.UTC) + ".jsonl");
|
||||
assertTrue(Files.isRegularFile(eventFile));
|
||||
var eventLines = Files.readAllLines(eventFile);
|
||||
assertEquals(1, eventLines.size());
|
||||
assertTrue(eventLines.getFirst().contains("\"specversion\":\"1.0\""));
|
||||
assertTrue(eventLines.getFirst().contains("\"subject\":\"" + subject.getUniqueId() + "\""));
|
||||
assertFalse(eventLines.getFirst().contains("Alex"));
|
||||
verify(task).cancel();
|
||||
assertEquals(0, monitor.trackedPlayerCount());
|
||||
assertEquals(0, PlayerMoveEvent.getHandlerList().getRegisteredListeners().length);
|
||||
|
||||
@@ -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,263 @@
|
||||
package games.dmg.purpursentinel;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import org.bukkit.Server;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
||||
class CloudEventLogTest {
|
||||
private static final Instant FIRST_DAY = Instant.parse("2026-10-29T23:59:58Z");
|
||||
@TempDir Path directory;
|
||||
|
||||
@Test
|
||||
void persistsOneStructuredCloudEventWhenNoAdministratorIsOnline() throws Exception {
|
||||
var clock = Clock.fixed(FIRST_DAY, ZoneOffset.UTC);
|
||||
var subjectId = UUID.fromString("39f42621-37ca-42de-a303-a2abdd2218f4");
|
||||
var server = mock(Server.class);
|
||||
when(server.getPlayer(subjectId)).thenReturn(null);
|
||||
when(server.getOnlinePlayers()).thenReturn(List.of());
|
||||
|
||||
try (var events = new CloudEventLog(directory.resolve("events"), clock, warning -> fail(warning))) {
|
||||
new SentinelAlerts(server, events).send(
|
||||
AlertDecision.contextual(subjectId, 6, 0.34, 0.22, 10));
|
||||
}
|
||||
|
||||
Path file = directory.resolve("events/2026-10-29.jsonl");
|
||||
var lines = Files.readAllLines(file);
|
||||
assertEquals(1, lines.size());
|
||||
@SuppressWarnings("unchecked")
|
||||
var event = (java.util.Map<String, Object>) new Yaml().load(lines.getFirst());
|
||||
assertEquals("1.0", event.get("specversion"));
|
||||
assertEquals("2026-10-29T23:59:58Z", event.get("time"));
|
||||
assertEquals("urn:somc:purpur-sentinel", event.get("source"));
|
||||
assertEquals("games.dmg.purpursentinel.alert.contextual-ground-speed", event.get("type"));
|
||||
assertEquals(subjectId.toString(), event.get("subject"));
|
||||
assertEquals("application/json", event.get("datacontenttype"));
|
||||
assertNotNull(event.get("id"));
|
||||
assertFalse(lines.getFirst().contains("Alex"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void appendsAfterRestartAndRemovesAnExistingPartialTail() throws Exception {
|
||||
Path events = directory.resolve("events");
|
||||
Files.createDirectories(events);
|
||||
Path file = events.resolve("2026-10-29.jsonl");
|
||||
Files.writeString(file, "partial");
|
||||
var alert = new AlertDecision(UUID.randomUUID(), 6, 1.03, 0.80);
|
||||
|
||||
try (var log = new CloudEventLog(events, Clock.fixed(FIRST_DAY, ZoneOffset.UTC), warning -> fail(warning))) {
|
||||
log.offer(alert);
|
||||
}
|
||||
try (var log = new CloudEventLog(events, Clock.fixed(FIRST_DAY.plusSeconds(1), ZoneOffset.UTC), warning -> fail(warning))) {
|
||||
log.offer(alert);
|
||||
}
|
||||
|
||||
var lines = Files.readAllLines(file);
|
||||
assertEquals(2, lines.size());
|
||||
assertTrue(lines.getFirst().startsWith("{\"specversion\":\"1.0\""));
|
||||
assertTrue(lines.get(1).startsWith("{\"specversion\":\"1.0\""));
|
||||
@SuppressWarnings("unchecked")
|
||||
var first = (java.util.Map<String, Object>) new Yaml().load(lines.getFirst());
|
||||
@SuppressWarnings("unchecked")
|
||||
var second = (java.util.Map<String, Object>) new Yaml().load(lines.get(1));
|
||||
assertNotEquals(first.get("id"), second.get("id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rollsOverAtUtcMidnightAndRetainsOnlyThirtyNamedUtcDayFiles() throws Exception {
|
||||
Path events = directory.resolve("events");
|
||||
Files.createDirectories(events);
|
||||
Files.writeString(events.resolve("2026-09-30.jsonl"), "old\n");
|
||||
Files.writeString(events.resolve("2026-10-01.jsonl"), "boundary\n");
|
||||
Files.writeString(events.resolve("notes.jsonl"), "keep\n");
|
||||
var clock = new MutableClock(FIRST_DAY);
|
||||
var alert = new AlertDecision(UUID.randomUUID(), 6, 1.03, 0.80);
|
||||
|
||||
try (var log = new CloudEventLog(events, clock, warning -> fail(warning))) {
|
||||
log.offer(alert);
|
||||
await(() -> Files.exists(events.resolve("2026-10-29.jsonl")));
|
||||
clock.set(Instant.parse("2026-10-30T00:00:01Z"));
|
||||
log.offer(alert);
|
||||
}
|
||||
|
||||
assertEquals(1, Files.readAllLines(events.resolve("2026-10-29.jsonl")).size());
|
||||
assertEquals(1, Files.readAllLines(events.resolve("2026-10-30.jsonl")).size());
|
||||
assertFalse(Files.exists(events.resolve("2026-09-30.jsonl")));
|
||||
assertTrue(Files.exists(events.resolve("2026-10-01.jsonl")));
|
||||
assertTrue(Files.exists(events.resolve("notes.jsonl")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void everySignalProducesValidUuidOnlyBoundedDataWithJsonEscaping() throws Exception {
|
||||
Path events = directory.resolve("events");
|
||||
var id = UUID.randomUUID();
|
||||
try (var log = new CloudEventLog(events, Clock.fixed(FIRST_DAY, ZoneOffset.UTC), warning -> fail(warning))) {
|
||||
for (var signal : DetectionSignal.values()) {
|
||||
log.offer(new AlertDecision(id, signal, 3, 1.5, 0.8, 20, 4.5, 2.0));
|
||||
}
|
||||
log.offer(new AlertDecision(id, DetectionSignal.UNSUPPORTED_BOAT_CRUISE,
|
||||
0, 0, 0, 0, 0, 40, 12, 0, "BAMBOO_\"RAFT\\TEST"));
|
||||
}
|
||||
|
||||
var lines = Files.readAllLines(events.resolve("2026-10-29.jsonl"));
|
||||
assertEquals(DetectionSignal.values().length + 1, lines.size());
|
||||
for (String line : lines) {
|
||||
@SuppressWarnings("unchecked")
|
||||
var event = (java.util.Map<String, Object>) new Yaml().load(line);
|
||||
@SuppressWarnings("unchecked")
|
||||
var data = (java.util.Map<String, Object>) event.get("data");
|
||||
assertEquals(id.toString(), event.get("subject"));
|
||||
assertTrue(event.get("type").toString().startsWith("games.dmg.purpursentinel.alert."));
|
||||
assertNotNull(data.get("signal"));
|
||||
assertFalse(line.contains("displayName"));
|
||||
assertFalse(line.contains("coordinate"));
|
||||
assertFalse(line.contains("inventory"));
|
||||
assertFalse(line.contains("permission"));
|
||||
}
|
||||
assertTrue(lines.getLast().contains("BAMBOO_\\\"RAFT\\\\TEST"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void queueSaturationDropsOnlyNewRecordAndRateLimitsWarnings() throws Exception {
|
||||
var warnings = new ArrayList<String>();
|
||||
var gate = new CountDownLatch(1);
|
||||
var entered = new CountDownLatch(1);
|
||||
CloudEventLog.EventStore blocked = (observedAt, alert) -> {
|
||||
entered.countDown();
|
||||
try { gate.await(); } catch (InterruptedException exception) { Thread.currentThread().interrupt(); }
|
||||
};
|
||||
var clock = new MutableClock(FIRST_DAY);
|
||||
var alert = new AlertDecision(UUID.randomUUID(), 6, 1.03, 0.80);
|
||||
var log = new CloudEventLog(clock, warnings::add, 1, java.time.Duration.ofSeconds(1), blocked);
|
||||
try {
|
||||
log.offer(alert);
|
||||
assertTrue(entered.await(1, TimeUnit.SECONDS));
|
||||
log.offer(alert);
|
||||
log.offer(alert);
|
||||
log.offer(alert);
|
||||
assertEquals(1, log.queuedEventCount());
|
||||
gate.countDown();
|
||||
await(() -> warnings.size() == 1);
|
||||
assertFalse(warnings.getFirst().contains(alert.playerId().toString()));
|
||||
} finally {
|
||||
gate.countDown();
|
||||
log.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void closeIsBoundedStopsAcceptanceAndClearsQueuedState() throws Exception {
|
||||
var gate = new CountDownLatch(1);
|
||||
var entered = new CountDownLatch(1);
|
||||
CloudEventLog.EventStore blocked = (observedAt, alert) -> {
|
||||
entered.countDown();
|
||||
try { gate.await(); } catch (InterruptedException exception) { Thread.currentThread().interrupt(); }
|
||||
};
|
||||
var alert = new AlertDecision(UUID.randomUUID(), 6, 1.03, 0.80);
|
||||
var log = new CloudEventLog(new MutableClock(FIRST_DAY), ignored -> {}, 2,
|
||||
java.time.Duration.ofMillis(25), blocked);
|
||||
log.offer(alert);
|
||||
assertTrue(entered.await(1, TimeUnit.SECONDS));
|
||||
log.offer(alert);
|
||||
|
||||
long started = System.nanoTime();
|
||||
log.close();
|
||||
long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started);
|
||||
log.offer(alert);
|
||||
|
||||
assertTrue(elapsedMillis < 500, "shutdown must remain bounded");
|
||||
assertEquals(0, log.queuedEventCount());
|
||||
gate.countDown();
|
||||
}
|
||||
|
||||
@Test
|
||||
void directoryFailuresFailOpenWithoutLeakingPathsOrSubjectData() throws Exception {
|
||||
Path events = directory.resolve("events");
|
||||
Files.writeString(events, "not a directory");
|
||||
var warnings = new ArrayList<String>();
|
||||
var alert = new AlertDecision(UUID.randomUUID(), 6, 1.03, 0.80);
|
||||
|
||||
try (var log = new CloudEventLog(events, Clock.fixed(FIRST_DAY, ZoneOffset.UTC), warnings::add)) {
|
||||
log.offer(alert);
|
||||
}
|
||||
|
||||
assertEquals(List.of("Sentinel could not write a durable alert; detection remains active."), warnings);
|
||||
assertFalse(warnings.getFirst().contains(events.toString()));
|
||||
assertFalse(warnings.getFirst().contains(alert.playerId().toString()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void retentionFailuresHaveASeparateRateLimitedWarningAndRemainFailOpen() throws Exception {
|
||||
var warnings = new ArrayList<String>();
|
||||
var attempts = new AtomicInteger();
|
||||
var completed = new CountDownLatch(1);
|
||||
CloudEventLog.EventStore failing = (observedAt, alert) -> {
|
||||
attempts.incrementAndGet();
|
||||
completed.countDown();
|
||||
throw new CloudEventLog.RetentionException(new java.io.IOException("sensitive retention detail"));
|
||||
};
|
||||
var alert = new AlertDecision(UUID.randomUUID(), 6, 1.03, 0.80);
|
||||
|
||||
try (var log = new CloudEventLog(new MutableClock(FIRST_DAY), warnings::add, 4,
|
||||
java.time.Duration.ofSeconds(1), failing)) {
|
||||
log.offer(alert);
|
||||
assertTrue(completed.await(1, TimeUnit.SECONDS));
|
||||
}
|
||||
|
||||
assertEquals(1, attempts.get());
|
||||
assertEquals(List.of("Sentinel could not apply event log retention; detection remains active."), warnings);
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeFailuresAreRateLimitedAndDoNotRejectLaterOffers() throws Exception {
|
||||
var warnings = new ArrayList<String>();
|
||||
var attempts = new AtomicInteger();
|
||||
var completed = new CountDownLatch(2);
|
||||
CloudEventLog.EventStore failing = (observedAt, alert) -> {
|
||||
attempts.incrementAndGet();
|
||||
completed.countDown();
|
||||
throw new java.io.IOException("sensitive failure detail");
|
||||
};
|
||||
var clock = new MutableClock(FIRST_DAY);
|
||||
var alert = new AlertDecision(UUID.randomUUID(), 6, 1.03, 0.80);
|
||||
|
||||
try (var log = new CloudEventLog(clock, warnings::add, 4, java.time.Duration.ofSeconds(1), failing)) {
|
||||
log.offer(alert);
|
||||
log.offer(alert);
|
||||
assertTrue(completed.await(1, TimeUnit.SECONDS));
|
||||
}
|
||||
|
||||
assertEquals(2, attempts.get());
|
||||
assertEquals(1, warnings.size());
|
||||
assertFalse(warnings.getFirst().contains("sensitive failure detail"));
|
||||
assertFalse(warnings.getFirst().contains(alert.playerId().toString()));
|
||||
}
|
||||
|
||||
private static void await(java.util.function.BooleanSupplier condition) throws InterruptedException {
|
||||
for (int attempt = 0; attempt < 100 && !condition.getAsBoolean(); attempt++) Thread.sleep(10);
|
||||
assertTrue(condition.getAsBoolean());
|
||||
}
|
||||
|
||||
private static final class MutableClock extends Clock {
|
||||
private volatile Instant instant;
|
||||
private MutableClock(Instant instant) { this.instant = instant; }
|
||||
private void set(Instant instant) { this.instant = instant; }
|
||||
@Override public ZoneOffset getZone() { return ZoneOffset.UTC; }
|
||||
@Override public Clock withZone(java.time.ZoneId zone) { return this; }
|
||||
@Override public Instant instant() { return instant; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
class PrivacyBoundaryTest {
|
||||
@TempDir Path directory;
|
||||
|
||||
@Test void observationAndInspectionNeverPersistPlayerIdentityOrEvidence() throws Exception {
|
||||
@Test void observationAndInspectionDoNotWriteOutsideTheExplicitAlertLogBoundary() throws Exception {
|
||||
var file = directory.resolve("config.yml");
|
||||
AtomicYamlSettingsStore.writeDefaults(file);
|
||||
var service = SettingsService.load(new AtomicYamlSettingsStore(file));
|
||||
|
||||
@@ -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