feat(enforcement): add graduated movement responses
This commit is contained in:
1 parent
8731548d5a
commit
2a2d307f6d
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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user