feat(enforcement): add graduated movement responses
Release / release (push) Successful in 2m1s
CI / build (push) Successful in 1m13s

Closes SM-350.
This commit was merged in pull request #7.
This commit is contained in:
dmg committed 2026-10-29 23:00:40 +00:00
commit 1317b85835
26 files changed
+1115 -24

No files matched your search

@@ -39,6 +39,24 @@ public final class AtomicYamlSettingsStore implements SettingsPersistence {
unsupported-boat-hover-ticks: 30 unsupported-boat-hover-ticks: 30
unsupported-boat-ascent-blocks: 2.0 unsupported-boat-ascent-blocks: 2.0
unsupported-boat-cruise-blocks: 12.0 unsupported-boat-cruise-blocks: 12.0
enforcement-mode: off
enforcement-window-seconds: 600
enforcement-warning-episodes: 3
enforcement-setback-episodes: 5
enforcement-kick-episodes: 7
enforcement-hold-seconds: 10
enforcement-recent-damage-exclusion-seconds: 10
enforcement-signal-absolute-ground-speed: true
enforcement-signal-contextual-ground-speed: true
enforcement-signal-unauthorized-flight-state: true
enforcement-signal-unsupported-ascent: true
enforcement-signal-unsupported-hover: true
enforcement-signal-unsupported-cruise: true
enforcement-signal-elytra-sourceless-propulsion: true
enforcement-signal-unsupported-boat-ascent: false
enforcement-signal-unsupported-boat-hover: false
enforcement-signal-unsupported-boat-cruise: false
enforcement-signal-elytra-implausible-wear: false
""", StandardOpenOption.CREATE_NEW); """, StandardOpenOption.CREATE_NEW);
} }
} }
@@ -0,0 +1,193 @@
package games.dmg.purpursentinel;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.function.LongSupplier;
import java.util.function.Predicate;
import java.util.function.Supplier;
import net.kyori.adventure.text.Component;
import org.bukkit.Location;
import org.bukkit.Server;
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.PlayerDeathEvent;
import org.bukkit.event.player.PlayerChangedWorldEvent;
import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.player.PlayerRespawnEvent;
import org.bukkit.event.player.PlayerTeleportEvent;
final class BukkitEnforcement implements Listener, EnforcementRuntime {
private static final Component WARNING = Component.text(
"Sentinel detected repeated movement that the server could not validate. Please return to ordinary movement.");
private static final Component KICK = Component.text(
"Sentinel detected repeated movement that the server could not validate. You have been disconnected as a precaution.");
private final Server server;
private final Supplier<EnforcementSettings> settings;
private final LongSupplier nanoTime;
private final Predicate<Location> safeLocation;
private final EnforcementAuditSink audit;
private final EnforcementMonitor monitor;
private final Map<UUID, Location> anchors = new HashMap<>();
private final Map<UUID, Long> damagedAt = new HashMap<>();
private final Map<UUID, Hold> holds = new HashMap<>();
private UUID internalTeleport;
BukkitEnforcement(Server server, Supplier<EnforcementSettings> settings, LongSupplier nanoTime,
Predicate<Location> safeLocation, EnforcementAuditSink audit) {
this.server = server;
this.settings = settings;
this.nanoTime = nanoTime;
this.safeLocation = safeLocation;
this.audit = audit;
monitor = new EnforcementMonitor(audit);
}
@Override public void observe(AlertDecision alert) {
var current = settings.get();
if (current.mode() == EnforcementMode.OFF) return;
var player = server.getPlayer(alert.playerId());
Location anchor = anchors.get(alert.playerId());
boolean safe = player != null && anchor != null && anchor.getWorld() != null
&& player.getWorld().equals(anchor.getWorld()) && safeLocation.test(anchor);
long now = nanoTime.getAsLong();
Long lastDamage = damagedAt.get(alert.playerId());
boolean recentDamage = lastDamage != null && now - lastDamage
<= seconds(current.recentDamageExclusionSeconds());
var decision = monitor.observe(alert.playerId(), alert.signal(), now, server.getCurrentTick(), current, safe,
recentDamage);
if (decision.isNone()) return;
if (player == null || !player.isOnline()) {
if (decision.action() != EnforcementAction.SKIPPED) audit(EnforcementAction.SKIPPED, alert, decision, "player-offline");
return;
}
switch (decision.action()) {
case WARNING -> player.sendMessage(WARNING);
case SETBACK -> {
Location target = anchor.clone();
Location currentLocation = player.getLocation();
if (currentLocation != null) {
target.setYaw(currentLocation.getYaw());
target.setPitch(currentLocation.getPitch());
}
internalTeleport = alert.playerId();
boolean moved;
try {
moved = player.teleport(target);
} finally {
internalTeleport = null;
}
if (moved) holds.put(alert.playerId(),
new Hold(target.clone(), now + seconds(decision.holdSeconds()), alert.signal()));
else audit(EnforcementAction.SKIPPED, alert, decision, "teleport-refused");
}
case KICK -> player.kick(KICK);
case NONE, SKIPPED, HOLD_ENDED, OPERATOR_RESET -> { }
}
}
void captureSafeAnchor(Player player, Location location) {
var current = settings.get();
if (!current.mode().setsBack() || location.getWorld() == null || !safeLocation.test(location)) return;
UUID playerId = player.getUniqueId();
if (holds.containsKey(playerId) || !monitor.inspect(playerId, nanoTime.getAsLong(), current).isEmpty()) return;
anchors.put(playerId, location.clone());
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
public void onMove(PlayerMoveEvent event) {
var hold = holds.get(event.getPlayer().getUniqueId());
if (hold == null) return;
long now = nanoTime.getAsLong();
if (now >= hold.untilNanos) {
endHold(event.getPlayer().getUniqueId(), "expired");
return;
}
Location to = event.getTo();
if (to == null || to.getWorld() == null || !to.getWorld().equals(hold.anchor.getWorld())) {
endHold(event.getPlayer().getUniqueId(), "external-movement");
return;
}
Location fixed = hold.anchor.clone();
fixed.setYaw(to.getYaw());
fixed.setPitch(to.getPitch());
event.setTo(fixed);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onDamage(EntityDamageEvent event) {
if (event.getEntity() instanceof Player player) recordDamage(player);
}
void recordDamage(Player player) {
long now = nanoTime.getAsLong();
damagedAt.put(player.getUniqueId(), now);
endHold(player.getUniqueId(), "damage");
}
@EventHandler(priority = EventPriority.MONITOR) public void onQuit(PlayerQuitEvent event) { endHold(event.getPlayer().getUniqueId(), "disconnect"); }
@EventHandler(priority = EventPriority.MONITOR) public void onDeath(PlayerDeathEvent event) { endHold(event.getEntity().getUniqueId(), "death"); }
@EventHandler(priority = EventPriority.MONITOR) public void onRespawn(PlayerRespawnEvent event) { endHold(event.getPlayer().getUniqueId(), "respawn"); }
@EventHandler(priority = EventPriority.MONITOR) public void onWorldChange(PlayerChangedWorldEvent event) { endHold(event.getPlayer().getUniqueId(), "world-change"); }
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onTeleport(PlayerTeleportEvent event) {
if (!event.getPlayer().getUniqueId().equals(internalTeleport)) {
endHold(event.getPlayer().getUniqueId(), "external-teleport");
}
}
@Override public void reset(UUID playerId) {
monitor.reset(playerId);
anchors.remove(playerId);
damagedAt.remove(playerId);
var prior = holds.remove(playerId);
audit.offer(new EnforcementAudit(playerId, prior == null ? null : prior.signal, EnforcementAction.OPERATOR_RESET,
0, settings.get().windowSeconds(), 0, "operator-reset"));
}
@Override public List<EnforcementSnapshot> inspect(UUID playerId, long nowNanos) {
return monitor.inspect(playerId, nowNanos, settings.get());
}
@Override public double holdRemainingSeconds(UUID playerId, long nowNanos) {
var hold = holds.get(playerId);
if (hold == null) return 0;
if (hold.untilNanos <= nowNanos) {
endHold(playerId, "expired");
return 0;
}
return (hold.untilNanos - nowNanos) / 1_000_000_000.0;
}
@Override public void policyChanged() {
monitor.clear();
anchors.clear();
damagedAt.clear();
for (UUID playerId : List.copyOf(holds.keySet())) endHold(playerId, "policy-changed");
}
void clear() {
monitor.clear();
anchors.clear();
damagedAt.clear();
for (UUID playerId : List.copyOf(holds.keySet())) endHold(playerId, "plugin-disable");
}
private void endHold(UUID playerId, String reason) {
var prior = holds.remove(playerId);
if (prior != null) audit.offer(new EnforcementAudit(playerId, prior.signal, EnforcementAction.HOLD_ENDED,
0, settings.get().windowSeconds(), 0, reason));
}
private void audit(EnforcementAction action, AlertDecision alert, EnforcementDecision decision, String reason) {
audit.offer(new EnforcementAudit(alert.playerId(), alert.signal(), action, decision.episodeCount(),
decision.windowSeconds(), decision.holdSeconds(), reason));
}
private static long seconds(int value) { return value * 1_000_000_000L; }
private record Hold(Location anchor, long untilNanos, DetectionSignal signal) { }
}
@@ -35,31 +35,39 @@ public final class BukkitMovementListener implements Listener {
private final BiPredicate<Location, Location> ordinaryEnvironment; private final BiPredicate<Location, Location> ordinaryEnvironment;
private final Function<Location, SupportState> supportState; private final Function<Location, SupportState> supportState;
private final Function<Player, GroundMovementContext> groundMovementContext; private final Function<Player, GroundMovementContext> groundMovementContext;
private final BukkitEnforcement enforcement;
private final Map<UUID, Location> lastFlightSamples = new HashMap<>(); private final Map<UUID, Location> lastFlightSamples = new HashMap<>();
public BukkitMovementListener(Server server, MovementMonitor monitor, Supplier<SentinelSettings> settings, LongSupplier nanoTime) { public BukkitMovementListener(Server server, MovementMonitor monitor, Supplier<SentinelSettings> settings, LongSupplier nanoTime) {
this(server, monitor, settings, nanoTime, this(server, monitor, settings, nanoTime,
(from, to) -> MovementEnvironment.isOrdinary(from) && MovementEnvironment.isOrdinary(to), (from, to) -> MovementEnvironment.isOrdinary(from) && MovementEnvironment.isOrdinary(to),
MovementEnvironment::supportState); MovementEnvironment::supportState, BukkitMovementListener::groundContext, null);
} }
BukkitMovementListener(Server server, MovementMonitor monitor, Supplier<SentinelSettings> settings, BukkitMovementListener(Server server, MovementMonitor monitor, Supplier<SentinelSettings> settings,
LongSupplier nanoTime, BiPredicate<Location, Location> ordinaryEnvironment) { LongSupplier nanoTime, BiPredicate<Location, Location> ordinaryEnvironment) {
this(server, monitor, settings, nanoTime, ordinaryEnvironment, MovementEnvironment::supportState, this(server, monitor, settings, nanoTime, ordinaryEnvironment, MovementEnvironment::supportState,
BukkitMovementListener::groundContext); BukkitMovementListener::groundContext, null);
} }
BukkitMovementListener(Server server, MovementMonitor monitor, Supplier<SentinelSettings> settings, BukkitMovementListener(Server server, MovementMonitor monitor, Supplier<SentinelSettings> settings,
LongSupplier nanoTime, BiPredicate<Location, Location> ordinaryEnvironment, LongSupplier nanoTime, BiPredicate<Location, Location> ordinaryEnvironment,
Function<Location, SupportState> supportState) { Function<Location, SupportState> supportState) {
this(server, monitor, settings, nanoTime, ordinaryEnvironment, supportState, this(server, monitor, settings, nanoTime, ordinaryEnvironment, supportState,
BukkitMovementListener::groundContext); BukkitMovementListener::groundContext, null);
} }
BukkitMovementListener(Server server, MovementMonitor monitor, Supplier<SentinelSettings> settings, BukkitMovementListener(Server server, MovementMonitor monitor, Supplier<SentinelSettings> settings,
LongSupplier nanoTime, BiPredicate<Location, Location> ordinaryEnvironment, LongSupplier nanoTime, BiPredicate<Location, Location> ordinaryEnvironment,
Function<Location, SupportState> supportState, Function<Location, SupportState> supportState,
Function<Player, GroundMovementContext> groundMovementContext) { Function<Player, GroundMovementContext> groundMovementContext) {
this(server, monitor, settings, nanoTime, ordinaryEnvironment, supportState, groundMovementContext, null);
}
BukkitMovementListener(Server server, MovementMonitor monitor, Supplier<SentinelSettings> settings,
LongSupplier nanoTime, BiPredicate<Location, Location> ordinaryEnvironment,
Function<Location, SupportState> supportState,
Function<Player, GroundMovementContext> groundMovementContext, BukkitEnforcement enforcement) {
this.server = server; this.server = server;
this.monitor = monitor; this.monitor = monitor;
this.settings = settings; this.settings = settings;
@@ -67,6 +75,7 @@ public final class BukkitMovementListener implements Listener {
this.ordinaryEnvironment = ordinaryEnvironment; this.ordinaryEnvironment = ordinaryEnvironment;
this.supportState = supportState; this.supportState = supportState;
this.groundMovementContext = groundMovementContext; this.groundMovementContext = groundMovementContext;
this.enforcement = enforcement;
} }
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false) @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false)
@@ -76,6 +85,7 @@ public final class BukkitMovementListener implements Listener {
Location to = event.getTo(); Location to = event.getTo();
var player = event.getPlayer(); var player = event.getPlayer();
ExclusionReason reason = classify(player, from, to, event.isCancelled()); ExclusionReason reason = classify(player, from, to, event.isCancelled());
if (reason == ExclusionReason.NONE && enforcement != null) enforcement.captureSafeAnchor(player, from);
monitor.record(player.getUniqueId(), new MovementFrame( monitor.record(player.getUniqueId(), new MovementFrame(
server.getCurrentTick(), server.getCurrentTick(),
nanoTime.getAsLong(), nanoTime.getAsLong(),
@@ -22,7 +22,7 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Consumer; import java.util.function.Consumer;
final class CloudEventLog implements AlertEventSink, AutoCloseable { final class CloudEventLog implements AlertEventSink, EnforcementAuditSink, AutoCloseable {
static final int DEFAULT_QUEUE_CAPACITY = 1_024; static final int DEFAULT_QUEUE_CAPACITY = 1_024;
static final int RETENTION_DAYS = 30; static final int RETENTION_DAYS = 30;
private static final Duration WARNING_INTERVAL = Duration.ofMinutes(1); private static final Duration WARNING_INTERVAL = Duration.ofMinutes(1);
@@ -52,13 +52,17 @@ final class CloudEventLog implements AlertEventSink, AutoCloseable {
worker = Thread.ofPlatform().name("purpur-sentinel-event-log").daemon(true).start(this::run); worker = Thread.ofPlatform().name("purpur-sentinel-event-log").daemon(true).start(this::run);
} }
@Override public void offer(AlertDecision alert) { @Override public void offer(AlertDecision alert) { offer(Event.alert(clock.instant(), alert)); }
@Override public void offer(EnforcementAudit audit) { offer(Event.audit(clock.instant(), audit)); }
private void offer(Event event) {
boolean dropped; boolean dropped;
synchronized (lifecycleLock) { synchronized (lifecycleLock) {
if (!accepting.get()) return; if (!accepting.get()) return;
dropped = !queue.offer(new Event(clock.instant(), alert)); dropped = !queue.offer(event);
} }
if (dropped) warn("Sentinel event log queue is full; one durable alert was dropped."); if (dropped) warn("Sentinel event log queue is full; one durable event was dropped.");
} }
@Override public void close() { @Override public void close() {
@@ -88,7 +92,7 @@ final class CloudEventLog implements AlertEventSink, AutoCloseable {
} }
if (event == null) continue; if (event == null) continue;
try { try {
store.append(event.observedAt, event.alert); store.append(event.observedAt, event.json());
} catch (RetentionException exception) { } catch (RetentionException exception) {
warn("Sentinel could not apply event log retention; detection remains active."); warn("Sentinel could not apply event log retention; detection remains active.");
} catch (IOException | RuntimeException exception) { } catch (IOException | RuntimeException exception) {
@@ -185,6 +189,19 @@ final class CloudEventLog implements AlertEventSink, AutoCloseable {
+ "\",\"datacontenttype\":\"application/json\",\"data\":" + data + '}'; + "\",\"datacontenttype\":\"application/json\",\"data\":" + data + '}';
} }
private static String toJson(Instant observedAt, EnforcementAudit audit) {
String type = audit.action().name().toLowerCase(Locale.ROOT).replace('_', '-');
return "{\"specversion\":\"1.0\",\"id\":\"" + UUID.randomUUID()
+ "\",\"source\":\"urn:somc:purpur-sentinel\",\"type\":\"games.dmg.purpursentinel.enforcement."
+ type + "\",\"subject\":\"" + audit.playerId() + "\",\"time\":\""
+ DateTimeFormatter.ISO_INSTANT.format(observedAt.truncatedTo(ChronoUnit.MILLIS))
+ "\",\"datacontenttype\":\"application/json\",\"data\":{\"signal\":\""
+ audit.signal() + "\",\"action\":\"" + audit.action() + "\",\"episodeCount\":"
+ audit.episodeCount() + ",\"windowSeconds\":" + audit.windowSeconds()
+ ",\"durationSeconds\":" + audit.durationSeconds() + ",\"reason\":\""
+ escape(audit.reason()) + "\"}}";
}
private static void appendTravel(StringBuilder data, AlertDecision alert) { private static void appendTravel(StringBuilder data, AlertDecision alert) {
data.append(",\"eligibleTicks\":").append(alert.eligibleTicks()) data.append(",\"eligibleTicks\":").append(alert.eligibleTicks())
.append(",\"horizontalDistance\":").append(number(alert.horizontalDistance())) .append(",\"horizontalDistance\":").append(number(alert.horizontalDistance()))
@@ -229,7 +246,7 @@ final class CloudEventLog implements AlertEventSink, AutoCloseable {
@FunctionalInterface @FunctionalInterface
interface EventStore extends AutoCloseable { interface EventStore extends AutoCloseable {
void append(Instant observedAt, AlertDecision alert) throws IOException; void append(Instant observedAt, String json) throws IOException;
@Override default void close() throws IOException { } @Override default void close() throws IOException { }
} }
@@ -239,7 +256,7 @@ final class CloudEventLog implements AlertEventSink, AutoCloseable {
private DailyEventStore(Path directory) { this.directory = directory; } private DailyEventStore(Path directory) { this.directory = directory; }
@Override public void append(Instant observedAt, AlertDecision alert) throws IOException { @Override public void append(Instant observedAt, String json) throws IOException {
LocalDate date = observedAt.atZone(ZoneOffset.UTC).toLocalDate(); LocalDate date = observedAt.atZone(ZoneOffset.UTC).toLocalDate();
IOException retentionFailure = null; IOException retentionFailure = null;
try { try {
@@ -252,7 +269,7 @@ final class CloudEventLog implements AlertEventSink, AutoCloseable {
retentionFailure = exception; retentionFailure = exception;
} }
} }
writer.output.write(toJson(observedAt, alert)); writer.output.write(json);
writer.output.newLine(); writer.output.newLine();
writer.output.flush(); writer.output.flush();
} catch (IOException exception) { } catch (IOException exception) {
@@ -277,7 +294,17 @@ final class CloudEventLog implements AlertEventSink, AutoCloseable {
RetentionException(IOException cause) { super(cause); } RetentionException(IOException cause) { super(cause); }
} }
private record Event(Instant observedAt, AlertDecision alert) { } private record Event(Instant observedAt, AlertDecision alert, EnforcementAudit audit) {
private static Event alert(Instant observedAt, AlertDecision alert) {
return new Event(observedAt, alert, null);
}
private static Event audit(Instant observedAt, EnforcementAudit audit) {
return new Event(observedAt, null, audit);
}
private String json() {
return alert != null ? toJson(observedAt, alert) : toJson(observedAt, audit);
}
}
private record Writer(LocalDate date, BufferedWriter output) implements AutoCloseable { private record Writer(LocalDate date, BufferedWriter output) implements AutoCloseable {
@Override public void close() throws IOException { output.close(); } @Override public void close() throws IOException { output.close(); }
} }
@@ -0,0 +1,11 @@
package games.dmg.purpursentinel;
enum EnforcementAction {
NONE,
WARNING,
SETBACK,
KICK,
SKIPPED,
HOLD_ENDED,
OPERATOR_RESET
}
@@ -0,0 +1,13 @@
package games.dmg.purpursentinel;
import java.util.UUID;
record EnforcementAudit(
UUID playerId,
DetectionSignal signal,
EnforcementAction action,
int episodeCount,
int windowSeconds,
int durationSeconds,
String reason) {
}
@@ -0,0 +1,8 @@
package games.dmg.purpursentinel;
@FunctionalInterface
interface EnforcementAuditSink {
EnforcementAuditSink NONE = audit -> { };
void offer(EnforcementAudit audit);
}
@@ -0,0 +1,16 @@
package games.dmg.purpursentinel;
record EnforcementDecision(
EnforcementAction action,
DetectionSignal signal,
int episodeCount,
int windowSeconds,
int holdSeconds,
String reason) {
static EnforcementDecision none() {
return new EnforcementDecision(EnforcementAction.NONE, null, 0, 0, 0, "none");
}
boolean isNone() { return action == EnforcementAction.NONE; }
}
@@ -0,0 +1,26 @@
package games.dmg.purpursentinel;
import java.util.Arrays;
import java.util.Locale;
import java.util.Optional;
public enum EnforcementMode {
OFF("off"),
WARN("warn"),
SETBACK("setback"),
KICK("kick"),
SETBACK_KICK("setback-kick");
private final String value;
EnforcementMode(String value) { this.value = value; }
String value() { return value; }
boolean warns() { return this == WARN || this == SETBACK || this == SETBACK_KICK; }
boolean setsBack() { return this == SETBACK || this == SETBACK_KICK; }
boolean kicks() { return this == KICK || this == SETBACK_KICK; }
static Optional<EnforcementMode> find(String value) {
return Arrays.stream(values()).filter(mode -> mode.value.equals(value.toLowerCase(Locale.ROOT))).findFirst();
}
}
@@ -0,0 +1,105 @@
package games.dmg.purpursentinel;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.EnumMap;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
final class EnforcementMonitor {
private final EnforcementAuditSink audit;
private final Map<UUID, EnumMap<DetectionSignal, State>> players = new HashMap<>();
EnforcementMonitor(EnforcementAuditSink audit) { this.audit = audit; }
EnforcementDecision observe(UUID playerId, DetectionSignal signal, long nowNanos,
EnforcementSettings settings, boolean safeAnchor, boolean recentDamage) {
return observe(playerId, signal, nowNanos, Long.MIN_VALUE, settings, safeAnchor, recentDamage);
}
EnforcementDecision observe(UUID playerId, DetectionSignal signal, long nowNanos, long episodeKey,
EnforcementSettings settings, boolean safeAnchor, boolean recentDamage) {
if (settings.mode() == EnforcementMode.OFF || !settings.eligibleSignals().contains(signal)) {
return EnforcementDecision.none();
}
var signals = players.computeIfAbsent(playerId, ignored -> new EnumMap<>(DetectionSignal.class));
var state = signals.computeIfAbsent(signal, ignored -> new State());
state.expire(nowNanos, settings.windowSeconds());
state.rearm(settings);
if (episodeKey != Long.MIN_VALUE && state.lastEpisodeKey == episodeKey) return EnforcementDecision.none();
state.lastEpisodeKey = episodeKey;
state.episodes.addLast(nowNanos);
int count = state.episodes.size();
EnforcementAction action = EnforcementAction.NONE;
String reason = "none";
if (settings.mode().kicks() && count >= settings.kickEpisodes() && !state.kicked) {
action = EnforcementAction.KICK;
state.kicked = true;
} else if (settings.mode().setsBack() && count >= settings.setbackEpisodes() && !state.setBack) {
if (recentDamage || !safeAnchor) {
action = EnforcementAction.SKIPPED;
reason = recentDamage ? "recent-damage" : "no-safe-anchor";
} else {
action = EnforcementAction.SETBACK;
}
state.setBack = true;
state.warned = true;
} else if (settings.mode().warns() && count >= settings.warningEpisodes() && !state.warned) {
action = EnforcementAction.WARNING;
state.warned = true;
}
if (action == EnforcementAction.NONE) return EnforcementDecision.none();
state.lastAction = action;
var decision = new EnforcementDecision(action, signal, count, settings.windowSeconds(),
action == EnforcementAction.SETBACK ? settings.holdSeconds() : 0, reason);
audit.offer(new EnforcementAudit(playerId, signal, action, count, settings.windowSeconds(),
decision.holdSeconds(), reason));
return decision;
}
List<EnforcementSnapshot> inspect(UUID playerId, long nowNanos, EnforcementSettings settings) {
var signals = players.get(playerId);
if (signals == null) return List.of();
var snapshots = new ArrayList<EnforcementSnapshot>();
signals.forEach((signal, state) -> {
state.expire(nowNanos, settings.windowSeconds());
if (!state.episodes.isEmpty()) {
long expires = state.episodes.getFirst() + seconds(settings.windowSeconds());
snapshots.add(new EnforcementSnapshot(signal, state.episodes.size(),
Math.max(0, expires - nowNanos) / 1_000_000_000.0, state.lastAction));
}
});
snapshots.sort(Comparator.comparing(snapshot -> snapshot.signal().name()));
return List.copyOf(snapshots);
}
void reset(UUID playerId) { players.remove(playerId); }
void clear() { players.clear(); }
private static long seconds(int value) { return value * 1_000_000_000L; }
private static final class State {
private final ArrayDeque<Long> episodes = new ArrayDeque<>();
private boolean warned;
private boolean setBack;
private boolean kicked;
private EnforcementAction lastAction = EnforcementAction.NONE;
private long lastEpisodeKey = Long.MIN_VALUE;
private void expire(long nowNanos, int windowSeconds) {
long cutoff = nowNanos - seconds(windowSeconds);
while (!episodes.isEmpty() && episodes.getFirst() <= cutoff) episodes.removeFirst();
if (episodes.isEmpty()) lastAction = EnforcementAction.NONE;
}
private void rearm(EnforcementSettings settings) {
int count = episodes.size();
if (count < settings.warningEpisodes()) warned = false;
if (count < settings.setbackEpisodes()) setBack = false;
if (count < settings.kickEpisodes()) kicked = false;
}
}
}
@@ -0,0 +1,20 @@
package games.dmg.purpursentinel;
import java.util.List;
import java.util.UUID;
interface EnforcementRuntime {
EnforcementRuntime NONE = new EnforcementRuntime() {
@Override public void observe(AlertDecision alert) { }
@Override public void reset(UUID playerId) { }
@Override public List<EnforcementSnapshot> inspect(UUID playerId, long nowNanos) { return List.of(); }
@Override public double holdRemainingSeconds(UUID playerId, long nowNanos) { return 0; }
@Override public void policyChanged() { }
};
void observe(AlertDecision alert);
void reset(UUID playerId);
List<EnforcementSnapshot> inspect(UUID playerId, long nowNanos);
double holdRemainingSeconds(UUID playerId, long nowNanos);
void policyChanged();
}
@@ -0,0 +1,115 @@
package games.dmg.purpursentinel;
import java.io.IOException;
import java.util.EnumSet;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
public final class EnforcementService {
private static final String MODE = "enforcement-mode";
private final SettingsPersistence persistence;
private final AtomicReference<EnforcementSettings> current;
private EnforcementService(SettingsPersistence persistence, EnforcementSettings settings) {
this.persistence = persistence;
current = new AtomicReference<>(settings);
}
public static EnforcementService load(SettingsPersistence persistence, SettingsService ignored) throws IOException {
return new EnforcementService(persistence, parse(persistence.load().values()));
}
public EnforcementSettings current() { return current.get(); }
public synchronized String update(String key, String rawValue) throws IOException {
var latest = persistence.load();
var copy = new java.util.LinkedHashMap<>(latest.values());
copy.put(documentKey(key), parseValue(key, rawValue));
var document = new SettingsDocument(copy);
var next = parse(document.values());
persistence.save(document);
current.set(next);
return value(key);
}
public synchronized String updateSignal(DetectionSignal signal, boolean enabled) throws IOException {
if (enabled && !EnforcementSettings.enforceableSignals().contains(signal)) {
throw new IllegalArgumentException(signal + " is alert-only and cannot be enabled for enforcement.");
}
var latest = persistence.load();
var copy = new java.util.LinkedHashMap<>(latest.values());
copy.put(signalKey(signal), enabled);
var document = new SettingsDocument(copy);
var next = parse(document.values());
persistence.save(document);
current.set(next);
return enabled ? "on" : "off";
}
public String value(String key) {
var settings = current();
return switch (key) {
case "mode" -> settings.mode().value();
case "window-seconds" -> Integer.toString(settings.windowSeconds());
case "warning-episodes" -> Integer.toString(settings.warningEpisodes());
case "setback-episodes" -> Integer.toString(settings.setbackEpisodes());
case "kick-episodes" -> Integer.toString(settings.kickEpisodes());
case "hold-seconds" -> Integer.toString(settings.holdSeconds());
case "recent-damage-exclusion-seconds" -> Integer.toString(settings.recentDamageExclusionSeconds());
default -> throw new IllegalArgumentException("Unknown enforcement setting: " + key + '.');
};
}
private static EnforcementSettings parse(Map<String, Object> values) {
var defaults = EnforcementSettings.defaults();
String modeValue = values.containsKey(MODE) ? String.valueOf(values.get(MODE)) : defaults.mode().value();
if (values.get(MODE) instanceof Boolean bool) modeValue = bool ? "warn" : "off";
var mode = EnforcementMode.find(modeValue)
.orElseThrow(() -> new IllegalArgumentException("Invalid enforcement-mode."));
var signals = EnumSet.noneOf(DetectionSignal.class);
for (DetectionSignal signal : DetectionSignal.values()) {
boolean enabled = values.containsKey(signalKey(signal))
? bool(values.get(signalKey(signal)), signalKey(signal)) : defaults.eligibleSignals().contains(signal);
if (enabled && EnforcementSettings.enforceableSignals().contains(signal)) signals.add(signal);
}
return new EnforcementSettings(mode,
integer(values, "enforcement-window-seconds", defaults.windowSeconds()),
integer(values, "enforcement-warning-episodes", defaults.warningEpisodes()),
integer(values, "enforcement-setback-episodes", defaults.setbackEpisodes()),
integer(values, "enforcement-kick-episodes", defaults.kickEpisodes()),
integer(values, "enforcement-hold-seconds", defaults.holdSeconds()),
integer(values, "enforcement-recent-damage-exclusion-seconds", defaults.recentDamageExclusionSeconds()),
signals);
}
private static Object parseValue(String key, String rawValue) {
if (key.equals("mode")) return EnforcementMode.find(rawValue)
.orElseThrow(() -> new IllegalArgumentException("Invalid enforcement mode." )).value();
try {
if (!rawValue.matches("0|[1-9][0-9]*")) throw new NumberFormatException();
return Integer.parseInt(rawValue);
} catch (NumberFormatException exception) {
throw new IllegalArgumentException("Invalid value for enforcement " + key + '.', exception);
}
}
private static int integer(Map<String, Object> values, String key, int fallback) {
if (!values.containsKey(key)) return fallback;
Object value = values.get(key);
if (!(value instanceof Number number) || number.doubleValue() != Math.rint(number.doubleValue())) {
throw new IllegalArgumentException(key + " must be an integer.");
}
return Math.toIntExact(number.longValue());
}
private static boolean bool(Object value, String key) {
if (value instanceof Boolean bool) return bool;
throw new IllegalArgumentException(key + " must be true or false.");
}
private static String documentKey(String key) { return "enforcement-" + key; }
private static String signalKey(DetectionSignal signal) {
return "enforcement-signal-" + signal.name().toLowerCase(Locale.ROOT).replace('_', '-');
}
}
@@ -0,0 +1,50 @@
package games.dmg.purpursentinel;
import java.util.EnumSet;
import java.util.Set;
public record EnforcementSettings(
EnforcementMode mode,
int windowSeconds,
int warningEpisodes,
int setbackEpisodes,
int kickEpisodes,
int holdSeconds,
int recentDamageExclusionSeconds,
Set<DetectionSignal> eligibleSignals) {
public EnforcementSettings {
eligibleSignals = Set.copyOf(eligibleSignals);
range(windowSeconds, 60, 3600, "window-seconds");
range(warningEpisodes, 1, 20, "warning-episodes");
range(setbackEpisodes, 1, 40, "setback-episodes");
range(kickEpisodes, 1, 60, "kick-episodes");
range(holdSeconds, 1, 60, "hold-seconds");
range(recentDamageExclusionSeconds, 0, 60, "recent-damage-exclusion-seconds");
if (mode.setsBack() && warningEpisodes > setbackEpisodes) {
throw new IllegalArgumentException("warning-episodes cannot exceed setback-episodes.");
}
if (mode == EnforcementMode.SETBACK_KICK && setbackEpisodes > kickEpisodes) {
throw new IllegalArgumentException("setback-episodes cannot exceed kick-episodes.");
}
}
static EnforcementSettings defaults() {
return new EnforcementSettings(EnforcementMode.OFF, 600, 3, 5, 7, 10, 10, defaultSignals());
}
static Set<DetectionSignal> defaultSignals() { return enforceableSignals(); }
static Set<DetectionSignal> enforceableSignals() {
return EnumSet.of(DetectionSignal.ABSOLUTE_GROUND_SPEED, DetectionSignal.CONTEXTUAL_GROUND_SPEED,
DetectionSignal.UNAUTHORIZED_FLIGHT_STATE, DetectionSignal.UNSUPPORTED_ASCENT,
DetectionSignal.UNSUPPORTED_HOVER, DetectionSignal.UNSUPPORTED_CRUISE,
DetectionSignal.ELYTRA_SOURCELESS_PROPULSION);
}
private static void range(int value, int minimum, int maximum, String key) {
if (value < minimum || value > maximum) {
throw new IllegalArgumentException(key + " must be between " + minimum + " and " + maximum + '.');
}
}
}
@@ -0,0 +1,8 @@
package games.dmg.purpursentinel;
record EnforcementSnapshot(
DetectionSignal signal,
int episodeCount,
double windowRemainingSeconds,
EnforcementAction lastAction) {
}
@@ -66,6 +66,26 @@ final class MovementEnvironment {
return supported ? SupportState.SUPPORTED : SupportState.UNSUPPORTED; return supported ? SupportState.SUPPORTED : SupportState.UNSUPPORTED;
} }
static boolean isSafeAnchor(Location location) {
var world = location.getWorld();
if (world == null || !finite(location.getX(), location.getY(), location.getZ())
|| location.getY() < world.getMinHeight() + 1 || location.getY() + 2 >= world.getMaxHeight()
|| !world.isChunkLoaded(location.getBlockX() >> 4, location.getBlockZ() >> 4)) {
return false;
}
var footing = world.getBlockAt(location.getBlockX(), (int) Math.floor(location.getY() - 0.01),
location.getBlockZ());
if (unsafe(footing.getType()) || footing.isLiquid() || footing.isPassable()) return false;
for (int y = location.getBlockY(); y <= location.getBlockY() + 1; y++) {
var body = world.getBlockAt(location.getBlockX(), y, location.getBlockZ());
if (unsafe(body.getType()) || body.isLiquid() || !body.isPassable()
|| body.getBlockData() instanceof Waterlogged waterlogged && waterlogged.isWaterlogged()) {
return false;
}
}
return true;
}
static boolean isOrdinary(Location location) { static boolean isOrdinary(Location location) {
var world = location.getWorld(); var world = location.getWorld();
if (world == null || !world.isChunkLoaded(location.getBlockX() >> 4, location.getBlockZ() >> 4)) { if (world == null || !world.isChunkLoaded(location.getBlockX() >> 4, location.getBlockZ() >> 4)) {
@@ -91,6 +111,16 @@ final class MovementEnvironment {
return true; return true;
} }
private static boolean unsafe(Material type) {
return ALTERING.contains(type) || type == Material.LAVA || type == Material.FIRE
|| type == Material.SOUL_FIRE || type == Material.CAMPFIRE || type == Material.SOUL_CAMPFIRE;
}
private static boolean finite(double... values) {
for (double value : values) if (!Double.isFinite(value)) return false;
return true;
}
private static boolean isIce(Material type) { private static boolean isIce(Material type) {
return type == Material.ICE || type == Material.PACKED_ICE return type == Material.ICE || type == Material.PACKED_ICE
|| type == Material.BLUE_ICE || type == Material.FROSTED_ICE; || type == Material.BLUE_ICE || type == Material.FROSTED_ICE;
@@ -13,6 +13,7 @@ public final class PurpurSentinelPlugin extends JavaPlugin {
private BukkitMovementListener listener; private BukkitMovementListener listener;
private BukkitBoatObserver boatObserver; private BukkitBoatObserver boatObserver;
private BukkitElytraObserver elytraObserver; private BukkitElytraObserver elytraObserver;
private BukkitEnforcement enforcementRuntime;
private CloudEventLog eventLog; private CloudEventLog eventLog;
private BukkitTask tickTask; private BukkitTask tickTask;
@@ -21,21 +22,29 @@ public final class PurpurSentinelPlugin extends JavaPlugin {
Files.createDirectories(getDataFolder().toPath()); Files.createDirectories(getDataFolder().toPath());
var configPath = getDataFolder().toPath().resolve("config.yml"); var configPath = getDataFolder().toPath().resolve("config.yml");
AtomicYamlSettingsStore.writeDefaults(configPath); AtomicYamlSettingsStore.writeDefaults(configPath);
var settings = SettingsService.load(new AtomicYamlSettingsStore(configPath)); var persistence = new AtomicYamlSettingsStore(configPath);
var settings = SettingsService.load(persistence);
var enforcement = EnforcementService.load(persistence, settings);
monitor = new MovementMonitor(new MovementDetector()); 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(), eventLog = new CloudEventLog(getDataFolder().toPath().resolve("events"), Clock.systemUTC(),
message -> getLogger().warning(message)); message -> getLogger().warning(message));
enforcementRuntime = new BukkitEnforcement(getServer(), enforcement::current, System::nanoTime,
MovementEnvironment::isSafeAnchor, eventLog);
listener = new BukkitMovementListener(getServer(), monitor, settings::current, System::nanoTime,
(from, to) -> MovementEnvironment.isOrdinary(from) && MovementEnvironment.isOrdinary(to),
MovementEnvironment::supportState, BukkitMovementListener::groundContext, enforcementRuntime);
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 command = Objects.requireNonNull(getCommand("sentinel"), "Missing sentinel command");
var handler = new SentinelCommand(getServer(), monitor, settings, System::nanoTime); var handler = new SentinelCommand(getServer(), monitor, settings, enforcement, enforcementRuntime,
System::nanoTime);
command.setExecutor(handler); command.setExecutor(handler);
command.setTabCompleter(handler); command.setTabCompleter(handler);
getServer().getPluginManager().registerEvents(listener, this); getServer().getPluginManager().registerEvents(listener, this);
getServer().getPluginManager().registerEvents(boatObserver, this); getServer().getPluginManager().registerEvents(boatObserver, this);
getServer().getPluginManager().registerEvents(elytraObserver, this); getServer().getPluginManager().registerEvents(elytraObserver, this);
var alerts = new SentinelAlerts(getServer(), eventLog); getServer().getPluginManager().registerEvents(enforcementRuntime, this);
var alerts = new SentinelAlerts(getServer(), eventLog, enforcementRuntime);
tickTask = getServer().getScheduler().runTaskTimer(this, () -> { tickTask = getServer().getScheduler().runTaskTimer(this, () -> {
listener.sampleFlight(); listener.sampleFlight();
boatObserver.sample(); boatObserver.sample();
@@ -75,6 +84,11 @@ public final class PurpurSentinelPlugin extends JavaPlugin {
monitor.clear(); monitor.clear();
monitor = null; monitor = null;
} }
if (enforcementRuntime != null) {
enforcementRuntime.clear();
HandlerList.unregisterAll(enforcementRuntime);
enforcementRuntime = null;
}
if (eventLog != null) { if (eventLog != null) {
eventLog.close(); eventLog.close();
eventLog = null; eventLog = null;
@@ -6,18 +6,25 @@ import org.bukkit.Server;
final class SentinelAlerts { final class SentinelAlerts {
private final Server server; private final Server server;
private final AlertEventSink events; private final AlertEventSink events;
private final EnforcementRuntime enforcement;
SentinelAlerts(Server server) { SentinelAlerts(Server server) {
this(server, AlertEventSink.NONE); this(server, AlertEventSink.NONE, EnforcementRuntime.NONE);
} }
SentinelAlerts(Server server, AlertEventSink events) { SentinelAlerts(Server server, AlertEventSink events) {
this(server, events, EnforcementRuntime.NONE);
}
SentinelAlerts(Server server, AlertEventSink events, EnforcementRuntime enforcement) {
this.server = server; this.server = server;
this.events = events; this.events = events;
this.enforcement = enforcement;
} }
void send(AlertDecision alert) { void send(AlertDecision alert) {
events.offer(alert); events.offer(alert);
enforcement.observe(alert);
var subject = server.getPlayer(alert.playerId()); var subject = server.getPlayer(alert.playerId());
if (subject == null || !subject.isOnline()) return; if (subject == null || !subject.isOnline()) return;
String message; String message;
@@ -19,12 +19,26 @@ public final class SentinelCommand implements CommandExecutor, TabCompleter {
private final Server server; private final Server server;
private final MovementMonitor monitor; private final MovementMonitor monitor;
private final SettingsService settings; private final SettingsService settings;
private final EnforcementService enforcement;
private final EnforcementRuntime enforcementRuntime;
private final LongSupplier nanoTime; private final LongSupplier nanoTime;
public SentinelCommand(Server server, MovementMonitor monitor, SettingsService settings, LongSupplier nanoTime) { public SentinelCommand(Server server, MovementMonitor monitor, SettingsService settings, LongSupplier nanoTime) {
this(server, monitor, settings, null, EnforcementRuntime.NONE, nanoTime);
}
public SentinelCommand(Server server, MovementMonitor monitor, SettingsService settings,
EnforcementService enforcement, LongSupplier nanoTime) {
this(server, monitor, settings, enforcement, EnforcementRuntime.NONE, nanoTime);
}
public SentinelCommand(Server server, MovementMonitor monitor, SettingsService settings,
EnforcementService enforcement, EnforcementRuntime enforcementRuntime, LongSupplier nanoTime) {
this.server = server; this.server = server;
this.monitor = monitor; this.monitor = monitor;
this.settings = settings; this.settings = settings;
this.enforcement = enforcement;
this.enforcementRuntime = enforcementRuntime;
this.nanoTime = nanoTime; this.nanoTime = nanoTime;
} }
@@ -36,6 +50,7 @@ public final class SentinelCommand implements CommandExecutor, TabCompleter {
} }
if (args[0].equalsIgnoreCase("inspect")) return inspect(sender, args); if (args[0].equalsIgnoreCase("inspect")) return inspect(sender, args);
if (args[0].equalsIgnoreCase("config")) return configure(sender, args); if (args[0].equalsIgnoreCase("config")) return configure(sender, args);
if (args[0].equalsIgnoreCase("enforcement")) return enforce(sender, args);
sender.sendMessage("Unsupported Sentinel route: " + args[0] + '.'); sender.sendMessage("Unsupported Sentinel route: " + args[0] + '.');
return true; return true;
} }
@@ -45,9 +60,23 @@ public final class SentinelCommand implements CommandExecutor, TabCompleter {
if (args.length == 1) { if (args.length == 1) {
var routes = new ArrayList<String>(); var routes = new ArrayList<String>();
if (sender.hasPermission("sentinel.config")) routes.add("config"); if (sender.hasPermission("sentinel.config")) routes.add("config");
if (sender.hasPermission("sentinel.enforcement")) routes.add("enforcement");
if (sender.hasPermission("sentinel.inspect")) routes.add("inspect"); if (sender.hasPermission("sentinel.inspect")) routes.add("inspect");
return filter(routes, args[0]); return filter(routes, args[0]);
} }
if (args[0].equalsIgnoreCase("enforcement") && sender.hasPermission("sentinel.enforcement")) {
if (args.length == 2) return filter(List.of("hold-seconds", "kick-episodes", "mode",
"recent-damage-exclusion-seconds", "reset", "setback-episodes", "signal", "status",
"warning-episodes", "window-seconds"), args[1]);
if (args.length == 3 && args[1].equalsIgnoreCase("mode")) {
return filter(java.util.Arrays.stream(EnforcementMode.values()).map(EnforcementMode::value).toList(), args[2]);
}
if (args.length == 3 && args[1].equalsIgnoreCase("signal")) {
return filter(java.util.Arrays.stream(DetectionSignal.values())
.map(signal -> signal.name().toLowerCase(Locale.ROOT).replace('_', '-')).toList(), args[2]);
}
if (args.length == 4 && args[1].equalsIgnoreCase("signal")) return filter(List.of("off", "on"), args[3]);
}
if (args.length == 2 && args[0].equalsIgnoreCase("inspect") && sender.hasPermission("sentinel.inspect")) { if (args.length == 2 && args[0].equalsIgnoreCase("inspect") && sender.hasPermission("sentinel.inspect")) {
return filter(server.getOnlinePlayers().stream().map(Player::getName) return filter(server.getOnlinePlayers().stream().map(Player::getName)
.sorted(String.CASE_INSENSITIVE_ORDER).toList(), args[1]); .sorted(String.CASE_INSENSITIVE_ORDER).toList(), args[1]);
@@ -87,9 +116,21 @@ public final class SentinelCommand implements CommandExecutor, TabCompleter {
} }
return true; return true;
} }
var snapshot = monitor.inspect(player.get().getUniqueId(), nanoTime.getAsLong()); long now = nanoTime.getAsLong();
var snapshot = monitor.inspect(player.get().getUniqueId(), now);
var enforcementEvidence = enforcementRuntime.inspect(player.get().getUniqueId(), now);
double hold = enforcementRuntime.holdRemainingSeconds(player.get().getUniqueId(), now);
if (!enforcementEvidence.isEmpty() || hold > 0) {
String counts = enforcementEvidence.stream().map(item -> item.signal() + "=" + item.episodeCount())
.collect(java.util.stream.Collectors.joining(","));
sender.sendMessage(String.format(Locale.ROOT, "%s enforcement: mode %s; episodes %s; hold %.1fs; last action %s.",
player.get().getName(), enforcement == null ? "off" : enforcement.current().mode().value(), counts,
hold, enforcementEvidence.isEmpty() ? EnforcementAction.NONE : enforcementEvidence.getLast().lastAction()));
}
if (snapshot.isEmpty()) { if (snapshot.isEmpty()) {
sender.sendMessage("No current evidence for " + player.get().getName() + "; none was recorded or it expired."); if (enforcementEvidence.isEmpty() && hold <= 0) {
sender.sendMessage("No current evidence for " + player.get().getName() + "; none was recorded or it expired.");
}
return true; return true;
} }
var evidence = snapshot.get(); var evidence = snapshot.get();
@@ -166,6 +207,74 @@ public final class SentinelCommand implements CommandExecutor, TabCompleter {
return true; return true;
} }
private boolean enforce(CommandSender sender, String[] args) {
if (!sender.hasPermission("sentinel.enforcement")) {
sender.sendMessage("You do not have permission to configure Sentinel enforcement.");
return true;
}
if (enforcement == null || args.length < 2) {
sender.sendMessage("Usage: /sentinel enforcement <status|mode|setting|signal|reset> ...");
return true;
}
if (args[1].equalsIgnoreCase("status") && args.length == 2) {
var current = enforcement.current();
sender.sendMessage(String.format(Locale.ROOT,
"Sentinel enforcement: mode %s; window %ds; warn %d; setback %d; kick %d; hold %ds; recent-damage exclusion %ds.",
current.mode().value(), current.windowSeconds(), current.warningEpisodes(),
current.setbackEpisodes(), current.kickEpisodes(), current.holdSeconds(),
current.recentDamageExclusionSeconds()));
return true;
}
if (args[1].equalsIgnoreCase("signal") && args.length == 4) {
var signal = java.util.Arrays.stream(DetectionSignal.values())
.filter(value -> value.name().toLowerCase(Locale.ROOT).replace('_', '-').equals(args[2].toLowerCase(Locale.ROOT)))
.findFirst();
if (signal.isEmpty() || !args[3].equalsIgnoreCase("on") && !args[3].equalsIgnoreCase("off")) {
sender.sendMessage("Invalid enforcement signal or state.");
return true;
}
try {
String value = enforcement.updateSignal(signal.get(), args[3].equalsIgnoreCase("on"));
enforcementRuntime.policyChanged();
sender.sendMessage("enforcement signal " + args[2].toLowerCase(Locale.ROOT) + " updated to " + value + '.');
} catch (IOException exception) {
sender.sendMessage("Could not save Sentinel enforcement; the prior value remains active.");
} catch (IllegalArgumentException exception) {
sender.sendMessage(exception.getMessage());
}
return true;
}
if (args[1].equalsIgnoreCase("reset")) {
if (args.length != 3) {
sender.sendMessage("Usage: /sentinel enforcement reset <player>");
return true;
}
var player = server.getOnlinePlayers().stream()
.filter(candidate -> candidate.getName().equalsIgnoreCase(args[2])).findFirst();
if (player.isEmpty()) {
sender.sendMessage("Unknown or offline player: " + args[2] + '.');
return true;
}
enforcementRuntime.reset(player.get().getUniqueId());
sender.sendMessage("Sentinel enforcement state reset for " + player.get().getName() + '.');
return true;
}
if (args.length != 3) {
sender.sendMessage("Usage: /sentinel enforcement <setting> <value>");
return true;
}
try {
String value = enforcement.update(args[1].toLowerCase(Locale.ROOT), args[2]);
enforcementRuntime.policyChanged();
sender.sendMessage("enforcement " + args[1].toLowerCase(Locale.ROOT) + " updated to " + value + '.');
} catch (IllegalArgumentException exception) {
sender.sendMessage(exception.getMessage());
} catch (IOException exception) {
sender.sendMessage("Could not save Sentinel enforcement; the prior value remains active.");
}
return true;
}
private static String yesNo(boolean value) { private static String yesNo(boolean value) {
return value ? "yes" : "no"; return value ? "yes" : "no";
} }
+18
View File
@@ -14,3 +14,21 @@ unsupported-boat-ticks: 40
unsupported-boat-hover-ticks: 30 unsupported-boat-hover-ticks: 30
unsupported-boat-ascent-blocks: 2.0 unsupported-boat-ascent-blocks: 2.0
unsupported-boat-cruise-blocks: 12.0 unsupported-boat-cruise-blocks: 12.0
enforcement-mode: off
enforcement-window-seconds: 600
enforcement-warning-episodes: 3
enforcement-setback-episodes: 5
enforcement-kick-episodes: 7
enforcement-hold-seconds: 10
enforcement-recent-damage-exclusion-seconds: 10
enforcement-signal-absolute-ground-speed: true
enforcement-signal-contextual-ground-speed: true
enforcement-signal-unauthorized-flight-state: true
enforcement-signal-unsupported-ascent: true
enforcement-signal-unsupported-hover: true
enforcement-signal-unsupported-cruise: true
enforcement-signal-elytra-sourceless-propulsion: true
enforcement-signal-unsupported-boat-ascent: false
enforcement-signal-unsupported-boat-hover: false
enforcement-signal-unsupported-boat-cruise: false
enforcement-signal-elytra-implausible-wear: false
+4 -1
View File
@@ -6,7 +6,7 @@ description: Passive and explainable movement-pattern observation for operators.
commands: commands:
sentinel: sentinel:
description: Inspect movement evidence or configure Sentinel. description: Inspect movement evidence or configure Sentinel.
usage: /sentinel <inspect|config> ... usage: /sentinel <inspect|config|enforcement> ...
permissions: permissions:
sentinel.alerts: sentinel.alerts:
description: Receive passive movement evidence alerts. description: Receive passive movement evidence alerts.
@@ -17,3 +17,6 @@ permissions:
sentinel.config: sentinel.config:
description: Read and update Sentinel configuration. description: Read and update Sentinel configuration.
default: op default: op
sentinel.enforcement:
description: Read, update, and reset optional Sentinel enforcement.
default: op
@@ -63,7 +63,7 @@ class PluginLifecycleTest {
var description = new PluginDescriptionFile(stream); var description = new PluginDescriptionFile(stream);
assertEquals("PurpurSentinel", description.getName()); assertEquals("PurpurSentinel", description.getName());
assertEquals("26.3", description.getAPIVersion()); assertEquals("26.3", description.getAPIVersion());
assertEquals(3, description.getPermissions().size()); assertEquals(4, description.getPermissions().size());
when(plugin.getDescription()).thenReturn(description); when(plugin.getDescription()).thenReturn(description);
when(plugin.getPluginMeta()).thenReturn(description); when(plugin.getPluginMeta()).thenReturn(description);
} }
@@ -102,6 +102,7 @@ class PluginLifecycleTest {
when(operator.hasPermission("sentinel.alerts")).thenReturn(true); when(operator.hasPermission("sentinel.alerts")).thenReturn(true);
when(operator.hasPermission("sentinel.inspect")).thenReturn(true); when(operator.hasPermission("sentinel.inspect")).thenReturn(true);
when(operator.hasPermission("sentinel.config")).thenReturn(true); when(operator.hasPermission("sentinel.config")).thenReturn(true);
when(operator.hasPermission("sentinel.enforcement")).thenReturn(true);
when(server.getPlayer(subject.getUniqueId())).thenReturn(subject); when(server.getPlayer(subject.getUniqueId())).thenReturn(subject);
doReturn((Collection) List.of(subject, operator)).when(server).getOnlinePlayers(); doReturn((Collection) List.of(subject, operator)).when(server).getOnlinePlayers();
@@ -118,6 +119,7 @@ class PluginLifecycleTest {
assertTrue(config.contains("unsupported-boat-ticks: 40")); assertTrue(config.contains("unsupported-boat-ticks: 40"));
verify(manager).registerEvents(isA(BukkitBoatObserver.class), eq(plugin)); verify(manager).registerEvents(isA(BukkitBoatObserver.class), eq(plugin));
verify(manager).registerEvents(isA(BukkitElytraObserver.class), eq(plugin)); verify(manager).registerEvents(isA(BukkitElytraObserver.class), eq(plugin));
verify(manager).registerEvents(isA(BukkitEnforcement.class), eq(plugin));
for (int sample = 1; sample <= 6; sample++) { for (int sample = 1; sample <= 6; sample++) {
currentTick.set(sample); currentTick.set(sample);
events.call(new PlayerMoveEvent(subject, new Location(world, 0, 64, 0), new Location(world, 1, 64, 0))); events.call(new PlayerMoveEvent(subject, new Location(world, 0, 64, 0), new Location(world, 1, 64, 0)));
@@ -131,6 +133,9 @@ class PluginLifecycleTest {
assertEquals(List.of("inspect"), command.tabComplete(operator, "sentinel", new String[]{"in"})); assertEquals(List.of("inspect"), command.tabComplete(operator, "sentinel", new String[]{"in"}));
assertEquals(List.of("unsupported-flight-ticks"), assertEquals(List.of("unsupported-flight-ticks"),
command.tabComplete(operator, "sentinel", new String[]{"config", "unsupported-f"})); command.tabComplete(operator, "sentinel", new String[]{"config", "unsupported-f"}));
assertEquals(List.of("enforcement"), command.tabComplete(operator, "sentinel", new String[]{"enf"}));
assertTrue(command.execute(operator, "sentinel", new String[]{"enforcement", "status"}));
verify(operator).sendMessage(contains("mode off"));
Field monitorField = PurpurSentinelPlugin.class.getDeclaredField("monitor"); Field monitorField = PurpurSentinelPlugin.class.getDeclaredField("monitor");
monitorField.setAccessible(true); monitorField.setAccessible(true);
@@ -0,0 +1,91 @@
package games.dmg.purpursentinel;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.util.EnumSet;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import org.bukkit.Location;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.event.player.PlayerMoveEvent;
import org.junit.jupiter.api.Test;
class BukkitEnforcementTest {
private static final DetectionSignal SIGNAL = DetectionSignal.ABSOLUTE_GROUND_SPEED;
@Test
void emittedEpisodesWarnSetbackHoldAndKickThroughFixedServerApis() {
var fixture = fixture(EnforcementMode.SETBACK_KICK);
fixture.listener.captureSafeAnchor(fixture.player, fixture.anchor);
for (int episode = 1; episode <= 7; episode++) {
when(fixture.server.getCurrentTick()).thenReturn(episode);
fixture.listener.observe(alert(fixture.id));
}
verify(fixture.player).sendMessage(argThat((net.kyori.adventure.text.Component component) -> component.toString().contains("repeated movement")));
verify(fixture.player).teleport(fixture.anchor);
verify(fixture.player).kick(argThat(component -> component.toString().contains("repeated movement")));
assertTrue(fixture.listener.holdRemainingSeconds(fixture.id, fixture.clock.get()) > 0);
var move = new PlayerMoveEvent(fixture.player, fixture.anchor,
new Location(fixture.world, 2, 64, 0, 90, 15));
fixture.listener.onMove(move);
assertEquals(fixture.anchor.getX(), move.getTo().getX());
assertEquals(90, move.getTo().getYaw());
assertEquals(15, move.getTo().getPitch());
}
@Test
void recentDamageSkipsSetbackAndDamageEndsAnActiveHoldWithoutAnotherTeleport() {
var fixture = fixture(EnforcementMode.SETBACK);
fixture.listener.captureSafeAnchor(fixture.player, fixture.anchor);
fixture.listener.recordDamage(fixture.player);
for (int episode = 1; episode <= 5; episode++) {
when(fixture.server.getCurrentTick()).thenReturn(episode);
fixture.listener.observe(alert(fixture.id));
}
verify(fixture.player, never()).teleport(any(Location.class));
fixture.clock.addAndGet(11_000_000_000L);
fixture.listener.reset(fixture.id);
fixture.listener.captureSafeAnchor(fixture.player, fixture.anchor);
for (int episode = 1; episode <= 5; episode++) {
when(fixture.server.getCurrentTick()).thenReturn(episode + 10);
fixture.listener.observe(alert(fixture.id));
}
verify(fixture.player).teleport(fixture.anchor);
fixture.listener.recordDamage(fixture.player);
assertEquals(0, fixture.listener.holdRemainingSeconds(fixture.id, fixture.clock.get()));
verify(fixture.player, times(1)).teleport(any(Location.class));
}
private static Fixture fixture(EnforcementMode mode) {
var server = mock(Server.class);
var player = mock(Player.class);
var world = mock(World.class);
var id = UUID.randomUUID();
var anchor = new Location(world, 0, 64, 0, 10, 5);
when(player.getUniqueId()).thenReturn(id);
when(player.isOnline()).thenReturn(true);
when(player.getWorld()).thenReturn(world);
when(player.getLocation()).thenReturn(anchor);
when(player.teleport(any(Location.class))).thenReturn(true);
when(server.getPlayer(id)).thenReturn(player);
var clock = new AtomicLong(1_000_000_000L);
var settings = new AtomicReference<>(new EnforcementSettings(mode, 600, 3, 5, 7, 10, 10,
EnumSet.of(SIGNAL)));
var listener = new BukkitEnforcement(server, settings::get, clock::get,
location -> true, EnforcementAuditSink.NONE);
return new Fixture(server, player, world, id, anchor, clock, listener);
}
private static AlertDecision alert(UUID id) { return new AlertDecision(id, 6, 1.03, 0.80); }
private record Fixture(Server server, Player player, World world, UUID id, Location anchor,
AtomicLong clock, BukkitEnforcement listener) { }
}
@@ -51,6 +51,25 @@ class CloudEventLogTest {
assertFalse(lines.getFirst().contains("Alex")); assertFalse(lines.getFirst().contains("Alex"));
} }
@Test
void persistsPrivacyLimitedEnforcementAuditsWithoutCoordinatesOrNames() throws Exception {
var subjectId = UUID.fromString("39f42621-37ca-42de-a303-a2abdd2218f4");
try (var log = new CloudEventLog(directory.resolve("events"), Clock.fixed(FIRST_DAY, ZoneOffset.UTC),
warning -> fail(warning))) {
log.offer(new EnforcementAudit(subjectId, DetectionSignal.UNSUPPORTED_HOVER, EnforcementAction.SKIPPED,
5, 600, 10, "unsafe-anchor"));
}
String line = Files.readAllLines(directory.resolve("events/2026-10-29.jsonl")).getFirst();
assertTrue(line.contains("games.dmg.purpursentinel.enforcement.skipped"));
assertTrue(line.contains(subjectId.toString()));
assertTrue(line.contains("UNSUPPORTED_HOVER"));
assertTrue(line.contains("unsafe-anchor"));
assertFalse(line.contains("Alex"));
assertFalse(line.contains("coordinate"));
assertFalse(line.contains("anchor" + "X"));
}
@Test @Test
void appendsAfterRestartAndRemovesAnExistingPartialTail() throws Exception { void appendsAfterRestartAndRemovesAnExistingPartialTail() throws Exception {
Path events = directory.resolve("events"); Path events = directory.resolve("events");
@@ -22,7 +22,8 @@ class DistributionTest {
assertEquals("26.3", metadata.get("api-version")); assertEquals("26.3", metadata.get("api-version"));
assertEquals(1, ((Map<?, ?>) metadata.get("commands")).size()); assertEquals(1, ((Map<?, ?>) metadata.get("commands")).size());
var permissions = (Map<?, ?>) metadata.get("permissions"); var permissions = (Map<?, ?>) metadata.get("permissions");
assertEquals(java.util.Set.of("sentinel.alerts", "sentinel.inspect", "sentinel.config"), permissions.keySet()); assertEquals(java.util.Set.of("sentinel.alerts", "sentinel.inspect", "sentinel.config",
"sentinel.enforcement"), permissions.keySet());
for (Object value : permissions.values()) assertEquals("op", ((Map<?, ?>) value).get("default")); for (Object value : permissions.values()) assertEquals("op", ((Map<?, ?>) value).get("default"));
assertNull(metadata.get("depend")); assertNull(metadata.get("depend"));
assertNull(metadata.get("softdepend")); assertNull(metadata.get("softdepend"));
@@ -39,6 +40,9 @@ class DistributionTest {
assertTrue(config.contains("unsupported-boat-hover-ticks: 30")); assertTrue(config.contains("unsupported-boat-hover-ticks: 30"));
assertTrue(config.contains("unsupported-boat-ascent-blocks: 2.0")); assertTrue(config.contains("unsupported-boat-ascent-blocks: 2.0"));
assertTrue(config.contains("unsupported-boat-cruise-blocks: 12.0")); assertTrue(config.contains("unsupported-boat-cruise-blocks: 12.0"));
assertTrue(config.contains("enforcement-mode: off"));
assertTrue(config.contains("enforcement-window-seconds: 600"));
assertTrue(config.contains("enforcement-hold-seconds: 10"));
var entry = jar.getEntry("games/dmg/purpursentinel/PurpurSentinelPlugin.class"); var entry = jar.getEntry("games/dmg/purpursentinel/PurpurSentinelPlugin.class");
assertNotNull(entry); assertNotNull(entry);
try (var bytes = new DataInputStream(jar.getInputStream(entry))) { try (var bytes = new DataInputStream(jar.getInputStream(entry))) {
@@ -0,0 +1,63 @@
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.util.List;
import org.bukkit.Server;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class EnforcementCommandTest {
@TempDir Path directory;
@Test
void operatorCanReadAndPersistDefaultOffGraduatedPolicyThroughCommands() throws Exception {
Path file = directory.resolve("config.yml");
AtomicYamlSettingsStore.writeDefaults(file);
var settings = SettingsService.load(new AtomicYamlSettingsStore(file));
var enforcement = EnforcementService.load(new AtomicYamlSettingsStore(file), settings);
var server = mock(Server.class);
var sender = mock(CommandSender.class);
when(sender.hasPermission("sentinel.enforcement")).thenReturn(true);
var handler = new SentinelCommand(server, new MovementMonitor(new MovementDetector()), settings,
enforcement, () -> 0L);
assertTrue(handler.onCommand(sender, mock(Command.class), "sentinel",
new String[]{"enforcement", "status"}));
verify(sender).sendMessage(contains("mode off"));
assertTrue(handler.onCommand(sender, mock(Command.class), "sentinel",
new String[]{"enforcement", "mode", "setback-kick"}));
assertTrue(handler.onCommand(sender, mock(Command.class), "sentinel",
new String[]{"enforcement", "hold-seconds", "60"}));
var reloaded = EnforcementService.load(new AtomicYamlSettingsStore(file), settings);
assertEquals(EnforcementMode.SETBACK_KICK, reloaded.current().mode());
assertEquals(60, reloaded.current().holdSeconds());
assertTrue(Files.readString(file).contains("enforcement-mode: setback-kick"));
assertEquals(List.of("enforcement"), handler.onTabComplete(sender, mock(Command.class), "sentinel",
new String[]{"enf"}));
assertTrue(handler.onCommand(sender, mock(Command.class), "sentinel",
new String[]{"enforcement", "signal", "unsupported-boat-hover", "on"}));
verify(sender).sendMessage(contains("alert-only"));
assertFalse(enforcement.current().eligibleSignals().contains(DetectionSignal.UNSUPPORTED_BOAT_HOVER));
var denied = mock(CommandSender.class);
assertTrue(handler.onCommand(denied, mock(Command.class), "sentinel",
new String[]{"enforcement", "mode", "kick"}));
verify(denied).sendMessage(contains("do not have permission"));
var beforeFailure = enforcement.current();
Files.delete(file);
Files.createDirectory(file);
assertTrue(handler.onCommand(sender, mock(Command.class), "sentinel",
new String[]{"enforcement", "hold-seconds", "30"}));
assertEquals(beforeFailure, enforcement.current());
verify(sender).sendMessage(contains("prior value remains active"));
}
}
@@ -0,0 +1,108 @@
package games.dmg.purpursentinel;
import static org.junit.jupiter.api.Assertions.*;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class EnforcementMonitorTest {
private static final UUID PLAYER = UUID.randomUUID();
private static final DetectionSignal SIGNAL = DetectionSignal.ABSOLUTE_GROUND_SPEED;
@Test
void countsSameSignalEpisodesAndFiresEachGraduatedThresholdOnce() {
var audits = new ArrayList<EnforcementAudit>();
var monitor = new EnforcementMonitor(audits::add);
var settings = settings(EnforcementMode.SETBACK_KICK);
EnforcementDecision decision = EnforcementDecision.none();
for (int episode = 1; episode <= 7; episode++) {
decision = monitor.observe(PLAYER, SIGNAL, episode * 1_000_000_000L, settings,
episode >= 5, false);
if (episode == 3) assertEquals(EnforcementAction.WARNING, decision.action());
else if (episode == 5) assertEquals(EnforcementAction.SETBACK, decision.action());
else if (episode == 7) assertEquals(EnforcementAction.KICK, decision.action());
else assertEquals(EnforcementAction.NONE, decision.action());
}
assertEquals(7, decision.episodeCount());
assertEquals(List.of(EnforcementAction.WARNING, EnforcementAction.SETBACK, EnforcementAction.KICK),
audits.stream().map(EnforcementAudit::action).toList());
}
@Test
void separatesSignalsExpiresWindowAndNeverEnforcesIneligibleOrOffSignals() {
var monitor = new EnforcementMonitor(EnforcementAuditSink.NONE);
var settings = settings(EnforcementMode.KICK);
for (int episode = 1; episode <= 2; episode++) {
monitor.observe(PLAYER, SIGNAL, episode, settings, false, false);
monitor.observe(PLAYER, DetectionSignal.CONTEXTUAL_GROUND_SPEED, episode, settings, false, false);
}
assertEquals(2, monitor.inspect(PLAYER, 3, settings).getFirst().episodeCount());
assertTrue(monitor.observe(PLAYER, DetectionSignal.ELYTRA_IMPLAUSIBLE_WEAR, 4, settings,
false, false).isNone());
assertTrue(monitor.observe(PLAYER, SIGNAL, 700_000_000_000L, settings,
false, false).isNone());
assertEquals(1, monitor.inspect(PLAYER, 700_000_000_001L, settings).getFirst().episodeCount());
assertTrue(monitor.observe(PLAYER, SIGNAL, 700_000_000_002L,
settings(EnforcementMode.OFF), false, false).isNone());
}
@Test
void deduplicatesMultipleSameSignalAlertsWithinOneServerTick() {
var monitor = new EnforcementMonitor(EnforcementAuditSink.NONE);
var settings = settings(EnforcementMode.WARN);
for (int tick = 1; tick <= 2; tick++) {
assertTrue(monitor.observe(PLAYER, SIGNAL, tick, tick, settings, true, false).isNone());
assertTrue(monitor.observe(PLAYER, SIGNAL, tick + 1, tick, settings, true, false).isNone());
}
var warning = monitor.observe(PLAYER, SIGNAL, 3, 3, settings, true, false);
assertEquals(EnforcementAction.WARNING, warning.action());
assertEquals(3, warning.episodeCount());
}
@Test
void everyModeExecutesOnlyItsApprovedStages() {
for (EnforcementMode mode : EnforcementMode.values()) {
var actions = new ArrayList<EnforcementAudit>();
var monitor = new EnforcementMonitor(actions::add);
UUID player = UUID.randomUUID();
for (int episode = 1; episode <= 7; episode++) {
monitor.observe(player, SIGNAL, episode, settings(mode), true, false);
}
if (mode == EnforcementMode.OFF) assertTrue(actions.isEmpty());
if (mode == EnforcementMode.WARN) assertTrue(actions.stream().allMatch(
audit -> audit.action() == EnforcementAction.WARNING));
if (mode == EnforcementMode.SETBACK) assertTrue(actions.stream().noneMatch(
audit -> audit.action() == EnforcementAction.KICK));
if (mode == EnforcementMode.KICK) assertTrue(actions.stream().noneMatch(
audit -> audit.action() == EnforcementAction.SETBACK));
if (mode == EnforcementMode.SETBACK_KICK) {
assertTrue(actions.stream().anyMatch(audit -> audit.action() == EnforcementAction.SETBACK));
assertTrue(actions.stream().anyMatch(audit -> audit.action() == EnforcementAction.KICK));
}
}
}
@Test
void recentDamageOrMissingAnchorSkipsSetbackWithoutBlockingLaterKick() {
var audits = new ArrayList<EnforcementAudit>();
var monitor = new EnforcementMonitor(audits::add);
var settings = settings(EnforcementMode.SETBACK_KICK);
for (int episode = 1; episode <= 4; episode++) {
monitor.observe(PLAYER, SIGNAL, episode, settings, false, false);
}
var skipped = monitor.observe(PLAYER, SIGNAL, 5, settings, false, true);
assertEquals(EnforcementAction.SKIPPED, skipped.action());
assertEquals("recent-damage", skipped.reason());
monitor.observe(PLAYER, SIGNAL, 6, settings, true, false);
var kicked = monitor.observe(PLAYER, SIGNAL, 7, settings, true, false);
assertEquals(EnforcementAction.KICK, kicked.action());
}
private static EnforcementSettings settings(EnforcementMode mode) {
return new EnforcementSettings(mode, 600, 3, 5, 7, 10, 10,
EnumSet.of(SIGNAL, DetectionSignal.CONTEXTUAL_GROUND_SPEED));
}
}