From 2a2d307f6db525cdb529dd3b95cc2fceec3dddcc Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Thu, 29 Oct 2026 18:58:14 -0400 Subject: [PATCH] feat(enforcement): add graduated movement responses --- .../AtomicYamlSettingsStore.java | 18 ++ .../dmg/purpursentinel/BukkitEnforcement.java | 193 ++++++++++++++++++ .../BukkitMovementListener.java | 16 +- .../dmg/purpursentinel/CloudEventLog.java | 45 +++- .../dmg/purpursentinel/EnforcementAction.java | 11 + .../dmg/purpursentinel/EnforcementAudit.java | 13 ++ .../purpursentinel/EnforcementAuditSink.java | 8 + .../purpursentinel/EnforcementDecision.java | 16 ++ .../dmg/purpursentinel/EnforcementMode.java | 26 +++ .../purpursentinel/EnforcementMonitor.java | 105 ++++++++++ .../purpursentinel/EnforcementRuntime.java | 20 ++ .../purpursentinel/EnforcementService.java | 115 +++++++++++ .../purpursentinel/EnforcementSettings.java | 50 +++++ .../purpursentinel/EnforcementSnapshot.java | 8 + .../purpursentinel/MovementEnvironment.java | 30 +++ .../purpursentinel/PurpurSentinelPlugin.java | 26 ++- .../dmg/purpursentinel/SentinelAlerts.java | 9 +- .../dmg/purpursentinel/SentinelCommand.java | 113 +++++++++- src/main/resources/config.yml | 18 ++ src/main/resources/plugin.yml | 5 +- .../purpursentinel/PluginLifecycleTest.java | 7 +- .../purpursentinel/BukkitEnforcementTest.java | 91 +++++++++ .../dmg/purpursentinel/CloudEventLogTest.java | 19 ++ .../dmg/purpursentinel/DistributionTest.java | 6 +- .../EnforcementCommandTest.java | 63 ++++++ .../EnforcementMonitorTest.java | 108 ++++++++++ 26 files changed, 1115 insertions(+), 24 deletions(-) create mode 100644 src/main/java/games/dmg/purpursentinel/BukkitEnforcement.java create mode 100644 src/main/java/games/dmg/purpursentinel/EnforcementAction.java create mode 100644 src/main/java/games/dmg/purpursentinel/EnforcementAudit.java create mode 100644 src/main/java/games/dmg/purpursentinel/EnforcementAuditSink.java create mode 100644 src/main/java/games/dmg/purpursentinel/EnforcementDecision.java create mode 100644 src/main/java/games/dmg/purpursentinel/EnforcementMode.java create mode 100644 src/main/java/games/dmg/purpursentinel/EnforcementMonitor.java create mode 100644 src/main/java/games/dmg/purpursentinel/EnforcementRuntime.java create mode 100644 src/main/java/games/dmg/purpursentinel/EnforcementService.java create mode 100644 src/main/java/games/dmg/purpursentinel/EnforcementSettings.java create mode 100644 src/main/java/games/dmg/purpursentinel/EnforcementSnapshot.java create mode 100644 src/test/java/games/dmg/purpursentinel/BukkitEnforcementTest.java create mode 100644 src/test/java/games/dmg/purpursentinel/EnforcementCommandTest.java create mode 100644 src/test/java/games/dmg/purpursentinel/EnforcementMonitorTest.java diff --git a/src/main/java/games/dmg/purpursentinel/AtomicYamlSettingsStore.java b/src/main/java/games/dmg/purpursentinel/AtomicYamlSettingsStore.java index b6378c2..79a19c7 100644 --- a/src/main/java/games/dmg/purpursentinel/AtomicYamlSettingsStore.java +++ b/src/main/java/games/dmg/purpursentinel/AtomicYamlSettingsStore.java @@ -39,6 +39,24 @@ public final class AtomicYamlSettingsStore implements SettingsPersistence { unsupported-boat-hover-ticks: 30 unsupported-boat-ascent-blocks: 2.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); } } diff --git a/src/main/java/games/dmg/purpursentinel/BukkitEnforcement.java b/src/main/java/games/dmg/purpursentinel/BukkitEnforcement.java new file mode 100644 index 0000000..365ab7f --- /dev/null +++ b/src/main/java/games/dmg/purpursentinel/BukkitEnforcement.java @@ -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 settings; + private final LongSupplier nanoTime; + private final Predicate safeLocation; + private final EnforcementAuditSink audit; + private final EnforcementMonitor monitor; + private final Map anchors = new HashMap<>(); + private final Map damagedAt = new HashMap<>(); + private final Map holds = new HashMap<>(); + private UUID internalTeleport; + + BukkitEnforcement(Server server, Supplier settings, LongSupplier nanoTime, + Predicate 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 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) { } +} diff --git a/src/main/java/games/dmg/purpursentinel/BukkitMovementListener.java b/src/main/java/games/dmg/purpursentinel/BukkitMovementListener.java index 2e69c22..27f2cc1 100644 --- a/src/main/java/games/dmg/purpursentinel/BukkitMovementListener.java +++ b/src/main/java/games/dmg/purpursentinel/BukkitMovementListener.java @@ -35,31 +35,39 @@ public final class BukkitMovementListener implements Listener { private final BiPredicate ordinaryEnvironment; private final Function supportState; private final Function groundMovementContext; + private final BukkitEnforcement enforcement; private final Map lastFlightSamples = new HashMap<>(); public BukkitMovementListener(Server server, MovementMonitor monitor, Supplier settings, LongSupplier nanoTime) { this(server, monitor, settings, nanoTime, (from, to) -> MovementEnvironment.isOrdinary(from) && MovementEnvironment.isOrdinary(to), - MovementEnvironment::supportState); + MovementEnvironment::supportState, BukkitMovementListener::groundContext, null); } BukkitMovementListener(Server server, MovementMonitor monitor, Supplier settings, LongSupplier nanoTime, BiPredicate ordinaryEnvironment) { this(server, monitor, settings, nanoTime, ordinaryEnvironment, MovementEnvironment::supportState, - BukkitMovementListener::groundContext); + BukkitMovementListener::groundContext, null); } BukkitMovementListener(Server server, MovementMonitor monitor, Supplier settings, LongSupplier nanoTime, BiPredicate ordinaryEnvironment, Function supportState) { this(server, monitor, settings, nanoTime, ordinaryEnvironment, supportState, - BukkitMovementListener::groundContext); + BukkitMovementListener::groundContext, null); } BukkitMovementListener(Server server, MovementMonitor monitor, Supplier settings, LongSupplier nanoTime, BiPredicate ordinaryEnvironment, Function supportState, Function groundMovementContext) { + this(server, monitor, settings, nanoTime, ordinaryEnvironment, supportState, groundMovementContext, null); + } + + BukkitMovementListener(Server server, MovementMonitor monitor, Supplier settings, + LongSupplier nanoTime, BiPredicate ordinaryEnvironment, + Function supportState, + Function groundMovementContext, BukkitEnforcement enforcement) { this.server = server; this.monitor = monitor; this.settings = settings; @@ -67,6 +75,7 @@ public final class BukkitMovementListener implements Listener { this.ordinaryEnvironment = ordinaryEnvironment; this.supportState = supportState; this.groundMovementContext = groundMovementContext; + this.enforcement = enforcement; } @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = false) @@ -76,6 +85,7 @@ public final class BukkitMovementListener implements Listener { Location to = event.getTo(); var player = event.getPlayer(); ExclusionReason reason = classify(player, from, to, event.isCancelled()); + if (reason == ExclusionReason.NONE && enforcement != null) enforcement.captureSafeAnchor(player, from); monitor.record(player.getUniqueId(), new MovementFrame( server.getCurrentTick(), nanoTime.getAsLong(), diff --git a/src/main/java/games/dmg/purpursentinel/CloudEventLog.java b/src/main/java/games/dmg/purpursentinel/CloudEventLog.java index e76115a..1a37949 100644 --- a/src/main/java/games/dmg/purpursentinel/CloudEventLog.java +++ b/src/main/java/games/dmg/purpursentinel/CloudEventLog.java @@ -22,7 +22,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; 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 RETENTION_DAYS = 30; 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); } - @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; synchronized (lifecycleLock) { 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() { @@ -88,7 +92,7 @@ final class CloudEventLog implements AlertEventSink, AutoCloseable { } if (event == null) continue; try { - store.append(event.observedAt, event.alert); + store.append(event.observedAt, event.json()); } catch (RetentionException exception) { warn("Sentinel could not apply event log retention; detection remains active."); } catch (IOException | RuntimeException exception) { @@ -185,6 +189,19 @@ final class CloudEventLog implements AlertEventSink, AutoCloseable { + "\",\"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) { data.append(",\"eligibleTicks\":").append(alert.eligibleTicks()) .append(",\"horizontalDistance\":").append(number(alert.horizontalDistance())) @@ -229,7 +246,7 @@ final class CloudEventLog implements AlertEventSink, AutoCloseable { @FunctionalInterface 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 { } } @@ -239,7 +256,7 @@ final class CloudEventLog implements AlertEventSink, AutoCloseable { 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(); IOException retentionFailure = null; try { @@ -252,7 +269,7 @@ final class CloudEventLog implements AlertEventSink, AutoCloseable { retentionFailure = exception; } } - writer.output.write(toJson(observedAt, alert)); + writer.output.write(json); writer.output.newLine(); writer.output.flush(); } catch (IOException exception) { @@ -277,7 +294,17 @@ final class CloudEventLog implements AlertEventSink, AutoCloseable { 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 { @Override public void close() throws IOException { output.close(); } } diff --git a/src/main/java/games/dmg/purpursentinel/EnforcementAction.java b/src/main/java/games/dmg/purpursentinel/EnforcementAction.java new file mode 100644 index 0000000..1c0e171 --- /dev/null +++ b/src/main/java/games/dmg/purpursentinel/EnforcementAction.java @@ -0,0 +1,11 @@ +package games.dmg.purpursentinel; + +enum EnforcementAction { + NONE, + WARNING, + SETBACK, + KICK, + SKIPPED, + HOLD_ENDED, + OPERATOR_RESET +} diff --git a/src/main/java/games/dmg/purpursentinel/EnforcementAudit.java b/src/main/java/games/dmg/purpursentinel/EnforcementAudit.java new file mode 100644 index 0000000..1c31a44 --- /dev/null +++ b/src/main/java/games/dmg/purpursentinel/EnforcementAudit.java @@ -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) { +} diff --git a/src/main/java/games/dmg/purpursentinel/EnforcementAuditSink.java b/src/main/java/games/dmg/purpursentinel/EnforcementAuditSink.java new file mode 100644 index 0000000..6c4c8f8 --- /dev/null +++ b/src/main/java/games/dmg/purpursentinel/EnforcementAuditSink.java @@ -0,0 +1,8 @@ +package games.dmg.purpursentinel; + +@FunctionalInterface +interface EnforcementAuditSink { + EnforcementAuditSink NONE = audit -> { }; + + void offer(EnforcementAudit audit); +} diff --git a/src/main/java/games/dmg/purpursentinel/EnforcementDecision.java b/src/main/java/games/dmg/purpursentinel/EnforcementDecision.java new file mode 100644 index 0000000..b44cc2e --- /dev/null +++ b/src/main/java/games/dmg/purpursentinel/EnforcementDecision.java @@ -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; } +} diff --git a/src/main/java/games/dmg/purpursentinel/EnforcementMode.java b/src/main/java/games/dmg/purpursentinel/EnforcementMode.java new file mode 100644 index 0000000..4c1edd5 --- /dev/null +++ b/src/main/java/games/dmg/purpursentinel/EnforcementMode.java @@ -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 find(String value) { + return Arrays.stream(values()).filter(mode -> mode.value.equals(value.toLowerCase(Locale.ROOT))).findFirst(); + } +} diff --git a/src/main/java/games/dmg/purpursentinel/EnforcementMonitor.java b/src/main/java/games/dmg/purpursentinel/EnforcementMonitor.java new file mode 100644 index 0000000..30b8631 --- /dev/null +++ b/src/main/java/games/dmg/purpursentinel/EnforcementMonitor.java @@ -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> 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 inspect(UUID playerId, long nowNanos, EnforcementSettings settings) { + var signals = players.get(playerId); + if (signals == null) return List.of(); + var snapshots = new ArrayList(); + 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 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; + } + } +} diff --git a/src/main/java/games/dmg/purpursentinel/EnforcementRuntime.java b/src/main/java/games/dmg/purpursentinel/EnforcementRuntime.java new file mode 100644 index 0000000..2cf9773 --- /dev/null +++ b/src/main/java/games/dmg/purpursentinel/EnforcementRuntime.java @@ -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 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 inspect(UUID playerId, long nowNanos); + double holdRemainingSeconds(UUID playerId, long nowNanos); + void policyChanged(); +} diff --git a/src/main/java/games/dmg/purpursentinel/EnforcementService.java b/src/main/java/games/dmg/purpursentinel/EnforcementService.java new file mode 100644 index 0000000..2bd6c5d --- /dev/null +++ b/src/main/java/games/dmg/purpursentinel/EnforcementService.java @@ -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 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 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 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('_', '-'); + } +} diff --git a/src/main/java/games/dmg/purpursentinel/EnforcementSettings.java b/src/main/java/games/dmg/purpursentinel/EnforcementSettings.java new file mode 100644 index 0000000..a11f3b0 --- /dev/null +++ b/src/main/java/games/dmg/purpursentinel/EnforcementSettings.java @@ -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 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 defaultSignals() { return enforceableSignals(); } + + static Set 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 + '.'); + } + } +} diff --git a/src/main/java/games/dmg/purpursentinel/EnforcementSnapshot.java b/src/main/java/games/dmg/purpursentinel/EnforcementSnapshot.java new file mode 100644 index 0000000..987ffb6 --- /dev/null +++ b/src/main/java/games/dmg/purpursentinel/EnforcementSnapshot.java @@ -0,0 +1,8 @@ +package games.dmg.purpursentinel; + +record EnforcementSnapshot( + DetectionSignal signal, + int episodeCount, + double windowRemainingSeconds, + EnforcementAction lastAction) { +} diff --git a/src/main/java/games/dmg/purpursentinel/MovementEnvironment.java b/src/main/java/games/dmg/purpursentinel/MovementEnvironment.java index 956ee07..9e42924 100644 --- a/src/main/java/games/dmg/purpursentinel/MovementEnvironment.java +++ b/src/main/java/games/dmg/purpursentinel/MovementEnvironment.java @@ -66,6 +66,26 @@ final class MovementEnvironment { 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) { var world = location.getWorld(); if (world == null || !world.isChunkLoaded(location.getBlockX() >> 4, location.getBlockZ() >> 4)) { @@ -91,6 +111,16 @@ final class MovementEnvironment { 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) { return type == Material.ICE || type == Material.PACKED_ICE || type == Material.BLUE_ICE || type == Material.FROSTED_ICE; diff --git a/src/main/java/games/dmg/purpursentinel/PurpurSentinelPlugin.java b/src/main/java/games/dmg/purpursentinel/PurpurSentinelPlugin.java index c4a1c52..4b21d12 100644 --- a/src/main/java/games/dmg/purpursentinel/PurpurSentinelPlugin.java +++ b/src/main/java/games/dmg/purpursentinel/PurpurSentinelPlugin.java @@ -13,6 +13,7 @@ public final class PurpurSentinelPlugin extends JavaPlugin { private BukkitMovementListener listener; private BukkitBoatObserver boatObserver; private BukkitElytraObserver elytraObserver; + private BukkitEnforcement enforcementRuntime; private CloudEventLog eventLog; private BukkitTask tickTask; @@ -21,21 +22,29 @@ public final class PurpurSentinelPlugin extends JavaPlugin { Files.createDirectories(getDataFolder().toPath()); var configPath = getDataFolder().toPath().resolve("config.yml"); 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()); - 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)); + 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 handler = new SentinelCommand(getServer(), monitor, settings, System::nanoTime); + var handler = new SentinelCommand(getServer(), monitor, settings, enforcement, enforcementRuntime, + System::nanoTime); command.setExecutor(handler); command.setTabCompleter(handler); getServer().getPluginManager().registerEvents(listener, this); getServer().getPluginManager().registerEvents(boatObserver, this); getServer().getPluginManager().registerEvents(elytraObserver, this); - var alerts = new SentinelAlerts(getServer(), eventLog); + getServer().getPluginManager().registerEvents(enforcementRuntime, this); + var alerts = new SentinelAlerts(getServer(), eventLog, enforcementRuntime); tickTask = getServer().getScheduler().runTaskTimer(this, () -> { listener.sampleFlight(); boatObserver.sample(); @@ -75,6 +84,11 @@ public final class PurpurSentinelPlugin extends JavaPlugin { monitor.clear(); monitor = null; } + if (enforcementRuntime != null) { + enforcementRuntime.clear(); + HandlerList.unregisterAll(enforcementRuntime); + enforcementRuntime = null; + } if (eventLog != null) { eventLog.close(); eventLog = null; diff --git a/src/main/java/games/dmg/purpursentinel/SentinelAlerts.java b/src/main/java/games/dmg/purpursentinel/SentinelAlerts.java index dc2a8a2..719421c 100644 --- a/src/main/java/games/dmg/purpursentinel/SentinelAlerts.java +++ b/src/main/java/games/dmg/purpursentinel/SentinelAlerts.java @@ -6,18 +6,25 @@ import org.bukkit.Server; final class SentinelAlerts { private final Server server; private final AlertEventSink events; + private final EnforcementRuntime enforcement; SentinelAlerts(Server server) { - this(server, AlertEventSink.NONE); + this(server, AlertEventSink.NONE, EnforcementRuntime.NONE); } SentinelAlerts(Server server, AlertEventSink events) { + this(server, events, EnforcementRuntime.NONE); + } + + SentinelAlerts(Server server, AlertEventSink events, EnforcementRuntime enforcement) { this.server = server; this.events = events; + this.enforcement = enforcement; } void send(AlertDecision alert) { events.offer(alert); + enforcement.observe(alert); var subject = server.getPlayer(alert.playerId()); if (subject == null || !subject.isOnline()) return; String message; diff --git a/src/main/java/games/dmg/purpursentinel/SentinelCommand.java b/src/main/java/games/dmg/purpursentinel/SentinelCommand.java index 2cb2614..092935d 100644 --- a/src/main/java/games/dmg/purpursentinel/SentinelCommand.java +++ b/src/main/java/games/dmg/purpursentinel/SentinelCommand.java @@ -19,12 +19,26 @@ public final class SentinelCommand implements CommandExecutor, TabCompleter { private final Server server; private final MovementMonitor monitor; private final SettingsService settings; + private final EnforcementService enforcement; + private final EnforcementRuntime enforcementRuntime; private final 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.monitor = monitor; this.settings = settings; + this.enforcement = enforcement; + this.enforcementRuntime = enforcementRuntime; 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("config")) return configure(sender, args); + if (args[0].equalsIgnoreCase("enforcement")) return enforce(sender, args); sender.sendMessage("Unsupported Sentinel route: " + args[0] + '.'); return true; } @@ -45,9 +60,23 @@ public final class SentinelCommand implements CommandExecutor, TabCompleter { if (args.length == 1) { var routes = new ArrayList(); if (sender.hasPermission("sentinel.config")) routes.add("config"); + if (sender.hasPermission("sentinel.enforcement")) routes.add("enforcement"); if (sender.hasPermission("sentinel.inspect")) routes.add("inspect"); 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")) { return filter(server.getOnlinePlayers().stream().map(Player::getName) .sorted(String.CASE_INSENSITIVE_ORDER).toList(), args[1]); @@ -87,9 +116,21 @@ public final class SentinelCommand implements CommandExecutor, TabCompleter { } 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()) { - 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; } var evidence = snapshot.get(); @@ -166,6 +207,74 @@ public final class SentinelCommand implements CommandExecutor, TabCompleter { 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 ..."); + 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 "); + 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 "); + 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) { return value ? "yes" : "no"; } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index f00991f..6fabed6 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -14,3 +14,21 @@ unsupported-boat-ticks: 40 unsupported-boat-hover-ticks: 30 unsupported-boat-ascent-blocks: 2.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 diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index e5ad374..dae7f63 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -6,7 +6,7 @@ description: Passive and explainable movement-pattern observation for operators. commands: sentinel: description: Inspect movement evidence or configure Sentinel. - usage: /sentinel ... + usage: /sentinel ... permissions: sentinel.alerts: description: Receive passive movement evidence alerts. @@ -17,3 +17,6 @@ permissions: sentinel.config: description: Read and update Sentinel configuration. default: op + sentinel.enforcement: + description: Read, update, and reset optional Sentinel enforcement. + default: op diff --git a/src/nativeTest/java/games/dmg/purpursentinel/PluginLifecycleTest.java b/src/nativeTest/java/games/dmg/purpursentinel/PluginLifecycleTest.java index 27a0134..61d2d31 100644 --- a/src/nativeTest/java/games/dmg/purpursentinel/PluginLifecycleTest.java +++ b/src/nativeTest/java/games/dmg/purpursentinel/PluginLifecycleTest.java @@ -63,7 +63,7 @@ class PluginLifecycleTest { var description = new PluginDescriptionFile(stream); assertEquals("PurpurSentinel", description.getName()); assertEquals("26.3", description.getAPIVersion()); - assertEquals(3, description.getPermissions().size()); + assertEquals(4, description.getPermissions().size()); when(plugin.getDescription()).thenReturn(description); when(plugin.getPluginMeta()).thenReturn(description); } @@ -102,6 +102,7 @@ class PluginLifecycleTest { when(operator.hasPermission("sentinel.alerts")).thenReturn(true); when(operator.hasPermission("sentinel.inspect")).thenReturn(true); when(operator.hasPermission("sentinel.config")).thenReturn(true); + when(operator.hasPermission("sentinel.enforcement")).thenReturn(true); when(server.getPlayer(subject.getUniqueId())).thenReturn(subject); doReturn((Collection) List.of(subject, operator)).when(server).getOnlinePlayers(); @@ -118,6 +119,7 @@ class PluginLifecycleTest { assertTrue(config.contains("unsupported-boat-ticks: 40")); verify(manager).registerEvents(isA(BukkitBoatObserver.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++) { currentTick.set(sample); 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("unsupported-flight-ticks"), 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"); monitorField.setAccessible(true); diff --git a/src/test/java/games/dmg/purpursentinel/BukkitEnforcementTest.java b/src/test/java/games/dmg/purpursentinel/BukkitEnforcementTest.java new file mode 100644 index 0000000..5902c59 --- /dev/null +++ b/src/test/java/games/dmg/purpursentinel/BukkitEnforcementTest.java @@ -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) { } +} diff --git a/src/test/java/games/dmg/purpursentinel/CloudEventLogTest.java b/src/test/java/games/dmg/purpursentinel/CloudEventLogTest.java index f249c74..e4171bc 100644 --- a/src/test/java/games/dmg/purpursentinel/CloudEventLogTest.java +++ b/src/test/java/games/dmg/purpursentinel/CloudEventLogTest.java @@ -51,6 +51,25 @@ class CloudEventLogTest { 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 void appendsAfterRestartAndRemovesAnExistingPartialTail() throws Exception { Path events = directory.resolve("events"); diff --git a/src/test/java/games/dmg/purpursentinel/DistributionTest.java b/src/test/java/games/dmg/purpursentinel/DistributionTest.java index 62de854..081e9c9 100644 --- a/src/test/java/games/dmg/purpursentinel/DistributionTest.java +++ b/src/test/java/games/dmg/purpursentinel/DistributionTest.java @@ -22,7 +22,8 @@ class DistributionTest { assertEquals("26.3", metadata.get("api-version")); assertEquals(1, ((Map) metadata.get("commands")).size()); 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")); assertNull(metadata.get("depend")); assertNull(metadata.get("softdepend")); @@ -39,6 +40,9 @@ class DistributionTest { assertTrue(config.contains("unsupported-boat-hover-ticks: 30")); assertTrue(config.contains("unsupported-boat-ascent-blocks: 2.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"); assertNotNull(entry); try (var bytes = new DataInputStream(jar.getInputStream(entry))) { diff --git a/src/test/java/games/dmg/purpursentinel/EnforcementCommandTest.java b/src/test/java/games/dmg/purpursentinel/EnforcementCommandTest.java new file mode 100644 index 0000000..4aba972 --- /dev/null +++ b/src/test/java/games/dmg/purpursentinel/EnforcementCommandTest.java @@ -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")); + } +} diff --git a/src/test/java/games/dmg/purpursentinel/EnforcementMonitorTest.java b/src/test/java/games/dmg/purpursentinel/EnforcementMonitorTest.java new file mode 100644 index 0000000..2c8789e --- /dev/null +++ b/src/test/java/games/dmg/purpursentinel/EnforcementMonitorTest.java @@ -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(); + 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(); + 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(); + 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)); + } +} -- 2.54.0