19 Commits
Author SHA1 Message Date
MineTec 8b616ba2a2 added Arena appliances for fights on a separate server 2026-10-15 22:33:48 +02:00
MineTec c6bf0b0163 Merge branch 'feature/banEvasion' 2026-10-15 22:33:48 +02:00
MineTec 6590513d56 simplified BanEvasion 2026-10-14 21:07:27 +02:00
MineTec 0800793e64 added lookup indexes to login attempts 2026-10-14 21:07:27 +02:00
MineTec 882bacf57b added optional request timeout to http repositories 2026-10-14 21:07:27 +02:00
MineTec ddcbdbaec5 fixed dropped database statements never completing their query 2026-10-14 21:07:27 +02:00
MineTec 7bd1e13660 reworked household and exemption dialogs 2026-10-14 17:16:13 +02:00
MineTec 974c5c5200 confirmed ban evasion cases stay closed 2026-10-14 17:16:13 +02:00
MineTec 66dace7f2a added timeouts to ban evasion lookups 2026-10-14 17:12:24 +02:00
MineTec f4faf763e3 fixed database queries never completing on runtime errors 2026-10-14 17:12:24 +02:00
MineTec ee265a858c made network migration repeatable 2026-10-14 17:06:18 +02:00
MineTec 84c3e7df13 added BanEvasion appliance 2026-10-14 17:04:33 +02:00
MineTec 675d87f6e9 added optional permission for appliance commands 2026-10-14 17:04:06 +02:00
MineTec 86ae261c60 added connection key and recorded event to LoginLog 2026-10-14 17:00:11 +02:00
MineTec 095db7e796 added website endpoint to LoginLog for reporting signup and login IPs 2026-10-14 14:47:59 +02:00
MineTec 490d8b9891 added floodgate uuid check to bedrock utils 2026-10-14 14:47:59 +02:00
MineTec 097222a565 fixed api error responses failing to serialize exceptions 2026-10-14 14:47:59 +02:00
MineTec 6bfb14db2b added LoginLog appliance recording login attempts with IP 2026-10-14 14:38:54 +02:00
MineTec e3de258124 added shared database access and file based migrations to core 2026-10-14 14:38:54 +02:00
70 changed files with 5528 additions and 7 deletions

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
dependencies {
implementation project(':common')
implementation project(':core')
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'
testImplementation platform('org.junit:junit-bom:5.13.4')
testImplementation 'org.junit.jupiter:junit-jupiter'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
test {
useJUnitPlatform()
}
@@ -0,0 +1,380 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena;
import com.google.gson.Gson;
import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.FightMode;
import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.Region;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.api.server.HttpServer;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
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.GameMode;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Sound;
import org.bukkit.World;
import org.bukkit.attribute.Attribute;
import org.bukkit.entity.Player;
import org.bukkit.entity.Villager;
import org.bukkit.event.Listener;
import org.bukkit.potion.PotionEffect;
import org.bukkit.scheduler.BukkitTask;
import org.bukkit.util.Vector;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.stream.Stream;
/**
* The arena server: tribune, start pads, arenas, fights and resets.
* The main server sends a copy of the player's inventory to {@code /api/arena/inventory} before connecting the player.
*/
public class Arena extends Appliance {
public enum PlayerState {
TRIBUNE,
PAD,
FIGHT,
BUILD
}
private record InventoryRequest(UUID player, String items) {
}
private final Map<UUID, PlayerState> states = new HashMap<>();
private ArenaWorlds worlds;
private ArenaSettings settings;
private Teams teams;
private ArenaResetter resetter;
private final List<ArenaSlot> slots = new ArrayList<>();
private @Nullable Lobby lobby;
private @Nullable DisplayVillager.ConfigBound villager;
private @Nullable BukkitTask tickTask;
public Arena() {
super("arena");
}
@Override
public void onEnable() {
this.worlds = new ArenaWorlds();
this.villager = new DisplayVillager.ConfigBound(this.localConfig(), villager -> {
villager.customName(Component.text("Zurück zu CraftAttack", NamedTextColor.GOLD));
villager.setProfession(Villager.Profession.CARTOGRAPHER);
villager.setVillagerType(Villager.Type.PLAINS);
});
this.villager.getVillager().addScoreboardTag(Displays.MANAGED_TAG);
this.start();
}
@Override
public void onDisable() {
this.stop();
}
/**
* Reads the settings and builds pads, arenas and displays. Separate from onEnable so setup changes
* can be applied without restarting the appliance.
*/
private void start() {
this.settings = ArenaSettings.read(this.localConfig(), this.worlds.play());
this.teams = new Teams();
this.resetter = new ArenaResetter(this.worlds, this.settings.resetBlocksPerTick());
this.settings.arenas().stream()
.filter(ArenaSettings.Slot::isComplete)
.forEach(slot -> this.slots.add(new ArenaSlot(slot, this.worlds.play())));
this.settings.arenas().stream()
.filter(slot -> !slot.isComplete())
.forEach(slot -> Main.logger().warning("Arena '" + slot.name() + "' is incomplete (region, spawn1, spawn2) and stays disabled"));
if(this.settings.padsComplete()) this.lobby = new Lobby(this, this.settings.pads());
else Main.logger().warning("Arena: pads are not set up, fights cannot be started");
this.syncRegions().forEach(region -> this.worlds.keepLoaded(region, Main.instance()));
this.placeStarterPlatform();
this.syncAll();
Bukkit.getOnlinePlayers().stream()
.filter(player -> this.state(player) != PlayerState.BUILD)
.forEach(this::toTribune);
this.tickTask = Bukkit.getScheduler().runTaskTimer(Main.instance(), this::tick, 1, 1);
}
private void stop() {
if(this.tickTask != null) this.tickTask.cancel();
this.slots.forEach(slot -> {
if(slot.fight != null) slot.fight.abort();
slot.remove();
});
this.slots.clear();
if(this.lobby != null) this.lobby.shutdown();
this.lobby = null;
this.resetter.cancel();
this.teams.clear();
this.worlds.releaseChunks(Main.instance());
}
public void reload() {
if(this.slots.stream().anyMatch(slot -> slot.state == ArenaSlot.State.FIGHT))
throw new ApplianceCommand.Error("Es läuft gerade ein Kampf");
if(this.lobby != null && !this.lobby.isIdle())
throw new ApplianceCommand.Error("Gerade steht jemand auf einem Startfeld");
this.stop();
this.start();
}
/**
* Copies the whole tribune area and all arenas from the template.
*/
public void syncAll() {
this.slots.forEach(slot -> slot.state = ArenaSlot.State.RESET);
List<Region> regions = this.syncRegions();
for(int i = 0; i < regions.size(); i++) {
boolean last = i == regions.size() - 1;
this.resetter.reset(regions.get(i), () -> {
if(!last) return;
this.slots.forEach(slot -> slot.state = ArenaSlot.State.FREE);
Main.logger().info("Arena: all regions synced from the template");
});
}
if(regions.isEmpty()) this.slots.forEach(slot -> slot.state = ArenaSlot.State.FREE);
}
public boolean isBusy() {
return this.slots.stream().anyMatch(slot -> slot.state != ArenaSlot.State.FREE)
|| (this.lobby != null && !this.lobby.isIdle());
}
private List<Region> syncRegions() {
return Stream.concat(
Stream.ofNullable(this.settings.syncRegion()),
this.slots.stream().map(slot -> slot.settings.region())
).toList();
}
private void tick() {
this.resetter.tick();
if(this.lobby != null) this.lobby.tick();
if(Bukkit.getCurrentTick() % 10 != 0) return;
this.slots.forEach(ArenaSlot::updateDisplay);
// nobody takes damage outside of fights, so falling into the void would never end
World play = this.worlds.play();
play.getPlayers().stream()
.filter(player -> player.getY() < play.getMinHeight() - 16 && this.state(player) != PlayerState.FIGHT)
.forEach(this::toTribune);
}
/**
* A fresh, empty world gets a small platform at the tribune spawn, so the first visitors do not fall into the void.
*/
private void placeStarterPlatform() {
Location spawn = this.settings.tribuneSpawn();
if(spawn == null) return;
for(World world : new World[]{this.worlds.play(), this.worlds.template()}) {
Location below = spawn.clone();
below.setWorld(world);
below.subtract(0, 1, 0);
if(!below.getBlock().getType().isAir()) return;
}
for(World world : new World[]{this.worlds.play(), this.worlds.template()}) {
for(int dx = -3; dx <= 3; dx++) {
for(int dz = -3; dz <= 3; dz++) {
world.getBlockAt(spawn.getBlockX() + dx, spawn.getBlockY() - 1, spawn.getBlockZ() + dz).setType(Material.SMOOTH_STONE, false);
}
}
}
Main.logger().info("Arena: placed a starter platform at the tribune spawn");
}
boolean hasFreeSlot() {
return this.slots.stream().anyMatch(slot -> slot.state == ArenaSlot.State.FREE);
}
void startFight(Player red, Player blue, UUID challenger, FightMode mode) {
ArenaSlot slot = this.slots.stream().filter(s -> s.state == ArenaSlot.State.FREE).findFirst().orElse(null);
if(slot == null) {
this.toTribune(red);
this.toTribune(blue);
return;
}
new Fight(this, slot, red, blue, challenger, mode).start();
}
void resetSlot(ArenaSlot slot) {
if(!this.slots.contains(slot)) return;
slot.state = ArenaSlot.State.RESET;
this.resetter.reset(slot.settings.region(), () -> {
if(!this.slots.contains(slot)) return;
slot.state = ArenaSlot.State.FREE;
Location display = slot.settings.displayLocation(this.worlds.play());
Style.play(display, Sound.BLOCK_BEACON_ACTIVATE, 1.5f, 1.2f);
});
}
/**
* Brings the player back to the tribune with the own inventory, locked.
*/
public void toTribune(Player player) {
if(this.lobby != null) this.lobby.leave(player);
this.setState(player, PlayerState.TRIBUNE);
this.teams.leave(player);
player.closeInventory();
Location spawn = this.settings.tribuneSpawn();
player.teleport(spawn != null ? spawn : this.worlds.play().getSpawnLocation());
restore(player, GameMode.ADVENTURE);
InventoryStore.apply(player, player.getUniqueId());
}
/**
* Full health and food, no effects, no fire.
*/
static void restore(Player player, GameMode gameMode) {
player.setGameMode(gameMode);
player.getActivePotionEffects().stream().map(PotionEffect::getType).forEach(player::removePotionEffect);
player.setHealth(Objects.requireNonNull(player.getAttribute(Attribute.MAX_HEALTH)).getValue());
player.setAbsorptionAmount(0);
player.setFoodLevel(20);
player.setSaturation(5);
player.setExhaustion(0);
player.setFireTicks(0);
player.setFreezeTicks(0);
player.setFallDistance(0);
player.setArrowsInBody(0, false);
player.setVelocity(new Vector());
}
public void toggleBuild(Player player) {
PlayerState state = this.state(player);
if(state == PlayerState.BUILD) {
Location target = player.getLocation().clone();
target.setWorld(this.worlds.play());
this.toTribune(player);
if(!this.isInsideArena(target)) player.teleport(target);
player.sendMessage(Style.message("Bau-Modus beendet. Änderungen an der Vorlage übernimmst du mit /arenasetup sync."));
return;
}
if(state != PlayerState.TRIBUNE) throw new ApplianceCommand.Error("Nur von der Tribüne aus möglich");
this.setState(player, PlayerState.BUILD);
this.teams.leave(player);
Location target = player.getLocation().clone();
target.setWorld(this.worlds.template());
player.getInventory().clear();
player.teleport(target);
player.setGameMode(GameMode.CREATIVE);
player.sendMessage(Style.message("Du bist in der Vorlage. Mit /build kommst du zurück."));
}
void moveVillager(Location location) {
Objects.requireNonNull(this.villager).updateLocation(location);
}
List<String> arenaNames() {
return this.settings.arenas().stream().map(ArenaSettings.Slot::name).toList();
}
public void sendToCraftAttack(Player player) {
PluginMessage.connect(player, this.settings.returnServer());
}
public void handleQuit(Player player) {
Fight fight = this.fightOf(player);
if(fight != null && this.state(player) == PlayerState.FIGHT) fight.forfeit(player.getUniqueId());
if(this.lobby != null) this.lobby.leave(player);
this.teams.leave(player);
this.states.remove(player.getUniqueId());
}
public PlayerState state(Player player) {
return this.states.getOrDefault(player.getUniqueId(), PlayerState.TRIBUNE);
}
void setState(Player player, PlayerState state) {
this.states.put(player.getUniqueId(), state);
}
/**
* The fight the player is part of, also during the short celebration after it ended.
*/
public @Nullable Fight fightOf(Player player) {
return this.slots.stream()
.map(slot -> slot.fight)
.filter(fight -> fight != null && fight.involves(player.getUniqueId()))
.findFirst()
.orElse(null);
}
/**
* Whether blocks may change at this position of the play world: only inside arenas.
*/
public boolean isInsideArena(Location location) {
return this.slots.stream().anyMatch(slot -> slot.settings.region().contains(location.getX(), location.getY(), location.getZ()));
}
public @Nullable Region arenaRegionOf(Player player) {
Fight fight = this.fightOf(player);
return fight == null ? null : fight.slot().settings.region();
}
public @Nullable Lobby lobby() {
return this.lobby;
}
public ArenaWorlds worlds() {
return this.worlds;
}
ArenaSettings settings() {
return this.settings;
}
Teams teams() {
return this.teams;
}
@Override
public void httpApi(HttpServer.ApiBuilder apiBuilder) {
Gson gson = new Gson();
apiBuilder.rawPost("inventory", (request, response) -> {
InventoryRequest body = gson.fromJson(request.body(), InventoryRequest.class);
if(body == null || body.player() == null || body.items() == null) throw new IllegalArgumentException("player and items are required");
InventoryStore.put(body.player(), Base64.getDecoder().decode(body.items()));
return HttpServer.nothing;
});
}
@Override
@NotNull
protected List<ApplianceCommand<?>> commands() {
return List.of(new BuildCommand(), new ArenaSetupCommand(), new CraftAttackCommand());
}
@Override
@NotNull
protected List<Listener> listeners() {
UUID villager = Objects.requireNonNull(this.villager).getUniqueId();
return List.of(
new PlayerConnectionListener(),
new LockListener(),
new FightListener(),
new WorldProtectionListener(),
new ModeMenuListener(),
new PlayerInteractAtEntityEventListener(villager, event -> this.sendToCraftAttack(event.getPlayer())),
new DismissInventoryOpenFromHolder(villager)
);
}
}
@@ -0,0 +1,127 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena;
import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.Region;
import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.ResetCursor;
import eu.mhsl.craftattack.spawn.core.Main;
import org.bukkit.Chunk;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.BlockState;
import org.bukkit.block.Container;
import org.bukkit.block.data.BlockData;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.bukkit.util.BoundingBox;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.List;
import java.util.Queue;
/**
* Copies regions from the template world over the play world. Only blocks that differ are written,
* without physics. Block entities (chests, signs, banners) are always copied, so changed contents are reset too.
* The work is spread over ticks with a fixed block budget.
*/
final class ArenaResetter {
private record Job(Region region, ResetCursor cursor, Runnable onDone) {
}
private final World play;
private final World template;
private final int blocksPerTick;
private final Queue<Job> jobs = new ArrayDeque<>();
private Job current;
ArenaResetter(ArenaWorlds worlds, int blocksPerTick) {
this.play = worlds.play();
this.template = worlds.template();
this.blocksPerTick = blocksPerTick;
}
void reset(Region region, Runnable onDone) {
this.jobs.add(new Job(region, new ResetCursor(region), onDone));
}
boolean isBusy() {
return this.current != null || !this.jobs.isEmpty();
}
void cancel() {
this.jobs.clear();
this.current = null;
}
/**
* Called every server tick.
*/
void tick() {
int budget = this.blocksPerTick;
while(budget > 0) {
if(this.current == null) {
this.current = this.jobs.poll();
if(this.current == null) return;
this.prepare(this.current.region);
}
budget -= this.current.cursor.advance(budget, this::copyBlock);
if(!this.current.cursor.isDone()) return;
this.finish(this.current.region);
Job done = this.current;
this.current = null;
try {
done.onDone.run();
} catch(Exception e) {
Main.logger().warning("Arena: error after reset: " + e.getMessage());
}
}
}
private void prepare(Region region) {
this.removeEntities(region);
// empty containers first, otherwise replacing them would drop their contents
for(BlockState state : this.blockEntities(this.play, region, false)) {
if(state instanceof Container container) container.getInventory().clear();
}
}
private void copyBlock(int x, int y, int z) {
BlockData source = this.template.getBlockAt(x, y, z).getBlockData();
Block target = this.play.getBlockAt(x, y, z);
if(!target.getBlockData().equals(source)) target.setBlockData(source, false);
}
private void finish(Region region) {
for(BlockState state : this.blockEntities(this.template, region, true)) {
state.copy(new Location(this.play, state.getX(), state.getY(), state.getZ())).update(true, false);
}
this.removeEntities(region);
}
private List<BlockState> blockEntities(World world, Region region, boolean snapshot) {
List<BlockState> states = new ArrayList<>();
for(int cx = region.minChunkX(); cx <= region.maxChunkX(); cx++) {
for(int cz = region.minChunkZ(); cz <= region.maxChunkZ(); cz++) {
Chunk chunk = world.getChunkAt(cx, cz);
for(BlockState state : chunk.getTileEntities(snapshot)) {
if(region.contains(state.getX(), state.getY(), state.getZ())) states.add(state);
}
}
}
return states;
}
private void removeEntities(Region region) {
BoundingBox box = new BoundingBox(
region.minX(), region.minY(), region.minZ(),
region.maxX() + 1, region.maxY() + 1, region.maxZ() + 1
);
for(Entity entity : this.play.getNearbyEntities(box)) {
if(entity instanceof Player) continue;
if(entity.getScoreboardTags().contains(Displays.MANAGED_TAG)) continue;
entity.remove();
}
}
}
@@ -0,0 +1,94 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena;
import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.Region;
import eu.mhsl.craftattack.spawn.core.config.ConfigUtil;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.configuration.ConfigurationSection;
import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
import java.util.List;
/**
* Positions and limits from the {@code arena} config section. All positions refer to the play world,
* no matter which world was stored.
*/
record ArenaSettings(
@Nullable Location tribuneSpawn,
@Nullable Region syncRegion,
@Nullable Region[] pads,
List<Slot> arenas,
int fightSeconds,
int countdownSeconds,
int resetBlocksPerTick,
String returnServer
) {
static final String[] PAD_KEYS = {"red", "blue"};
record Slot(String name, @Nullable Region region, @Nullable Location[] spawns, @Nullable Location display) {
boolean isComplete() {
return this.region != null && this.spawns[0] != null && this.spawns[1] != null;
}
Location displayLocation(World world) {
if(this.display != null) return this.display;
return new Location(world, this.region.centerX(), this.region.maxY() + 1.5, this.region.centerZ());
}
}
static ArenaSettings read(ConfigurationSection config, World world) {
Region[] pads = new Region[PAD_KEYS.length];
for(int i = 0; i < PAD_KEYS.length; i++) {
pads[i] = readRegion(config.getConfigurationSection("pads." + PAD_KEYS[i]));
}
List<Slot> arenas = new ArrayList<>();
ConfigurationSection arenaSection = config.getConfigurationSection("arenas");
if(arenaSection != null) {
for(String name : arenaSection.getKeys(false)) {
ConfigurationSection slot = arenaSection.getConfigurationSection(name);
if(slot == null) continue;
arenas.add(new Slot(
name,
readRegion(slot.getConfigurationSection("region")),
new Location[]{
readLocation(slot.getConfigurationSection("spawn1"), world),
readLocation(slot.getConfigurationSection("spawn2"), world)
},
readLocation(slot.getConfigurationSection("display"), world)
));
}
}
return new ArenaSettings(
readLocation(config.getConfigurationSection("tribuneSpawn"), world),
readRegion(config.getConfigurationSection("syncRegion")),
pads,
arenas,
Math.max(1, config.getInt("fightMinutes", 10)) * 60,
Math.max(1, config.getInt("countdownSeconds", 5)),
Math.max(1000, config.getInt("resetBlocksPerTick", 20000)),
config.getString("connect-server-name", "craftattack")
);
}
boolean padsComplete() {
return this.pads[0] != null && this.pads[1] != null;
}
private static @Nullable Region readRegion(@Nullable ConfigurationSection section) {
if(section == null) return null;
List<Integer> a = section.getIntegerList("pos1");
List<Integer> b = section.getIntegerList("pos2");
if(a.size() != 3 || b.size() != 3) return null;
return Region.of(a.get(0), a.get(1), a.get(2), b.get(0), b.get(1), b.get(2));
}
private static @Nullable Location readLocation(@Nullable ConfigurationSection section, World world) {
if(section == null) return null;
Location location = ConfigUtil.Position.paseLocation(section);
location.setWorld(world);
return location;
}
}
@@ -0,0 +1,116 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena;
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 org.bukkit.Location;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.configuration.ConfigurationSection;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
/**
* Setting up tribune, pads and arenas at the own position. Works in the play world and in the template.
* Changes take effect with {@code /arenasetup reload}.
*/
class ArenaSetupCommand extends ApplianceCommand.PlayerChecked<Arena> {
private static final String USAGE = String.join("\n",
"/arenasetup tribune",
"/arenasetup villager",
"/arenasetup pad <red|blue> <pos1|pos2>",
"/arenasetup arena <name> <pos1|pos2|spawn1|spawn2|display|remove>",
"/arenasetup syncregion <pos1|pos2>",
"/arenasetup sync",
"/arenasetup reload"
);
ArenaSetupCommand() {
super("arenasetup");
}
@Override
public @Nullable String permission() {
return "admin";
}
@Override
protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
if(args.length == 0) throw new Error("\n" + USAGE);
Location location = this.getPlayer().getLocation().clone();
location.setWorld(this.getAppliance().worlds().play());
ConfigurationSection config = this.getAppliance().localConfig();
switch(args[0]) {
case "tribune" -> this.writeLocation(config, "tribuneSpawn", location);
case "villager" -> this.getAppliance().moveVillager(location);
case "pad" -> {
if(args.length < 3 || !List.of(ArenaSettings.PAD_KEYS).contains(args[1])) throw new Error(USAGE);
this.writeCorner(config, "pads." + args[1], args[2], location);
}
case "arena" -> {
if(args.length < 3) throw new Error(USAGE);
String path = "arenas." + args[1];
switch(args[2]) {
case "pos1", "pos2" -> this.writeCorner(config, path + ".region", args[2], location);
case "spawn1", "spawn2", "display" -> this.writeLocation(config, path + "." + args[2], location);
case "remove" -> config.set(path, null);
default -> throw new Error(USAGE);
}
}
case "syncregion" -> {
if(args.length < 2) throw new Error(USAGE);
this.writeCorner(config, "syncRegion", args[1], location);
}
case "sync" -> {
if(this.getAppliance().isBusy()) throw new Error("Es läuft gerade ein Kampf, Countdown oder Reset");
this.getAppliance().syncAll();
sender.sendMessage(Style.message("Die Spielwelt wird aus der Vorlage übernommen."));
return;
}
case "reload" -> {
this.getAppliance().reload();
sender.sendMessage(Style.message("Einstellungen übernommen. Die Arenen werden aus der Vorlage aufgebaut."));
return;
}
default -> throw new Error("\n" + USAGE);
}
Configuration.saveChanges();
sender.sendMessage(Style.message("Gespeichert. Mit /arenasetup reload übernehmen."));
}
private void writeCorner(ConfigurationSection config, String path, String corner, Location location) {
if(!corner.equals("pos1") && !corner.equals("pos2")) throw new Error(USAGE);
config.set(path + "." + corner, List.of(location.getBlockX(), location.getBlockY(), location.getBlockZ()));
}
private void writeLocation(ConfigurationSection config, String path, Location location) {
ConfigurationSection section = Optional.ofNullable(config.getConfigurationSection(path)).orElseGet(() -> config.createSection(path));
ConfigUtil.Position.writeLocation(section, location);
}
@Override
public @Nullable List<String> onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
List<String> options = switch(args.length) {
case 1 -> List.of("tribune", "villager", "pad", "arena", "syncregion", "sync", "reload");
case 2 -> switch(args[0]) {
case "pad" -> Arrays.asList(ArenaSettings.PAD_KEYS);
case "syncregion" -> List.of("pos1", "pos2");
case "arena" -> this.getAppliance().arenaNames();
default -> List.of();
};
case 3 -> switch(args[0]) {
case "pad" -> List.of("pos1", "pos2");
case "arena" -> List.of("pos1", "pos2", "spawn1", "spawn2", "display", "remove");
default -> List.of();
};
default -> List.of();
};
return this.tabCompleteReducer(options, args);
}
}
@@ -0,0 +1,44 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.World;
import org.bukkit.entity.TextDisplay;
import org.jetbrains.annotations.Nullable;
/**
* One arena: free, in a fight or being reset.
*/
final class ArenaSlot {
enum State {
FREE,
FIGHT,
RESET
}
final ArenaSettings.Slot settings;
State state = State.RESET;
@Nullable Fight fight;
private final TextDisplay display;
ArenaSlot(ArenaSettings.Slot settings, World world) {
this.settings = settings;
this.display = Displays.spawn(settings.displayLocation(world));
}
void updateDisplay() {
Component title = Component.text("Arena " + this.settings.name(), NamedTextColor.GOLD);
Component status = switch(this.state) {
case FREE -> Component.text("Frei", NamedTextColor.GREEN);
case RESET -> Component.text("Wird zurückgesetzt", NamedTextColor.YELLOW);
case FIGHT -> this.fight == null
? Component.text("Kampf", NamedTextColor.RED)
: this.fight.headline().appendNewline().append(Component.text(Style.time(this.fight.secondsLeft()), NamedTextColor.WHITE));
};
this.display.text(title.appendNewline().append(status));
}
void remove() {
this.display.remove();
}
}
@@ -0,0 +1,89 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena;
import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.Region;
import org.bukkit.Bukkit;
import org.bukkit.GameRules;
import org.bukkit.World;
import org.bukkit.WorldCreator;
import org.bukkit.generator.ChunkGenerator;
import org.bukkit.plugin.Plugin;
import java.util.Objects;
/**
* The play world and its template. Players only fight in the play world; admins build in the template,
* which is copied over the play world on every reset.
*/
final class ArenaWorlds {
static final String PLAY = "arena";
static final String TEMPLATE = "arena_template";
private static final class VoidGenerator extends ChunkGenerator {
}
private final World play;
private final World template;
ArenaWorlds() {
this.play = load(PLAY);
this.template = load(TEMPLATE);
for(World world : new World[]{this.play, this.template}) {
world.setGameRule(GameRules.ADVANCE_TIME, false);
world.setGameRule(GameRules.ADVANCE_WEATHER, false);
world.setGameRule(GameRules.SPAWN_MOBS, false);
world.setGameRule(GameRules.SPAWN_MONSTERS, false);
world.setGameRule(GameRules.SPAWN_PHANTOMS, false);
world.setGameRule(GameRules.SPAWN_PATROLS, false);
world.setGameRule(GameRules.SPAWN_WANDERING_TRADERS, false);
world.setGameRule(GameRules.SPAWN_WARDENS, false);
world.setGameRule(GameRules.RAIDS, false);
world.setGameRule(GameRules.SHOW_DEATH_MESSAGES, false);
world.setStorm(false);
world.setThundering(false);
world.setTime(6000);
}
this.play.setGameRule(GameRules.IMMEDIATE_RESPAWN, true);
this.play.setGameRule(GameRules.KEEP_INVENTORY, true);
this.template.setGameRule(GameRules.RANDOM_TICK_SPEED, 0);
this.template.setGameRule(GameRules.FIRE_SPREAD_RADIUS_AROUND_PLAYER, 0);
}
private static World load(String name) {
World world = Bukkit.getWorld(name);
if(world != null) return world;
return Objects.requireNonNull(
new WorldCreator(name).generator(new VoidGenerator()).generateStructures(false).createWorld(),
"Could not create world " + name
);
}
World play() {
return this.play;
}
World template() {
return this.template;
}
boolean isPlay(World world) {
return this.play.equals(world);
}
/**
* Keeps all chunks of the region loaded in both worlds, so resets never wait for chunk loading.
*/
void keepLoaded(Region region, Plugin plugin) {
for(int cx = region.minChunkX(); cx <= region.maxChunkX(); cx++) {
for(int cz = region.minChunkZ(); cz <= region.maxChunkZ(); cz++) {
this.play.addPluginChunkTicket(cx, cz, plugin);
this.template.addPluginChunkTicket(cx, cz, plugin);
}
}
}
void releaseChunks(Plugin plugin) {
this.play.removePluginChunkTickets(plugin);
this.template.removePluginChunkTickets(plugin);
}
}
@@ -0,0 +1,26 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
/**
* Switches between the play world and the template at the same position.
*/
class BuildCommand extends ApplianceCommand.PlayerChecked<Arena> {
BuildCommand() {
super("build");
}
@Override
public @Nullable String permission() {
return "arena.build";
}
@Override
protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
this.getAppliance().toggleBuild(this.getPlayer());
}
}
@@ -0,0 +1,17 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.jetbrains.annotations.NotNull;
class CraftAttackCommand extends ApplianceCommand.PlayerChecked<Arena> {
CraftAttackCommand() {
super("craftattack");
}
@Override
protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
this.getAppliance().sendToCraftAttack(this.getPlayer());
}
}
@@ -0,0 +1,31 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena;
import eu.mhsl.craftattack.spawn.core.Main;
import org.bukkit.Color;
import org.bukkit.Location;
import org.bukkit.entity.Display;
import org.bukkit.entity.TextDisplay;
/**
* Floating texts above pads and arenas. They are not saved with the world and come back with every start.
*/
final class Displays {
static final String MANAGED_TAG = "arena_managed";
private Displays() {
}
static TextDisplay spawn(Location location) {
// displays are not saved, they would vanish with an unloaded chunk
location.getWorld().addPluginChunkTicket(location.getBlockX() >> 4, location.getBlockZ() >> 4, Main.instance());
return location.getWorld().spawn(location, TextDisplay.class, display -> {
display.setPersistent(false);
display.setBillboard(Display.Billboard.CENTER);
display.setAlignment(TextDisplay.TextAlignment.CENTER);
display.setShadowed(true);
display.setBackgroundColor(Color.fromARGB(110, 0, 0, 0));
display.setLineWidth(220);
display.addScoreboardTag(MANAGED_TAG);
});
}
}
@@ -0,0 +1,292 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena;
import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.FightMode;
import eu.mhsl.craftattack.spawn.core.Main;
import net.kyori.adventure.bossbar.BossBar;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.title.Title;
import org.bukkit.Bukkit;
import org.bukkit.Color;
import org.bukkit.GameMode;
import org.bukkit.Location;
import org.bukkit.Particle;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
import org.bukkit.scheduler.BukkitTask;
import org.jetbrains.annotations.Nullable;
import java.time.Duration;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
/**
* A running fight in one arena. Fighter 0 comes from the red pad and uses spawn 1, fighter 1 the blue pad and spawn 2.
*/
final class Fight {
private static final int CELEBRATION_TICKS = 60;
private static final double SPECTATOR_RANGE = 24;
private final Arena arena;
private final ArenaSlot slot;
private final UUID[] fighters;
private final String[] names;
private final UUID challenger;
private final FightMode mode;
private final BossBar bossBar;
private final Set<UUID> bossBarViewers = new HashSet<>();
private int secondsLeft;
private boolean over;
private @Nullable BukkitTask timer;
Fight(Arena arena, ArenaSlot slot, Player red, Player blue, UUID challenger, FightMode mode) {
this.arena = arena;
this.slot = slot;
this.fighters = new UUID[]{red.getUniqueId(), blue.getUniqueId()};
this.names = new String[]{red.getName(), blue.getName()};
this.challenger = challenger;
this.mode = mode;
this.secondsLeft = arena.settings().fightSeconds();
this.bossBar = BossBar.bossBar(Component.empty(), 1f, BossBar.Color.RED, BossBar.Overlay.NOTCHED_10);
}
void start() {
this.slot.state = ArenaSlot.State.FIGHT;
this.slot.fight = this;
FightMode.Role[] roles = new FightMode.Role[2];
for(int i = 0; i < 2; i++) {
roles[i] = this.fighters[i].equals(this.challenger) ? FightMode.Role.CHALLENGER : FightMode.Role.OPPONENT;
}
UUID challengerId = this.challenger;
UUID opponentId = this.fighters[0].equals(challengerId) ? this.fighters[1] : this.fighters[0];
for(int i = 0; i < 2; i++) {
Player player = Bukkit.getPlayer(this.fighters[i]);
if(player == null) continue;
UUID kitOwner = this.mode.kitSource(roles[i]) == FightMode.Role.CHALLENGER ? challengerId : opponentId;
this.arena.setState(player, Arena.PlayerState.FIGHT);
this.arena.teams().join(player, Teams.fight(i));
player.closeInventory();
player.teleport(this.slot.settings.spawns()[i]);
Arena.restore(player, GameMode.SURVIVAL);
InventoryStore.apply(player, kitOwner);
player.showTitle(Title.title(
Component.text("Kampf!", NamedTextColor.GOLD),
Component.text("gegen ", NamedTextColor.GRAY).append(Component.text(this.names[1 - i], Style.PAD_TEXT_COLORS[1 - i])),
Title.Times.times(Duration.ZERO, Duration.ofMillis(1500), Duration.ofMillis(500))
));
Style.play(player, Sound.EVENT_RAID_HORN, 1.2f);
}
Bukkit.broadcast(Style.message(
this.headline().append(Component.text(" in Arena " + this.slot.settings.name() + ". Modus: " + this.mode.displayName(), NamedTextColor.GRAY))
));
this.updateBossBar();
this.timer = Bukkit.getScheduler().runTaskTimer(Main.instance(), this::tickSecond, 20, 20);
}
Component headline() {
return Component.text(this.names[0], Style.PAD_TEXT_COLORS[0])
.append(Component.text(" gegen ", NamedTextColor.GRAY))
.append(Component.text(this.names[1], Style.PAD_TEXT_COLORS[1]));
}
int secondsLeft() {
return this.secondsLeft;
}
boolean isOver() {
return this.over;
}
boolean involves(UUID player) {
return this.fighters[0].equals(player) || this.fighters[1].equals(player);
}
ArenaSlot slot() {
return this.slot;
}
private void tickSecond() {
if(this.over) return;
if(--this.secondsLeft <= 0) {
this.finish(-1, Component.text("Die Zeit ist abgelaufen.", NamedTextColor.GRAY));
return;
}
if(this.secondsLeft == 60 || this.secondsLeft == 10) {
this.fighterPlayers().forEach(player -> {
player.sendActionBar(Component.text("Noch " + this.secondsLeft + " Sekunden", NamedTextColor.YELLOW));
Style.play(player, Sound.BLOCK_NOTE_BLOCK_HAT, 0.8f);
});
}
this.updateBossBar();
}
private void updateBossBar() {
this.bossBar.name(this.headline().append(Component.text(" " + Style.time(this.secondsLeft), NamedTextColor.WHITE)));
this.bossBar.progress(Math.clamp((float) this.secondsLeft / this.arena.settings().fightSeconds(), 0f, 1f));
Set<UUID> viewers = new HashSet<>();
for(Player player : this.arena.worlds().play().getPlayers()) {
if(this.involves(player.getUniqueId()) || this.isSpectating(player)) viewers.add(player.getUniqueId());
}
for(UUID uuid : this.bossBarViewers) {
Player player = Bukkit.getPlayer(uuid);
if(player != null && !viewers.contains(uuid)) player.hideBossBar(this.bossBar);
}
for(UUID uuid : viewers) {
Player player = Bukkit.getPlayer(uuid);
if(player != null) player.showBossBar(this.bossBar);
}
this.bossBarViewers.clear();
this.bossBarViewers.addAll(viewers);
}
private boolean isSpectating(Player player) {
if(this.arena.state(player) != Arena.PlayerState.TRIBUNE && this.arena.state(player) != Arena.PlayerState.PAD) return false;
var region = this.slot.settings.region();
double dx = Math.max(0, Math.max(region.minX() - player.getX(), player.getX() - (region.maxX() + 1)));
double dz = Math.max(0, Math.max(region.minZ() - player.getZ(), player.getZ() - (region.maxZ() + 1)));
return Math.hypot(dx, dz) <= SPECTATOR_RANGE;
}
/**
* The player took lethal damage.
*/
void defeated(Player loser) {
int index = this.indexOf(loser.getUniqueId());
if(index == -1 || this.over) return;
Player winner = Bukkit.getPlayer(this.fighters[1 - index]);
Component detail = winner == null
? Component.empty()
: Component.text("(" + this.mode.displayName() + ", " + Style.hearts(winner.getHealth() + winner.getAbsorptionAmount()) + " ❤ übrig)", NamedTextColor.GRAY);
this.finish(1 - index, detail);
}
/**
* The player left the server during the fight.
*/
void forfeit(UUID quitter) {
int index = this.indexOf(quitter);
if(index == -1 || this.over) return;
this.finish(1 - index, Component.text("(" + this.names[index] + " hat die Arena verlassen)", NamedTextColor.GRAY));
}
/**
* Ends the fight without a winner and without celebration, for example on shutdown.
*/
void abort() {
if(this.over) return;
this.over = true;
this.stopTimer();
this.hideBossBar();
this.fighterPlayers().forEach(this.arena::toTribune);
this.slot.fight = null;
}
private void finish(int winnerIndex, Component detail) {
this.over = true;
this.stopTimer();
if(winnerIndex == -1) {
Bukkit.broadcast(Style.message(
Component.text(this.names[0], Style.PAD_TEXT_COLORS[0])
.append(Component.text(" und ", NamedTextColor.GRAY))
.append(Component.text(this.names[1], Style.PAD_TEXT_COLORS[1]))
.append(Component.text(" trennen sich unentschieden. ", NamedTextColor.GRAY))
.append(detail)
));
this.fighterPlayers().forEach(player -> {
player.showTitle(Title.title(Component.text("Unentschieden", NamedTextColor.YELLOW), detail));
Style.play(player, Sound.BLOCK_BEACON_DEACTIVATE, 1f);
this.arena.toTribune(player);
});
this.afterCelebration();
return;
}
int loserIndex = 1 - winnerIndex;
Bukkit.broadcast(Style.message(
Component.text(this.names[winnerIndex], Style.PAD_TEXT_COLORS[winnerIndex])
.append(Component.text(" hat gegen ", NamedTextColor.GRAY))
.append(Component.text(this.names[loserIndex], Style.PAD_TEXT_COLORS[loserIndex]))
.append(Component.text(" gewonnen ", NamedTextColor.GRAY))
.append(detail)
));
Player loser = Bukkit.getPlayer(this.fighters[loserIndex]);
if(loser != null) {
loser.showTitle(Title.title(
Component.text("Verloren", NamedTextColor.RED),
Component.text(this.names[winnerIndex] + " hat gewonnen", NamedTextColor.GRAY)
));
Style.play(loser, Sound.BLOCK_BEACON_DEACTIVATE, 1f);
this.arena.toTribune(loser);
}
Player winner = Bukkit.getPlayer(this.fighters[winnerIndex]);
if(winner != null) {
winner.showTitle(Title.title(
Component.text("Gewonnen!", NamedTextColor.GOLD),
Component.text("gegen " + this.names[loserIndex], NamedTextColor.GRAY)
));
Style.play(winner, Sound.UI_TOAST_CHALLENGE_COMPLETE, 1f);
this.celebrate(winner.getLocation(), Style.PAD_PARTICLE_COLORS[winnerIndex]);
}
this.afterCelebration();
}
private void celebrate(Location location, Color color) {
Location top = location.clone().add(0, 2.2, 0);
Particle.FIREWORK.builder().location(top).count(40).offset(0.6, 0.6, 0.6).extra(0.15).receivers(64).spawn();
Particle.DUST.builder().location(top).count(60).offset(1, 1, 1).data(new Particle.DustOptions(color, 1.6f)).receivers(64).spawn();
Style.play(top, Sound.ENTITY_FIREWORK_ROCKET_TWINKLE, 2f, 1f);
Style.play(top, Sound.ENTITY_FIREWORK_ROCKET_LARGE_BLAST, 2f, 1f);
}
private void hideBossBar() {
this.bossBarViewers.forEach(uuid -> {
Player player = Bukkit.getPlayer(uuid);
if(player != null) player.hideBossBar(this.bossBar);
});
this.bossBarViewers.clear();
}
private void afterCelebration() {
this.hideBossBar();
Bukkit.getScheduler().runTaskLater(Main.instance(), () -> {
for(UUID uuid : this.fighters) {
Player player = Bukkit.getPlayer(uuid);
if(player != null && this.arena.fightOf(player) == this) this.arena.toTribune(player);
}
this.slot.fight = null;
this.arena.resetSlot(this.slot);
}, CELEBRATION_TICKS);
}
private void stopTimer() {
if(this.timer != null) this.timer.cancel();
this.timer = null;
}
private int indexOf(UUID player) {
if(this.fighters[0].equals(player)) return 0;
if(this.fighters[1].equals(player)) return 1;
return -1;
}
private List<Player> fighterPlayers() {
List<Player> players = new ArrayList<>();
for(UUID uuid : this.fighters) {
Player player = Bukkit.getPlayer(uuid);
if(player != null) players.add(player);
}
return players;
}
}
@@ -0,0 +1,93 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena;
import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.Region;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import io.papermc.paper.datacomponent.DataComponentTypes;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.entity.Projectile;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.PlayerDeathEvent;
import org.bukkit.event.player.PlayerRespawnEvent;
import org.bukkit.event.player.PlayerTeleportEvent;
import org.bukkit.inventory.ItemStack;
/**
* Lethal damage ends the fight instead of killing the player. Totems still work as usual.
*/
class FightListener extends ApplianceListener<Arena> {
private boolean isFighting(Player player) {
Fight fight = this.getAppliance().fightOf(player);
return fight != null && !fight.isOver() && this.getAppliance().state(player) == Arena.PlayerState.FIGHT;
}
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
public void onDamageByPlayer(EntityDamageByEntityEvent event) {
if(!(event.getEntity() instanceof Player)) return;
Player attacker = switch(event.getDamager()) {
case Player player -> player;
case Projectile projectile when projectile.getShooter() instanceof Player shooter -> shooter;
default -> null;
};
if(attacker == null || attacker == event.getEntity()) return;
if(!this.isFighting(attacker)) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onDamage(EntityDamageEvent event) {
if(!(event.getEntity() instanceof Player player)) return;
if(this.getAppliance().state(player) != Arena.PlayerState.FIGHT) return;
Fight fight = this.getAppliance().fightOf(player);
if(fight == null || fight.isOver()) {
event.setCancelled(true);
return;
}
if(event.getFinalDamage() < player.getHealth()) return;
if(this.hasDeathProtection(player.getInventory().getItemInMainHand())
|| this.hasDeathProtection(player.getInventory().getItemInOffHand())) return;
event.setCancelled(true);
fight.defeated(player);
}
private boolean hasDeathProtection(ItemStack item) {
return !item.isEmpty() && item.hasData(DataComponentTypes.DEATH_PROTECTION);
}
/**
* Safety net if a death gets through anyway: nothing drops and the fight ends.
*/
@EventHandler
public void onDeath(PlayerDeathEvent event) {
event.setKeepInventory(true);
event.getDrops().clear();
event.setKeepLevel(true);
event.setDroppedExp(0);
event.deathMessage(null);
Fight fight = this.getAppliance().fightOf(event.getPlayer());
if(fight != null) fight.defeated(event.getPlayer());
}
@EventHandler
public void onRespawn(PlayerRespawnEvent event) {
Bukkit.getScheduler().runTask(Main.instance(), () -> this.getAppliance().toTribune(event.getPlayer()));
}
/**
* Ender pearls and chorus fruits must not leave the arena.
*/
@EventHandler(ignoreCancelled = true)
public void onTeleport(PlayerTeleportEvent event) {
if(event.getCause() == PlayerTeleportEvent.TeleportCause.PLUGIN || event.getCause() == PlayerTeleportEvent.TeleportCause.COMMAND) return;
if(this.getAppliance().state(event.getPlayer()) != Arena.PlayerState.FIGHT) return;
Region region = this.getAppliance().arenaRegionOf(event.getPlayer());
if(region == null) return;
if(event.getTo().getWorld() != event.getFrom().getWorld() || !region.contains(event.getTo().getX(), event.getTo().getY(), event.getTo().getZ())) {
event.setCancelled(true);
}
}
}
@@ -0,0 +1,43 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Inventory copies sent by the main server. Kept in memory only: after a restart of the arena server
* players arrive with an empty inventory until they come over again.
*/
final class InventoryStore {
static final int MAX_BYTES = 16 * 1024 * 1024;
private static final Map<UUID, byte[]> snapshots = new ConcurrentHashMap<>();
private InventoryStore() {
}
static void put(UUID player, byte[] serializedItems) {
if(serializedItems.length > MAX_BYTES) throw new IllegalArgumentException("Inventory too large");
snapshots.put(player, serializedItems);
}
/**
* @return a fresh copy of the player's inventory, empty if none was sent
*/
static ItemStack[] get(UUID player) {
byte[] bytes = snapshots.get(player);
if(bytes == null) return new ItemStack[0];
return ItemStack.deserializeItemsFromBytes(bytes);
}
static void apply(Player player, UUID source) {
ItemStack[] items = get(source);
int size = player.getInventory().getSize();
ItemStack[] contents = new ItemStack[size];
System.arraycopy(items, 0, contents, 0, Math.min(items.length, size));
player.getInventory().setContents(contents);
player.setItemOnCursor(null);
}
}
@@ -0,0 +1,299 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena;
import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.FightMode;
import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.PadLobby;
import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.Region;
import eu.mhsl.craftattack.spawn.core.util.world.InteractSounds;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.title.Title;
import org.bukkit.Bukkit;
import org.bukkit.Color;
import org.bukkit.Location;
import org.bukkit.Particle;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
import org.bukkit.entity.TextDisplay;
import org.bukkit.util.Vector;
import org.jetbrains.annotations.Nullable;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* The two start pads in the world: who stands where, pushing others away from taken pads,
* inventories, countdown, texts, particles and sounds. The rules themselves live in {@link PadLobby}.
*/
final class Lobby implements PadLobby.Listener {
/** How far around a taken pad other players get pushed away. */
private static final double PUSH_MARGIN = 0.4;
private final Arena arena;
private final Region[] regions;
private final PadLobby pads;
private final TextDisplay[] displays = new TextDisplay[PadLobby.PADS];
private final Map<UUID, Long> lastPushSound = new HashMap<>();
private long ticks;
Lobby(Arena arena, Region[] regions) {
this.arena = arena;
this.regions = regions;
this.pads = new PadLobby(this, arena.settings().countdownSeconds());
for(int i = 0; i < PadLobby.PADS; i++) {
Region region = regions[i];
this.displays[i] = Displays.spawn(new Location(arena.worlds().play(), region.centerX(), region.maxY() + 2.6, region.centerZ()));
}
}
void tick() {
this.ticks++;
for(Player player : this.arena.worlds().play().getPlayers()) {
Arena.PlayerState state = this.arena.state(player);
if(state == Arena.PlayerState.PAD) this.checkLeave(player);
else if(state == Arena.PlayerState.TRIBUNE) this.checkEnter(player);
}
this.pads.tick(this.arena.hasFreeSlot());
if(this.ticks % 3 == 0) this.drawParticles();
if(this.ticks % 5 == 0) this.updateDisplays();
}
private void checkLeave(Player player) {
int pad = this.pads.padOf(player.getUniqueId());
if(pad != -1 && this.isOn(player, pad, 0)) return;
this.leave(player);
}
private void checkEnter(Player player) {
for(int pad = 0; pad < PadLobby.PADS; pad++) {
if(this.pads.occupant(pad) != null) {
if(this.isOn(player, pad, PUSH_MARGIN)) this.pushAway(player, pad);
continue;
}
if(!this.isOn(player, pad, 0)) continue;
if(!this.pads.enter(pad, player.getUniqueId())) continue;
this.arena.setState(player, Arena.PlayerState.PAD);
this.arena.teams().join(player, Teams.pad(pad));
Style.play(player, Sound.BLOCK_NOTE_BLOCK_PLING, 1.4f);
return;
}
}
/**
* Removes the player from the pad, if standing on one.
*/
void leave(Player player) {
if(this.pads.padOf(player.getUniqueId()) == -1) return;
this.pads.leave(player.getUniqueId());
if(ModeMenu.isOpen(player)) player.closeInventory();
this.arena.teams().leave(player);
this.arena.setState(player, Arena.PlayerState.TRIBUNE);
}
void chooseMode(Player player, FightMode mode) {
if(!this.pads.setMode(player.getUniqueId(), mode)) {
player.closeInventory();
return;
}
InteractSounds.of(player).click();
player.closeInventory();
player.sendActionBar(Component.text("Modus: " + mode.displayName(), NamedTextColor.GOLD));
}
boolean isIdle() {
return this.pads.isIdle();
}
void shutdown() {
for(int pad = 0; pad < PadLobby.PADS; pad++) {
UUID occupant = this.pads.occupant(pad);
Player player = occupant == null ? null : Bukkit.getPlayer(occupant);
if(player != null) this.leave(player);
}
for(TextDisplay display : this.displays) display.remove();
}
private boolean isOn(Player player, int pad, double margin) {
Region region = this.regions[pad];
return player.getX() >= region.minX() - margin && player.getX() < region.maxX() + 1 + margin
&& player.getZ() >= region.minZ() - margin && player.getZ() < region.maxZ() + 1 + margin
&& player.getY() >= region.minY() - 0.5 && player.getY() < region.maxY() + 2;
}
private void pushAway(Player player, int pad) {
Region region = this.regions[pad];
Vector direction = new Vector(player.getX() - region.centerX(), 0, player.getZ() - region.centerZ());
if(direction.lengthSquared() < 0.01) direction = new Vector(1, 0, 0);
player.setVelocity(direction.normalize().multiply(0.6).setY(0.25));
long now = System.currentTimeMillis();
if(now - this.lastPushSound.getOrDefault(player.getUniqueId(), 0L) < 1000) return;
this.lastPushSound.put(player.getUniqueId(), now);
Style.play(player, Sound.BLOCK_NOTE_BLOCK_BASS, 0.6f);
player.sendActionBar(Component.text("Dieses Feld ist besetzt", NamedTextColor.RED));
}
@Override
public void challengerSelected(UUID challenger) {
Player player = Bukkit.getPlayer(challenger);
if(player == null) return;
player.sendMessage(Style.message("Du forderst heraus. Wähle den Modus. Wer sich auf das andere Feld stellt, nimmt an."));
InteractSounds.of(player).open();
ModeMenu.open(player, this.pads.mode());
}
@Override
public void kitsApplied(UUID challenger, UUID opponent, FightMode mode) {
Player challengerPlayer = Bukkit.getPlayer(challenger);
Player opponentPlayer = Bukkit.getPlayer(opponent);
if(challengerPlayer == null || opponentPlayer == null) return;
if(ModeMenu.isOpen(challengerPlayer)) challengerPlayer.closeInventory();
InventoryStore.apply(challengerPlayer, mode.kitSource(FightMode.Role.CHALLENGER) == FightMode.Role.CHALLENGER ? challenger : opponent);
InventoryStore.apply(opponentPlayer, mode.kitSource(FightMode.Role.OPPONENT) == FightMode.Role.CHALLENGER ? challenger : opponent);
String kitText = switch(mode) {
case OWN -> "Jeder kämpft mit seinem eigenen Inventar.";
case CHALLENGER -> "Ihr kämpft beide mit dem Inventar von " + challengerPlayer.getName() + ".";
case OPPONENT -> "Ihr kämpft beide mit dem Inventar von " + opponentPlayer.getName() + ".";
};
opponentPlayer.sendMessage(Style.message("Du nimmst die Herausforderung von " + challengerPlayer.getName() + " an. " + kitText + " Verlasse das Feld, um abzubrechen."));
challengerPlayer.sendMessage(Style.message(opponentPlayer.getName() + " nimmt deine Herausforderung an. " + kitText + " Verlasse das Feld, um abzubrechen."));
for(Player player : new Player[]{challengerPlayer, opponentPlayer}) {
Style.play(player, Sound.ITEM_ARMOR_EQUIP_NETHERITE, 1f);
}
}
@Override
public void kitReverted(UUID uuid) {
Player player = Bukkit.getPlayer(uuid);
if(player != null) InventoryStore.apply(player, uuid);
}
@Override
public void countdown(int secondsLeft) {
for(int pad = 0; pad < PadLobby.PADS; pad++) {
Player player = this.occupantPlayer(pad);
Player other = this.occupantPlayer(1 - pad);
if(player == null || other == null) continue;
player.showTitle(Title.title(
Component.text(secondsLeft, NamedTextColor.GOLD),
Component.text("Kampf gegen ", NamedTextColor.GRAY).append(Component.text(other.getName(), Style.PAD_TEXT_COLORS[1 - pad])),
Title.Times.times(Duration.ZERO, Duration.ofMillis(1100), Duration.ZERO)
));
Style.play(player, Sound.BLOCK_NOTE_BLOCK_PLING, 0.6f + (this.arena.settings().countdownSeconds() - secondsLeft) * 0.2f);
}
}
@Override
public void countdownCancelled() {
for(int pad = 0; pad < PadLobby.PADS; pad++) {
Player player = this.occupantPlayer(pad);
if(player == null) continue;
player.clearTitle();
player.sendActionBar(Component.text("Abgebrochen", NamedTextColor.RED));
Style.play(player, Sound.BLOCK_NOTE_BLOCK_BASS, 0.5f);
}
}
@Override
public void start(UUID challenger, UUID opponent, FightMode mode, int challengerPad) {
Player challengerPlayer = Bukkit.getPlayer(challenger);
Player opponentPlayer = Bukkit.getPlayer(opponent);
if(challengerPlayer == null || opponentPlayer == null) {
if(challengerPlayer != null) this.arena.toTribune(challengerPlayer);
if(opponentPlayer != null) this.arena.toTribune(opponentPlayer);
return;
}
Player red = challengerPad == 0 ? challengerPlayer : opponentPlayer;
Player blue = challengerPad == 0 ? opponentPlayer : challengerPlayer;
this.arena.startFight(red, blue, challenger, mode);
}
private @Nullable Player occupantPlayer(int pad) {
UUID occupant = this.pads.occupant(pad);
return occupant == null ? null : Bukkit.getPlayer(occupant);
}
private void updateDisplays() {
boolean waiting = this.pads.isFull() && !this.arena.hasFreeSlot();
Player challenger = this.pads.challenger() == null ? null : Bukkit.getPlayer(this.pads.challenger());
String modeText = "Modus: " + this.pads.mode().displayName();
for(int pad = 0; pad < PadLobby.PADS; pad++) {
Component title = Component.text(Style.PAD_NAMES[pad], Style.PAD_TEXT_COLORS[pad]);
Player occupant = this.occupantPlayer(pad);
Component body;
if(waiting) {
body = Component.text("Alle Arenen belegt", NamedTextColor.GRAY).appendNewline()
.append(Component.text("Gleich geht es los", NamedTextColor.WHITE));
} else if(this.pads.isCountingDown()) {
body = Component.text(occupant == null ? "" : occupant.getName(), Style.PAD_TEXT_COLORS[pad]).appendNewline()
.append(Component.text("Kampf beginnt in " + this.pads.secondsLeft(), NamedTextColor.GOLD));
} else if(occupant != null) {
body = Component.text(occupant.getName(), Style.PAD_TEXT_COLORS[pad]).appendNewline()
.append(Component.text("fordert heraus", NamedTextColor.GRAY)).appendNewline()
.append(Component.text(modeText, NamedTextColor.WHITE));
} else if(challenger != null) {
body = Component.text("Stell dich hierhin, um gegen", NamedTextColor.WHITE).appendNewline()
.append(Component.text(challenger.getName(), Style.PAD_TEXT_COLORS[1 - pad]))
.append(Component.text(" zu kämpfen", NamedTextColor.WHITE)).appendNewline()
.append(Component.text(modeText, NamedTextColor.GRAY));
} else if(!this.arena.hasFreeSlot()) {
body = Component.text("Alle Arenen belegt", NamedTextColor.GRAY).appendNewline()
.append(Component.text("Du kannst dich trotzdem anstellen", NamedTextColor.WHITE));
} else {
body = Component.text("Stell dich hierhin,", NamedTextColor.WHITE).appendNewline()
.append(Component.text("um herauszufordern", NamedTextColor.WHITE));
}
this.displays[pad].text(title.appendNewline().append(body));
}
}
private void drawParticles() {
boolean waiting = this.pads.isFull() && !this.arena.hasFreeSlot();
boolean countdown = this.pads.isCountingDown() && !waiting;
for(int pad = 0; pad < PadLobby.PADS; pad++) {
Region region = this.regions[pad];
Color color = waiting ? Style.WAITING_PARTICLE_COLOR : Style.PAD_PARTICLE_COLORS[pad];
boolean taken = this.pads.occupant(pad) != null;
double spacing = countdown ? 0.25 : taken ? 0.4 : 0.6;
this.drawOutline(region, new Particle.DustOptions(color, taken ? 1.2f : 0.9f), spacing);
Player occupant = this.occupantPlayer(pad);
if(occupant == null) continue;
double speed = countdown ? 0.6 : 0.3;
for(int strand = 0; strand < 2; strand++) {
double angle = this.ticks * speed + strand * Math.PI;
double height = (this.ticks % 40) / 20.0;
Location point = occupant.getLocation().add(Math.cos(angle) * 0.7, height, Math.sin(angle) * 0.7);
Particle.DUST.builder().location(point).data(new Particle.DustOptions(color, 1f)).receivers(48).spawn();
}
}
}
private void drawOutline(Region region, Particle.DustOptions dust, double spacing) {
double y = region.minY() + 0.1;
double minX = region.minX();
double maxX = region.maxX() + 1;
double minZ = region.minZ();
double maxZ = region.maxZ() + 1;
Location location = new Location(this.arena.worlds().play(), 0, y, 0);
for(double x = minX; x <= maxX; x += spacing) {
this.dust(location.set(x, y, minZ), dust);
this.dust(location.set(x, y, maxZ), dust);
}
for(double z = minZ; z <= maxZ; z += spacing) {
this.dust(location.set(minX, y, z), dust);
this.dust(location.set(maxX, y, z), dust);
}
}
private void dust(Location location, Particle.DustOptions dust) {
Particle.DUST.builder().location(location).data(dust).receivers(48).spawn();
}
}
@@ -0,0 +1,99 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import org.bukkit.entity.Player;
import org.bukkit.event.Cancellable;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.EntityPickupItemEvent;
import org.bukkit.event.entity.EntityToggleGlideEvent;
import org.bukkit.event.entity.FoodLevelChangeEvent;
import org.bukkit.event.entity.ProjectileLaunchEvent;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryDragEvent;
import org.bukkit.event.inventory.InventoryOpenEvent;
import org.bukkit.event.inventory.InventoryType;
import org.bukkit.event.player.PlayerDropItemEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerItemConsumeEvent;
import org.bukkit.event.player.PlayerSwapHandItemsEvent;
/**
* Outside of a fight the inventory can be looked at, but nothing can be moved, dropped or used.
* Nobody takes damage or gets hungry on the tribune.
*/
class LockListener extends ApplianceListener<Arena> {
private boolean isLocked(Object entity) {
if(!(entity instanceof Player player)) return false;
Arena.PlayerState state = this.getAppliance().state(player);
return state == Arena.PlayerState.TRIBUNE || state == Arena.PlayerState.PAD;
}
private void cancelIfLocked(Object entity, Cancellable event) {
if(this.isLocked(entity)) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.LOW)
public void onClick(InventoryClickEvent event) {
this.cancelIfLocked(event.getWhoClicked(), event);
}
@EventHandler(priority = EventPriority.LOW)
public void onDrag(InventoryDragEvent event) {
this.cancelIfLocked(event.getWhoClicked(), event);
}
@EventHandler(priority = EventPriority.LOW)
public void onDrop(PlayerDropItemEvent event) {
this.cancelIfLocked(event.getPlayer(), event);
}
@EventHandler(priority = EventPriority.LOW)
public void onSwapHands(PlayerSwapHandItemsEvent event) {
this.cancelIfLocked(event.getPlayer(), event);
}
@EventHandler(priority = EventPriority.LOW)
public void onInteract(PlayerInteractEvent event) {
this.cancelIfLocked(event.getPlayer(), event);
}
@EventHandler(priority = EventPriority.LOW)
public void onConsume(PlayerItemConsumeEvent event) {
this.cancelIfLocked(event.getPlayer(), event);
}
@EventHandler(priority = EventPriority.LOW)
public void onPickup(EntityPickupItemEvent event) {
this.cancelIfLocked(event.getEntity(), event);
}
@EventHandler(priority = EventPriority.LOW)
public void onLaunch(ProjectileLaunchEvent event) {
this.cancelIfLocked(event.getEntity().getShooter(), event);
}
@EventHandler(priority = EventPriority.LOW)
public void onGlide(EntityToggleGlideEvent event) {
if(event.isGliding()) this.cancelIfLocked(event.getEntity(), event);
}
@EventHandler(priority = EventPriority.LOW)
public void onHunger(FoodLevelChangeEvent event) {
this.cancelIfLocked(event.getEntity(), event);
}
@EventHandler(priority = EventPriority.LOW)
public void onDamage(EntityDamageEvent event) {
this.cancelIfLocked(event.getEntity(), event);
}
/**
* Ender chests would keep items between fights.
*/
@EventHandler
public void onOpen(InventoryOpenEvent event) {
if(event.getInventory().getType() == InventoryType.ENDER_CHEST) event.setCancelled(true);
}
}
@@ -0,0 +1,74 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena;
import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.FightMode;
import eu.mhsl.craftattack.spawn.core.util.inventory.ItemBuilder;
import eu.mhsl.craftattack.spawn.core.util.inventory.PlaceholderItems;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextDecoration;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.InventoryHolder;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.SkullMeta;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
/**
* Chest menu in which the challenger picks the mode. Works for Bedrock players as well.
*/
final class ModeMenu implements InventoryHolder {
private static final int[] SLOTS = {11, 13, 15};
private static final FightMode[] MODES = {FightMode.OWN, FightMode.CHALLENGER, FightMode.OPPONENT};
private final Inventory inventory;
private ModeMenu(Player challenger, FightMode current) {
this.inventory = Bukkit.createInventory(this, 27, Component.text("Wähle den Kampfmodus"));
for(int i = 0; i < this.inventory.getSize(); i++) this.inventory.setItem(i, PlaceholderItems.grayStainedGlassPane);
for(int i = 0; i < MODES.length; i++) this.inventory.setItem(SLOTS[i], this.item(MODES[i], challenger, MODES[i] == current));
}
static void open(Player challenger, FightMode current) {
challenger.openInventory(new ModeMenu(challenger, current).getInventory());
}
static boolean isOpen(Player player) {
return player.getOpenInventory().getTopInventory().getHolder(false) instanceof ModeMenu;
}
@Nullable FightMode modeAt(int slot) {
for(int i = 0; i < SLOTS.length; i++) {
if(SLOTS[i] == slot) return MODES[i];
}
return null;
}
private ItemStack item(FightMode mode, Player challenger, boolean selected) {
ItemBuilder builder = switch(mode) {
case OWN -> ItemBuilder.of(Material.IRON_SWORD)
.lore("Ihr kämpft beide mit eurem eigenen Inventar.");
case CHALLENGER -> ItemBuilder.of(Material.PLAYER_HEAD)
.withMeta(meta -> ((SkullMeta) meta).setOwningPlayer(challenger))
.lore("Ihr kämpft beide mit deinem Inventar.");
case OPPONENT -> ItemBuilder.of(Material.CHEST)
.lore("Ihr kämpft beide mit dem Inventar von dem, der sich auf das andere Feld stellt.");
};
builder.displayName(Component.text(mode.displayName(), NamedTextColor.GOLD).decoration(TextDecoration.ITALIC, false));
if(selected) {
builder.glint().appendLore(Component.empty())
.appendLore(Component.text("Ausgewählt", NamedTextColor.GREEN).decoration(TextDecoration.ITALIC, false));
} else {
builder.appendLore(Component.empty())
.appendLore(Component.text("Klicken zum Auswählen", NamedTextColor.YELLOW).decoration(TextDecoration.ITALIC, false));
}
return builder.build();
}
@Override
public @NotNull Inventory getInventory() {
return this.inventory;
}
}
@@ -0,0 +1,21 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena;
import eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.FightMode;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.inventory.InventoryClickEvent;
class ModeMenuListener extends ApplianceListener<Arena> {
@EventHandler
public void onClick(InventoryClickEvent event) {
if(!(event.getView().getTopInventory().getHolder(false) instanceof ModeMenu menu)) return;
event.setCancelled(true);
if(!(event.getWhoClicked() instanceof Player player)) return;
if(event.getRawSlot() >= event.getView().getTopInventory().getSize()) return;
FightMode mode = menu.modeAt(event.getRawSlot());
Lobby lobby = this.getAppliance().lobby();
if(mode != null && lobby != null) lobby.chooseMode(player, mode);
}
}
@@ -0,0 +1,22 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import org.bukkit.event.EventHandler;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
class PlayerConnectionListener extends ApplianceListener<Arena> {
@EventHandler
public void onJoin(PlayerJoinEvent event) {
event.getPlayer().getEnderChest().clear();
this.getAppliance().toTribune(event.getPlayer());
event.getPlayer().sendMessage(Style.message(
"Willkommen in der Arena! Stell dich auf ein Startfeld, um jemanden herauszufordern. Zurück geht es beim Villager oder mit /craftattack."
));
}
@EventHandler
public void onQuit(PlayerQuitEvent event) {
this.getAppliance().handleQuit(event.getPlayer());
}
}
@@ -0,0 +1,55 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextColor;
import org.bukkit.Color;
import org.bukkit.Location;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
import java.util.Locale;
/**
* Colors, texts and sounds of the arena, so everything looks and sounds the same everywhere.
* Player facing texts use no dashes.
*/
final class Style {
static final Component PREFIX = Component.text("[Arena] ", NamedTextColor.GOLD);
static final NamedTextColor[] PAD_TEXT_COLORS = {NamedTextColor.RED, NamedTextColor.BLUE};
static final Color[] PAD_PARTICLE_COLORS = {Color.fromRGB(255, 70, 70), Color.fromRGB(80, 120, 255)};
static final String[] PAD_NAMES = {"Rotes Feld", "Blaues Feld"};
static final Color WAITING_PARTICLE_COLOR = Color.fromRGB(150, 150, 150);
private Style() {
}
static Component message(String text) {
return PREFIX.append(Component.text(text, NamedTextColor.GRAY));
}
static Component message(Component text) {
return PREFIX.append(text.colorIfAbsent(NamedTextColor.GRAY));
}
static Component name(String name, TextColor color) {
return Component.text(name, color);
}
static String hearts(double health) {
return String.format(Locale.GERMAN, "%.1f", health / 2);
}
static String time(int seconds) {
return String.format("%d:%02d", seconds / 60, seconds % 60);
}
static void play(Player player, Sound sound, float pitch) {
player.playSound(player, sound, 1f, pitch);
}
static void play(Location location, Sound sound, float volume, float pitch) {
location.getWorld().playSound(location, sound, volume, pitch);
}
}
@@ -0,0 +1,48 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.scoreboard.Scoreboard;
import org.bukkit.scoreboard.Team;
/**
* Scoreboard teams for the colored names. Players on a pad cannot be pushed (no collision),
* on the tribune and in a fight collision stays on.
*/
final class Teams {
private static final String[] NAMES = {"arena_pad_red", "arena_pad_blue", "arena_fight_red", "arena_fight_blue"};
private final Team[] teams = new Team[NAMES.length];
Teams() {
Scoreboard scoreboard = Bukkit.getScoreboardManager().getMainScoreboard();
for(int i = 0; i < NAMES.length; i++) {
Team team = scoreboard.getTeam(NAMES[i]);
if(team == null) team = scoreboard.registerNewTeam(NAMES[i]);
team.color(Style.PAD_TEXT_COLORS[i % 2]);
team.setOption(Team.Option.COLLISION_RULE, i < 2 ? Team.OptionStatus.NEVER : Team.OptionStatus.ALWAYS);
this.teams[i] = team;
}
}
static int pad(int pad) {
return pad;
}
static int fight(int side) {
return 2 + side;
}
void join(Player player, int team) {
this.leave(player);
this.teams[team].addEntry(player.getName());
}
void leave(Player player) {
for(Team team : this.teams) team.removeEntry(player.getName());
}
void clear() {
for(Team team : this.teams) team.getEntries().forEach(team::removeEntry);
}
}
@@ -0,0 +1,145 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import org.bukkit.Location;
import org.bukkit.block.Block;
import org.bukkit.event.Cancellable;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.block.BlockBreakEvent;
import org.bukkit.event.block.BlockBurnEvent;
import org.bukkit.event.block.BlockExplodeEvent;
import org.bukkit.event.block.BlockFadeEvent;
import org.bukkit.event.block.BlockFormEvent;
import org.bukkit.event.block.BlockFromToEvent;
import org.bukkit.event.block.BlockIgniteEvent;
import org.bukkit.event.block.BlockPistonExtendEvent;
import org.bukkit.event.block.BlockPistonRetractEvent;
import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.event.block.BlockSpreadEvent;
import org.bukkit.event.block.LeavesDecayEvent;
import org.bukkit.event.entity.EntityChangeBlockEvent;
import org.bukkit.event.entity.EntityExplodeEvent;
import org.bukkit.event.hanging.HangingPlaceEvent;
import org.bukkit.event.player.PlayerBucketEmptyEvent;
import org.bukkit.event.player.PlayerBucketFillEvent;
import org.bukkit.event.world.StructureGrowEvent;
import java.util.List;
/**
* In the play world blocks may only change inside the arenas. Everything else (tribune, pads, the space
* between arenas) stays exactly as in the template.
*/
class WorldProtectionListener extends ApplianceListener<Arena> {
private boolean isProtected(Block block) {
return this.isProtected(block.getLocation());
}
private boolean isProtected(Location location) {
if(!this.getAppliance().worlds().isPlay(location.getWorld())) return false;
return !this.getAppliance().isInsideArena(location);
}
private void cancelIfProtected(Block block, Cancellable event) {
if(this.isProtected(block)) event.setCancelled(true);
}
@EventHandler(priority = EventPriority.LOW, ignoreCancelled = true)
public void onPlace(BlockPlaceEvent event) {
this.cancelIfProtected(event.getBlock(), event);
}
@EventHandler(priority = EventPriority.LOW, ignoreCancelled = true)
public void onBreak(BlockBreakEvent event) {
this.cancelIfProtected(event.getBlock(), event);
}
@EventHandler(priority = EventPriority.LOW, ignoreCancelled = true)
public void onBucketEmpty(PlayerBucketEmptyEvent event) {
this.cancelIfProtected(event.getBlock(), event);
}
@EventHandler(priority = EventPriority.LOW, ignoreCancelled = true)
public void onBucketFill(PlayerBucketFillEvent event) {
this.cancelIfProtected(event.getBlock(), event);
}
@EventHandler(priority = EventPriority.LOW, ignoreCancelled = true)
public void onFlow(BlockFromToEvent event) {
this.cancelIfProtected(event.getToBlock(), event);
}
@EventHandler(priority = EventPriority.LOW, ignoreCancelled = true)
public void onSpread(BlockSpreadEvent event) {
this.cancelIfProtected(event.getBlock(), event);
}
@EventHandler(priority = EventPriority.LOW, ignoreCancelled = true)
public void onForm(BlockFormEvent event) {
this.cancelIfProtected(event.getBlock(), event);
}
@EventHandler(priority = EventPriority.LOW, ignoreCancelled = true)
public void onFade(BlockFadeEvent event) {
this.cancelIfProtected(event.getBlock(), event);
}
@EventHandler(priority = EventPriority.LOW, ignoreCancelled = true)
public void onIgnite(BlockIgniteEvent event) {
this.cancelIfProtected(event.getBlock(), event);
}
@EventHandler(priority = EventPriority.LOW, ignoreCancelled = true)
public void onBurn(BlockBurnEvent event) {
this.cancelIfProtected(event.getBlock(), event);
}
@EventHandler(priority = EventPriority.LOW, ignoreCancelled = true)
public void onLeavesDecay(LeavesDecayEvent event) {
this.cancelIfProtected(event.getBlock(), event);
}
@EventHandler(priority = EventPriority.LOW, ignoreCancelled = true)
public void onEntityChangeBlock(EntityChangeBlockEvent event) {
this.cancelIfProtected(event.getBlock(), event);
}
@EventHandler(priority = EventPriority.LOW, ignoreCancelled = true)
public void onHangingPlace(HangingPlaceEvent event) {
this.cancelIfProtected(event.getBlock().getRelative(event.getBlockFace()), event);
}
@EventHandler(priority = EventPriority.LOW, ignoreCancelled = true)
public void onGrow(StructureGrowEvent event) {
if(!this.getAppliance().worlds().isPlay(event.getWorld())) return;
event.getBlocks().removeIf(state -> this.isProtected(state.getLocation()));
}
@EventHandler(priority = EventPriority.LOW, ignoreCancelled = true)
public void onPistonExtend(BlockPistonExtendEvent event) {
if(this.anyProtected(event.getBlock(), event.getBlocks(), event.getDirection().getModX(), event.getDirection().getModY(), event.getDirection().getModZ()))
event.setCancelled(true);
}
@EventHandler(priority = EventPriority.LOW, ignoreCancelled = true)
public void onPistonRetract(BlockPistonRetractEvent event) {
if(this.anyProtected(event.getBlock(), event.getBlocks(), event.getDirection().getModX(), event.getDirection().getModY(), event.getDirection().getModZ()))
event.setCancelled(true);
}
private boolean anyProtected(Block piston, List<Block> moved, int dx, int dy, int dz) {
if(this.isProtected(piston) || this.isProtected(piston.getRelative(dx, dy, dz))) return true;
return moved.stream().anyMatch(block -> this.isProtected(block) || this.isProtected(block.getRelative(dx, dy, dz)));
}
@EventHandler(priority = EventPriority.LOW, ignoreCancelled = true)
public void onEntityExplode(EntityExplodeEvent event) {
event.blockList().removeIf(this::isProtected);
}
@EventHandler(priority = EventPriority.LOW, ignoreCancelled = true)
public void onBlockExplode(BlockExplodeEvent event) {
event.blockList().removeIf(this::isProtected);
}
}
@@ -0,0 +1,36 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena.logic;
/**
* Which inventory the two fighters use. The challenger chooses it.
*/
public enum FightMode {
OWN("Jeder sein eigenes"),
CHALLENGER("Beide mit meinem"),
OPPONENT("Beide mit dem des Gegners");
public enum Role {
CHALLENGER,
OPPONENT
}
private final String displayName;
FightMode(String displayName) {
this.displayName = displayName;
}
public String displayName() {
return this.displayName;
}
/**
* @return whose inventory the fighter with the given role gets
*/
public Role kitSource(Role fighter) {
return switch(this) {
case OWN -> fighter;
case CHALLENGER -> Role.CHALLENGER;
case OPPONENT -> Role.OPPONENT;
};
}
}
@@ -0,0 +1,164 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena.logic;
import org.jetbrains.annotations.Nullable;
import java.util.UUID;
/**
* State of the two start pads. The first player on a pad becomes the challenger and picks the mode,
* stepping on the other pad means accepting. Once both stand there long enough, the fight starts.
*/
public class PadLobby {
public static final int PADS = 2;
public interface Listener {
/** The player is now the challenger and should pick a mode. */
void challengerSelected(UUID challenger);
/** Both pads are taken: give both the inventories of the chosen mode. */
void kitsApplied(UUID challenger, UUID opponent, FightMode mode);
/** The player gets the own inventory back. */
void kitReverted(UUID player);
void countdown(int secondsLeft);
void countdownCancelled();
void start(UUID challenger, UUID opponent, FightMode mode, int challengerPad);
}
private final Listener listener;
private final int countdownTicks;
private final UUID[] occupants = new UUID[PADS];
private int challengerPad = -1;
private FightMode mode = FightMode.OWN;
private int ticksLeft = -1;
public PadLobby(Listener listener, int countdownSeconds) {
this.listener = listener;
this.countdownTicks = countdownSeconds * 20;
}
/**
* @return false if the pad is already taken
*/
public boolean enter(int pad, UUID player) {
if(this.occupants[pad] != null) return false;
if(this.padOf(player) != -1) return false;
this.occupants[pad] = player;
if(this.challengerPad == -1) {
this.challengerPad = pad;
this.mode = FightMode.OWN;
this.listener.challengerSelected(player);
return true;
}
this.listener.kitsApplied(this.challenger(), this.opponent(), this.mode);
this.ticksLeft = this.countdownTicks;
return true;
}
public void leave(UUID player) {
int pad = this.padOf(player);
if(pad == -1) return;
int other = 1 - pad;
boolean bothThere = this.isFull();
this.occupants[pad] = null;
this.listener.kitReverted(player);
if(bothThere) {
this.ticksLeft = -1;
this.listener.kitReverted(this.occupants[other]);
this.listener.countdownCancelled();
}
if(pad != this.challengerPad) return;
if(this.occupants[other] == null) {
this.challengerPad = -1;
return;
}
this.challengerPad = other;
this.mode = FightMode.OWN;
this.listener.challengerSelected(this.occupants[other]);
}
/**
* Only the challenger can change the mode, and only while nobody stands on the other pad.
*/
public boolean setMode(UUID player, FightMode mode) {
if(!player.equals(this.challenger()) || this.isFull()) return false;
this.mode = mode;
return true;
}
/**
* Called every server tick. The countdown only runs while an arena is free.
*/
public void tick(boolean arenaAvailable) {
if(this.ticksLeft < 0) return;
if(!arenaAvailable) {
this.ticksLeft = this.countdownTicks;
return;
}
if(this.ticksLeft % 20 == 0 && this.ticksLeft > 0) this.listener.countdown(this.ticksLeft / 20);
if(this.ticksLeft-- > 0) return;
UUID challenger = this.challenger();
UUID opponent = this.opponent();
FightMode mode = this.mode;
int challengerPad = this.challengerPad;
this.occupants[0] = null;
this.occupants[1] = null;
this.challengerPad = -1;
this.mode = FightMode.OWN;
this.ticksLeft = -1;
this.listener.start(challenger, opponent, mode, challengerPad);
}
public int padOf(UUID player) {
for(int i = 0; i < PADS; i++) {
if(player.equals(this.occupants[i])) return i;
}
return -1;
}
public @Nullable UUID occupant(int pad) {
return this.occupants[pad];
}
public @Nullable UUID challenger() {
return this.challengerPad == -1 ? null : this.occupants[this.challengerPad];
}
public @Nullable UUID opponent() {
return this.challengerPad == -1 ? null : this.occupants[1 - this.challengerPad];
}
public int challengerPad() {
return this.challengerPad;
}
public FightMode mode() {
return this.mode;
}
public boolean isFull() {
return this.occupants[0] != null && this.occupants[1] != null;
}
public boolean isCountingDown() {
return this.ticksLeft >= 0;
}
public boolean isIdle() {
return this.occupants[0] == null && this.occupants[1] == null;
}
/** Seconds until the start, rounded up; -1 without countdown. */
public int secondsLeft() {
return this.ticksLeft < 0 ? -1 : (this.ticksLeft + 19) / 20;
}
}
@@ -0,0 +1,58 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena.logic;
/**
* Axis-aligned box of whole blocks, both corners inclusive.
*/
public record Region(int minX, int minY, int minZ, int maxX, int maxY, int maxZ) {
public static Region of(int x1, int y1, int z1, int x2, int y2, int z2) {
return new Region(
Math.min(x1, x2), Math.min(y1, y2), Math.min(z1, z2),
Math.max(x1, x2), Math.max(y1, y2), Math.max(z1, z2)
);
}
public boolean contains(int x, int y, int z) {
return x >= this.minX && x <= this.maxX
&& y >= this.minY && y <= this.maxY
&& z >= this.minZ && z <= this.maxZ;
}
public boolean contains(double x, double y, double z) {
return this.contains((int) Math.floor(x), (int) Math.floor(y), (int) Math.floor(z));
}
public Region expand(int horizontal, int up) {
return new Region(
this.minX - horizontal, this.minY, this.minZ - horizontal,
this.maxX + horizontal, this.maxY + up, this.maxZ + horizontal
);
}
public long volume() {
return (long) (this.maxX - this.minX + 1) * (this.maxY - this.minY + 1) * (this.maxZ - this.minZ + 1);
}
public double centerX() {
return (this.minX + this.maxX + 1) / 2.0;
}
public double centerZ() {
return (this.minZ + this.maxZ + 1) / 2.0;
}
public int minChunkX() {
return this.minX >> 4;
}
public int maxChunkX() {
return this.maxX >> 4;
}
public int minChunkZ() {
return this.minZ >> 4;
}
public int maxChunkZ() {
return this.maxZ >> 4;
}
}
@@ -0,0 +1,52 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena.logic;
/**
* Walks through every block of a region in portions, so a reset can be spread over several ticks.
*/
public class ResetCursor {
@FunctionalInterface
public interface BlockVisitor {
void visit(int x, int y, int z);
}
private final Region region;
private int x;
private int y;
private int z;
private boolean done;
public ResetCursor(Region region) {
this.region = region;
this.x = region.minX();
this.y = region.minY();
this.z = region.minZ();
}
/**
* Visits up to {@code budget} blocks, continuing where the last call stopped.
*
* @return number of visited blocks
*/
public int advance(int budget, BlockVisitor visitor) {
int visited = 0;
while(!this.done && visited < budget) {
visitor.visit(this.x, this.y, this.z);
visited++;
this.step();
}
return visited;
}
public boolean isDone() {
return this.done;
}
private void step() {
if(++this.y <= this.region.maxY()) return;
this.y = this.region.minY();
if(++this.z <= this.region.maxZ()) return;
this.z = this.region.minZ();
if(++this.x <= this.region.maxX()) return;
this.done = true;
}
}
@@ -0,0 +1,27 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena.logic;
import org.junit.jupiter.api.Test;
import static eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.FightMode.Role.CHALLENGER;
import static eu.mhsl.craftattack.spawn.arena.appliances.arena.logic.FightMode.Role.OPPONENT;
import static org.junit.jupiter.api.Assertions.assertEquals;
class FightModeTest {
@Test
void ownKeepsEveryonesInventory() {
assertEquals(CHALLENGER, FightMode.OWN.kitSource(CHALLENGER));
assertEquals(OPPONENT, FightMode.OWN.kitSource(OPPONENT));
}
@Test
void challengerKitForBoth() {
assertEquals(CHALLENGER, FightMode.CHALLENGER.kitSource(CHALLENGER));
assertEquals(CHALLENGER, FightMode.CHALLENGER.kitSource(OPPONENT));
}
@Test
void opponentKitForBoth() {
assertEquals(OPPONENT, FightMode.OPPONENT.kitSource(CHALLENGER));
assertEquals(OPPONENT, FightMode.OPPONENT.kitSource(OPPONENT));
}
}
@@ -0,0 +1,151 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena.logic;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
class PadLobbyTest {
private final UUID a = UUID.randomUUID();
private final UUID b = UUID.randomUUID();
private final UUID c = UUID.randomUUID();
private final List<String> events = new ArrayList<>();
private PadLobby lobby;
@BeforeEach
void setUp() {
this.lobby = new PadLobby(new PadLobby.Listener() {
public void challengerSelected(UUID challenger) {
PadLobbyTest.this.events.add("challenger " + PadLobbyTest.this.name(challenger));
}
public void kitsApplied(UUID challenger, UUID opponent, FightMode mode) {
PadLobbyTest.this.events.add("kits " + PadLobbyTest.this.name(challenger) + PadLobbyTest.this.name(opponent) + " " + mode);
}
public void kitReverted(UUID player) {
PadLobbyTest.this.events.add("revert " + PadLobbyTest.this.name(player));
}
public void countdown(int secondsLeft) {
PadLobbyTest.this.events.add("countdown " + secondsLeft);
}
public void countdownCancelled() {
PadLobbyTest.this.events.add("cancelled");
}
public void start(UUID challenger, UUID opponent, FightMode mode, int challengerPad) {
PadLobbyTest.this.events.add("start " + PadLobbyTest.this.name(challenger) + PadLobbyTest.this.name(opponent) + " " + mode + " " + challengerPad);
}
}, 2);
}
private String name(UUID uuid) {
if(uuid.equals(this.a)) return "a";
if(uuid.equals(this.b)) return "b";
return "c";
}
@Test
void firstOnPadBecomesChallenger() {
assertTrue(this.lobby.enter(1, this.a));
assertEquals(List.of("challenger a"), this.events);
assertEquals(this.a, this.lobby.challenger());
assertNull(this.lobby.opponent());
assertFalse(this.lobby.isCountingDown());
}
@Test
void takenPadIsRefused() {
this.lobby.enter(0, this.a);
assertFalse(this.lobby.enter(0, this.b));
assertFalse(this.lobby.enter(1, this.a), "a player can only take one pad");
}
@Test
void secondPlayerAppliesKitsAndStartsAfterCountdown() {
this.lobby.enter(0, this.a);
assertTrue(this.lobby.setMode(this.a, FightMode.CHALLENGER));
this.lobby.enter(1, this.b);
assertFalse(this.lobby.setMode(this.a, FightMode.OWN), "mode is fixed once the opponent stands there");
for(int i = 0; i < 41; i++) this.lobby.tick(true);
assertEquals(List.of(
"challenger a",
"kits ab CHALLENGER",
"countdown 2",
"countdown 1",
"start ab CHALLENGER 0"
), this.events);
assertTrue(this.lobby.isIdle());
assertNull(this.lobby.challenger());
}
@Test
void onlyChallengerMayChangeMode() {
this.lobby.enter(0, this.a);
assertFalse(this.lobby.setMode(this.b, FightMode.OPPONENT));
assertEquals(FightMode.OWN, this.lobby.mode());
}
@Test
void countdownWaitsForFreeArena() {
this.lobby.enter(0, this.a);
this.lobby.enter(1, this.b);
for(int i = 0; i < 100; i++) this.lobby.tick(false);
assertFalse(this.events.contains("countdown 2"));
assertEquals(2, this.lobby.secondsLeft());
for(int i = 0; i < 41; i++) this.lobby.tick(true);
assertTrue(this.events.contains("start ab OWN 0"));
}
@Test
void opponentLeavingCancelsAndRevertsBoth() {
this.lobby.enter(0, this.a);
this.lobby.setMode(this.a, FightMode.OPPONENT);
this.lobby.enter(1, this.b);
this.events.clear();
this.lobby.leave(this.b);
assertEquals(List.of("revert b", "revert a", "cancelled"), this.events);
assertEquals(this.a, this.lobby.challenger());
assertEquals(FightMode.OPPONENT, this.lobby.mode(), "challenger keeps the chosen mode");
assertFalse(this.lobby.isCountingDown());
}
@Test
void challengerLeavingHandsOverRole() {
this.lobby.enter(0, this.a);
this.lobby.setMode(this.a, FightMode.CHALLENGER);
this.lobby.enter(1, this.b);
this.events.clear();
this.lobby.leave(this.a);
assertEquals(List.of("revert a", "revert b", "cancelled", "challenger b"), this.events);
assertEquals(this.b, this.lobby.challenger());
assertEquals(1, this.lobby.challengerPad());
assertEquals(FightMode.OWN, this.lobby.mode());
this.lobby.enter(0, this.c);
assertEquals("kits bc OWN", this.events.getLast());
}
@Test
void lastPlayerLeavingResetsLobby() {
this.lobby.enter(0, this.a);
this.lobby.leave(this.a);
assertTrue(this.lobby.isIdle());
assertNull(this.lobby.challenger());
this.lobby.leave(this.b);
assertEquals(List.of("challenger a", "revert a"), this.events);
}
}
@@ -0,0 +1,52 @@
package eu.mhsl.craftattack.spawn.arena.appliances.arena.logic;
import org.junit.jupiter.api.Test;
import java.util.HashSet;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
class RegionTest {
@Test
void cornersAreNormalized() {
assertEquals(new Region(-2, 1, -5, 3, 4, 6), Region.of(3, 4, -5, -2, 1, 6));
}
@Test
void containsIsInclusiveAndFloorsNegativeCoordinates() {
Region region = Region.of(-2, 0, -2, 1, 3, 1);
assertTrue(region.contains(-2, 0, -2));
assertTrue(region.contains(1, 3, 1));
assertFalse(region.contains(2, 0, 0));
assertTrue(region.contains(-1.5, 0.0, 1.99));
assertFalse(region.contains(-2.01, 0.0, 0.0));
assertFalse(region.contains(1.0, 4.0, 0.0));
}
@Test
void volumeAndChunks() {
Region region = Region.of(-1, 0, 15, 16, 9, 32);
assertEquals(18L * 10 * 18, region.volume());
assertEquals(-1, region.minChunkX());
assertEquals(1, region.maxChunkX());
assertEquals(0, region.minChunkZ());
assertEquals(2, region.maxChunkZ());
}
@Test
void resetCursorVisitsEveryBlockOnceInPortions() {
Region region = Region.of(0, 0, 0, 4, 3, 2);
ResetCursor cursor = new ResetCursor(region);
Set<String> visited = new HashSet<>();
int calls = 0;
while(!cursor.isDone()) {
int count = cursor.advance(7, (x, y, z) -> assertTrue(visited.add(x + "," + y + "," + z)));
assertTrue(count <= 7);
calls++;
}
assertEquals(region.volume(), visited.size());
assertEquals((int) Math.ceil(region.volume() / 7.0), calls);
assertEquals(0, cursor.advance(7, (x, y, z) -> fail()));
}
}
@@ -0,0 +1,32 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.loginLog;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.net.InetAddress;
import java.time.Instant;
import java.util.UUID;
record LoginAttempt(
@NotNull Instant at,
@NotNull UUID uuid,
@NotNull String name,
@NotNull String ip,
boolean bedrock,
@NotNull String stage,
@NotNull String result,
@Nullable String reason
) {
static final String STAGE_PRE_LOGIN = "PRE_LOGIN";
static final String STAGE_LOGIN = "LOGIN";
static final String STAGE_WEBSITE = "WEBSITE";
static final String RESULT_ABORTED = "ABORTED";
static String ipOf(@Nullable InetAddress address) {
return address == null ? "unknown" : address.getHostAddress();
}
LoginAttempt withOutcome(@NotNull String stage, @NotNull String result, @Nullable String reason) {
return new LoginAttempt(this.at, this.uuid, this.name, this.ip, this.bedrock, stage, result, reason);
}
}
@@ -0,0 +1,196 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.loginLog;
import com.google.common.net.InetAddresses;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.api.server.HttpServer;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import eu.mhsl.craftattack.spawn.core.database.Database;
import eu.mhsl.craftattack.spawn.core.event.LoginAttemptRecordedEvent;
import eu.mhsl.craftattack.spawn.core.event.SpawnEvent;
import eu.mhsl.craftattack.spawn.core.util.network.Networks;
import eu.mhsl.craftattack.spawn.core.util.server.Floodgate;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import org.bukkit.Bukkit;
import org.bukkit.event.Listener;
import org.bukkit.scheduler.BukkitTask;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.net.InetAddress;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
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.List;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Records every login attempt (allowed or not) with its IP address in the table login_attempts
* (schema: resources/migrations/loginLog). Basis for detecting ban evasion.
* After each insert a LoginAttemptRecordedEvent is fired on the database thread.
* The website reports signups and logins of its users via POST /api/loginlog/website.
* Never blocks or delays a login: writes run on the database thread, failures are only logged.
*/
public class LoginLog extends Appliance {
private static final int MAX_REASON_LENGTH = 255;
private static final Duration PENDING_TIMEOUT = Duration.ofSeconds(60);
private static final long TICKS_PER_MINUTE = 20L * 60;
private final Map<UUID, LoginAttempt> pendingAttempts = new ConcurrentHashMap<>();
private final List<BukkitTask> tasks = new ArrayList<>();
private Database database;
private int retentionMonths;
public LoginLog() {
super("loginLog");
}
@Override
public void onEnable() {
this.database = Main.instance().getDatabase();
if(!this.database.isAvailable()) {
Main.logger().warning("LoginLog: no database configured, login attempts are not recorded");
return;
}
this.retentionMonths = this.localConfig().getInt("retentionMonths", 16);
this.tasks.add(Bukkit.getScheduler().runTaskTimerAsynchronously(
Main.instance(), this::flushStaleAttempts, TICKS_PER_MINUTE, TICKS_PER_MINUTE
));
this.tasks.add(Bukkit.getScheduler().runTaskTimerAsynchronously(
Main.instance(), this::deleteExpired, TICKS_PER_MINUTE, TICKS_PER_MINUTE * 60 * 24
));
}
@Override
public void onDisable() {
this.tasks.forEach(BukkitTask::cancel);
}
void onPreLogin(UUID uuid, String name, @Nullable InetAddress address, String result, @Nullable Component kickMessage) {
if(!this.database.isAvailable()) return;
LoginAttempt attempt = new LoginAttempt(
Instant.now(), uuid, name, LoginAttempt.ipOf(address), Floodgate.isFloodgateUuid(uuid),
LoginAttempt.STAGE_PRE_LOGIN, result, plainText(kickMessage)
);
// allowed attempts are written after the login event, so they carry the final result
if(kickMessage == null) this.pendingAttempts.put(uuid, attempt);
else this.write(attempt);
}
void onLogin(UUID uuid, String name, @Nullable InetAddress address, String result, @Nullable Component kickMessage) {
if(!this.database.isAvailable()) return;
LoginAttempt pending = this.pendingAttempts.remove(uuid);
LoginAttempt attempt = pending != null
? pending
: new LoginAttempt(Instant.now(), uuid, name, LoginAttempt.ipOf(address), Floodgate.isFloodgateUuid(uuid), "", "", null);
this.write(attempt.withOutcome(LoginAttempt.STAGE_LOGIN, result, plainText(kickMessage)));
}
private void flushStaleAttempts() {
Instant threshold = Instant.now().minus(PENDING_TIMEOUT);
this.pendingAttempts.values().removeIf(attempt -> {
if(attempt.at().isAfter(threshold)) return false;
this.write(attempt.withOutcome(LoginAttempt.STAGE_PRE_LOGIN, LoginAttempt.RESULT_ABORTED, null));
return true;
});
}
private void write(LoginAttempt attempt) {
String network = Networks.networkOf(attempt.ip());
String sql = "INSERT INTO login_attempts (at, uuid, name, ip, network, bedrock, stage, result, reason) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)";
this.database.execute("recording login attempt of " + attempt.name(), connection -> {
long id;
try(PreparedStatement statement = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
statement.setObject(1, LocalDateTime.ofInstant(attempt.at(), ZoneOffset.UTC));
statement.setString(2, attempt.uuid().toString());
statement.setString(3, attempt.name());
statement.setString(4, attempt.ip());
statement.setString(5, network);
statement.setBoolean(6, attempt.bedrock());
statement.setString(7, attempt.stage());
statement.setString(8, attempt.result());
statement.setString(9, attempt.reason());
statement.executeUpdate();
try(ResultSet keys = statement.getGeneratedKeys()) {
if(!keys.next()) return;
id = keys.getLong(1);
}
}
SpawnEvent.call(new LoginAttemptRecordedEvent(new LoginAttemptRecordedEvent.RecordedAttempt(
id, attempt.at(), attempt.uuid(), attempt.name(), network, attempt.stage(), attempt.result()
)));
});
}
private void deleteExpired() {
String sql = "DELETE FROM login_attempts WHERE at < UTC_TIMESTAMP() - INTERVAL ? MONTH";
this.database.execute("deleting expired login attempts", connection -> {
try(PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setInt(1, this.retentionMonths);
int deleted = statement.executeUpdate();
if(deleted > 0) Main.logger().info(String.format("LoginLog: deleted %d expired login attempts", deleted));
}
});
}
private record WebsiteEvent(String event, String uuid, String username, String ip) {
}
private void recordWebsiteEvent(WebsiteEvent request) {
if(request == null) throw new IllegalArgumentException("Request body missing");
if(!this.database.isAvailable()) throw new IllegalStateException("No database configured");
String event = request.event() == null ? "" : request.event().trim().toUpperCase(Locale.ROOT);
if(!event.equals("SIGNUP") && !event.equals("LOGIN"))
throw new IllegalArgumentException("Field 'event' must be 'signup' or 'login'");
if(request.uuid() == null || request.uuid().isBlank())
throw new IllegalArgumentException("Field 'uuid' is required");
UUID uuid;
try {
uuid = UUID.fromString(request.uuid().trim());
} catch(IllegalArgumentException e) {
throw new IllegalArgumentException("Field 'uuid' is not a valid UUID");
}
if(request.username() == null || request.username().isBlank())
throw new IllegalArgumentException("Field 'username' is required");
if(request.ip() == null || !InetAddresses.isInetAddress(request.ip().trim()))
throw new IllegalArgumentException("Field 'ip' is not a valid IP address");
this.write(new LoginAttempt(
Instant.now(), uuid, request.username().trim(), request.ip().trim(), Floodgate.isFloodgateUuid(uuid),
LoginAttempt.STAGE_WEBSITE, event, null
));
}
@Override
public void httpApi(HttpServer.ApiBuilder apiBuilder) {
apiBuilder.post("website", WebsiteEvent.class, (request, original) -> {
this.recordWebsiteEvent(request);
return HttpServer.nothing;
});
}
private static @Nullable String plainText(@Nullable Component component) {
if(component == null) return null;
String text = PlainTextComponentSerializer.plainText().serialize(component)
.strip()
.replaceAll("\\s*\\n\\s*", " | ");
return text.length() > MAX_REASON_LENGTH ? text.substring(0, MAX_REASON_LENGTH) : text;
}
@Override
protected @NotNull List<Listener> listeners() {
return List.of(new LoginLogListener());
}
}
@@ -0,0 +1,34 @@
package eu.mhsl.craftattack.spawn.common.appliances.tooling.loginLog;
import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.player.AsyncPlayerPreLoginEvent;
import org.bukkit.event.player.PlayerLoginEvent;
/**
* Listens on MONITOR priority, so the final result of all other checks (whitelist, bans, maintenance, ...) is recorded.
*/
class LoginLogListener extends ApplianceListener<LoginLog> {
@EventHandler(priority = EventPriority.MONITOR)
public void onPreLogin(AsyncPlayerPreLoginEvent event) {
this.getAppliance().onPreLogin(
event.getUniqueId(),
event.getName(),
event.getAddress(),
event.getLoginResult().name(),
event.getLoginResult() == AsyncPlayerPreLoginEvent.Result.ALLOWED ? null : event.kickMessage()
);
}
@EventHandler(priority = EventPriority.MONITOR)
public void onLogin(PlayerLoginEvent event) {
this.getAppliance().onLogin(
event.getPlayer().getUniqueId(),
event.getPlayer().getName(),
event.getAddress(),
event.getResult().name(),
event.getResult() == PlayerLoginEvent.Result.ALLOWED ? null : event.kickMessage()
);
}
}
@@ -0,0 +1,15 @@
-- Every login attempt (allowed or not) with the player's IP, basis for detecting ban evasion.
CREATE TABLE login_attempts (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
at DATETIME(3) NOT NULL COMMENT 'UTC',
uuid CHAR(36) NOT NULL,
name VARCHAR(64) NOT NULL,
ip VARCHAR(45) NOT NULL,
bedrock BOOLEAN NOT NULL,
stage VARCHAR(16) NOT NULL COMMENT 'PRE_LOGIN or LOGIN',
result VARCHAR(32) NOT NULL COMMENT 'Bukkit login result, or ABORTED',
reason VARCHAR(255) NULL COMMENT 'kick message as plain text',
KEY idx_at (at),
KEY idx_uuid (uuid),
KEY idx_ip (ip)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -0,0 +1,6 @@
-- Connection key per attempt: the IPv4 address or the IPv6 /64 prefix. Groups accounts by internet connection.
ALTER TABLE login_attempts
ADD COLUMN IF NOT EXISTS network VARCHAR(45) NULL COMMENT 'IPv4 address or IPv6 /64 prefix, NULL if unknown' AFTER ip,
ADD KEY IF NOT EXISTS idx_network_at (network, at);
UPDATE login_attempts SET network = ip WHERE ip REGEXP '^[0-9]{1,3}([.][0-9]{1,3}){3}$';
@@ -0,0 +1,5 @@
-- Latest name per account and account per name, used for every name shown in the ban evasion dialogs.
ALTER TABLE login_attempts
ADD KEY IF NOT EXISTS idx_uuid_at (uuid, at),
ADD KEY IF NOT EXISTS idx_name_at (name, at),
DROP KEY IF EXISTS idx_uuid;
@@ -4,6 +4,7 @@ import eu.mhsl.craftattack.spawn.core.api.client.RepositoryLoader;
import eu.mhsl.craftattack.spawn.core.api.server.HttpServer;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import eu.mhsl.craftattack.spawn.core.config.Configuration;
import eu.mhsl.craftattack.spawn.core.database.Database;
import org.bukkit.Bukkit;
import org.bukkit.event.HandlerList;
import org.bukkit.plugin.java.JavaPlugin;
@@ -23,6 +24,7 @@ public final class Main extends JavaPlugin {
private List<Appliance> appliances;
private RepositoryLoader repositoryLoader;
private Database database;
private Reflections reflections;
@Override
@@ -42,6 +44,8 @@ public final class Main extends JavaPlugin {
Configuration.readConfig();
List<String> disabledAppliances = Configuration.pluginConfig.getStringList("disabledAppliances");
this.database = new Database(Configuration.cfg.getConfigurationSection("database"));
Main.logger().info("Initializing reflections...");
this.reflections = new Reflections(projectPackage);
@@ -94,6 +98,8 @@ public final class Main extends JavaPlugin {
HandlerList.unregisterAll(this);
Bukkit.getScheduler().cancelTasks(this);
Main.logger().info("Disabled " + this.appliances.size() + " appliances!");
if(this.database != null) this.database.close();
}
public Appliance restartAppliance(Class<? extends Appliance> applianceClass) {
@@ -138,6 +144,10 @@ public final class Main extends JavaPlugin {
return this.appliances;
}
public Database getDatabase() {
return this.database;
}
public RepositoryLoader getRepositoryLoader() {
return this.repositoryLoader;
}
@@ -10,6 +10,7 @@ import java.net.URISyntaxException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
import java.util.function.Consumer;
@@ -48,6 +49,19 @@ public abstract class HttpRepository extends Repository {
return this.execute(request, outputType, data);
}
/**
* Like {@link #post(String, Object, Class)}, but the request fails with an exception once the timeout has passed.
*/
protected <TInput, TOutput> ReqResp<TOutput> post(String command, Duration timeout, TInput data, Class<TOutput> outputType) {
HttpRequest request = this.getRequestBuilder(this.getUri(command, parameters -> {
}))
.timeout(timeout)
.POST(HttpRequest.BodyPublishers.ofString(this.gson.toJson(data)))
.build();
return this.execute(request, outputType, data);
}
protected <TInput, TOutput> ReqResp<TOutput> put(String command, TInput data, Class<TOutput> outputType) {
return this.put(command, parameters -> {
}, data, outputType);
@@ -74,6 +88,19 @@ public abstract class HttpRepository extends Repository {
return this.execute(request, outputType, null);
}
/**
* Like {@link #get(String, Class)}, but the request fails with an exception once the timeout has passed.
*/
protected <TOutput> ReqResp<TOutput> get(String command, Duration timeout, Class<TOutput> outputType) {
HttpRequest request = this.getRequestBuilder(this.getUri(command, parameters -> {
}))
.timeout(timeout)
.GET()
.build();
return this.execute(request, outputType, null);
}
private URI getUri(String command, Consumer<URIBuilder> parameters) {
try {
URIBuilder builder = new URIBuilder(this.basePath + "/" + command);
@@ -103,12 +130,19 @@ public abstract class HttpRepository extends Repository {
return builder;
}
/**
* Whether request bodies are written to the log. Turn off for large or sensitive payloads.
*/
protected boolean logRequestData() {
return true;
}
private <TResponse> ReqResp<TResponse> execute(HttpRequest request, Class<TResponse> clazz, Object original) {
ReqResp<String> rawResponse = this.sendHttp(request);
Main.logger().info(String.format(
"Request: %s\nRequest-Data: %s\nResponse: %s",
request,
this.gson.toJson(original),
this.logRequestData() ? this.gson.toJson(original) : "<not logged>",
rawResponse
));
return new ReqResp<>(rawResponse.status(), this.gson.fromJson(rawResponse.data(), clazz));
@@ -4,6 +4,7 @@ import com.google.gson.Gson;
import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.api.server.hooks.HttpHook;
import eu.mhsl.craftattack.spawn.core.appliance.Appliance;
import eu.mhsl.craftattack.spawn.core.config.Configuration;
import org.bukkit.configuration.ConfigurationSection;
import spark.Request;
import spark.Spark;
@@ -19,7 +20,7 @@ public class HttpServer {
public static Object nothing = null;
public HttpServer() {
Spark.port(8080);
Spark.port(Configuration.pluginConfig.getInt("httpPort", 8080));
Spark.get("/ping", (request, response) -> System.currentTimeMillis());
@@ -79,7 +80,7 @@ public class HttpServer {
try {
response = new Response(Response.Status.SUCCESS, null, exec.get());
} catch(Exception e) {
response = new Response(Response.Status.FAILURE, e, null);
response = new Response(Response.Status.FAILURE, e.getMessage(), null);
}
return HttpServer.this.gson.toJson(response);
}
@@ -4,6 +4,7 @@ import eu.mhsl.craftattack.spawn.core.Main;
import eu.mhsl.craftattack.spawn.core.api.client.Repository;
import eu.mhsl.craftattack.spawn.core.api.server.HttpServer;
import eu.mhsl.craftattack.spawn.core.config.Configuration;
import eu.mhsl.craftattack.spawn.core.database.Migrations;
import org.bukkit.Bukkit;
import org.bukkit.command.PluginCommand;
import org.bukkit.configuration.ConfigurationSection;
@@ -107,6 +108,7 @@ public abstract class Appliance {
}
public void enableSequence(JavaPlugin plugin) {
Migrations.apply(Main.instance().getDatabase(), this.migrationsName());
this.onEnable();
this.initialize(plugin);
}
@@ -116,6 +118,14 @@ public abstract class Appliance {
this.destruct(plugin);
}
/**
* Name of the folder containing this appliance's SQL migrations: {@code migrations/<name>/} in the resources.
*/
private String migrationsName() {
String name = this.getClass().getSimpleName();
return Character.toLowerCase(name.charAt(0)) + name.substring(1);
}
protected <T extends Appliance> T queryAppliance(Class<T> clazz) {
return Main.instance().getAppliance(clazz);
}
@@ -129,6 +139,7 @@ public abstract class Appliance {
PluginCommand command = this.createPluginCommand(name, plugin);
command.setExecutor(executor);
command.setTabCompleter(executor);
if(executor.permission() != null) command.setPermission(executor.permission());
plugin.getServer().getCommandMap().register(plugin.getName(), command);
} catch(Exception e) {
throw new RuntimeException(String.format("Failed to register command '%s'", name), e);
@@ -30,6 +30,14 @@ public abstract class ApplianceCommand<T extends Appliance> extends CachedApplia
this.errorMessage = errorMessage;
}
/**
* Permission needed to see and run the command. Paper leaves the command out of the command tree
* of players without it, so for them it does not exist. Null means everyone.
*/
public @Nullable String permission() {
return null;
}
@Override
public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
try {
@@ -0,0 +1,145 @@
package eu.mhsl.craftattack.spawn.core.database;
import eu.mhsl.craftattack.spawn.core.Main;
import org.bukkit.configuration.ConfigurationSection;
import org.jetbrains.annotations.Nullable;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
/**
* Shared MariaDB access for all appliances. Configured in the "database" section of the config.
* All statements run on a single background thread in submission order, so callers (e.g. login events)
* are never blocked. Without configuration the database is unavailable and all calls are ignored.
*/
public class Database {
@FunctionalInterface
public interface SqlAction {
void run(Connection connection) throws SQLException;
}
@FunctionalInterface
public interface SqlQuery<T> {
T run(Connection connection) throws SQLException;
}
private final @Nullable String jdbcUrl;
private final String user;
private final String password;
private final ThreadPoolExecutor executor = new ThreadPoolExecutor(
1, 1, 0, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(1000),
runnable -> new Thread(runnable, "Spawn-Database"),
new ThreadPoolExecutor.AbortPolicy()
);
private @Nullable Connection connection;
public Database(@Nullable ConfigurationSection config) {
String host = config == null ? "" : config.getString("host", "");
if(host.isBlank()) {
this.jdbcUrl = null;
this.user = "";
this.password = "";
Main.logger().info("No database configured, database features are disabled");
return;
}
this.jdbcUrl = String.format(
"jdbc:mysql://%s:%d/%s?connectTimeout=3000&socketTimeout=10000&allowMultiQueries=true",
host,
config.getInt("port", 3306),
config.getString("name", "craftattack")
);
this.user = config.getString("user", "");
this.password = config.getString("password", "");
}
public boolean isAvailable() {
return this.jdbcUrl != null;
}
/**
* Runs the action in the background without waiting for it. Errors are logged.
*/
public void execute(String description, SqlAction action) {
if(!this.isAvailable()) return;
try {
this.executor.execute(() -> {
try {
action.run(this.getConnection());
} catch(SQLException e) {
this.handleError(description, e);
} catch(RuntimeException e) {
Main.logger().log(Level.WARNING, String.format("Database: %s failed", description), e);
}
});
} catch(RejectedExecutionException e) {
Main.logger().warning("Database queue full, dropping statement");
}
}
/**
* Runs the query in the background. The future fails if the database is unavailable or the query fails.
*/
public <T> CompletableFuture<T> query(SqlQuery<T> query) {
CompletableFuture<T> future = new CompletableFuture<>();
if(!this.isAvailable()) {
future.completeExceptionally(new IllegalStateException("No database configured"));
return future;
}
try {
this.executor.execute(() -> {
try {
future.complete(query.run(this.getConnection()));
} catch(SQLException e) {
this.closeConnection();
future.completeExceptionally(e);
} catch(RuntimeException e) {
future.completeExceptionally(e);
}
});
} catch(RejectedExecutionException e) {
Main.logger().warning("Database queue full, dropping statement");
future.completeExceptionally(e);
}
return future;
}
public void close() {
this.executor.shutdown();
try {
if(!this.executor.awaitTermination(5, TimeUnit.SECONDS))
Main.logger().warning("Database: pending statements were not finished before shutdown");
} catch(InterruptedException e) {
Thread.currentThread().interrupt();
}
this.closeConnection();
}
private Connection getConnection() throws SQLException {
if(this.connection == null || !this.connection.isValid(2))
this.connection = DriverManager.getConnection(this.jdbcUrl, this.user, this.password);
return this.connection;
}
private void closeConnection() {
if(this.connection == null) return;
try {
this.connection.close();
} catch(SQLException ignored) {
}
this.connection = null;
}
private void handleError(String description, SQLException e) {
Main.logger().log(Level.WARNING, String.format("Database: %s failed: %s", description, e.getMessage()));
this.closeConnection();
}
}
@@ -0,0 +1,138 @@
package eu.mhsl.craftattack.spawn.core.database;
import eu.mhsl.craftattack.spawn.core.Main;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Applies SQL migration files shipped in the plugin jar.
* Files live in {@code migrations/<appliance>/NNN_description.sql} and run in ascending order of NNN.
* Applied versions are tracked per appliance in the table {@code schema_migrations}.
*/
public class Migrations {
private static final String DIRECTORY = "migrations/";
private static final Pattern FILE_PATTERN = Pattern.compile("^([^/]+)/(\\d+)_[^/]+\\.sql$");
private static Map<String, List<Migration>> migrationsByAppliance;
private record Migration(int version, String fileName, String path) {
}
/**
* Queues all pending migrations of the appliance on the database thread,
* so they run before any statement the appliance submits afterwards.
*/
public static void apply(Database database, String appliance) {
List<Migration> migrations = findMigrations().getOrDefault(appliance, List.of());
if(migrations.isEmpty()) return;
if(!database.isAvailable()) {
Main.logger().warning(String.format("Appliance '%s' has migrations, but no database is configured", appliance));
return;
}
database.execute("migrations of " + appliance, connection -> {
createMigrationTable(connection);
Set<Integer> applied = appliedVersions(connection, appliance);
for(Migration migration : migrations) {
if(applied.contains(migration.version())) continue;
Main.logger().info(String.format("Applying migration %s/%s", appliance, migration.fileName()));
try(Statement statement = connection.createStatement()) {
statement.execute(readResource(migration.path()));
}
markApplied(connection, appliance, migration);
}
});
}
private static synchronized Map<String, List<Migration>> findMigrations() {
if(migrationsByAppliance != null) return migrationsByAppliance;
Map<String, List<Migration>> migrations = new HashMap<>();
try(JarFile jar = new JarFile(pluginJar())) {
jar.stream()
.map(JarEntry::getName)
.filter(name -> name.startsWith(DIRECTORY))
.forEach(path -> {
Matcher matcher = FILE_PATTERN.matcher(path.substring(DIRECTORY.length()));
if(!matcher.matches()) return;
String fileName = path.substring(path.lastIndexOf('/') + 1);
migrations.computeIfAbsent(matcher.group(1), appliance -> new ArrayList<>())
.add(new Migration(Integer.parseInt(matcher.group(2)), fileName, path));
});
} catch(IOException e) {
throw new IllegalStateException("Could not read migrations from plugin jar", e);
}
migrations.values().forEach(list -> list.sort(Comparator.comparingInt(Migration::version)));
migrationsByAppliance = migrations;
return migrations;
}
private static void createMigrationTable(Connection connection) throws SQLException {
try(Statement statement = connection.createStatement()) {
statement.execute("""
CREATE TABLE IF NOT EXISTS schema_migrations (
appliance VARCHAR(64) NOT NULL,
version INT NOT NULL,
file_name VARCHAR(255) NOT NULL,
applied_at DATETIME NOT NULL DEFAULT UTC_TIMESTAMP(),
PRIMARY KEY (appliance, version)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""");
}
}
private static Set<Integer> appliedVersions(Connection connection, String appliance) throws SQLException {
Set<Integer> versions = new HashSet<>();
try(PreparedStatement statement = connection.prepareStatement("SELECT version FROM schema_migrations WHERE appliance = ?")) {
statement.setString(1, appliance);
try(ResultSet result = statement.executeQuery()) {
while(result.next()) versions.add(result.getInt(1));
}
}
return versions;
}
private static void markApplied(Connection connection, String appliance, Migration migration) throws SQLException {
try(PreparedStatement statement = connection.prepareStatement("INSERT INTO schema_migrations (appliance, version, file_name) VALUES (?, ?, ?)")) {
statement.setString(1, appliance);
statement.setInt(2, migration.version());
statement.setString(3, migration.fileName());
statement.executeUpdate();
}
}
private static String readResource(String path) throws SQLException {
try(InputStream stream = Main.class.getClassLoader().getResourceAsStream(path)) {
if(stream == null) throw new SQLException("Migration file not found: " + path);
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
} catch(IOException e) {
throw new SQLException("Could not read migration file " + path, e);
}
}
private static File pluginJar() {
try {
return new File(Main.class.getProtectionDomain().getCodeSource().getLocation().toURI());
} catch(Exception e) {
throw new IllegalStateException("Could not locate plugin jar", e);
}
}
}
@@ -0,0 +1,48 @@
package eu.mhsl.craftattack.spawn.core.event;
import org.bukkit.event.Event;
import org.bukkit.event.HandlerList;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.time.Instant;
import java.util.UUID;
/**
* Fired after a login attempt was stored in login_attempts. Called on the database thread:
* handlers must return quickly and must never wait for results of the shared Database.
*/
public class LoginAttemptRecordedEvent extends Event {
private static final HandlerList HANDLERS = new HandlerList();
@Override
public @NotNull HandlerList getHandlers() {
return HANDLERS;
}
public static HandlerList getHandlerList() {
return HANDLERS;
}
public record RecordedAttempt(
long id,
Instant at,
UUID uuid,
String name,
@Nullable String network,
String stage,
String result
) {
}
private final RecordedAttempt attempt;
public LoginAttemptRecordedEvent(RecordedAttempt attempt) {
super(true);
this.attempt = attempt;
}
public RecordedAttempt getAttempt() {
return this.attempt;
}
}
@@ -0,0 +1,72 @@
package eu.mhsl.craftattack.spawn.core.util.network;
import org.jetbrains.annotations.Nullable;
import java.net.Inet4Address;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.StringJoiner;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Groups IP addresses by internet connection: an IPv4 address stands for itself,
* an IPv6 address for its /64 prefix (one prefix per household line).
*/
public final class Networks {
private static final Pattern IPV4 = Pattern.compile("^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})$");
private static final Pattern IPV6 = Pattern.compile("^[0-9a-fA-F:.]*:[0-9a-fA-F:.]*$");
private static final String IPV6_SUFFIX = "::/64";
private Networks() {
}
/**
* @return the connection key ("93.204.10.20" or "2003:e1:1f00:ab00::/64"), or null if the text is no IP address
*/
public static @Nullable String networkOf(@Nullable String ip) {
if(ip == null) return null;
String text = ip.trim();
Matcher ipv4 = IPV4.matcher(text);
if(ipv4.matches()) return ipv4Of(ipv4);
if(!IPV6.matcher(text).matches()) return null;
InetAddress address;
try {
// the text contains a colon, so it is parsed as IPv6 literal and never resolved via DNS
address = InetAddress.getByName(text);
} catch(UnknownHostException e) {
return null;
}
if(address instanceof Inet4Address) return address.getHostAddress();
byte[] bytes = address.getAddress();
return String.format("%x:%x:%x:%x%s", group(bytes, 0), group(bytes, 2), group(bytes, 4), group(bytes, 6), IPV6_SUFFIX);
}
/**
* Shortened form for display: "93.204.x.x" or "2003:e1:x:x::/64".
*/
public static String mask(String network) {
if(network.endsWith(IPV6_SUFFIX)) {
String[] groups = network.substring(0, network.length() - IPV6_SUFFIX.length()).split(":");
return groups[0] + ":" + groups[1] + ":x:x" + IPV6_SUFFIX;
}
String[] octets = network.split("\\.");
return octets.length == 4 ? octets[0] + "." + octets[1] + ".x.x" : network;
}
private static @Nullable String ipv4Of(Matcher matcher) {
StringJoiner joiner = new StringJoiner(".");
for(int i = 1; i <= 4; i++) {
int octet = Integer.parseInt(matcher.group(i));
if(octet > 255) return null;
joiner.add(Integer.toString(octet));
}
return joiner.toString();
}
private static int group(byte[] bytes, int offset) {
return ((bytes[offset] & 0xff) << 8) | (bytes[offset + 1] & 0xff);
}
}
@@ -5,21 +5,32 @@ import org.geysermc.cumulus.form.SimpleForm;
import org.geysermc.floodgate.api.FloodgateApi;
import org.geysermc.floodgate.api.player.FloodgatePlayer;
import java.util.UUID;
import java.util.function.Consumer;
public class Floodgate {
private static final FloodgateApi instance = FloodgateApi.getInstance();
private static FloodgateApi api() {
return FloodgateApi.getInstance();
}
public static boolean isBedrock(Player p) {
return instance.isFloodgatePlayer(p.getUniqueId());
return api().isFloodgatePlayer(p.getUniqueId());
}
/**
* Whether the UUID has the format Floodgate uses for Bedrock players (most significant bits zero).
* Works without the player being online and without the Floodgate API.
*/
public static boolean isFloodgateUuid(UUID uuid) {
return uuid.getMostSignificantBits() == 0;
}
public static FloodgatePlayer getBedrockPlayer(Player p) {
return instance.getPlayer(p.getUniqueId());
return api().getPlayer(p.getUniqueId());
}
public static void runBedrockOnly(Player p, Consumer<FloodgatePlayer> callback) {
if(isBedrock(p)) callback.accept(instance.getPlayer(p.getUniqueId()));
if(isBedrock(p)) callback.accept(api().getPlayer(p.getUniqueId()));
}
public static void runJavaOnly(Player p, Consumer<Player> callback) {
+54
View File
@@ -1,5 +1,6 @@
plugin:
httpServerEnabled: true
httpPort: 8080
disabledAppliances:
- NameOfApplianceClass
@@ -7,6 +8,22 @@ api:
secret: secret
baseurl: https://mhsl.eu/craftattack/api/
database:
host:
port: 3306
name: craftattack
user:
password:
loginLog:
retentionMonths: 16
banEvasion:
windowDays: 14
rejectionWindowHours: 24
banCacheMinutes: 5
retentionMonths: 16
worldMuseum:
uuid:
connect-server-name: worldmuseum
@@ -18,6 +35,43 @@ worldMuseum:
yaw: 0
pitch: 0
arenaPortal:
api: http://127.0.0.1:8081/
connect-server-name: arena
uuid:
villagerLocation:
world: world
x: 0
y: 0
z: 0
yaw: 0
pitch: 0
arena:
connect-server-name: craftattack
fightMinutes: 10
countdownSeconds: 5
resetBlocksPerTick: 20000
uuid:
villagerLocation:
world: arena
x: 0
y: 64
z: 0
yaw: 0
pitch: 0
tribuneSpawn:
world: arena
x: 0.5
y: 64
z: 0.5
yaw: 0
pitch: 0
# alles Weitere setzt /arenasetup auf dem Arena-Server
syncRegion: {}
pads: {}
arenas: {}
adminMarker:
permission: admin
color: AQUA
@@ -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,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,124 @@
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.ArrayList;
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));
List<ItemStack> contents = new ArrayList<>(List.of(player.getInventory().getContents()));
String items = Base64.getEncoder().encodeToString(ItemStack.serializeItemsAsBytes(contents));
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));
}
}
@@ -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;
}
}
@@ -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;
+11
View File
@@ -22,3 +22,14 @@ tasks.register('deployCraftAttackPlugin', Copy) {
into file('path') // path to plugins folder
rename { fileName -> "craftattack.jar" }
}
tasks.register("uploadArenaPlugin") {
dependsOn(":arena:shadowJar")
doLast {
def jarFile = project(":arena").tasks.named("shadowJar").get().outputs.files.singleFile
providers.exec {
commandLine "scp", "-4", "-P", "22", jarFile.absolutePath, "user@host:path/arena.jar"
}.result.get()
}
}
+1
View File
@@ -5,4 +5,5 @@ include 'craftattack'
include 'common'
include 'varo'
include 'event'
include 'arena'