Merge branch 'master' into feature/silence-named-mobs

This commit is contained in:
MineTec committed 2026-11-09 18:44:39 +00:00
commit b601eba016
117 files changed
+6731 -900

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@ dependencies {
implementation project(':core')
implementation project(':common')
compileOnly 'io.papermc.paper:paper-api:1.21.10-R0.1-SNAPSHOT'
compileOnly 'io.papermc.paper:paper-api:26.2.build.129-stable'
compileOnly 'org.geysermc.floodgate:api:2.2.4-SNAPSHOT'
implementation 'org.apache.httpcomponents:httpclient:4.5.14'
implementation 'com.sparkjava:spark-core:2.9.4'
@@ -4,6 +4,7 @@ import eu.mhsl.craftattack.spawn.core.api.client.HttpRepository;
import eu.mhsl.craftattack.spawn.core.api.client.ReqResp;
import eu.mhsl.craftattack.spawn.common.api.CraftAttackApi;
import java.time.Duration;
import java.util.List;
import java.util.UUID;
@@ -29,4 +30,12 @@ public class WhitelistRepository extends HttpRepository {
UserData.class
);
}
public ReqResp<UserData> getUserData(UUID userId, Duration timeout) {
return this.get(
"users/%s".formatted(userId.toString()),
timeout,
UserData.class
);
}
}
@@ -0,0 +1,48 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.gameplay.newYearFirework;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import org.bukkit.Color;
import org.bukkit.FireworkEffect;
import org.bukkit.NamespacedKey;
import org.bukkit.entity.Firework;
import org.bukkit.entity.Player;
import org.bukkit.event.Listener;
import org.bukkit.inventory.meta.FireworkMeta;
import org.bukkit.persistence.PersistentDataType;
import org.jetbrains.annotations.NotNull;
import java.time.LocalDate;
import java.util.List;
import java.util.Locale;
public class NewYearFirework extends Appliance {
private static final String namespace = NewYearFirework.class.getSimpleName().toLowerCase(Locale.ROOT);
public static NamespacedKey fireworkKey = new NamespacedKey(namespace, "newyear_firework".toLowerCase());
public void spawnFirework(Player player) {
for (int i = 0; i < 5; i++) {
Firework firework = player.getWorld().spawn(player.getLocation(), Firework.class);
firework.getPersistentDataContainer().set(fireworkKey, PersistentDataType.BOOLEAN, true);
FireworkMeta fireworkMeta = firework.getFireworkMeta();
fireworkMeta.addEffect(FireworkEffect.builder()
.with(FireworkEffect.Type.BALL)
.withColor(Color.AQUA, Color.RED, Color.LIME, Color.ORANGE, Color.YELLOW)
.withFade(Color.FUCHSIA, Color.WHITE)
.trail(true)
.flicker(true)
.build()
);
fireworkMeta.setPower((int) (i % 3 + 0.25));
firework.setFireworkMeta(fireworkMeta);
}
}
public boolean shouldSpawnFirework() {
return LocalDate.now().getDayOfYear() == 1;
}
@Override
protected @NotNull List<Listener> listeners() {
return List.of(new NewYearFireworkListener());
}
}
@@ -0,0 +1,21 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.gameplay.newYearFirework;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import org.bukkit.entity.Firework;
import org.bukkit.event.EventHandler;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.player.PlayerJoinEvent;
class NewYearFireworkListener extends ApplianceListener<NewYearFirework> {
@EventHandler
public void onPlayerJoin(PlayerJoinEvent event) {
if(!this.getAppliance().shouldSpawnFirework()) return;
this.getAppliance().spawnFirework(event.getPlayer());
}
@EventHandler
public void onDamage(EntityDamageByEntityEvent event) {
if(!(event.getDamager() instanceof Firework firework)) return;
if(!firework.getPersistentDataContainer().has(NewYearFirework.fireworkKey)) return;
event.setCancelled(true);
}
}
@@ -1,7 +0,0 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.gameplay.outlawed;
class OutlawChangeNotPermitted extends Exception {
public OutlawChangeNotPermitted(String message) {
super(message);
}
}
@@ -1,165 +0,0 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.gameplay.outlawed;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.displayName.DisplayName;
import eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.whitelist.Whitelist;
import eu.mhsl.craftattack.spawn.core.config.Configuration;
import eu.mhsl.craftattack.spawn.core.util.text.DisconnectInfo;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.event.HoverEvent;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.event.Listener;
import org.jetbrains.annotations.NotNull;
import java.util.*;
public class Outlawed extends Appliance implements DisplayName.Prefixed {
public final int timeoutInMs = 1000 * 60 * 60 * 6;
private final Map<UUID, Long> timeouts = new HashMap<>();
public enum Status {
DISABLED,
VOLUNTARILY,
FORCED
}
private final Map<Player, Status> playerStatusMap = new WeakHashMap<>();
private final String voluntarilyEntry = "voluntarily";
public Outlawed() {
super("outlawed");
Bukkit.getScheduler().runTaskTimerAsynchronously(
Main.instance(),
() -> this.playerStatusMap.forEach((player, status) -> {
if(!player.isOnline()) return;
if(status != Status.FORCED) return;
try {
this.queryAppliance(Whitelist.class).fullIntegrityCheck(player);
} catch(DisconnectInfo.Throwable e) {
e.getDisconnectScreen().applyKick(player);
}
}),
20 * 60,
20 * 60 * 5
);
}
void askForConfirmation(Player player) {
Component confirmationMessage = switch(this.getLawStatus(player)) {
case DISABLED -> Component.text("Wenn du den PVP-Modus aktivierst, darfst du von allen anderen PVP-Spielern grundlos angegriffen werden." +
" Du kannst den PVP-Modus erst wieder nach " + this.timeoutInMs / 1000 / 60 / 60 + " Stunden deaktivieren!");
case VOLUNTARILY -> Component.text("Wenn du den PVP-Modus deaktivierst, darfst du nicht mehr grundlos von anderen Spielern angegriffen werden.");
case FORCED -> Component.text("Du darfst zurzeit deinen PVP-Modus nicht ändern, da die˝ser als Strafe auferlegt wurde!");
};
String command = String.format("/%s confirm", OutlawedCommand.commandName);
Component changeText = Component.text(
String.format(
"Zum ändern deines PVP-Status klicke auf diese Nachricht oder tippe '%s'",
command
),
NamedTextColor.GOLD
).clickEvent(ClickEvent.suggestCommand(command));
player.sendMessage(
Component.text()
.append(confirmationMessage.color(NamedTextColor.RED))
.appendNewline()
.append(changeText)
);
}
void switchLawStatus(Player player) throws OutlawChangeNotPermitted {
if(this.getLawStatus(player).equals(Status.FORCED)) {
throw new OutlawChangeNotPermitted("Dein PVP-Status wurde als Strafe auferlegt und kann daher nicht verändert werden.");
}
if(this.isTimeout(player)) {
throw new OutlawChangeNotPermitted("Du kannst deinen PVP-Status nicht so schnell wechseln. Bitte warte einige Stunden bevor du umschaltest!");
}
this.setLawStatus(player, this.isOutlawed(player) ? Status.DISABLED : Status.VOLUNTARILY);
this.setTimeout(player);
}
public void updateForcedStatus(Player player, boolean forced) {
this.setLawStatus(player, forced ? Status.FORCED : this.getLawStatus(player) == Status.FORCED ? Status.DISABLED : this.getLawStatus(player));
}
public Status getLawStatus(Player player) {
return this.playerStatusMap.computeIfAbsent(player, p -> {
if(this.localConfig().getStringList(this.voluntarilyEntry).contains(p.getUniqueId().toString()))
return Status.VOLUNTARILY;
return Status.DISABLED;
});
}
private void setLawStatus(Player player, Status status) {
this.playerStatusMap.put(player, status);
this.queryAppliance(DisplayName.class).update(player);
List<String> newList = this.localConfig().getStringList(this.voluntarilyEntry);
if(status.equals(Status.VOLUNTARILY)) {
newList.add(player.getUniqueId().toString());
} else {
newList.remove(player.getUniqueId().toString());
}
this.localConfig().set(this.voluntarilyEntry, newList.stream().distinct().toList());
Configuration.saveChanges();
}
public boolean isOutlawed(Player player) {
return this.getLawStatus(player) != Status.DISABLED;
}
private boolean isTimeout(Player player) {
return this.timeouts.getOrDefault(player.getUniqueId(), 0L) > System.currentTimeMillis() - this.timeoutInMs;
}
private void setTimeout(Player player) {
this.timeouts.put(player.getUniqueId(), System.currentTimeMillis());
}
public Component getStatusDescription(Status status) {
return switch(status) {
case DISABLED -> Component.text("PVP-Modus inaktiv: ", NamedTextColor.GREEN)
.append(Component.text("Es gelten die normalen Regeln!", NamedTextColor.GOLD));
case VOLUNTARILY, FORCED -> Component.text("PVP-Modus aktiv: ", NamedTextColor.RED)
.append(Component.text(
"Du darfst von allen anderen PVP-Spielern angegriffen und getötet werden!\n" +
"Wenn du getötet wirst, müssen andere Spieler deine Items *nicht* zurückerstatten!",
NamedTextColor.GOLD
));
};
}
@Override
public Component getNamePrefix(Player player) {
if(this.isOutlawed(player)) {
return Component.text("[☠]", NamedTextColor.RED)
.hoverEvent(HoverEvent.showText(Component.text(
"PVP-Modus Spieler dürfen von anderen vogelfreien Spielern ohne Grund angegriffen werden!"
)));
}
return null;
}
@Override
@NotNull
protected List<ApplianceCommand<?>> commands() {
return List.of(new OutlawedCommand());
}
@Override
@NotNull
protected List<Listener> listeners() {
return List.of(new OutlawedReminderListener());
}
}
@@ -1,30 +0,0 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.gameplay.outlawed;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.jetbrains.annotations.NotNull;
class OutlawedCommand extends ApplianceCommand.PlayerChecked<Outlawed> {
public static final String commandName = "pvp";
public OutlawedCommand() {
super(commandName);
}
@Override
protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) throws Exception {
if(args.length == 1 && args[0].equals("confirm")) {
try {
this.getAppliance().switchLawStatus(this.getPlayer());
sender.sendMessage(this.getAppliance().getStatusDescription(this.getAppliance().getLawStatus(this.getPlayer())));
} catch(OutlawChangeNotPermitted e) {
sender.sendMessage(Component.text(e.getMessage(), NamedTextColor.RED));
}
} else {
this.getAppliance().askForConfirmation(this.getPlayer());
}
}
}
@@ -1,14 +0,0 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.gameplay.outlawed;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import org.bukkit.event.EventHandler;
import org.bukkit.event.player.PlayerJoinEvent;
class OutlawedReminderListener extends ApplianceListener<Outlawed> {
@EventHandler
public void onJoin(PlayerJoinEvent e) {
if(this.getAppliance().isOutlawed(e.getPlayer())) {
e.getPlayer().sendMessage(this.getAppliance().getStatusDescription(this.getAppliance().getLawStatus(e.getPlayer())));
}
}
}
@@ -0,0 +1,37 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.gameplay.quarzRecipe;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.entity.Player;
import org.bukkit.event.Listener;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.ShapelessRecipe;
import org.jetbrains.annotations.NotNull;
import java.util.List;
public class QuarzRecipe extends Appliance {
protected NamespacedKey recipeKey;
@Override
public void onEnable() {
this.recipeKey = new NamespacedKey(Main.instance(), "quarz_block_to_quarz");
ShapelessRecipe recipe = new ShapelessRecipe(this.recipeKey, new ItemStack(Material.QUARTZ, 4));
recipe.addIngredient(Material.QUARTZ_BLOCK);
Bukkit.addRecipe(recipe);
Bukkit.getOnlinePlayers().forEach(this::unlock);
}
public void unlock(Player player) {
player.discoverRecipe(this.recipeKey);
}
@Override
protected @NotNull List<Listener> listeners() {
return List.of(new QuarzRecipeListener());
}
}
@@ -0,0 +1,19 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.gameplay.quarzRecipe;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import io.papermc.paper.event.player.PlayerInventorySlotChangeEvent;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.inventory.ItemStack;
class QuarzRecipeListener extends ApplianceListener<QuarzRecipe> {
@EventHandler
public void shouldDiscover(PlayerInventorySlotChangeEvent event) {
ItemStack clickedItem = event.getNewItemStack();
Player player = event.getPlayer();
if(clickedItem.getType() != Material.QUARTZ_BLOCK) return;
if(player.hasDiscoveredRecipe(this.getAppliance().recipeKey)) return;
this.getAppliance().unlock(player);
}
}
@@ -0,0 +1,45 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.metaGameplay.antiPlayerLootDespawn;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import org.bukkit.NamespacedKey;
import org.bukkit.entity.Item;
import org.bukkit.event.Listener;
import org.bukkit.inventory.ItemStack;
import org.bukkit.persistence.PersistentDataType;
import org.jetbrains.annotations.NotNull;
import java.util.List;
import java.util.Locale;
public class AntiPlayerLootDespawn extends Appliance {
private static final int extraCycles = 5;
private static final String namespace = AntiPlayerLootDespawnListener.class.getSimpleName().toLowerCase(Locale.ROOT);
public static NamespacedKey cyclesKey = new NamespacedKey(namespace, "item_deaths_cycles".toLowerCase());
public void markItems(List<ItemStack> item) {
item.forEach(itemStack ->
itemStack.editPersistentDataContainer(persistentDataContainer ->
persistentDataContainer.set(cyclesKey, PersistentDataType.INTEGER, extraCycles)
)
);
}
public boolean isItemAboveMaxAge(Item item) {
ItemStack itemStack = item.getItemStack();
int cycles = itemStack.getPersistentDataContainer()
.getOrDefault(AntiPlayerLootDespawn.cyclesKey, PersistentDataType.INTEGER, 0);
itemStack.editPersistentDataContainer(data ->
data.set(AntiPlayerLootDespawn.cyclesKey, PersistentDataType.INTEGER, cycles - 1)
);
item.setItemStack(itemStack);
return cycles > 0;
}
@Override
protected @NotNull List<Listener> listeners() {
return List.of(new AntiPlayerLootDespawnListener());
}
}
@@ -0,0 +1,19 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.metaGameplay.antiPlayerLootDespawn;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import org.bukkit.event.EventHandler;
import org.bukkit.event.entity.ItemDespawnEvent;
import org.bukkit.event.entity.PlayerDeathEvent;
class AntiPlayerLootDespawnListener extends ApplianceListener<AntiPlayerLootDespawn> {
@EventHandler
public void onDeath(PlayerDeathEvent event) {
this.getAppliance().markItems(event.getDrops());
}
@EventHandler
public void onItemDespawn(ItemDespawnEvent event) {
event.setCancelled(this.getAppliance().isItemAboveMaxAge(event.getEntity()));
}
}
@@ -0,0 +1,17 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.metaGameplay.arenaPortal;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.jetbrains.annotations.NotNull;
class ArenaCommand extends ApplianceCommand.PlayerChecked<ArenaPortal> {
public ArenaCommand() {
super("arena");
}
@Override
protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
this.getAppliance().sendToArena(this.getPlayer());
}
}
@@ -0,0 +1,122 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.metaGameplay.arenaPortal;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.api.client.ReqResp;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import eu.mhsl.craftattack.spawn.core.config.ConfigUtil;
import eu.mhsl.craftattack.spawn.core.config.Configuration;
import eu.mhsl.craftattack.spawn.core.util.entity.DisplayVillager;
import eu.mhsl.craftattack.spawn.core.util.listener.DismissInventoryOpenFromHolder;
import eu.mhsl.craftattack.spawn.core.util.listener.PlayerInteractAtEntityEventListener;
import eu.mhsl.craftattack.spawn.core.util.server.PluginMessage;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.entity.Player;
import org.bukkit.entity.Villager;
import org.bukkit.event.Listener;
import org.bukkit.inventory.ItemStack;
import org.jetbrains.annotations.NotNull;
import java.util.Base64;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.logging.Level;
/**
* Entrance to the arena server: sends a copy of the player's inventory there and connects the player.
* The inventory on this server is never changed.
*/
public class ArenaPortal extends Appliance {
private static final Component PREFIX = Component.text("[Arena] ", NamedTextColor.GOLD);
private DisplayVillager.ConfigBound villager;
private final Set<UUID> pendingTransfers = new HashSet<>();
public ArenaPortal() {
super("arenaPortal");
}
@Override
public void onEnable() {
if(this.localConfig().getConfigurationSection("villagerLocation") == null) {
Main.logger().warning("ArenaPortal: no villagerLocation configured, villager disabled");
return;
}
this.villager = new DisplayVillager.ConfigBound(
this.localConfig(),
villager -> {
villager.customName(Component.text("Arena", NamedTextColor.RED));
villager.setProfession(Villager.Profession.WEAPONSMITH);
villager.setVillagerType(Villager.Type.SAVANNA);
}
);
}
/**
* @return false if the villager only appears after the next restart
*/
public boolean updateVillagerPosition(Location location) {
if(this.villager != null) {
this.villager.updateLocation(location);
return true;
}
ConfigUtil.Position.writeLocation(this.localConfig().createSection("villagerLocation"), location);
Configuration.saveChanges();
return false;
}
public void sendToArena(Player player) {
if(!this.pendingTransfers.add(player.getUniqueId())) return;
player.sendActionBar(Component.text("Du wirst zur Arena gebracht...", NamedTextColor.GRAY));
String items = Base64.getEncoder().encodeToString(ItemStack.serializeItemsAsBytes(player.getInventory().getContents()));
ArenaRepository.InventorySnapshot snapshot = new ArenaRepository.InventorySnapshot(player.getUniqueId(), items);
Bukkit.getScheduler().runTaskAsynchronously(Main.instance(), () -> {
boolean success;
try {
ReqResp<ArenaRepository.Response> response = this.queryRepository(ArenaRepository.class).sendInventory(snapshot);
success = response.status() == 200 && response.data() != null && "SUCCESS".equals(response.data().status());
} catch(Exception e) {
Main.logger().log(Level.WARNING, "ArenaPortal: arena server not reachable: " + e.getMessage());
success = false;
}
boolean finalSuccess = success;
Bukkit.getScheduler().runTask(Main.instance(), () -> {
this.pendingTransfers.remove(player.getUniqueId());
if(!player.isOnline()) return;
if(!finalSuccess) {
player.sendMessage(PREFIX.append(Component.text(
"Die Arena ist gerade nicht erreichbar. Versuche es später noch einmal.",
NamedTextColor.RED
)));
return;
}
Main.logger().info("Sending " + player.getName() + " to the arena");
PluginMessage.connect(player, this.localConfig().getString("connect-server-name", "arena"));
});
});
}
@Override
@NotNull
protected List<ApplianceCommand<?>> commands() {
return List.of(new ArenaCommand(), new MoveArenaVillagerCommand());
}
@Override
@NotNull
protected List<Listener> listeners() {
if(this.villager == null) return List.of();
return List.of(
new PlayerInteractAtEntityEventListener(this.villager.getUniqueId(), event -> this.sendToArena(event.getPlayer())),
new DismissInventoryOpenFromHolder(this.villager.getUniqueId())
);
}
}
@@ -0,0 +1,33 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.metaGameplay.arenaPortal;
import eu.mhsl.craftattack.spawn.core.api.client.HttpRepository;
import eu.mhsl.craftattack.spawn.core.api.client.ReqResp;
import eu.mhsl.craftattack.spawn.core.config.Configuration;
import java.net.URI;
import java.time.Duration;
import java.util.UUID;
public class ArenaRepository extends HttpRepository {
public ArenaRepository() {
super(URI.create(Configuration.cfg.getString("arenaPortal.api", "http://127.0.0.1:8081").replaceAll("/+$", "")));
}
/**
* @param items Base64 of {@link org.bukkit.inventory.ItemStack#serializeItemsAsBytes(java.util.Collection)}
*/
public record InventorySnapshot(UUID player, String items) {
}
public record Response(String status, String error) {
}
public ReqResp<Response> sendInventory(InventorySnapshot snapshot) {
return this.post("api/arena/inventory", Duration.ofSeconds(5), snapshot, Response.class);
}
@Override
protected boolean logRequestData() {
return false;
}
}
@@ -0,0 +1,26 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.metaGameplay.arenaPortal;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
class MoveArenaVillagerCommand extends ApplianceCommand.PlayerChecked<ArenaPortal> {
public MoveArenaVillagerCommand() {
super("moveArenaVillager");
}
@Override
public @Nullable String permission() {
return "admin";
}
@Override
protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
if(!this.getAppliance().updateVillagerPosition(this.getPlayer().getLocation()))
sender.sendMessage(Component.text("Position gespeichert. Der Villager erscheint nach dem nächsten Neustart.", NamedTextColor.GOLD));
}
}
@@ -20,7 +20,7 @@ import org.bukkit.event.Event;
import org.bukkit.event.Listener;
import org.jetbrains.annotations.NotNull;
import javax.annotation.Nullable;
import org.jetbrains.annotations.Nullable;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentSkipListMap;
@@ -0,0 +1,34 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import eu.mhsl.craftattack.spawn.core.util.server.Floodgate;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
/**
* /accountLinks – list of suspected cases; /accountLinks <player> – accounts sharing a connection with the player.
* Only visible to admins.
*/
class AccountLinksCommand extends ApplianceCommand.PlayerChecked<BanEvasion> {
AccountLinksCommand() {
super("accountLinks");
}
@Override
public @Nullable String permission() {
return BanEvasion.ADMIN_PERMISSION;
}
@Override
protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
Player player = this.getPlayer();
if(Floodgate.isBedrock(player)) throw new Error("Nur für Java-Clients");
if(!this.getAppliance().isActive()) throw new Error("Keine Datenbank konfiguriert");
if(args.length == 0) this.getAppliance().dialogs().openList(player, CaseStatus.OPEN);
else this.getAppliance().dialogs().openLinks(player, args[0]);
}
}
@@ -0,0 +1,78 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
import eu.mhsl.craftattack.spawn.core.Main;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import java.util.Comparator;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Chat messages to online admins. Never contains IP addresses.
*/
final class AdminNotifier {
record Notice(
long caseId,
String firstName,
boolean firstBanned,
String secondName,
boolean secondBanned,
Severity severity,
Set<Reason> reasons,
boolean reopened
) {
}
private final BanEvasion appliance;
AdminNotifier(BanEvasion appliance) {
this.appliance = appliance;
}
void announce(Notice notice) {
Bukkit.getScheduler().runTask(Main.instance(), () -> {
Component message = this.format(notice);
Bukkit.getOnlinePlayers().stream()
.filter(player -> player.hasPermission(BanEvasion.ADMIN_PERMISSION))
.forEach(player -> player.sendMessage(message));
});
}
void announceOpenCases(Player admin, BanEvasionRepository.OpenCount count) {
if(count.total() == 0) return;
String text = count.total() == 1 ? "1 offener Verdachtsfall" : count.total() + " offene Verdachtsfälle";
admin.sendMessage(Component.text()
.append(Component.text("⚑ " + text, count.high() > 0 ? NamedTextColor.RED : NamedTextColor.GRAY))
.append(Component.text(count.high() > 0 ? " (" + count.high() + " hoch)" : "", NamedTextColor.RED))
.append(Component.text(" [Liste]", NamedTextColor.GOLD)
.clickEvent(BanEvasionDialogs.adminClick(player -> this.appliance.dialogs().openList(player, CaseStatus.OPEN))))
.build());
}
private Component format(Notice notice) {
long caseId = notice.caseId();
NamedTextColor color = notice.severity() == Severity.HIGH ? NamedTextColor.RED : NamedTextColor.GRAY;
String reasons = notice.reasons().stream()
.filter(reason -> !reason.informational)
.sorted(Comparator.reverseOrder())
.map(reason -> reason.label)
.collect(Collectors.joining(", "));
return Component.text()
.append(Component.text(String.format("⚑ %s [%s]: ", notice.reopened() ? "Wieder offen" : "Verdacht", notice.severity().label), color))
.append(account(notice.firstName(), notice.firstBanned()))
.append(Component.text(" ↔ ", color))
.append(account(notice.secondName(), notice.secondBanned()))
.append(Component.text(" – " + reasons + " ", color))
.append(Component.text("[Öffnen]", NamedTextColor.GOLD)
.clickEvent(BanEvasionDialogs.adminClick(player -> this.appliance.dialogs().openCase(player, caseId, false))))
.build();
}
private static Component account(String name, boolean banned) {
Component component = Component.text(name, NamedTextColor.WHITE);
return banned ? component.append(Component.text(" (gebannt)", NamedTextColor.RED)) : component;
}
}
@@ -0,0 +1,31 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
import eu.mhsl.craftattack.spawn.core.event.LoginAttemptRecordedEvent;
import java.time.Instant;
import java.util.UUID;
/**
* One row of login_attempts with a known connection key.
*/
record Attempt(long id, Instant at, UUID uuid, String name, String network, String stage, String result) {
private static final String STAGE_WEBSITE = "WEBSITE";
/**
* Only for attempts with a known connection key.
*/
static Attempt of(LoginAttemptRecordedEvent.RecordedAttempt recorded) {
return new Attempt(recorded.id(), recorded.at(), recorded.uuid(), recorded.name(), recorded.network(), recorded.stage(), recorded.result());
}
boolean isWebsiteSignup() {
return STAGE_WEBSITE.equals(this.stage) && "SIGNUP".equals(this.result);
}
/**
* Rejected by the server (whitelist, ban, maintenance, ...), not merely aborted by the client.
*/
boolean isRejection() {
return !STAGE_WEBSITE.equals(this.stage) && this.result.startsWith("KICK_");
}
}
@@ -0,0 +1,322 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.api.HttpStatus;
import eu.mhsl.craftattack.spawn.core.api.client.ReqResp;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import eu.mhsl.craftattack.spawn.core.database.Database;
import eu.mhsl.craftattack.spawn.core.event.LoginAttemptRecordedEvent;
import eu.mhsl.craftattack.spawn.core.util.network.Networks;
import eu.mhsl.craftattack.spawn.craftattack.api.repositories.WhitelistRepository;
import eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.strikes.Strikes;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.entity.Player;
import org.bukkit.event.Listener;
import org.bukkit.scheduler.BukkitTask;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.net.InetSocketAddress;
import java.sql.Connection;
import java.sql.SQLException;
import java.time.Clock;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import java.util.function.Consumer;
import java.util.logging.Level;
/**
* Detects possible ban evasion: accounts that shared an internet connection (IPv4 address or IPv6 /64)
* within a time window, judged together with bans from the website. Only reports to admins
* (chat and dialogs via /accountLinks), never punishes and never changes the login of players.
* Rules: CaseEvaluator, deduplication: CaseDecision, storage: BanEvasionRepository.
* Evaluations and dialog queries run on an own worker thread, never on the main or database thread.
*/
public class BanEvasion extends Appliance {
static final String ADMIN_PERMISSION = "admin";
private static final long TICKS_PER_MINUTE = 20L * 60;
private static final long TICKS_PER_DAY = TICKS_PER_MINUTE * 60 * 24;
private static final Duration BAN_LOOKUP_TIMEOUT = Duration.ofSeconds(5);
private static final Duration BAN_LOOKUP_BACKOFF = Duration.ofSeconds(30);
@FunctionalInterface
interface Loader<T> {
T load() throws Exception;
}
/**
* Everything an evaluation needs from the database, loaded in one go.
*/
private record Context(List<Attempt> partners, Map<UUID, Long> households, Map<Long, Set<UUID>> householdMembers) {
}
private final BanEvasionRepository repository = new BanEvasionRepository();
private final OnlineNetworks onlineNetworks = new OnlineNetworks();
private Database database;
private ExecutorService worker;
private BukkitTask retentionTask;
private BanStatusCache banStatus;
private AdminNotifier notifier;
private BanEvasionDialogs dialogs;
private Duration window;
private Duration rejectionWindow;
private int retentionMonths;
private volatile boolean active;
public BanEvasion() {
super("banEvasion");
}
@Override
public void onEnable() {
this.database = Main.instance().getDatabase();
if(!this.database.isAvailable()) {
Main.logger().warning("BanEvasion: no database configured, ban evasion detection is disabled");
return;
}
ConfigurationSection config = this.localConfig();
this.window = Duration.ofDays(config.getInt("windowDays", 14));
this.rejectionWindow = Duration.ofHours(config.getInt("rejectionWindowHours", 24));
this.retentionMonths = config.getInt("retentionMonths", 16);
this.banStatus = new BanStatusCache(this::lookupBanState, Duration.ofMinutes(config.getInt("banCacheMinutes", 5)), BAN_LOOKUP_BACKOFF, Clock.systemUTC());
this.worker = Executors.newSingleThreadExecutor(runnable -> {
Thread thread = new Thread(runnable, "Spawn-BanEvasion");
thread.setDaemon(true);
return thread;
});
this.notifier = new AdminNotifier(this);
this.dialogs = new BanEvasionDialogs(this);
this.retentionTask = Bukkit.getScheduler().runTaskTimerAsynchronously(Main.instance(), this::deleteExpired, TICKS_PER_MINUTE, TICKS_PER_DAY);
this.active = true;
}
@Override
public void onDisable() {
this.active = false;
if(this.retentionTask != null) this.retentionTask.cancel();
if(this.worker != null) this.worker.shutdownNow();
}
boolean isActive() {
return this.active;
}
BanEvasionRepository repository() {
return this.repository;
}
BanEvasionDialogs dialogs() {
return this.dialogs;
}
/**
* Runs the query on the database thread and waits for it. Only for the worker thread.
*/
<T> T query(Database.SqlQuery<T> query) {
return this.database.query(query).join();
}
/**
* Blocks for HTTP requests. Only for the worker thread.
*/
Map<UUID, BanState> banStates(Collection<UUID> uuids) {
return this.banStatus.getAll(uuids);
}
/**
* Loads data on the worker thread and hands it to the main thread, if the admin is still online.
*/
<T> void loadFor(Player admin, Loader<T> loader, Consumer<T> onMainThread) {
if(!this.active) return;
this.submit(() -> {
T result;
try {
result = loader.load();
} catch(Exception e) {
Main.logger().log(Level.WARNING, "BanEvasion: loading data for " + admin.getName() + " failed", e);
Bukkit.getScheduler().runTask(Main.instance(), () -> admin.sendMessage(Component.text("Fehler beim Laden der Daten.", NamedTextColor.RED)));
return;
}
Bukkit.getScheduler().runTask(Main.instance(), () -> {
if(admin.isOnline()) onMainThread.accept(result);
});
});
}
/**
* Shortcut of {@link #loadFor} for a single database query.
*/
<T> void queryFor(Player admin, Database.SqlQuery<T> query, Consumer<T> onMainThread) {
this.loadFor(admin, () -> this.query(query), onMainThread);
}
/**
* Runs a database change for the admin, then continues on the main thread.
*/
void runFor(Player admin, Database.SqlAction action, Runnable onMainThread) {
this.queryFor(admin, connection -> {
action.run(connection);
return true;
}, done -> onMainThread.run());
}
/**
* Called on the database thread: hands the attempt to the worker without waiting.
*/
void onAttemptRecorded(LoginAttemptRecordedEvent.RecordedAttempt recorded) {
if(!this.active || recorded.network() == null) return;
Attempt attempt = Attempt.of(recorded);
Set<UUID> online = this.onlineNetworks.onlineOn(attempt.network());
this.submit(() -> this.evaluate(attempt, online));
}
void playerJoined(Player player) {
InetSocketAddress address = player.getAddress();
String network = address == null || address.getAddress() == null ? null : Networks.networkOf(address.getAddress().getHostAddress());
this.onlineNetworks.joined(player.getUniqueId(), network);
if(!this.active || !player.hasPermission(ADMIN_PERMISSION)) return;
this.queryFor(player, this.repository::countOpen, count -> this.notifier.announceOpenCases(player, count));
}
void playerLeft(Player player) {
this.onlineNetworks.left(player.getUniqueId());
}
private void submit(Runnable task) {
try {
this.worker.execute(task);
} catch(RejectedExecutionException e) {
Main.logger().warning("BanEvasion: worker stopped, task dropped");
}
}
private void evaluate(Attempt attempt, Set<UUID> online) {
try {
Context context = this.query(connection -> this.loadContext(connection, attempt));
if(context == null) return;
Set<UUID> banStateNeeded = new HashSet<>();
banStateNeeded.add(attempt.uuid());
context.partners().forEach(partner -> banStateNeeded.add(partner.uuid()));
context.householdMembers().values().forEach(banStateNeeded::addAll);
Map<UUID, BanState> banStates = this.banStatus.getAll(banStateNeeded);
List<PairFinding> findings = CaseEvaluator.evaluate(new CaseEvaluator.Input(
attempt, context.partners(), banStates, context.households(), context.householdMembers(), online, this.rejectionWindow
));
if(findings.isEmpty()) return;
// partner attempts are ordered by time, so the last one carries the latest name
Map<UUID, String> names = new HashMap<>();
context.partners().forEach(partner -> names.put(partner.uuid(), partner.name()));
names.put(attempt.uuid(), attempt.name());
List<AdminNotifier.Notice> notices = this.query(connection -> {
List<AdminNotifier.Notice> announced = new ArrayList<>();
for(PairFinding finding : findings) {
AdminNotifier.Notice notice = this.applyFinding(connection, finding, attempt, banStates, names);
if(notice != null) announced.add(notice);
}
return announced;
});
notices.forEach(this.notifier::announce);
} catch(Exception e) {
Main.logger().log(Level.WARNING, String.format("BanEvasion: evaluating login attempt #%d failed", attempt.id()), e);
}
}
/**
* @return null if the attempt is exempted or nobody else used the connection within the window
*/
private @Nullable Context loadContext(Connection connection, Attempt attempt) throws SQLException {
if(this.repository.isExempt(connection, attempt.network(), attempt.at())) return null;
List<Attempt> partners = this.repository.partnerAttempts(connection, attempt, this.window);
if(partners.isEmpty()) return null;
Set<UUID> involved = new HashSet<>();
involved.add(attempt.uuid());
partners.forEach(partner -> involved.add(partner.uuid()));
Map<UUID, Long> households = this.repository.householdsOf(connection, involved);
return new Context(partners, households, this.repository.householdMembers(connection, new HashSet<>(households.values())));
}
private @Nullable AdminNotifier.Notice applyFinding(
Connection connection,
PairFinding finding,
Attempt attempt,
Map<UUID, BanState> banStates,
Map<UUID, String> names
) throws SQLException {
CaseDecision.ExistingCase existing = this.repository.findCase(connection, finding.first(), finding.second());
CaseDecision.Result decision = CaseDecision.decide(existing, finding);
long caseId;
if(existing == null) {
caseId = this.repository.createCase(connection, finding, attempt.at());
} else {
caseId = existing.id();
this.repository.updateCase(connection, caseId, finding, attempt.at(), decision.reopen());
}
this.repository.addReasons(connection, caseId, finding.reasons(), decision.newReasons(), attempt.id(), attempt.at());
if(existing == null || !decision.newReasons().isEmpty()) {
String change = existing == null ? "created" : decision.reopen() ? "reopened" : "updated";
Main.logger().info(String.format("BanEvasion: case #%d %s %s", caseId, change, decision.newReasons()));
}
if(!decision.announce()) return null;
return new AdminNotifier.Notice(
caseId,
names.get(finding.first()),
banStates.get(finding.first()) == BanState.BANNED,
names.get(finding.second()),
banStates.get(finding.second()) == BanState.BANNED,
finding.severity(),
finding.reasons().keySet(),
decision.reopen()
);
}
private BanState lookupBanState(UUID uuid) {
try {
ReqResp<WhitelistRepository.UserData> response = this.queryRepository(WhitelistRepository.class).getUserData(uuid, BAN_LOOKUP_TIMEOUT);
if(response.status() == HttpStatus.NOT_FOUND) return BanState.NOT_BANNED;
if(response.status() != HttpStatus.OK || response.data() == null) return BanState.UNKNOWN;
List<WhitelistRepository.UserData.Strike> strikes = Objects.requireNonNullElse(response.data().strikes(), List.of());
return this.queryAppliance(Strikes.class).isBanned(strikes).isBanned() ? BanState.BANNED : BanState.NOT_BANNED;
} catch(RuntimeException e) {
Main.logger().warning("BanEvasion: ban state of " + uuid + " unavailable: " + e.getMessage());
return BanState.UNKNOWN;
}
}
private void deleteExpired() {
this.database.execute("deleting expired ban evasion data", connection -> {
int deleted = this.repository.deleteExpired(connection, this.retentionMonths);
if(deleted > 0) Main.logger().info(String.format("BanEvasion: deleted %d expired cases and exemptions", deleted));
});
}
@Override
protected @NotNull List<Listener> listeners() {
return List.of(new BanEvasionListener());
}
@Override
protected @NotNull List<ApplianceCommand<?>> commands() {
return List.of(new AccountLinksCommand());
}
}
@@ -0,0 +1,443 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.util.network.Networks;
import io.papermc.paper.dialog.Dialog;
import io.papermc.paper.dialog.DialogResponseView;
import io.papermc.paper.registry.data.dialog.ActionButton;
import io.papermc.paper.registry.data.dialog.DialogBase;
import io.papermc.paper.registry.data.dialog.action.DialogAction;
import io.papermc.paper.registry.data.dialog.body.DialogBody;
import io.papermc.paper.registry.data.dialog.input.DialogInput;
import io.papermc.paper.registry.data.dialog.input.SingleOptionDialogInput;
import io.papermc.paper.registry.data.dialog.type.DialogType;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.JoinConfiguration;
import net.kyori.adventure.text.event.ClickCallback;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.Nullable;
import java.sql.Connection;
import java.sql.SQLException;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import java.util.stream.Collectors;
/**
* Admin dialogs of the ban evasion detection. Every button checks the admin permission again on the server
* and captures only the ids it needs, so the registered callbacks stay small.
*/
final class BanEvasionDialogs {
private static final int LIST_LIMIT = 30;
private static final int LINK_LIMIT = 30;
private static final int TIMELINE_LIMIT = 20;
private static final int RECENT_NETWORKS = 5;
private static final int BUTTON_WIDTH = 310;
private static final int BODY_WIDTH = 400;
private static final ClickCallback.Options CHAT_CALLBACK = ClickCallback.Options.builder()
.uses(ClickCallback.UNLIMITED_USES).lifetime(Duration.ofHours(12)).build();
private static final ClickCallback.Options DIALOG_CALLBACK = ClickCallback.Options.builder()
.uses(ClickCallback.UNLIMITED_USES).lifetime(Duration.ofMinutes(30)).build();
private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("dd.MM.yy HH:mm").withZone(ZoneId.of("Europe/Berlin"));
private static final String NOTE_KEY = "note";
private static final String NETWORK_KEY = "network";
private static final String HOURS_KEY = "hours";
private record CaseView(BanEvasionRepository.CaseDetails details, Map<UUID, BanState> banStates, Map<UUID, Long> households) {
}
private record LinksView(String name, List<BanEvasionRepository.Link> links, List<String> networks, @Nullable Long household) {
}
private record HouseholdView(BanEvasionRepository.Household household, Map<UUID, BanState> banStates) {
}
private final BanEvasion appliance;
private final BanEvasionRepository repository;
BanEvasionDialogs(BanEvasion appliance) {
this.appliance = appliance;
this.repository = appliance.repository();
}
/**
* Click event for chat messages that only works for admins.
*/
static ClickEvent<?> adminClick(Consumer<Player> action) {
return ClickEvent.callback(audience -> {
if(audience instanceof Player player && player.hasPermission(BanEvasion.ADMIN_PERMISSION)) action.accept(player);
}, CHAT_CALLBACK);
}
void openList(Player admin, CaseStatus status) {
this.appliance.queryFor(admin, connection -> this.repository.listCases(connection, status, LIST_LIMIT), cases -> {
List<ActionButton> buttons = new ArrayList<>();
cases.forEach(summary -> {
long caseId = summary.id();
buttons.add(button(caseLabel(summary), player -> this.openCase(player, caseId, false)));
});
CaseStatus other = status == CaseStatus.OPEN ? CaseStatus.CLOSED : CaseStatus.OPEN;
buttons.add(button(other == CaseStatus.OPEN ? "Offene Fälle" : "Geschlossene Fälle", player -> this.openList(player, other)));
buttons.add(button("Haushalte", this::openHouseholds));
buttons.add(button("IP-Ausnahmen", this::openExemptions));
Component body = cases.isEmpty()
? Component.text("Keine Fälle.")
: Component.text(String.format("%d Fälle (höchstens %d), hohe zuerst, dann neueste.", cases.size(), LIST_LIMIT));
show(admin, status == CaseStatus.OPEN ? "Verdachtsfälle – offen" : "Verdachtsfälle – geschlossen", body, List.of(), buttons);
});
}
void openCase(Player admin, long caseId, boolean fullNetworks) {
this.appliance.loadFor(admin, () -> {
record Loaded(BanEvasionRepository.CaseDetails details, Map<UUID, Long> households) {
}
Loaded loaded = this.appliance.query(connection -> {
BanEvasionRepository.CaseDetails details = this.repository.loadCase(connection, caseId, TIMELINE_LIMIT);
if(details == null) return null;
return new Loaded(details, this.repository.householdsOf(connection, List.of(details.summary().first(), details.summary().second())));
});
if(loaded == null) return null;
List<UUID> accounts = List.of(loaded.details().summary().first(), loaded.details().summary().second());
return new CaseView(loaded.details(), this.appliance.banStates(accounts), loaded.households());
}, view -> {
if(view == null) {
admin.sendMessage(Component.text("Fall #" + caseId + " gibt es nicht (mehr).", NamedTextColor.RED));
return;
}
this.showCase(admin, view, fullNetworks);
});
}
void openLinks(Player admin, String name) {
this.appliance.queryFor(admin, connection -> {
BanEvasionRepository.Member account = this.repository.accountByName(connection, name);
return account == null ? null : this.loadLinks(connection, account.uuid(), account.name());
}, view -> {
if(view == null) admin.sendMessage(Component.text("Keine Einträge zu " + name + ".", NamedTextColor.RED));
else this.showLinks(admin, view);
});
}
void openLinks(Player admin, UUID uuid) {
this.appliance.queryFor(admin, connection -> this.loadLinks(connection, uuid, this.repository.nameOf(connection, uuid)),
view -> this.showLinks(admin, view));
}
void openNewExemption(Player admin, List<String> networks) {
List<SingleOptionDialogInput.OptionEntry> options = new ArrayList<>();
for(int i = 0; i < networks.size(); i++)
options.add(SingleOptionDialogInput.OptionEntry.create(networks.get(i), Component.text(networks.get(i)), i == 0));
List<DialogInput> inputs = List.of(
DialogInput.singleOption(NETWORK_KEY, Component.text("Anschluss"), options).width(BODY_WIDTH).build(),
DialogInput.numberRange(HOURS_KEY, Component.text("Dauer in Stunden"), 1, 168).step(1f).initial(12f).width(BODY_WIDTH).build(),
noteInput("Notiz, z. B. LAN-Party bei …")
);
List<ActionButton> buttons = List.of(
inputButton("Ausnahme anlegen", (player, response) -> this.createExemption(player, networks, response)),
this.backToList("Abbrechen")
);
show(admin, "IP-Ausnahme anlegen",
Component.text("Einträge von diesem Anschluss lösen in der gewählten Zeit keine Meldung aus. Sie werden weiter protokolliert."),
inputs, buttons);
}
void openExemptions(Player admin) {
Instant now = Instant.now();
this.appliance.queryFor(admin, connection -> this.repository.activeExemptions(connection, now), exemptions -> {
Component body = Component.text(exemptions.isEmpty()
? "Keine aktiven IP-Ausnahmen."
: exemptions.size() + " aktive IP-Ausnahmen, zuerst endende oben.");
List<ActionButton> buttons = new ArrayList<>();
exemptions.forEach(exemption -> buttons.add(button(
String.format("%s bis %s%s", Networks.mask(exemption.network()), TIME.format(exemption.validUntil()),
exemption.note() == null ? "" : " · " + exemption.note()),
player -> this.showExemption(player, exemption))));
buttons.add(this.backToList("Zur Fallliste"));
show(admin, "IP-Ausnahmen", body, List.of(), buttons);
});
}
void openHouseholds(Player admin) {
this.appliance.queryFor(admin, this.repository::listHouseholds, households -> {
String text = households.isEmpty() ? "Keine Haushalte." : households.size() + " Haushalte, neueste zuerst.";
if(households.size() > LIST_LIMIT)
text += " Gezeigt werden die neuesten " + LIST_LIMIT + " – ältere über /accountLinks <spieler> finden.";
List<ActionButton> buttons = new ArrayList<>();
households.stream().limit(LIST_LIMIT).forEach(household -> {
long householdId = household.id();
buttons.add(button(
"#" + householdId + ": " + household.members().stream().map(BanEvasionRepository.Member::name).collect(Collectors.joining(", ")),
player -> this.openHousehold(player, householdId)));
});
buttons.add(this.backToList("Zur Fallliste"));
show(admin, "Haushalte", Component.text(text), List.of(), buttons);
});
}
void openHousehold(Player admin, long householdId) {
this.appliance.loadFor(admin, () -> {
BanEvasionRepository.Household household = this.appliance.query(connection -> this.repository.household(connection, householdId));
if(household == null) return null;
return new HouseholdView(household, this.appliance.banStates(household.members().stream().map(BanEvasionRepository.Member::uuid).toList()));
}, view -> {
if(view == null) {
admin.sendMessage(Component.text("Haushalt #" + householdId + " gibt es nicht (mehr).", NamedTextColor.GRAY));
this.openHouseholds(admin);
return;
}
BanEvasionRepository.Household household = view.household();
List<Component> lines = new ArrayList<>();
lines.add(Component.text("Angelegt " + TIME.format(household.createdAt()) + " von " + orUnknown(household.createdByName())));
lines.add(Component.text("Notiz: " + (household.note() == null ? "–" : household.note())));
lines.add(Component.empty());
household.members().forEach(member -> lines.add(accountLine(member.name(), view.banStates().get(member.uuid()), null)));
List<ActionButton> buttons = new ArrayList<>();
household.members().forEach(member -> buttons.add(button("Entfernen: " + member.name(), player -> this.removeMember(player, householdId, member))));
buttons.add(button("Zurück", this::openHouseholds));
show(admin, "Haushalt #" + householdId, lines(lines), List.of(), buttons);
});
}
private LinksView loadLinks(Connection connection, UUID uuid, String name) throws SQLException {
return new LinksView(
name,
this.repository.linksOf(connection, uuid, LINK_LIMIT),
this.repository.recentNetworks(connection, uuid, RECENT_NETWORKS),
this.repository.householdsOf(connection, List.of(uuid)).get(uuid)
);
}
private void showLinks(Player admin, LinksView view) {
String name = view.name();
Long household = view.household();
List<String> networks = view.networks();
List<Component> lines = new ArrayList<>();
if(household != null) lines.add(Component.text(name + " gehört zu Haushalt #" + household + "."));
if(view.links().isEmpty()) lines.add(Component.text("Kein anderer Account hat je einen Anschluss mit " + name + " geteilt."));
view.links().forEach(link -> lines.add(Component.text(String.format("%s – %d Einträge an gemeinsamen Anschlüssen, %s bis %s%s",
link.name(), link.sharedAttempts(), TIME.format(link.firstAt()), TIME.format(link.lastAt()),
link.caseId() == null ? "" : " · Fall #" + link.caseId()))));
List<ActionButton> buttons = new ArrayList<>();
if(household != null) buttons.add(button("Haushalt #" + household, player -> this.openHousehold(player, household)));
view.links().forEach(link -> {
Long caseId = link.caseId();
if(caseId != null) buttons.add(button("Fall #" + caseId + ": " + link.name(), player -> this.openCase(player, caseId, false)));
});
if(!networks.isEmpty()) buttons.add(button("IP-Ausnahme für Anschluss von " + name, player -> this.openNewExemption(player, networks)));
buttons.add(this.backToList("Zur Fallliste"));
show(admin, "Accounts zu " + name, lines(lines), List.of(), buttons);
}
private void showExemption(Player admin, BanEvasionRepository.Exemption exemption) {
long exemptionId = exemption.id();
Component body = lines(List.of(
Component.text("Anschluss: " + exemption.network()),
Component.text("Gültig: " + TIME.format(exemption.validFrom()) + " bis " + TIME.format(exemption.validUntil())),
Component.text("Angelegt von: " + orUnknown(exemption.createdByName())),
Component.text("Notiz: " + (exemption.note() == null ? "–" : exemption.note()))
));
show(admin, "IP-Ausnahme", body, List.of(), List.of(
button("Jetzt beenden", player -> this.endExemption(player, exemptionId)),
button("Zurück", this::openExemptions)
));
}
private void showCase(Player admin, CaseView view, boolean fullNetworks) {
BanEvasionRepository.CaseDetails details = view.details();
BanEvasionRepository.CaseSummary summary = details.summary();
List<Component> lines = new ArrayList<>();
lines.add(Component.text(String.format("Status: %s%s · Stärke: %s · %d Treffer",
summary.status() == CaseStatus.OPEN ? "offen" : "geschlossen",
summary.outcome() == null ? "" : " (" + summary.outcome().label + ")",
summary.severity().label, summary.hits())));
lines.add(Component.text("Erstellt " + TIME.format(details.createdAt()) + " · zuletzt " + TIME.format(summary.lastSeenAt())));
if(details.banStatusUnknown())
lines.add(Component.text("Bann-Status war bei der letzten Prüfung nicht abrufbar.", NamedTextColor.GOLD));
lines.add(Component.empty());
lines.add(accountLine(summary.firstName(), view.banStates().get(summary.first()), view.households().get(summary.first())));
lines.add(accountLine(summary.secondName(), view.banStates().get(summary.second()), view.households().get(summary.second())));
lines.add(Component.empty());
lines.add(Component.text("Gründe:"));
details.reasons().forEach(reason -> lines.add(Component.text(
String.format("• %s (%s) seit %s", reason.reason().label, reason.severity().label, TIME.format(reason.firstSeenAt())),
reason.severity() == Severity.HIGH && !reason.reason().informational ? NamedTextColor.RED : NamedTextColor.GRAY)));
lines.add(Component.empty());
lines.add(Component.text("Gemeinsame Anschlüsse: " + networks(details.sharedNetworks(), fullNetworks)));
lines.add(Component.empty());
lines.add(Component.text("Letzte Einträge:"));
details.timeline().forEach(entry -> lines.add(Component.text(String.format("%s %s %s %s %s",
TIME.format(entry.at()), entry.name(), entry.stage(), entry.result(),
entry.network() == null ? "?" : network(entry.network(), fullNetworks)), NamedTextColor.GRAY)));
if(details.closedAt() != null) {
lines.add(Component.empty());
lines.add(Component.text(String.format("Zuletzt geschlossen %s von %s%s",
TIME.format(details.closedAt()), orUnknown(details.closedByName()),
details.note() == null ? "" : ": " + details.note()), NamedTextColor.GRAY));
}
long caseId = summary.id();
UUID first = summary.first();
UUID second = summary.second();
CaseStatus status = summary.status();
List<String> shared = details.sharedNetworks();
List<DialogInput> inputs = new ArrayList<>();
List<ActionButton> buttons = new ArrayList<>();
if(status == CaseStatus.OPEN) {
inputs.add(noteInput("Notiz (optional)"));
for(Outcome outcome : Outcome.values())
buttons.add(inputButton("Schließen: " + outcome.label,
(player, response) -> this.close(player, caseId, first, second, outcome, response.getText(NOTE_KEY))));
}
buttons.add(button(fullNetworks ? "Anschlüsse gekürzt" : "Volle Anschlüsse zeigen", player -> this.openCase(player, caseId, !fullNetworks)));
buttons.add(button("Accounts zu " + summary.firstName(), player -> this.openLinks(player, first)));
buttons.add(button("Accounts zu " + summary.secondName(), player -> this.openLinks(player, second)));
if(!shared.isEmpty()) buttons.add(button("IP-Ausnahme anlegen", player -> this.openNewExemption(player, shared)));
buttons.add(button("Zurück zur Liste", player -> this.openList(player, status)));
show(admin, "Verdachtsfall #" + caseId, lines(lines), inputs, buttons);
}
private void close(Player admin, long caseId, UUID first, UUID second, Outcome outcome, @Nullable String rawNote) {
String note = note(rawNote);
UUID adminUuid = admin.getUniqueId();
Instant now = Instant.now();
this.appliance.runFor(admin, connection -> {
this.repository.closeCase(connection, caseId, outcome, adminUuid, note, now);
if(outcome == Outcome.HOUSEHOLD) this.repository.linkHousehold(connection, List.of(first, second), adminUuid, note, now);
}, () -> {
Main.logger().info(String.format("BanEvasion: case #%d closed as %s by %s", caseId, outcome, admin.getName()));
admin.sendMessage(Component.text(String.format("Fall #%d geschlossen: %s", caseId, outcome.label), NamedTextColor.GRAY));
this.openList(admin, CaseStatus.OPEN);
});
}
private void createExemption(Player admin, List<String> allowed, DialogResponseView response) {
String network = response.getText(NETWORK_KEY);
Float hours = response.getFloat(HOURS_KEY);
if(network == null || !allowed.contains(network) || hours == null || hours < 1) {
admin.sendMessage(Component.text("Ungültige Eingabe.", NamedTextColor.RED));
return;
}
String note = note(response.getText(NOTE_KEY));
Instant from = Instant.now();
Instant until = from.plus(Duration.ofHours(Math.round(hours)));
UUID adminUuid = admin.getUniqueId();
this.appliance.runFor(admin, connection -> this.repository.addExemption(connection, network, from, until, adminUuid, note), () -> {
Main.logger().info(String.format("BanEvasion: exemption until %s added by %s", until, admin.getName()));
admin.sendMessage(Component.text(String.format("IP-Ausnahme für %s bis %s angelegt.", Networks.mask(network), TIME.format(until)), NamedTextColor.GRAY));
});
}
private void endExemption(Player admin, long exemptionId) {
Instant now = Instant.now();
this.appliance.runFor(admin, connection -> this.repository.endExemption(connection, exemptionId, now), () -> {
Main.logger().info(String.format("BanEvasion: exemption #%d ended by %s", exemptionId, admin.getName()));
this.openExemptions(admin);
});
}
private void removeMember(Player admin, long householdId, BanEvasionRepository.Member member) {
this.appliance.runFor(admin, connection -> this.repository.removeFromHousehold(connection, member.uuid()), () -> {
Main.logger().info(String.format("BanEvasion: %s removed from household #%d by %s", member.name(), householdId, admin.getName()));
this.openHousehold(admin, householdId);
});
}
private ActionButton backToList(String label) {
return button(label, player -> this.openList(player, CaseStatus.OPEN));
}
private static Component caseLabel(BanEvasionRepository.CaseSummary summary) {
NamedTextColor color = summary.severity() == Severity.HIGH ? NamedTextColor.RED : NamedTextColor.WHITE;
String outcome = summary.outcome() == null ? "" : " · " + summary.outcome().label;
return Component.text(String.format("#%d %s ↔ %s · %s%s",
summary.id(), summary.firstName(), summary.secondName(), TIME.format(summary.lastSeenAt()), outcome), color);
}
private static Component accountLine(String name, @Nullable BanState state, @Nullable Long household) {
String ban = switch(state == null ? BanState.UNKNOWN : state) {
case BANNED -> "gebannt";
case NOT_BANNED -> "nicht gebannt";
case UNKNOWN -> "Bann-Status unbekannt";
};
String householdText = household == null ? "" : " · Haushalt #" + household;
return Component.text(name + " – " + ban + householdText, state == BanState.BANNED ? NamedTextColor.RED : NamedTextColor.WHITE);
}
private static String network(String network, boolean full) {
return full ? network : Networks.mask(network);
}
private static String networks(List<String> networks, boolean full) {
if(networks.isEmpty()) return "keine";
return networks.stream().map(network -> network(network, full)).collect(Collectors.joining(", "));
}
private static String orUnknown(@Nullable String name) {
return name == null ? "?" : name;
}
private static @Nullable String note(@Nullable String raw) {
return raw == null || raw.isBlank() ? null : raw.strip();
}
private static DialogInput noteInput(String label) {
return DialogInput.text(NOTE_KEY, Component.text(label)).width(BODY_WIDTH).maxLength(255).build();
}
private static Component lines(List<Component> lines) {
return Component.join(JoinConfiguration.newlines(), lines);
}
private static DialogAction adminAction(BiConsumer<Player, DialogResponseView> action) {
return DialogAction.customClick((response, audience) -> {
if(!(audience instanceof Player player)) return;
Bukkit.getScheduler().runTask(Main.instance(), () -> {
if(player.isOnline() && player.hasPermission(BanEvasion.ADMIN_PERMISSION)) action.accept(player, response);
});
}, DIALOG_CALLBACK);
}
private static ActionButton button(Component label, Consumer<Player> action) {
return ActionButton.builder(label).width(BUTTON_WIDTH).action(adminAction((player, response) -> action.accept(player))).build();
}
private static ActionButton button(String label, Consumer<Player> action) {
return button(Component.text(label), action);
}
/**
* Button that reads the inputs of the dialog.
*/
private static ActionButton inputButton(String label, BiConsumer<Player, DialogResponseView> action) {
return ActionButton.builder(Component.text(label)).width(BUTTON_WIDTH).action(adminAction(action)).build();
}
private static void show(Player admin, String title, Component body, List<DialogInput> inputs, List<ActionButton> buttons) {
Dialog dialog = Dialog.create(factory -> factory.empty()
.base(DialogBase.builder(Component.text(title))
.canCloseWithEscape(true)
.afterAction(DialogBase.DialogAfterAction.CLOSE)
.body(List.of(DialogBody.plainMessage(body, BODY_WIDTH)))
.inputs(inputs)
.build())
.type(DialogType.multiAction(buttons)
.columns(1)
.exitAction(ActionButton.builder(Component.text("Schließen")).width(BUTTON_WIDTH).build())
.build()));
admin.showDialog(dialog);
}
}
@@ -0,0 +1,24 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import eu.mhsl.craftattack.spawn.core.event.LoginAttemptRecordedEvent;
import org.bukkit.event.EventHandler;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
class BanEvasionListener extends ApplianceListener<BanEvasion> {
@EventHandler
public void onAttemptRecorded(LoginAttemptRecordedEvent event) {
this.getAppliance().onAttemptRecorded(event.getAttempt());
}
@EventHandler
public void onJoin(PlayerJoinEvent event) {
this.getAppliance().playerJoined(event.getPlayer());
}
@EventHandler
public void onQuit(PlayerQuitEvent event) {
this.getAppliance().playerLeft(event.getPlayer());
}
}
@@ -0,0 +1,504 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
import org.jetbrains.annotations.Nullable;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
/**
* All SQL of the ban evasion detection. Plain JDBC without Bukkit, every method runs on the caller's connection.
* Tables: resources/migrations/banEvasion, connection keys: login_attempts.network.
*/
final class BanEvasionRepository {
record CaseSummary(
long id,
UUID first,
String firstName,
UUID second,
String secondName,
CaseStatus status,
@Nullable Outcome outcome,
Severity severity,
Instant lastSeenAt,
int hits
) {
}
record CaseReason(Reason reason, Severity severity, Instant firstSeenAt) {
}
record TimelineEntry(Instant at, String name, @Nullable String network, String stage, String result) {
}
record CaseDetails(
CaseSummary summary,
boolean banStatusUnknown,
Instant createdAt,
@Nullable Instant closedAt,
@Nullable String closedByName,
@Nullable String note,
List<CaseReason> reasons,
List<String> sharedNetworks,
List<TimelineEntry> timeline
) {
}
record OpenCount(int total, int high) {
}
record Link(UUID uuid, String name, int sharedAttempts, Instant firstAt, Instant lastAt, @Nullable Long caseId) {
}
record Member(UUID uuid, String name) {
}
record Household(long id, Instant createdAt, @Nullable String createdByName, @Nullable String note, List<Member> members) {
}
record Exemption(long id, String network, Instant validFrom, Instant validUntil, @Nullable String createdByName, @Nullable String note) {
}
@FunctionalInterface
private interface RowMapper<T> {
T map(ResultSet row) throws SQLException;
}
/**
* Latest known name of the account in the given column.
*/
private static final String NAME_OF = "(SELECT n.name FROM login_attempts n WHERE n.uuid = %s ORDER BY n.at DESC LIMIT 1)";
boolean isExempt(Connection connection, String network, Instant at) throws SQLException {
String sql = "SELECT 1 FROM ban_evasion_ip_exemptions WHERE network = ? AND ? BETWEEN valid_from AND valid_until LIMIT 1";
return first(connection, sql, row -> true, network, at) != null;
}
List<Attempt> partnerAttempts(Connection connection, Attempt current, Duration window) throws SQLException {
String sql = """
SELECT a.id, a.at, a.uuid, a.name, a.network, a.stage, a.result
FROM login_attempts a
WHERE a.network = ? AND a.uuid <> ? AND a.at BETWEEN ? AND ?
AND NOT EXISTS (
SELECT 1 FROM ban_evasion_ip_exemptions e
WHERE e.network = a.network AND a.at BETWEEN e.valid_from AND e.valid_until
)
ORDER BY a.at
""";
return list(connection, sql, row -> new Attempt(
row.getLong("id"),
fromDb(row, "at"),
uuid(row, "uuid"),
row.getString("name"),
row.getString("network"),
row.getString("stage"),
row.getString("result")
), current.network(), current.uuid(), current.at().minus(window), current.at());
}
Map<UUID, Long> householdsOf(Connection connection, Collection<UUID> uuids) throws SQLException {
if(uuids.isEmpty()) return Map.of();
String sql = "SELECT uuid, household_id FROM ban_evasion_household_members WHERE uuid IN (" + placeholders(uuids.size()) + ")";
Map<UUID, Long> households = new HashMap<>();
list(connection, sql, row -> Map.entry(uuid(row, "uuid"), row.getLong("household_id")), uuids.toArray())
.forEach(entry -> households.put(entry.getKey(), entry.getValue()));
return households;
}
Map<Long, Set<UUID>> householdMembers(Connection connection, Collection<Long> householdIds) throws SQLException {
if(householdIds.isEmpty()) return Map.of();
String sql = "SELECT household_id, uuid FROM ban_evasion_household_members WHERE household_id IN (" + placeholders(householdIds.size()) + ")";
Map<Long, Set<UUID>> members = new HashMap<>();
list(connection, sql, row -> Map.entry(row.getLong("household_id"), uuid(row, "uuid")), householdIds.toArray())
.forEach(entry -> members.computeIfAbsent(entry.getKey(), id -> new HashSet<>()).add(entry.getValue()));
return members;
}
@Nullable CaseDecision.ExistingCase findCase(Connection connection, UUID first, UUID second) throws SQLException {
String sql = "SELECT id, status, outcome FROM ban_evasion_cases WHERE uuid_a = ? AND uuid_b = ?";
CaseDecision.ExistingCase found = first(connection, sql, row -> new CaseDecision.ExistingCase(
row.getLong("id"), CaseStatus.valueOf(row.getString("status")), outcomeOf(row), EnumSet.noneOf(Reason.class)
), first, second);
if(found == null) return null;
this.reasonsOf(connection, found.id()).forEach(reason -> found.reasons().add(reason.reason()));
return found;
}
/**
* Creates the case, or returns the id of the existing case of the pair.
*/
long createCase(Connection connection, PairFinding finding, Instant at) throws SQLException {
String sql = """
INSERT INTO ban_evasion_cases (uuid_a, uuid_b, status, severity, ban_status_unknown, created_at, last_seen_at, hits)
VALUES (?, ?, 'OPEN', ?, ?, ?, ?, 1)
ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)
""";
return insert(connection, sql, finding.first(), finding.second(), finding.severity(), finding.banStatusUnknown(), at, at);
}
void updateCase(Connection connection, long caseId, PairFinding finding, Instant at, boolean reopen) throws SQLException {
String sql = """
UPDATE ban_evasion_cases SET
last_seen_at = GREATEST(last_seen_at, ?),
hits = hits + 1,
severity = IF(severity = 'HIGH' OR ? = 'HIGH', 'HIGH', 'NORMAL'),
ban_status_unknown = ?,
status = IF(?, 'OPEN', status),
outcome = IF(?, NULL, outcome)
WHERE id = ?
""";
update(connection, sql, at, finding.severity(), finding.banStatusUnknown(), reopen, reopen, caseId);
}
void addReasons(Connection connection, long caseId, Map<Reason, Severity> reasons, Set<Reason> which, long attemptId, Instant at) throws SQLException {
String sql = "INSERT IGNORE INTO ban_evasion_case_reasons (case_id, reason, severity, first_seen_at, attempt_id) VALUES (?, ?, ?, ?, ?)";
for(Reason reason : which) update(connection, sql, caseId, reason, reasons.get(reason), at, attemptId);
}
List<CaseSummary> listCases(Connection connection, CaseStatus status, int limit) throws SQLException {
String sql = """
SELECT c.id, c.uuid_a, c.uuid_b, c.status, c.outcome, c.severity, c.last_seen_at, c.hits,
%s AS name_a, %s AS name_b
FROM ban_evasion_cases c
WHERE c.status = ?
ORDER BY c.severity = 'HIGH' DESC, c.last_seen_at DESC
LIMIT ?
""".formatted(NAME_OF.formatted("c.uuid_a"), NAME_OF.formatted("c.uuid_b"));
return list(connection, sql, BanEvasionRepository::summaryOf, status, limit);
}
OpenCount countOpen(Connection connection) throws SQLException {
String sql = "SELECT COUNT(*) AS total, COALESCE(SUM(severity = 'HIGH'), 0) AS high FROM ban_evasion_cases WHERE status = 'OPEN'";
return Objects.requireNonNull(first(connection, sql, row -> new OpenCount(row.getInt("total"), row.getInt("high"))));
}
@Nullable CaseDetails loadCase(Connection connection, long caseId, int timelineLimit) throws SQLException {
String sql = """
SELECT c.*, %s AS name_a, %s AS name_b, %s AS closed_by_name
FROM ban_evasion_cases c
WHERE c.id = ?
""".formatted(NAME_OF.formatted("c.uuid_a"), NAME_OF.formatted("c.uuid_b"), NAME_OF.formatted("c.closed_by"));
CaseDetails found = first(connection, sql, row -> new CaseDetails(
summaryOf(row),
row.getBoolean("ban_status_unknown"),
fromDb(row, "created_at"),
nullableFromDb(row, "closed_at"),
row.getString("closed_by_name"),
row.getString("note"),
List.of(), List.of(), List.of()
), caseId);
if(found == null) return null;
UUID first = found.summary().first();
UUID second = found.summary().second();
return new CaseDetails(
found.summary(), found.banStatusUnknown(), found.createdAt(), found.closedAt(), found.closedByName(), found.note(),
this.reasonsOf(connection, caseId),
this.sharedNetworks(connection, first, second),
this.timeline(connection, first, second, timelineLimit)
);
}
void closeCase(Connection connection, long caseId, Outcome outcome, UUID admin, @Nullable String note, Instant at) throws SQLException {
String sql = "UPDATE ban_evasion_cases SET status = 'CLOSED', outcome = ?, closed_at = ?, closed_by = ?, note = ? WHERE id = ?";
update(connection, sql, outcome, at, admin, note, caseId);
}
/**
* All accounts that ever used one of the connections of the given account, most recent first.
*/
List<Link> linksOf(Connection connection, UUID uuid, int limit) throws SQLException {
String sql = """
SELECT o.uuid, %s AS name, COUNT(*) AS shared, MIN(o.at) AS first_at, MAX(o.at) AS last_at,
(SELECT c.id FROM ban_evasion_cases c
WHERE (c.uuid_a = ? AND c.uuid_b = o.uuid) OR (c.uuid_a = o.uuid AND c.uuid_b = ?)) AS case_id
FROM login_attempts o
WHERE o.uuid <> ? AND o.network IN (
SELECT s.network FROM login_attempts s WHERE s.uuid = ? AND s.network IS NOT NULL
)
GROUP BY o.uuid
ORDER BY last_at DESC
LIMIT ?
""".formatted(NAME_OF.formatted("o.uuid"));
return list(connection, sql, row -> {
UUID other = uuid(row, "uuid");
return new Link(
other,
nameOr(row.getString("name"), other),
row.getInt("shared"),
fromDb(row, "first_at"),
fromDb(row, "last_at"),
row.getObject("case_id", Long.class)
);
}, uuid, uuid, uuid, uuid, limit);
}
/**
* Latest account that used the name (case-insensitive), with its name as stored.
*/
@Nullable Member accountByName(Connection connection, String name) throws SQLException {
String sql = "SELECT uuid, name FROM login_attempts WHERE name = ? ORDER BY at DESC LIMIT 1";
return first(connection, sql, row -> new Member(uuid(row, "uuid"), row.getString("name")), name);
}
String nameOf(Connection connection, UUID uuid) throws SQLException {
String name = first(connection, "SELECT name FROM login_attempts WHERE uuid = ? ORDER BY at DESC LIMIT 1", row -> row.getString("name"), uuid);
return nameOr(name, uuid);
}
List<String> recentNetworks(Connection connection, UUID uuid, int limit) throws SQLException {
String sql = """
SELECT network, MAX(at) AS last_at FROM login_attempts
WHERE uuid = ? AND network IS NOT NULL
GROUP BY network ORDER BY last_at DESC LIMIT ?
""";
return list(connection, sql, row -> row.getString("network"), uuid, limit);
}
void addExemption(Connection connection, String network, Instant from, Instant until, UUID admin, @Nullable String note) throws SQLException {
String sql = "INSERT INTO ban_evasion_ip_exemptions (network, valid_from, valid_until, created_by, note) VALUES (?, ?, ?, ?, ?)";
update(connection, sql, network, from, until, admin, note);
}
List<Exemption> activeExemptions(Connection connection, Instant now) throws SQLException {
String sql = """
SELECT e.id, e.network, e.valid_from, e.valid_until, e.note, %s AS created_by_name
FROM ban_evasion_ip_exemptions e
WHERE e.valid_until >= ?
ORDER BY e.valid_until
""".formatted(NAME_OF.formatted("e.created_by"));
return list(connection, sql, row -> new Exemption(
row.getLong("id"),
row.getString("network"),
fromDb(row, "valid_from"),
fromDb(row, "valid_until"),
row.getString("created_by_name"),
row.getString("note")
), now);
}
void endExemption(Connection connection, long id, Instant now) throws SQLException {
update(connection, "UPDATE ban_evasion_ip_exemptions SET valid_until = ? WHERE id = ? AND valid_until > ?", now, id, now);
}
/**
* Puts the accounts into one household; existing households of these accounts are merged.
*/
void linkHousehold(Connection connection, Collection<UUID> uuids, UUID admin, @Nullable String note, Instant at) throws SQLException {
boolean autoCommit = connection.getAutoCommit();
connection.setAutoCommit(false);
try {
List<Long> existing = this.householdsOf(connection, uuids).values().stream().distinct().sorted().toList();
long target = existing.isEmpty()
? insert(connection, "INSERT INTO ban_evasion_households (created_at, created_by, note) VALUES (?, ?, ?)", at, admin, note)
: existing.getFirst();
if(existing.size() > 1) {
Object[] merged = existing.subList(1, existing.size()).toArray();
String in = placeholders(merged.length);
Object[] moveParams = new Object[merged.length + 1];
moveParams[0] = target;
System.arraycopy(merged, 0, moveParams, 1, merged.length);
update(connection, "UPDATE ban_evasion_household_members SET household_id = ? WHERE household_id IN (" + in + ")", moveParams);
update(connection, "DELETE FROM ban_evasion_households WHERE id IN (" + in + ")", merged);
}
for(UUID uuid : uuids)
update(connection, "INSERT IGNORE INTO ban_evasion_household_members (uuid, household_id) VALUES (?, ?)", uuid, target);
connection.commit();
} catch(SQLException | RuntimeException e) {
connection.rollback();
throw e;
} finally {
connection.setAutoCommit(autoCommit);
}
}
/**
* All households, newest first.
*/
List<Household> listHouseholds(Connection connection) throws SQLException {
return this.households(connection, "");
}
@Nullable Household household(Connection connection, long id) throws SQLException {
List<Household> households = this.households(connection, "WHERE h.id = ?", id);
return households.isEmpty() ? null : households.getFirst();
}
private List<Household> households(Connection connection, String where, Object... params) throws SQLException {
String sql = """
SELECT h.id, h.created_at, h.note, %s AS created_by_name, m.uuid, %s AS name
FROM ban_evasion_households h
JOIN ban_evasion_household_members m ON m.household_id = h.id
%s
ORDER BY h.id DESC, name
""".formatted(NAME_OF.formatted("h.created_by"), NAME_OF.formatted("m.uuid"), where);
Map<Long, Household> households = new LinkedHashMap<>();
list(connection, sql, row -> {
UUID uuid = uuid(row, "uuid");
Household household = households.get(row.getLong("id"));
if(household == null) {
household = new Household(row.getLong("id"), fromDb(row, "created_at"), row.getString("created_by_name"), row.getString("note"), new ArrayList<>());
households.put(household.id(), household);
}
return household.members().add(new Member(uuid, nameOr(row.getString("name"), uuid)));
}, params);
return List.copyOf(households.values());
}
/**
* Removes the account from its household; a household with fewer than two members is dissolved.
*/
void removeFromHousehold(Connection connection, UUID uuid) throws SQLException {
Long household = this.householdsOf(connection, List.of(uuid)).get(uuid);
if(household == null) return;
update(connection, "DELETE FROM ban_evasion_household_members WHERE uuid = ?", uuid);
update(connection,
"DELETE FROM ban_evasion_households WHERE id = ? AND (SELECT COUNT(*) FROM ban_evasion_household_members WHERE household_id = ?) < 2",
household, household);
}
int deleteExpired(Connection connection, int retentionMonths) throws SQLException {
return update(connection, "DELETE FROM ban_evasion_cases WHERE last_seen_at < UTC_TIMESTAMP() - INTERVAL ? MONTH", retentionMonths)
+ update(connection, "DELETE FROM ban_evasion_ip_exemptions WHERE valid_until < UTC_TIMESTAMP() - INTERVAL ? MONTH", retentionMonths);
}
private List<CaseReason> reasonsOf(Connection connection, long caseId) throws SQLException {
String sql = "SELECT reason, severity, first_seen_at FROM ban_evasion_case_reasons WHERE case_id = ? ORDER BY first_seen_at, reason";
return list(connection, sql, row -> new CaseReason(
Reason.valueOf(row.getString("reason")),
Severity.valueOf(row.getString("severity")),
fromDb(row, "first_seen_at")
), caseId);
}
private List<String> sharedNetworks(Connection connection, UUID first, UUID second) throws SQLException {
String sql = """
SELECT DISTINCT a.network FROM login_attempts a
WHERE a.uuid = ? AND a.network IS NOT NULL
AND EXISTS (SELECT 1 FROM login_attempts b WHERE b.uuid = ? AND b.network = a.network)
ORDER BY a.network
""";
return list(connection, sql, row -> row.getString("network"), first, second);
}
private List<TimelineEntry> timeline(Connection connection, UUID first, UUID second, int limit) throws SQLException {
String sql = "SELECT at, name, network, stage, result FROM login_attempts WHERE uuid IN (?, ?) ORDER BY at DESC LIMIT ?";
return list(connection, sql, row -> new TimelineEntry(
fromDb(row, "at"), row.getString("name"), row.getString("network"), row.getString("stage"), row.getString("result")
), first, second, limit);
}
private static CaseSummary summaryOf(ResultSet row) throws SQLException {
UUID first = uuid(row, "uuid_a");
UUID second = uuid(row, "uuid_b");
return new CaseSummary(
row.getLong("id"),
first,
nameOr(row.getString("name_a"), first),
second,
nameOr(row.getString("name_b"), second),
CaseStatus.valueOf(row.getString("status")),
outcomeOf(row),
Severity.valueOf(row.getString("severity")),
fromDb(row, "last_seen_at"),
row.getInt("hits")
);
}
private static <T> List<T> list(Connection connection, String sql, RowMapper<T> mapper, Object... params) throws SQLException {
try(PreparedStatement statement = connection.prepareStatement(sql)) {
bind(statement, params);
List<T> rows = new ArrayList<>();
try(ResultSet row = statement.executeQuery()) {
while(row.next()) rows.add(mapper.map(row));
}
return rows;
}
}
private static <T> @Nullable T first(Connection connection, String sql, RowMapper<T> mapper, Object... params) throws SQLException {
List<T> rows = list(connection, sql, mapper, params);
return rows.isEmpty() ? null : rows.getFirst();
}
private static int update(Connection connection, String sql, Object... params) throws SQLException {
try(PreparedStatement statement = connection.prepareStatement(sql)) {
bind(statement, params);
return statement.executeUpdate();
}
}
/**
* Executes an insert and returns the generated (or, with LAST_INSERT_ID(id), the existing) id.
*/
private static long insert(Connection connection, String sql, Object... params) throws SQLException {
try(PreparedStatement statement = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
bind(statement, params);
statement.executeUpdate();
try(ResultSet keys = statement.getGeneratedKeys()) {
if(!keys.next()) throw new SQLException("no id returned by insert");
return keys.getLong(1);
}
}
}
private static void bind(PreparedStatement statement, Object... params) throws SQLException {
for(int i = 0; i < params.length; i++) {
Object value = switch(params[i]) {
case UUID uuid -> uuid.toString();
case Instant instant -> toDb(instant);
case Enum<?> constant -> constant.name();
case null -> null;
default -> params[i];
};
statement.setObject(i + 1, value);
}
}
private static UUID uuid(ResultSet row, String column) throws SQLException {
return UUID.fromString(row.getString(column));
}
private static @Nullable Outcome outcomeOf(ResultSet row) throws SQLException {
String outcome = row.getString("outcome");
return outcome == null ? null : Outcome.valueOf(outcome);
}
private static String nameOr(@Nullable String name, UUID uuid) {
return name != null ? name : uuid.toString();
}
private static String placeholders(int count) {
return String.join(", ", Collections.nCopies(count, "?"));
}
private static LocalDateTime toDb(Instant instant) {
return LocalDateTime.ofInstant(instant, ZoneOffset.UTC);
}
private static Instant fromDb(ResultSet row, String column) throws SQLException {
return Objects.requireNonNull(nullableFromDb(row, column), column);
}
private static @Nullable Instant nullableFromDb(ResultSet row, String column) throws SQLException {
LocalDateTime value = row.getObject(column, LocalDateTime.class);
return value == null ? null : value.toInstant(ZoneOffset.UTC);
}
}
@@ -0,0 +1,10 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
enum BanState {
BANNED,
NOT_BANNED,
/**
* The website API could not be asked.
*/
UNKNOWN
}
@@ -0,0 +1,54 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
/**
* Ban states from the website, kept for a short time. UNKNOWN (API unreachable) is never cached,
* but after a failure the website is not asked again for the backoff period, so an unreachable website
* does not slow down every evaluation. The lookup blocks (HTTP), so never call this on the main or database thread.
*/
final class BanStatusCache {
private record Entry(BanState state, Instant fetchedAt) {
}
private final Map<UUID, Entry> entries = new ConcurrentHashMap<>();
private final Function<UUID, BanState> lookup;
private final Duration ttl;
private final Duration backoff;
private final Clock clock;
private volatile Instant failingUntil = Instant.MIN;
BanStatusCache(Function<UUID, BanState> lookup, Duration ttl, Duration backoff, Clock clock) {
this.lookup = lookup;
this.ttl = ttl;
this.backoff = backoff;
this.clock = clock;
}
BanState get(UUID uuid) {
Instant now = this.clock.instant();
Entry entry = this.entries.get(uuid);
if(entry != null && entry.fetchedAt().plus(this.ttl).isAfter(now)) return entry.state();
if(now.isBefore(this.failingUntil)) return BanState.UNKNOWN;
BanState state = this.lookup.apply(uuid);
if(state == BanState.UNKNOWN) this.failingUntil = this.clock.instant().plus(this.backoff);
else this.entries.put(uuid, new Entry(state, now));
return state;
}
Map<UUID, BanState> getAll(Collection<UUID> uuids) {
Map<UUID, BanState> states = new LinkedHashMap<>();
uuids.forEach(uuid -> states.put(uuid, this.get(uuid)));
return states;
}
}
@@ -0,0 +1,42 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
import org.jetbrains.annotations.Nullable;
import java.util.EnumSet;
import java.util.Set;
/**
* Deduplication: one case per pair; admins are only notified about new information.
* A closed case reopens only for a new reason of severity HIGH, a case closed as confirmed evasion never:
* further attempts of the banned person are expected there and only recorded.
*/
final class CaseDecision {
record ExistingCase(long id, CaseStatus status, @Nullable Outcome outcome, Set<Reason> reasons) {
}
/**
* @param newReasons reasons not yet recorded for the case (all reasons for a new case)
*/
record Result(boolean reopen, Set<Reason> newReasons, boolean announce) {
}
private CaseDecision() {
}
static Result decide(@Nullable ExistingCase existing, PairFinding finding) {
if(existing == null) return new Result(false, Set.copyOf(finding.reasons().keySet()), true);
Set<Reason> newReasons = EnumSet.noneOf(Reason.class);
finding.reasons().keySet().stream()
.filter(reason -> !existing.reasons().contains(reason))
.forEach(newReasons::add);
boolean newRelevant = newReasons.stream().anyMatch(reason -> !reason.informational);
boolean newHigh = newReasons.stream()
.anyMatch(reason -> !reason.informational && finding.reasons().get(reason) == Severity.HIGH);
if(existing.status() == CaseStatus.OPEN) return new Result(false, newReasons, newRelevant);
if(existing.outcome() == Outcome.CONFIRMED) return new Result(false, newReasons, false);
return new Result(newHigh, newReasons, newHigh);
}
}
@@ -0,0 +1,97 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
import org.jetbrains.annotations.Nullable;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.EnumMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
/**
* Rules of the ban evasion detection, free of Bukkit and database access.
* Judges one new login attempt against all attempts of other accounts from the same connection.
*/
final class CaseEvaluator {
/**
* @param partnerAttempts attempts of other accounts from the same connection within the time window, not exempted
* @param banStates ban state of the current account, its partners and all members of their households
* @param households household id per account, only for accounts in a household
* @param householdMembers members per household id
* @param onlineOnSameNetwork accounts currently online from the same connection
*/
record Input(
Attempt current,
List<Attempt> partnerAttempts,
Map<UUID, BanState> banStates,
Map<UUID, Long> households,
Map<Long, Set<UUID>> householdMembers,
Set<UUID> onlineOnSameNetwork,
Duration rejectionWindow
) {
}
private CaseEvaluator() {
}
static List<PairFinding> evaluate(Input input) {
UUID self = input.current().uuid();
Map<UUID, List<Attempt>> byPartner = new LinkedHashMap<>();
input.partnerAttempts().stream()
.filter(attempt -> !attempt.uuid().equals(self))
.forEach(attempt -> byPartner.computeIfAbsent(attempt.uuid(), uuid -> new ArrayList<>()).add(attempt));
List<PairFinding> findings = new ArrayList<>();
byPartner.forEach((partner, attempts) -> {
PairFinding finding = evaluatePair(input, partner, attempts);
if(finding != null) findings.add(finding);
});
return findings;
}
private static @Nullable PairFinding evaluatePair(Input input, UUID partner, List<Attempt> partnerAttempts) {
Attempt current = input.current();
BanState selfState = banState(input, current.uuid());
BanState partnerState = banState(input, partner);
Long household = input.households().get(current.uuid());
boolean sameHousehold = household != null && household.equals(input.households().get(partner));
if(sameHousehold && !householdBanned(input, household)) return null;
Map<Reason, Severity> reasons = new EnumMap<>(Reason.class);
reasons.put(Reason.SHARED_CONNECTION, Severity.NORMAL);
if(selfState == BanState.BANNED || partnerState == BanState.BANNED)
reasons.put(Reason.BANNED, Severity.HIGH);
Instant rejectionStart = current.at().minus(input.rejectionWindow());
boolean partnerRejectedRecently = partnerAttempts.stream()
.anyMatch(attempt -> attempt.isRejection()
&& !attempt.at().isBefore(rejectionStart)
&& !attempt.at().isAfter(current.at()));
if(partnerState == BanState.BANNED && partnerRejectedRecently)
reasons.put(Reason.AFTER_REJECTION, Severity.HIGH);
if(current.isWebsiteSignup())
reasons.put(Reason.WEBSITE_SIGNUP, partnerState == BanState.BANNED ? Severity.HIGH : Severity.NORMAL);
if(sameHousehold)
reasons.put(Reason.HOUSEHOLD_MEMBER_BANNED, Severity.HIGH);
if(input.onlineOnSameNetwork().contains(partner))
reasons.put(Reason.ONLINE_SIMULTANEOUSLY, Severity.NORMAL);
boolean unknown = selfState == BanState.UNKNOWN || partnerState == BanState.UNKNOWN;
return PairFinding.of(current.uuid(), partner, reasons, unknown);
}
private static BanState banState(Input input, UUID uuid) {
return input.banStates().getOrDefault(uuid, BanState.UNKNOWN);
}
private static boolean householdBanned(Input input, long household) {
return input.householdMembers().getOrDefault(household, Set.of()).stream()
.anyMatch(member -> banState(input, member) == BanState.BANNED);
}
}
@@ -0,0 +1,6 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
enum CaseStatus {
OPEN,
CLOSED
}
@@ -0,0 +1,32 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
import org.jetbrains.annotations.Nullable;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
/**
* Connection key of every online player, readable from any thread.
*/
final class OnlineNetworks {
private final Map<UUID, String> networks = new ConcurrentHashMap<>();
void joined(UUID uuid, @Nullable String network) {
if(network == null) this.networks.remove(uuid);
else this.networks.put(uuid, network);
}
void left(UUID uuid) {
this.networks.remove(uuid);
}
Set<UUID> onlineOn(String network) {
return this.networks.entrySet().stream()
.filter(entry -> entry.getValue().equals(network))
.map(Map.Entry::getKey)
.collect(Collectors.toUnmodifiableSet());
}
}
@@ -0,0 +1,13 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
enum Outcome {
CONFIRMED("Umgehung bestätigt"),
UNFOUNDED("unbegründet"),
HOUSEHOLD("Haushalt");
final String label;
Outcome(String label) {
this.label = label;
}
}
@@ -0,0 +1,26 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
import java.util.Collections;
import java.util.EnumMap;
import java.util.Map;
import java.util.UUID;
/**
* Reasons why two accounts may belong to the same person. The pair is ordered by the textual UUID,
* so (a, b) and (b, a) always map to the same case.
*/
record PairFinding(UUID first, UUID second, Map<Reason, Severity> reasons, boolean banStatusUnknown) {
static PairFinding of(UUID x, UUID y, Map<Reason, Severity> reasons, boolean banStatusUnknown) {
boolean ordered = x.toString().compareTo(y.toString()) < 0;
EnumMap<Reason, Severity> copy = new EnumMap<>(Reason.class);
copy.putAll(reasons);
return new PairFinding(ordered ? x : y, ordered ? y : x, Collections.unmodifiableMap(copy), banStatusUnknown);
}
Severity severity() {
return this.reasons.entrySet().stream()
.filter(entry -> !entry.getKey().informational)
.map(Map.Entry::getValue)
.reduce(Severity.NORMAL, Severity::max);
}
}
@@ -0,0 +1,21 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
enum Reason {
SHARED_CONNECTION("gemeinsamer Anschluss", false),
BANNED("gebannt", false),
AFTER_REJECTION("nach Abweisung", false),
WEBSITE_SIGNUP("Webseiten-Anmeldung", false),
HOUSEHOLD_MEMBER_BANNED("Haushalt, Mitglied gebannt", false),
ONLINE_SIMULTANEOUSLY("gleichzeitig online", true);
final String label;
/**
* Informational reasons are shown in the case, but never trigger a notification.
*/
final boolean informational;
Reason(String label, boolean informational) {
this.label = label;
this.informational = informational;
}
}
@@ -0,0 +1,16 @@
package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion;
enum Severity {
NORMAL("normal"),
HIGH("hoch");
final String label;
Severity(String label) {
this.label = label;
}
static Severity max(Severity a, Severity b) {
return a.compareTo(b) >= 0 ? a : b;
}
}
@@ -26,10 +26,8 @@ import org.bukkit.inventory.ItemStack;
import org.jetbrains.annotations.NotNull;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import static java.util.Map.entry;
import static org.bukkit.Sound.MUSIC_DISC_LAVA_CHICKEN;
public class ProjectStart extends Appliance {
@@ -57,17 +55,23 @@ public class ProjectStart extends Appliance {
)
);
private final Map<GameRule<Boolean>, Boolean> gameRulesAfterStart = Map.ofEntries(
entry(GameRule.DO_DAYLIGHT_CYCLE, true),
entry(GameRule.DO_INSOMNIA, true),
entry(GameRule.DISABLE_RAIDS, false),
entry(GameRule.DO_FIRE_TICK, true),
entry(GameRule.DO_ENTITY_DROPS, true),
entry(GameRule.DO_PATROL_SPAWNING, true),
entry(GameRule.DO_TRADER_SPAWNING, true),
entry(GameRule.DO_WEATHER_CYCLE, true),
entry(GameRule.FALL_DAMAGE, true),
entry(GameRule.FIRE_DAMAGE, true)
private record GameRuleSetting<T>(GameRule<T> rule, T before, T after) {
void apply(boolean afterStart) {
IteratorUtil.worlds(world -> world.setGameRule(this.rule, afterStart ? this.after : this.before));
}
}
private final List<GameRuleSetting<?>> gameRules = List.of(
new GameRuleSetting<>(GameRules.ADVANCE_TIME, false, true),
new GameRuleSetting<>(GameRules.SPAWN_PHANTOMS, false, true),
new GameRuleSetting<>(GameRules.RAIDS, false, true),
new GameRuleSetting<>(GameRules.ENTITY_DROPS, false, true),
new GameRuleSetting<>(GameRules.SPAWN_PATROLS, false, true),
new GameRuleSetting<>(GameRules.SPAWN_WANDERING_TRADERS, false, true),
new GameRuleSetting<>(GameRules.ADVANCE_WEATHER, false, true),
new GameRuleSetting<>(GameRules.FALL_DAMAGE, false, true),
new GameRuleSetting<>(GameRules.FIRE_DAMAGE, false, true),
new GameRuleSetting<>(GameRules.FIRE_SPREAD_RADIUS_AROUND_PLAYER, 0, -1)
);
public ProjectStart() {
@@ -76,7 +80,7 @@ public class ProjectStart extends Appliance {
this.countdown.addCustomAnnouncement(
new Countdown.CustomAnnouncements(
counter -> counter == this.startMusicAt,
counter -> this.glassLocation
_ -> this.glassLocation
.getWorld()
.playSound(this.glassLocation, MUSIC_DISC_LAVA_CHICKEN, SoundCategory.RECORDS, 500f, 1f)
)
@@ -121,7 +125,7 @@ public class ProjectStart extends Appliance {
this.setEnabled(false);
IteratorUtil.worlds(World::getWorldBorder, worldBorder -> worldBorder.setSize(worldBorder.getMaxSize()));
IteratorUtil.worlds(world -> IteratorUtil.setGameRules(this.gameRulesAfterStart, false));
this.gameRules.forEach(setting -> setting.apply(true));
Bukkit.getWorlds().getFirst().setFullTime(0);
this.netherFireLocations.forEach(location -> Objects.requireNonNull(this.startWorld).getBlockAt(location).setType(Material.FIRE));
@@ -168,7 +172,7 @@ public class ProjectStart extends Appliance {
worldBorder.setDamageAmount(0);
});
IteratorUtil.worlds(world -> world, world -> IteratorUtil.setGameRules(this.gameRulesAfterStart, true));
this.gameRules.forEach(setting -> setting.apply(false));
this.resetAdvancements();
this.blockCycle.reset();
@@ -0,0 +1,55 @@
-- Suspected ban evasion: one case per pair of accounts that shared an internet connection.
CREATE TABLE ban_evasion_cases (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
uuid_a CHAR(36) NOT NULL COMMENT 'textually smaller UUID of the pair',
uuid_b CHAR(36) NOT NULL,
status VARCHAR(16) NOT NULL COMMENT 'OPEN or CLOSED',
outcome VARCHAR(16) NULL COMMENT 'CONFIRMED, UNFOUNDED or HOUSEHOLD once closed',
severity VARCHAR(16) NOT NULL COMMENT 'NORMAL or HIGH',
ban_status_unknown BOOLEAN NOT NULL DEFAULT FALSE COMMENT 'website API unreachable at the last evaluation',
created_at DATETIME(3) NOT NULL COMMENT 'UTC',
last_seen_at DATETIME(3) NOT NULL COMMENT 'UTC',
hits INT UNSIGNED NOT NULL DEFAULT 1,
closed_at DATETIME(3) NULL COMMENT 'UTC',
closed_by CHAR(36) NULL,
note VARCHAR(255) NULL,
UNIQUE KEY uq_pair (uuid_a, uuid_b),
KEY idx_uuid_b (uuid_b),
KEY idx_status (status, last_seen_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE ban_evasion_case_reasons (
case_id BIGINT UNSIGNED NOT NULL,
reason VARCHAR(32) NOT NULL,
severity VARCHAR(16) NOT NULL,
first_seen_at DATETIME(3) NOT NULL COMMENT 'UTC',
attempt_id BIGINT UNSIGNED NOT NULL COMMENT 'login_attempts.id that first produced the reason',
PRIMARY KEY (case_id, reason),
CONSTRAINT fk_reason_case FOREIGN KEY (case_id) REFERENCES ban_evasion_cases (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Accounts marked by admins as one household (siblings, ...): no reports among them unless a member is banned.
CREATE TABLE ban_evasion_households (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
created_at DATETIME(3) NOT NULL COMMENT 'UTC',
created_by CHAR(36) NOT NULL,
note VARCHAR(255) NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE ban_evasion_household_members (
uuid CHAR(36) NOT NULL PRIMARY KEY COMMENT 'an account belongs to at most one household',
household_id BIGINT UNSIGNED NOT NULL,
KEY idx_household (household_id),
CONSTRAINT fk_member_household FOREIGN KEY (household_id) REFERENCES ban_evasion_households (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Temporary exemptions of a connection (LAN party, ...): attempts within the period are not evaluated.
CREATE TABLE ban_evasion_ip_exemptions (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
network VARCHAR(45) NOT NULL COMMENT 'same format as login_attempts.network',
valid_from DATETIME(3) NOT NULL COMMENT 'UTC',
valid_until DATETIME(3) NOT NULL COMMENT 'UTC',
created_by CHAR(36) NOT NULL,
note VARCHAR(255) NULL,
KEY idx_network (network, valid_until)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;