diff --git a/arena/build.gradle b/arena/build.gradle new file mode 100644 index 0000000..88090e6 --- /dev/null +++ b/arena/build.gradle @@ -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() +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Arena.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Arena.java new file mode 100644 index 0000000..a9ceaa0 --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Arena.java @@ -0,0 +1,401 @@ +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.Entity; +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 states = new HashMap<>(); + private ArenaWorlds worlds; + private ArenaSettings settings; + private Teams teams; + private ArenaResetter resetter; + private final List 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.removeStrayVillagers(); + 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 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 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); + } + + /** + * Removes old copies of the return villager, for example after its position was changed in the config by hand. + */ + private void removeStrayVillagers() { + Region region = this.settings.syncRegion(); + if(region == null || this.villager == null) return; + UUID current = this.villager.getUniqueId(); + for(int cx = region.minChunkX(); cx <= region.maxChunkX(); cx++) { + for(int cz = region.minChunkZ(); cz <= region.maxChunkZ(); cz++) { + for(Entity entity : this.worlds.play().getChunkAt(cx, cz).getEntities()) { + if(!(entity instanceof Villager) || entity.getUniqueId().equals(current)) continue; + if(!entity.getScoreboardTags().contains(Displays.MANAGED_TAG)) continue; + entity.remove(); + Main.logger().info("Arena: removed a stray return villager at " + entity.getLocation().toVector()); + } + } + } + } + + /** + * 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(List> teams, FightMode mode, Map kits) { + ArenaSlot slot = this.slots.stream().filter(s -> s.state == ArenaSlot.State.FREE).findFirst().orElse(null); + if(slot == null) { + teams.forEach(team -> team.forEach(this::toTribune)); + return; + } + new Fight(this, slot, teams, mode, kits).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 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 && fight.isAlive(player.getUniqueId())) 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.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> commands() { + return List.of(new BuildCommand(), new ArenaSetupCommand(), new CraftAttackCommand()); + } + + @Override + @NotNull + protected List listeners() { + UUID villager = Objects.requireNonNull(this.villager).getUniqueId(); + return List.of( + new PlayerConnectionListener(), + new LockListener(), + new FightListener(), + new WorldProtectionListener(), + new ModeMenuListener(), + new NoProgressListener(), + new PlayerInteractAtEntityEventListener(villager, event -> this.sendToCraftAttack(event.getPlayer())), + new DismissInventoryOpenFromHolder(villager) + ); + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ArenaResetter.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ArenaResetter.java new file mode 100644 index 0000000..23ac8d3 --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ArenaResetter.java @@ -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 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 blockEntities(World world, Region region, boolean snapshot) { + List 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(); + } + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ArenaSettings.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ArenaSettings.java new file mode 100644 index 0000000..41876d8 --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ArenaSettings.java @@ -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 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 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 a = section.getIntegerList("pos1"); + List 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; + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ArenaSetupCommand.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ArenaSetupCommand.java new file mode 100644 index 0000000..26d1eaa --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ArenaSetupCommand.java @@ -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 { + private static final String USAGE = String.join("\n", + "/arenasetup tribune", + "/arenasetup villager", + "/arenasetup pad ", + "/arenasetup arena ", + "/arenasetup syncregion ", + "/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 onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) { + List 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); + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ArenaSlot.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ArenaSlot.java new file mode 100644 index 0000000..897ffdb --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ArenaSlot.java @@ -0,0 +1,45 @@ +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; + // seen from the tribune across half an arena, so twice the size + this.display = Displays.spawn(settings.displayLocation(world), 2f); + } + + 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(); + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ArenaWorlds.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ArenaWorlds.java new file mode 100644 index 0000000..cccc52f --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ArenaWorlds.java @@ -0,0 +1,90 @@ +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.setGameRule(GameRules.SHOW_ADVANCEMENT_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); + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/BuildCommand.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/BuildCommand.java new file mode 100644 index 0000000..725b9d1 --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/BuildCommand.java @@ -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 { + 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()); + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/CraftAttackCommand.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/CraftAttackCommand.java new file mode 100644 index 0000000..53a9fc1 --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/CraftAttackCommand.java @@ -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 { + CraftAttackCommand() { + super("craftattack"); + } + + @Override + protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) { + this.getAppliance().sendToCraftAttack(this.getPlayer()); + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Displays.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Displays.java new file mode 100644 index 0000000..0d4fd4c --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Displays.java @@ -0,0 +1,41 @@ +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; +import org.bukkit.util.Transformation; + +/** + * 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) { + return spawn(location, 1f); + } + + static TextDisplay spawn(Location location, float scale) { + // 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); + if(scale != 1f) { + Transformation transformation = display.getTransformation(); + transformation.getScale().set(scale); + display.setTransformation(transformation); + } + }); + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Fight.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Fight.java new file mode 100644 index 0000000..87aef79 --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Fight.java @@ -0,0 +1,405 @@ +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.Region; +import eu.mhsl.craftattack.spawn.core.Main; +import net.kyori.adventure.bossbar.BossBar; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.JoinConfiguration; +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.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +/** + * A running fight in one arena between team 0 (red pad, spawn 1) and team 1 (blue pad, spawn 2). + * A team loses once none of its players is left in the arena. + */ +final class Fight { + private static final int CELEBRATION_TICKS = 60; + private static final double SPECTATOR_RANGE = 32; + private static final double SPAWN_SPREAD = 2; + + private final Arena arena; + private final ArenaSlot slot; + private final List> teams; + private final Map names = new HashMap<>(); + private final List> alive; + private final FightMode mode; + private final Map kits; + private final BossBar bossBar; + private final Set bossBarViewers = new HashSet<>(); + private int secondsLeft; + private boolean over; + private @Nullable BukkitTask timer; + + Fight(Arena arena, ArenaSlot slot, List> teams, FightMode mode, Map kits) { + this.arena = arena; + this.slot = slot; + this.teams = teams.stream().map(team -> team.stream().map(Player::getUniqueId).toList()).toList(); + teams.forEach(team -> team.forEach(player -> this.names.put(player.getUniqueId(), player.getName()))); + this.alive = this.teams.stream().map(team -> (Set) new LinkedHashSet<>(team)).toList(); + this.mode = mode; + this.kits = kits; + 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; + + for(int team = 0; team < 2; team++) { + List members = this.teams.get(team); + for(int i = 0; i < members.size(); i++) { + Player player = Bukkit.getPlayer(members.get(i)); + if(player == null) continue; + this.arena.setState(player, Arena.PlayerState.FIGHT); + this.arena.teams().join(player, Teams.fight(team)); + player.closeInventory(); + player.teleport(this.spawnFor(team, i, members.size())); + Arena.restore(player, GameMode.SURVIVAL); + InventoryStore.apply(player, this.kits.getOrDefault(player.getUniqueId(), player.getUniqueId())); + + player.showTitle(Title.title( + Component.text("Kampf!", NamedTextColor.GOLD), + Component.text("gegen ", NamedTextColor.GRAY).append(this.teamNames(1 - team)), + Title.Times.times(Duration.ZERO, Duration.ofMillis(1500), Duration.ofMillis(500)) + )); + Style.play(player, Sound.EVENT_RAID_HORN, 1.2f); + } + } + // players who left during the countdown never arrived + for(int team = 0; team < 2; team++) this.alive.get(team).removeIf(uuid -> Bukkit.getPlayer(uuid) == null); + + Bukkit.broadcast(Style.message( + this.teamNames(0).append(Component.text(" gegen ", NamedTextColor.GRAY)).append(this.teamNames(1)) + .append(Component.text(" in Arena " + this.slot.settings.name() + ". Modus: " + this.mode.displayName(this.isTeamFight()), NamedTextColor.GRAY)) + )); + this.updateBossBar(); + this.timer = Bukkit.getScheduler().runTaskTimer(Main.instance(), this::tickSecond, 20, 20); + if(this.alive.get(0).isEmpty() || this.alive.get(1).isEmpty()) this.checkEnd(); + } + + /** + * Team members stand in a small circle around the spawn, facing the same way. + */ + private Location spawnFor(int team, int index, int count) { + Location center = this.slot.settings.spawns()[team]; + if(count == 1) return center; + double angle = 2 * Math.PI * index / count; + Location spot = center.clone().add(Math.cos(angle) * SPAWN_SPREAD, 0, Math.sin(angle) * SPAWN_SPREAD); + boolean free = spot.getBlock().isPassable() && spot.clone().add(0, 1, 0).getBlock().isPassable() + && !spot.clone().subtract(0, 1, 0).getBlock().isPassable(); + return free ? spot : center; + } + + private boolean isTeamFight() { + return this.teams.get(0).size() > 1 || this.teams.get(1).size() > 1; + } + + private Component teamNames(int team) { + return Component.join( + JoinConfiguration.separator(Component.text(", ", NamedTextColor.GRAY)), + this.teams.get(team).stream().map(uuid -> Component.text(this.names.get(uuid), Style.PAD_TEXT_COLORS[team])).toList() + ); + } + + /** + * Short form for bossbar and displays: names in a duel, "Rot 2/3" in a team fight. + */ + Component headline() { + if(!this.isTeamFight()) { + return this.teamNames(0).append(Component.text(" gegen ", NamedTextColor.GRAY)).append(this.teamNames(1)); + } + return Component.text("Rot " + this.alive.get(0).size() + "/" + this.teams.get(0).size(), Style.PAD_TEXT_COLORS[0]) + .append(Component.text(" gegen ", NamedTextColor.GRAY)) + .append(Component.text("Blau " + this.alive.get(1).size() + "/" + this.teams.get(1).size(), Style.PAD_TEXT_COLORS[1])); + } + + int secondsLeft() { + return this.secondsLeft; + } + + boolean isOver() { + return this.over; + } + + boolean involves(UUID player) { + return this.teams.get(0).contains(player) || this.teams.get(1).contains(player); + } + + boolean isAlive(UUID player) { + return this.alive.get(0).contains(player) || this.alive.get(1).contains(player); + } + + boolean sameTeam(UUID a, UUID b) { + int team = this.teamOf(a); + return team != -1 && team == this.teamOf(b); + } + + Region region() { + return this.slot.settings.region(); + } + + ArenaSlot slot() { + return this.slot; + } + + private void tickSecond() { + if(this.over) return; + if(--this.secondsLeft <= 0) { + this.timeUp(); + return; + } + if(this.secondsLeft == 60 || this.secondsLeft == 10) { + this.alivePlayers().forEach(player -> { + player.sendActionBar(Component.text("Noch " + this.secondsLeft + " Sekunden", NamedTextColor.YELLOW)); + Style.play(player, Sound.BLOCK_NOTE_BLOCK_HAT, 0.8f); + }); + } + this.updateBossBar(); + } + + /** + * More survivors win, then more hearts left, otherwise a draw. + */ + private void timeUp() { + int red = this.alive.get(0).size(); + int blue = this.alive.get(1).size(); + if(red != blue) { + this.finish(red > blue ? 0 : 1, Component.text("(Zeit abgelaufen, mehr Überlebende)", NamedTextColor.GRAY)); + return; + } + double redHealth = this.health(0); + double blueHealth = this.health(1); + if(Math.abs(redHealth - blueHealth) >= 0.5) { + this.finish(redHealth > blueHealth ? 0 : 1, Component.text("(Zeit abgelaufen, mehr Herzen übrig)", NamedTextColor.GRAY)); + return; + } + this.finish(-1, Component.text("Die Zeit ist abgelaufen.", NamedTextColor.GRAY)); + } + + private double health(int team) { + return this.alive.get(team).stream() + .map(Bukkit::getPlayer) + .filter(player -> player != null) + .mapToDouble(player -> player.getHealth() + player.getAbsorptionAmount()) + .sum(); + } + + 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 viewers = new HashSet<>(); + for(Player player : this.arena.worlds().play().getPlayers()) { + if(this.isAlive(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; + Region 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: out of the fight and back to the tribune. + */ + void defeated(Player player) { + this.knockOut(player, Component.empty(), "ist ausgeschieden"); + } + + /** + * The player got out of the box, for example by glitching through a wall with an ender pearl. + */ + void leftBox(Player player) { + this.knockOut(player, Component.text("Du hast die Arena verlassen", NamedTextColor.GRAY), "hat die Arena verlassen"); + } + + private void knockOut(Player player, Component subtitle, String what) { + if(this.over || !this.eliminate(player.getUniqueId())) return; + player.showTitle(Title.title(Component.text("Ausgeschieden", NamedTextColor.RED), subtitle)); + Style.play(player, Sound.BLOCK_BEACON_DEACTIVATE, 1f); + this.arena.toTribune(player); + this.announceElimination(player.getUniqueId(), what); + this.checkEnd(); + } + + /** + * The player left the server during the fight. + */ + void forfeit(UUID quitter) { + if(this.over || !this.eliminate(quitter)) return; + this.announceElimination(quitter, "hat die Arena verlassen"); + this.checkEnd(); + } + + private boolean eliminate(UUID player) { + int team = this.teamOf(player); + return team != -1 && this.alive.get(team).remove(player); + } + + private void announceElimination(UUID player, String what) { + if(!this.isTeamFight()) return; + int team = this.teamOf(player); + Component message = Component.text(this.names.get(player), Style.PAD_TEXT_COLORS[team]) + .append(Component.text(" " + what + ". ", NamedTextColor.GRAY)) + .append(this.headline()); + this.alivePlayers().forEach(fighter -> fighter.sendActionBar(message)); + this.updateBossBar(); + } + + private void checkEnd() { + if(this.over) return; + boolean redOut = this.alive.get(0).isEmpty(); + boolean blueOut = this.alive.get(1).isEmpty(); + if(redOut && blueOut) this.finish(-1, Component.text("Niemand ist mehr übrig.", NamedTextColor.GRAY)); + else if(redOut) this.finish(1, this.detail(1)); + else if(blueOut) this.finish(0, this.detail(0)); + } + + private Component detail(int winner) { + String hearts = Style.hearts(this.health(winner)); + String survivors = this.isTeamFight() ? this.alive.get(winner).size() + " übrig, " : ""; + return Component.text("(" + this.mode.displayName(this.isTeamFight()) + ", " + survivors + hearts + " ❤)", 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.alivePlayers().forEach(this.arena::toTribune); + this.slot.fight = null; + } + + private void finish(int winner, Component detail) { + this.over = true; + this.stopTimer(); + + if(winner == -1) { + Bukkit.broadcast(Style.message( + this.teamNames(0).append(Component.text(" und ", NamedTextColor.GRAY)).append(this.teamNames(1)) + .append(Component.text(" trennen sich unentschieden. ", NamedTextColor.GRAY)).append(detail) + )); + this.alivePlayers().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 loser = 1 - winner; + boolean plural = this.teams.get(winner).size() > 1; + Bukkit.broadcast(Style.message( + this.teamNames(winner) + .append(Component.text(plural ? " gewinnen gegen " : " hat gegen ", NamedTextColor.GRAY)) + .append(this.teamNames(loser)) + .append(Component.text(plural ? " " : " gewonnen ", NamedTextColor.GRAY)) + .append(detail) + )); + + for(UUID uuid : this.teams.get(loser)) { + Player player = Bukkit.getPlayer(uuid); + if(player == null) continue; + player.showTitle(Title.title(Component.text("Verloren", NamedTextColor.RED), Component.empty())); + if(this.alive.get(loser).contains(uuid)) this.arena.toTribune(player); + } + for(UUID uuid : this.teams.get(winner)) { + Player player = Bukkit.getPlayer(uuid); + if(player == null) continue; + player.showTitle(Title.title( + Component.text("Gewonnen!", NamedTextColor.GOLD), + Component.text("gegen ", NamedTextColor.GRAY).append(this.teamNames(loser)) + )); + Style.play(player, Sound.UI_TOAST_CHALLENGE_COMPLETE, 1f); + if(this.alive.get(winner).contains(uuid)) this.celebrate(player.getLocation(), Style.PAD_PARTICLE_COLORS[winner]); + } + 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(Player player : this.alivePlayers()) { + if(this.arena.fightOf(player) == this && this.arena.state(player) == Arena.PlayerState.FIGHT) 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 teamOf(UUID player) { + if(this.teams.get(0).contains(player)) return 0; + if(this.teams.get(1).contains(player)) return 1; + return -1; + } + + private List alivePlayers() { + List players = new ArrayList<>(); + for(Set team : this.alive) { + for(UUID uuid : team) { + Player player = Bukkit.getPlayer(uuid); + if(player != null) players.add(player); + } + } + return players; + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/FightListener.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/FightListener.java new file mode 100644 index 0000000..fba1bc7 --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/FightListener.java @@ -0,0 +1,111 @@ +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.PlayerMoveEvent; +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 { + private boolean isFighting(Player player) { + Fight fight = this.getAppliance().fightOf(player); + return fight != null && !fight.isOver() && fight.isAlive(player.getUniqueId()); + } + + /** + * Only fighters hit each other, and never their own team directly. Explosions still hurt everyone. + */ + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onDamageByPlayer(EntityDamageByEntityEvent event) { + if(!(event.getEntity() instanceof Player victim)) 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 == victim) return; + Fight fight = this.getAppliance().fightOf(attacker); + if(!this.isFighting(attacker) || fight.sameTeam(attacker.getUniqueId(), victim.getUniqueId())) 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); + } + } + + /** + * Whoever ends up outside the box anyway (ender pearl through a wall or corner) loses. + */ + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onMove(PlayerMoveEvent event) { + if(!event.hasChangedBlock()) return; + Player player = event.getPlayer(); + if(this.getAppliance().state(player) != Arena.PlayerState.FIGHT || !this.isFighting(player)) return; + Fight fight = this.getAppliance().fightOf(player); + if(event.getTo().getWorld() == this.getAppliance().worlds().play() && fight.region().contains(event.getTo().getX(), event.getTo().getY(), event.getTo().getZ())) return; + fight.leftBox(player); + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/InventoryStore.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/InventoryStore.java new file mode 100644 index 0000000..61fe4ed --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/InventoryStore.java @@ -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 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); + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Lobby.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Lobby.java new file mode 100644 index 0000000..0727830 --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Lobby.java @@ -0,0 +1,410 @@ +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.JoinConfiguration; +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.List; +import java.util.Map; +import java.util.Objects; +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; + /** How long a full lobby waits for the challenger to close the options. */ + private static final int CHOOSING_SECONDS = 20; + /** Whoever keeps the options open this long without clicking anything gets sent off the pad. */ + private static final int MENU_IDLE_TICKS = 60 * 20; + + private final Arena arena; + private final Region[] regions; + private final PadLobby pads; + private final TextDisplay[] displays = new TextDisplay[PadLobby.PADS]; + private final Map lastPushSound = new HashMap<>(); + private long ticks; + private @Nullable UUID menuViewer; + private int menuIdleTicks; + + Lobby(Arena arena, Region[] regions) { + this.arena = arena; + this.regions = regions; + this.pads = new PadLobby(this, arena.settings().countdownSeconds(), CHOOSING_SECONDS); + 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.checkMenuIdle(); + this.pads.tick(this.arena.hasFreeSlot()); + + if(this.ticks % 3 == 0) this.drawParticles(); + if(this.ticks % 5 == 0) this.updateDisplays(); + } + + private void checkMenuIdle() { + if(this.menuViewer == null || ++this.menuIdleTicks < MENU_IDLE_TICKS) return; + Player player = Bukkit.getPlayer(this.menuViewer); + this.menuViewer = null; + if(player == null || !ModeMenu.isOpen(player)) return; + this.arena.toTribune(player); + player.sendMessage(Style.message("Du hast eine Minute lang nichts ausgewählt und wurdest vom Feld geschickt.")); + Style.play(player, Sound.BLOCK_NOTE_BLOCK_BASS, 0.6f); + } + + 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.isPadFull(pad)) { + 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); + if(this.pads.isWaitingForChallenger()) { + player.sendActionBar(this.choosingText()); + } else if(!player.getUniqueId().equals(this.pads.challenger())) { + player.sendActionBar(Component.text(Style.PAD_NAMES[pad] + " " + this.pads.players(pad).size() + "/" + this.pads.size(pad) + + ", Modus: " + this.modeName(), Style.PAD_TEXT_COLORS[pad])); + } + 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 handleMenuClick(Player player, ModeMenu menu, ModeMenu.Click click) { + int ownSize = menu.ownSize; + int opponentSize = menu.opponentSize; + FightMode mode = menu.mode; + this.menuIdleTicks = 0; + switch(click) { + case ModeMenu.OwnSize(int size) -> ownSize = size; + case ModeMenu.OpponentSize(int size) -> opponentSize = size; + case ModeMenu.Mode(FightMode chosen) -> mode = chosen; + case ModeMenu.Done() -> { + InteractSounds.of(player).success(); + player.closeInventory(); + player.sendActionBar(Component.text(this.pads.size(this.pads.challengerPad()) + " gegen " + this.pads.size(1 - this.pads.challengerPad()) + + ", Modus: " + this.modeName() + ". Rechtsklick öffnet die Auswahl wieder.", NamedTextColor.GOLD)); + return; + } + } + // switching to teams drops the opponent's inventory mode + if(!mode.isAllowed(ownSize, opponentSize)) mode = FightMode.OWN; + + int ownPad = this.pads.challengerPad(); + String problem = switch(this.pads.configure(player.getUniqueId(), ownSize, opponentSize, mode)) { + case OK -> null; + case NOT_ALLOWED -> "Der Countdown läuft schon"; + case OWN_TOO_SMALL -> "Auf deinem Feld stehen schon " + this.pads.players(ownPad).size(); + case OPPONENT_TOO_SMALL -> "Auf dem anderen Feld stehen schon " + this.pads.players(1 - ownPad).size(); + case INVALID -> "Das geht so nicht"; + }; + if(problem != null) { + Style.play(player, Sound.BLOCK_NOTE_BLOCK_BASS, 0.6f); + player.sendActionBar(Component.text(problem, NamedTextColor.RED)); + if(!this.pads.canConfigure(player.getUniqueId())) player.closeInventory(); + return; + } + menu.ownSize = ownSize; + menu.opponentSize = opponentSize; + menu.mode = mode; + menu.draw(); + InteractSounds.of(player).click(); + } + + /** + * Right click on the pad: the challenger opens the options again. + */ + boolean openMenu(Player player) { + if(!this.pads.canConfigure(player.getUniqueId()) || ModeMenu.isOpen(player)) return false; + int pad = this.pads.challengerPad(); + InteractSounds.of(player).open(); + ModeMenu.open(player, pad, this.pads.size(pad), this.pads.size(1 - pad), this.pads.mode()); + this.menuViewer = player.getUniqueId(); + this.menuIdleTicks = 0; + this.pads.setChoosing(player.getUniqueId(), true); + return true; + } + + /** + * The challenger closed the options: a full lobby starts counting down now. + */ + void menuClosed(Player player) { + if(player.getUniqueId().equals(this.menuViewer)) this.menuViewer = null; + this.pads.setChoosing(player.getUniqueId(), false); + } + + private Component choosingText() { + Player challenger = this.pads.challenger() == null ? null : Bukkit.getPlayer(this.pads.challenger()); + String name = challenger == null ? "Der Herausforderer" : challenger.getName(); + return Component.text(name + " wählt noch Teams und Modus", NamedTextColor.YELLOW); + } + + private boolean isTeamFight() { + return this.pads.size(0) > 1 || this.pads.size(1) > 1; + } + + private String modeName() { + return this.pads.mode().displayName(this.isTeamFight()); + } + + boolean isIdle() { + return this.pads.isIdle(); + } + + void shutdown() { + for(int pad = 0; pad < PadLobby.PADS; pad++) this.playersOn(pad).forEach(this::leave); + 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 voll", NamedTextColor.RED)); + } + + @Override + public void challengerSelected(UUID challenger, boolean first) { + Player player = Bukkit.getPlayer(challenger); + if(player == null) return; + if(!first) { + player.sendMessage(Style.message("Du führst jetzt die Herausforderung an. Rechtsklick auf dem Feld öffnet die Auswahl.")); + Style.play(player, Sound.BLOCK_NOTE_BLOCK_PLING, 1.8f); + return; + } + player.sendMessage(Style.message("Du forderst heraus. Wähle Teamgröße und Modus, Rechtsklick auf dem Feld öffnet die Auswahl wieder. Wer sich auf die Felder stellt, macht mit.")); + this.openMenu(player); + } + + @Override + public void waitingForChallenger() { + UUID challenger = this.pads.challenger(); + Player player = challenger == null ? null : Bukkit.getPlayer(challenger); + if(player != null && player.getOpenInventory().getTopInventory().getHolder(false) instanceof ModeMenu menu) { + menu.everyoneThere = true; + menu.draw(); + Style.play(player, Sound.BLOCK_NOTE_BLOCK_PLING, 1.8f); + } + for(int pad = 0; pad < PadLobby.PADS; pad++) { + this.playersOn(pad).stream() + .filter(occupant -> !occupant.getUniqueId().equals(challenger)) + .forEach(occupant -> occupant.sendActionBar(this.choosingText())); + } + } + + @Override + public void countdownStarted() { + UUID challenger = this.pads.challenger(); + Player player = challenger == null ? null : Bukkit.getPlayer(challenger); + if(player != null && ModeMenu.isOpen(player)) player.closeInventory(); + } + + @Override + public void kitAssigned(UUID uuid, UUID kitOwner) { + Player player = Bukkit.getPlayer(uuid); + if(player == null) return; + InventoryStore.apply(player, kitOwner); + if(uuid.equals(kitOwner)) return; + Player owner = Bukkit.getPlayer(kitOwner); + player.sendMessage(Style.message("Du kämpfst mit dem Inventar von " + (owner == null ? "jemand anderem" : owner.getName()) + ". Verlasse das Feld, um abzubrechen.")); + Style.play(player, Sound.ITEM_ARMOR_EQUIP_NETHERITE, 1f); + } + + @Override + public void countdown(int secondsLeft) { + for(int pad = 0; pad < PadLobby.PADS; pad++) { + Component opponents = this.namesOn(1 - pad); + for(Player player : this.playersOn(pad)) { + player.showTitle(Title.title( + Component.text(secondsLeft, NamedTextColor.GOLD), + Component.text("Kampf gegen ", NamedTextColor.GRAY).append(opponents), + 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++) { + for(Player player : this.playersOn(pad)) { + player.clearTitle(); + player.sendActionBar(Component.text("Abgebrochen", NamedTextColor.RED)); + Style.play(player, Sound.BLOCK_NOTE_BLOCK_BASS, 0.5f); + } + } + } + + @Override + public void start(List> teams, UUID challenger, FightMode mode, Map kits) { + List> players = teams.stream() + .map(team -> team.stream().map(Bukkit::getPlayer).filter(Objects::nonNull).toList()) + .toList(); + if(players.get(0).isEmpty() || players.get(1).isEmpty()) { + players.forEach(team -> team.forEach(this.arena::toTribune)); + return; + } + this.arena.startFight(players, mode, kits); + } + + private List playersOn(int pad) { + return this.pads.players(pad).stream().map(Bukkit::getPlayer).filter(Objects::nonNull).toList(); + } + + private Component namesOn(int pad) { + return Component.join( + JoinConfiguration.separator(Component.text(", ", NamedTextColor.GRAY)), + this.playersOn(pad).stream().map(player -> Component.text(player.getName(), Style.PAD_TEXT_COLORS[pad])).toList() + ); + } + + private void updateDisplays() { + boolean waiting = this.pads.isFull() && !this.arena.hasFreeSlot(); + Player challenger = this.pads.challenger() == null ? null : Bukkit.getPlayer(this.pads.challenger()); + int challengerPad = this.pads.challengerPad(); + String modeText = "Modus: " + this.modeName(); + + for(int pad = 0; pad < PadLobby.PADS; pad++) { + List players = this.playersOn(pad); + Component title = Component.text(Style.PAD_NAMES[pad], Style.PAD_TEXT_COLORS[pad]); + if(challenger != null) title = title.append(Component.text(" " + players.size() + "/" + this.pads.size(pad), NamedTextColor.WHITE)); + + Component body; + if(waiting) { + body = this.namesOn(pad).appendNewline() + .append(Component.text("Alle Arenen belegt, gleich geht es los", NamedTextColor.GRAY)); + } else if(this.pads.isWaitingForChallenger() && challenger != null) { + body = this.namesOn(pad).appendNewline() + .append(Component.text(challenger.getName() + " wählt noch", NamedTextColor.YELLOW)); + } else if(this.pads.isCountingDown()) { + body = this.namesOn(pad).appendNewline() + .append(Component.text("Kampf beginnt in " + this.pads.secondsLeft(), NamedTextColor.GOLD)); + } else if(challenger == null) { + body = !this.arena.hasFreeSlot() + ? Component.text("Alle Arenen belegt", NamedTextColor.GRAY).appendNewline() + .append(Component.text("Du kannst dich trotzdem anstellen", NamedTextColor.WHITE)) + : Component.text("Stell dich hierhin,", NamedTextColor.WHITE).appendNewline() + .append(Component.text("um herauszufordern", NamedTextColor.WHITE)); + } else if(pad == challengerPad) { + body = this.namesOn(pad).appendNewline() + .append(Component.text(players.size() == 1 ? "fordert heraus" : "fordern heraus", NamedTextColor.GRAY)).appendNewline() + .append(Component.text(modeText, NamedTextColor.WHITE)); + } else { + Component who = players.isEmpty() + ? Component.text("Stell dich hierhin, um gegen", NamedTextColor.WHITE).appendNewline() + .append(Component.text(challenger.getName(), Style.PAD_TEXT_COLORS[challengerPad])) + .append(Component.text(" zu kämpfen", NamedTextColor.WHITE)) + : this.namesOn(pad).appendNewline().append(Component.text("nehmen an", NamedTextColor.GRAY)); + body = who.appendNewline().append(Component.text(modeText, NamedTextColor.GRAY)); + } + 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 full = this.pads.isPadFull(pad); + double spacing = countdown ? 0.25 : full ? 0.4 : 0.6; + this.drawOutline(region, new Particle.DustOptions(color, full ? 1.2f : 0.9f), spacing); + + double speed = countdown ? 0.6 : 0.3; + for(Player occupant : this.playersOn(pad)) { + 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(); + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/LockListener.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/LockListener.java new file mode 100644 index 0000000..8125d88 --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/LockListener.java @@ -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 { + 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); + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ModeMenu.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ModeMenu.java new file mode 100644 index 0000000..b3d4132 --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ModeMenu.java @@ -0,0 +1,150 @@ +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.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; + +/** + * Chest menu in which the challenger picks team sizes and mode. Every click applies at once. + * Works for Bedrock players as well. + */ +final class ModeMenu implements InventoryHolder { + private static final int OWN_ROW = 10; + private static final int OPPONENT_ROW = 19; + private static final int[] MODE_SLOTS = {29, 31, 33}; + private static final FightMode[] MODES = {FightMode.OWN, FightMode.CHALLENGER, FightMode.OPPONENT}; + private static final int DONE_SLOT = 40; + + sealed interface Click { + } + + record OwnSize(int size) implements Click { + } + + record OpponentSize(int size) implements Click { + } + + record Mode(FightMode mode) implements Click { + } + + record Done() implements Click { + } + + private final Inventory inventory; + private final Player challenger; + private final int ownPad; + int ownSize; + int opponentSize; + FightMode mode; + /** Both pads are full, only the challenger closing the menu is missing. */ + boolean everyoneThere; + + private ModeMenu(Player challenger, int ownPad, int ownSize, int opponentSize, FightMode mode) { + this.challenger = challenger; + this.ownPad = ownPad; + this.ownSize = ownSize; + this.opponentSize = opponentSize; + this.mode = mode; + this.inventory = Bukkit.createInventory(this, 45, Component.text("Teams und Kampfmodus")); + this.draw(); + } + + static void open(Player challenger, int ownPad, int ownSize, int opponentSize, FightMode mode) { + challenger.openInventory(new ModeMenu(challenger, ownPad, ownSize, opponentSize, mode).getInventory()); + } + + static boolean isOpen(Player player) { + return player.getOpenInventory().getTopInventory().getHolder(false) instanceof ModeMenu; + } + + Click clickAt(int slot) { + if(slot > OWN_ROW && slot <= OWN_ROW + PadLobby.MAX_TEAM_SIZE) return new OwnSize(slot - OWN_ROW); + if(slot > OPPONENT_ROW && slot <= OPPONENT_ROW + PadLobby.MAX_TEAM_SIZE) return new OpponentSize(slot - OPPONENT_ROW); + for(int i = 0; i < MODE_SLOTS.length; i++) { + if(MODE_SLOTS[i] == slot) return new Mode(MODES[i]); + } + if(slot == DONE_SLOT) return new Done(); + return null; + } + + void draw() { + for(int i = 0; i < this.inventory.getSize(); i++) this.inventory.setItem(i, PlaceholderItems.grayStainedGlassPane); + int opponentPad = 1 - this.ownPad; + boolean teams = this.ownSize > 1 || this.opponentSize > 1; + + this.inventory.setItem(OWN_ROW, this.label(Material.PLAYER_HEAD, "Dein Team", this.ownPad, "So viele stehen mit dir auf dem " + Style.PAD_NAMES[this.ownPad] + ".") + .withMeta(meta -> ((SkullMeta) meta).setOwningPlayer(this.challenger)).build()); + this.inventory.setItem(OPPONENT_ROW, this.label(Material.SKELETON_SKULL, "Gegner", opponentPad, "So viele stellen sich auf das " + Style.PAD_NAMES[opponentPad] + ".").build()); + for(int size = 1; size <= PadLobby.MAX_TEAM_SIZE; size++) { + this.inventory.setItem(OWN_ROW + size, this.sizeItem(size, this.ownPad, size == this.ownSize)); + this.inventory.setItem(OPPONENT_ROW + size, this.sizeItem(size, opponentPad, size == this.opponentSize)); + } + + for(int i = 0; i < MODES.length; i++) { + FightMode mode = MODES[i]; + this.inventory.setItem(MODE_SLOTS[i], this.modeItem(mode, teams, mode == this.mode, mode.isAllowed(this.ownSize, this.opponentSize))); + } + + ItemBuilder done = ItemBuilder.of(Material.LIME_CONCRETE) + .displayName(Component.text(this.everyoneThere ? "Fertig, Kampf starten" : "Fertig", NamedTextColor.GREEN).decoration(TextDecoration.ITALIC, false)) + .lore(this.ownSize + " gegen " + this.opponentSize + ", " + this.mode.displayName(teams) + ". " + + (this.everyoneThere ? "Alle sind da, der Countdown startet, sobald du fertig bist." : "Jetzt fehlen nur noch die anderen auf den Feldern.")); + if(this.everyoneThere) done.glint(); + this.inventory.setItem(DONE_SLOT, done.build()); + } + + private ItemBuilder label(Material material, String name, int pad, String text) { + return ItemBuilder.of(material) + .displayName(Component.text(name, Style.PAD_TEXT_COLORS[pad]).decoration(TextDecoration.ITALIC, false)) + .lore(text); + } + + private ItemStack sizeItem(int size, int pad, boolean selected) { + ItemBuilder builder = ItemBuilder.of(pad == 0 ? Material.RED_CONCRETE : Material.BLUE_CONCRETE) + .amount(size) + .displayName(Component.text(size + " Spieler", Style.PAD_TEXT_COLORS[pad]).decoration(TextDecoration.ITALIC, false)); + builder.lore(selected ? "Ausgewählt" : "Klicken zum Auswählen", selected ? NamedTextColor.GREEN : NamedTextColor.YELLOW); + if(selected) builder.glint(); + return builder.build(); + } + + private ItemStack modeItem(FightMode mode, boolean teams, boolean selected, boolean allowed) { + ItemBuilder builder = switch(mode) { + case OWN -> ItemBuilder.of(Material.IRON_SWORD) + .lore("Jeder kämpft mit seinem eigenen Inventar."); + case CHALLENGER -> ItemBuilder.of(Material.PLAYER_HEAD) + .withMeta(meta -> ((SkullMeta) meta).setOwningPlayer(this.challenger)) + .lore(teams ? "Alle kämpfen mit deinem Inventar." : "Ihr kämpft beide mit deinem Inventar."); + case OPPONENT -> ItemBuilder.of(allowed ? Material.CHEST : Material.BARRIER) + .lore(allowed + ? "Ihr kämpft beide mit dem Inventar von dem, der sich auf das andere Feld stellt." + : "Geht nur bei 1 gegen 1."); + }; + builder.displayName(Component.text(mode.displayName(teams), allowed ? NamedTextColor.GOLD : NamedTextColor.DARK_GRAY).decoration(TextDecoration.ITALIC, false)); + if(!allowed) return builder.build(); + builder.appendLore(Component.empty()); + if(selected) { + builder.glint().appendLore(Component.text("Ausgewählt", NamedTextColor.GREEN).decoration(TextDecoration.ITALIC, false)); + } else { + builder.appendLore(Component.text("Klicken zum Auswählen", NamedTextColor.YELLOW).decoration(TextDecoration.ITALIC, false)); + } + return builder.build(); + } + + @Override + public @NotNull Inventory getInventory() { + return this.inventory; + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ModeMenuListener.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ModeMenuListener.java new file mode 100644 index 0000000..d71150c --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/ModeMenuListener.java @@ -0,0 +1,41 @@ +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.EventHandler; +import org.bukkit.event.block.Action; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryCloseEvent; + +class ModeMenuListener extends ApplianceListener { + @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; + + ModeMenu.Click click = menu.clickAt(event.getRawSlot()); + Lobby lobby = this.getAppliance().lobby(); + if(click != null && lobby != null) lobby.handleMenuClick(player, menu, click); + } + + @EventHandler + public void onClose(InventoryCloseEvent event) { + if(!(event.getInventory().getHolder(false) instanceof ModeMenu)) return; + if(!(event.getPlayer() instanceof Player player)) return; + Lobby lobby = this.getAppliance().lobby(); + if(lobby != null) lobby.menuClosed(player); + } + + @EventHandler + public void onRightClick(PlayerInteractEvent event) { + if(event.getHand() != EquipmentSlot.HAND) return; + if(event.getAction() != Action.RIGHT_CLICK_AIR && event.getAction() != Action.RIGHT_CLICK_BLOCK) return; + if(this.getAppliance().state(event.getPlayer()) != Arena.PlayerState.PAD) return; + Lobby lobby = this.getAppliance().lobby(); + if(lobby != null && lobby.openMenu(event.getPlayer())) event.setCancelled(true); + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/NoProgressListener.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/NoProgressListener.java new file mode 100644 index 0000000..e4392af --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/NoProgressListener.java @@ -0,0 +1,21 @@ +package eu.mhsl.craftattack.spawn.arena.appliances.arena; + +import com.destroystokyo.paper.event.player.PlayerAdvancementCriterionGrantEvent; +import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener; +import org.bukkit.event.EventHandler; +import org.bukkit.event.player.PlayerRecipeDiscoverEvent; + +/** + * Nothing done in the arena counts as progress: no advancements, no recipe unlocks. + */ +class NoProgressListener extends ApplianceListener { + @EventHandler + public void onCriterion(PlayerAdvancementCriterionGrantEvent event) { + event.setCancelled(true); + } + + @EventHandler + public void onRecipe(PlayerRecipeDiscoverEvent event) { + event.setCancelled(true); + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/PlayerConnectionListener.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/PlayerConnectionListener.java new file mode 100644 index 0000000..b9030d0 --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/PlayerConnectionListener.java @@ -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 { + @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()); + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Style.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Style.java new file mode 100644 index 0000000..8d0da13 --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Style.java @@ -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); + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Teams.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Teams.java new file mode 100644 index 0000000..b519785 --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/Teams.java @@ -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); + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/WorldProtectionListener.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/WorldProtectionListener.java new file mode 100644 index 0000000..7bc4201 --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/WorldProtectionListener.java @@ -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 { + 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 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); + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/FightMode.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/FightMode.java new file mode 100644 index 0000000..6dc00af --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/FightMode.java @@ -0,0 +1,25 @@ +package eu.mhsl.craftattack.spawn.arena.appliances.arena.logic; + +/** + * Which inventory the fighters use. The challenger chooses it. + */ +public enum FightMode { + OWN, + CHALLENGER, + OPPONENT; + + /** + * With more than one player on a side, "the opponent's inventory" would be ambiguous. + */ + public boolean isAllowed(int ownSize, int opponentSize) { + return this != OPPONENT || (ownSize == 1 && opponentSize == 1); + } + + public String displayName(boolean teams) { + return switch(this) { + case OWN -> "Jeder sein eigenes"; + case CHALLENGER -> teams ? "Alle mit meinem" : "Beide mit meinem"; + case OPPONENT -> "Beide mit dem des Gegners"; + }; + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/PadLobby.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/PadLobby.java new file mode 100644 index 0000000..fa96e01 --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/PadLobby.java @@ -0,0 +1,282 @@ +package eu.mhsl.craftattack.spawn.arena.appliances.arena.logic; + +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * State of the two start pads. The first player on a pad becomes the challenger and picks team sizes and mode. + * Everyone else joins a team by stepping on a pad. Once both pads hold exactly the chosen number of players + * and the challenger is done choosing (or took too long), the countdown runs and then the fight starts. + */ +public class PadLobby { + public static final int PADS = 2; + public static final int MAX_TEAM_SIZE = 4; + + public interface Listener { + /** The player is now the challenger and may change the options until the countdown runs. */ + void challengerSelected(UUID challenger, boolean first); + + /** The player should now hold the inventory of {@code kitOwner} (the own one to revert). */ + void kitAssigned(UUID player, UUID kitOwner); + + /** Both pads are exactly full, but the challenger still has the options open. */ + void waitingForChallenger(); + + /** Both pads are exactly full, the options are fixed now. */ + void countdownStarted(); + + void countdown(int secondsLeft); + + void countdownCancelled(); + + /** + * @param teams players per pad + * @param kits whose inventory each player fights with + */ + void start(List> teams, UUID challenger, FightMode mode, Map kits); + } + + private final Listener listener; + private final int countdownTicks; + private final int choosingTicks; + private final List> pads = List.of(new ArrayList<>(), new ArrayList<>()); + /** Everyone on a pad, in the order they stepped on. The first one is the challenger. */ + private final List order = new ArrayList<>(); + private final Map kits = new HashMap<>(); + private final int[] sizes = {1, 1}; + private FightMode mode = FightMode.OWN; + private int ticksLeft = -1; + private boolean choosing; + /** How much longer everyone waits for the challenger to finish choosing; -1 while nobody waits. */ + private int choosingTicksLeft = -1; + + /** + * @param choosingSeconds how long a full lobby waits for the challenger to close the options + */ + public PadLobby(Listener listener, int countdownSeconds, int choosingSeconds) { + this.listener = listener; + this.countdownTicks = countdownSeconds * 20; + this.choosingTicks = choosingSeconds * 20; + } + + /** + * @return false if the pad is already full or the player stands on a pad + */ + public boolean enter(int pad, UUID player) { + if(this.padOf(player) != -1) return false; + boolean first = this.order.isEmpty(); + if(!first && this.pads.get(pad).size() >= this.sizes[pad]) return false; + + this.pads.get(pad).add(player); + this.order.add(player); + this.kits.put(player, player); + if(first) { + this.sizes[0] = 1; + this.sizes[1] = 1; + this.mode = FightMode.OWN; + this.listener.challengerSelected(player, true); + return true; + } + + this.refreshKits(); + this.startCountdownIfReady(); + return true; + } + + public void leave(UUID player) { + int pad = this.padOf(player); + if(pad == -1) return; + + boolean wasFull = this.isFull(); + boolean wasChallenger = player.equals(this.challenger()); + this.pads.get(pad).remove(player); + this.order.remove(player); + this.kits.remove(player); + this.listener.kitAssigned(player, player); + + if(wasFull) { + if(this.isCountingDown()) this.listener.countdownCancelled(); + this.ticksLeft = -1; + this.choosingTicksLeft = -1; + } + if(wasChallenger) this.choosing = false; + + this.refreshKits(); + // the longest waiting player takes over; team sizes and mode stay as chosen + if(wasChallenger && !this.order.isEmpty()) { + this.listener.challengerSelected(this.order.getFirst(), false); + } + } + + public enum ConfigureResult { + OK, + NOT_ALLOWED, + OWN_TOO_SMALL, + OPPONENT_TOO_SMALL, + INVALID + } + + /** + * Only the challenger can change the options, and only until the countdown runs. + * Teams cannot be smaller than the players already standing there. + * + * @param ownSize players on the challenger's pad + * @param opponentSize players on the other pad + */ + public ConfigureResult configure(UUID player, int ownSize, int opponentSize, FightMode mode) { + if(!this.canConfigure(player)) return ConfigureResult.NOT_ALLOWED; + if(ownSize < 1 || ownSize > MAX_TEAM_SIZE || opponentSize < 1 || opponentSize > MAX_TEAM_SIZE) return ConfigureResult.INVALID; + if(!mode.isAllowed(ownSize, opponentSize)) return ConfigureResult.INVALID; + int own = this.challengerPad(); + if(ownSize < this.pads.get(own).size()) return ConfigureResult.OWN_TOO_SMALL; + if(opponentSize < this.pads.get(1 - own).size()) return ConfigureResult.OPPONENT_TOO_SMALL; + + this.sizes[own] = ownSize; + this.sizes[1 - own] = opponentSize; + this.mode = mode; + this.refreshKits(); + this.startCountdownIfReady(); + return ConfigureResult.OK; + } + + /** + * The challenger opened or closed the options. While they are open, a full lobby waits instead of counting down. + */ + public void setChoosing(UUID player, boolean choosing) { + if(!this.canConfigure(player) || this.choosing == choosing) return; + this.choosing = choosing; + this.startCountdownIfReady(); + } + + public boolean isWaitingForChallenger() { + return this.choosingTicksLeft >= 0; + } + + private void startCountdownIfReady() { + if(this.isCountingDown()) return; + if(!this.isFull()) { + this.choosingTicksLeft = -1; + return; + } + if(this.choosing) { + if(this.choosingTicksLeft >= 0) return; + this.choosingTicksLeft = this.choosingTicks; + this.listener.waitingForChallenger(); + return; + } + this.choosingTicksLeft = -1; + this.ticksLeft = this.countdownTicks; + this.listener.countdownStarted(); + } + + public boolean canConfigure(UUID player) { + return player.equals(this.challenger()) && !this.isCountingDown(); + } + + /** + * Called every server tick. The countdown only runs while an arena is free. + */ + public void tick(boolean arenaAvailable) { + if(this.choosingTicksLeft >= 0 && this.choosingTicksLeft-- == 0) { + this.choosing = false; + this.startCountdownIfReady(); + } + 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; + + List> teams = List.of(List.copyOf(this.pads.get(0)), List.copyOf(this.pads.get(1))); + UUID challenger = this.challenger(); + FightMode mode = this.mode; + Map kits = Map.copyOf(this.kits); + this.pads.forEach(List::clear); + this.order.clear(); + this.kits.clear(); + this.sizes[0] = 1; + this.sizes[1] = 1; + this.mode = FightMode.OWN; + this.ticksLeft = -1; + this.choosing = false; + this.listener.start(teams, challenger, mode, kits); + } + + /** + * Works out whose inventory everyone should hold and reports only the changes. + */ + private void refreshKits() { + UUID challenger = this.challenger(); + if(challenger == null) return; + int challengerPad = this.challengerPad(); + UUID firstOpponent = this.pads.get(1 - challengerPad).isEmpty() ? null : this.pads.get(1 - challengerPad).getFirst(); + + for(UUID player : this.order) { + UUID wanted = switch(this.mode) { + case OWN -> player; + case CHALLENGER -> challenger; + case OPPONENT -> player.equals(challenger) && firstOpponent != null ? firstOpponent : player; + }; + if(wanted.equals(this.kits.get(player))) continue; + this.kits.put(player, wanted); + this.listener.kitAssigned(player, wanted); + } + } + + public int padOf(UUID player) { + for(int i = 0; i < PADS; i++) { + if(this.pads.get(i).contains(player)) return i; + } + return -1; + } + + public List players(int pad) { + return List.copyOf(this.pads.get(pad)); + } + + public int size(int pad) { + return this.sizes[pad]; + } + + public boolean isPadFull(int pad) { + return !this.order.isEmpty() && this.pads.get(pad).size() >= this.sizes[pad]; + } + + public @Nullable UUID challenger() { + return this.order.isEmpty() ? null : this.order.getFirst(); + } + + public int challengerPad() { + UUID challenger = this.challenger(); + return challenger == null ? -1 : this.padOf(challenger); + } + + public FightMode mode() { + return this.mode; + } + + public boolean isFull() { + return this.pads.get(0).size() == this.sizes[0] && this.pads.get(1).size() == this.sizes[1]; + } + + public boolean isCountingDown() { + return this.ticksLeft >= 0; + } + + public boolean isIdle() { + return this.order.isEmpty(); + } + + /** Seconds until the start, rounded up; -1 without countdown. */ + public int secondsLeft() { + return this.ticksLeft < 0 ? -1 : (this.ticksLeft + 19) / 20; + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/Region.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/Region.java new file mode 100644 index 0000000..293ba91 --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/Region.java @@ -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; + } +} diff --git a/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/ResetCursor.java b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/ResetCursor.java new file mode 100644 index 0000000..55730d6 --- /dev/null +++ b/arena/src/main/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/ResetCursor.java @@ -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; + } +} diff --git a/arena/src/test/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/FightModeTest.java b/arena/src/test/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/FightModeTest.java new file mode 100644 index 0000000..10037f8 --- /dev/null +++ b/arena/src/test/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/FightModeTest.java @@ -0,0 +1,22 @@ +package eu.mhsl.craftattack.spawn.arena.appliances.arena.logic; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class FightModeTest { + @Test + void opponentInventoryOnlyInOneVersusOne() { + assertTrue(FightMode.OPPONENT.isAllowed(1, 1)); + assertFalse(FightMode.OPPONENT.isAllowed(1, 2)); + assertFalse(FightMode.OPPONENT.isAllowed(3, 1)); + assertTrue(FightMode.CHALLENGER.isAllowed(4, 3)); + assertTrue(FightMode.OWN.isAllowed(2, 4)); + } + + @Test + void namesForTeams() { + assertEquals("Beide mit meinem", FightMode.CHALLENGER.displayName(false)); + assertEquals("Alle mit meinem", FightMode.CHALLENGER.displayName(true)); + } +} diff --git a/arena/src/test/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/PadLobbyTest.java b/arena/src/test/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/PadLobbyTest.java new file mode 100644 index 0000000..d651ff5 --- /dev/null +++ b/arena/src/test/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/PadLobbyTest.java @@ -0,0 +1,302 @@ +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.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +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 UUID d = UUID.randomUUID(); + private final UUID e = UUID.randomUUID(); + private final List events = new ArrayList<>(); + private Map startedKits; + private PadLobby lobby; + + @BeforeEach + void setUp() { + this.lobby = new PadLobby(new PadLobby.Listener() { + public void challengerSelected(UUID challenger, boolean first) { + PadLobbyTest.this.events.add("challenger " + PadLobbyTest.this.name(challenger) + (first ? " first" : "")); + } + + public void waitingForChallenger() { + PadLobbyTest.this.events.add("waiting"); + } + + public void countdownStarted() { + PadLobbyTest.this.events.add("countdown started"); + } + + public void kitAssigned(UUID player, UUID kitOwner) { + PadLobbyTest.this.events.add("kit " + PadLobbyTest.this.name(player) + "<-" + PadLobbyTest.this.name(kitOwner)); + } + + public void countdown(int secondsLeft) { + PadLobbyTest.this.events.add("countdown " + secondsLeft); + } + + public void countdownCancelled() { + PadLobbyTest.this.events.add("cancelled"); + } + + public void start(List> teams, UUID challenger, FightMode mode, Map kits) { + PadLobbyTest.this.startedKits = kits; + PadLobbyTest.this.events.add("start " + PadLobbyTest.this.names(teams.get(0)) + " vs " + PadLobbyTest.this.names(teams.get(1)) + + " by " + PadLobbyTest.this.name(challenger) + " " + mode); + } + }, 2, 3); + } + + private String name(UUID uuid) { + if(uuid.equals(this.a)) return "a"; + if(uuid.equals(this.b)) return "b"; + if(uuid.equals(this.c)) return "c"; + if(uuid.equals(this.d)) return "d"; + return "e"; + } + + private String names(List uuids) { + return uuids.stream().map(this::name).collect(Collectors.joining()); + } + + private void run(int ticks) { + for(int i = 0; i < ticks; i++) this.lobby.tick(true); + } + + @Test + void firstOnPadBecomesChallengerAndMayConfigure() { + assertTrue(this.lobby.enter(1, this.a)); + assertEquals(List.of("challenger a first"), this.events); + assertEquals(this.a, this.lobby.challenger()); + assertTrue(this.lobby.canConfigure(this.a)); + assertFalse(this.lobby.isCountingDown()); + } + + @Test + void oneVersusOneStartsAfterCountdown() { + this.lobby.enter(0, this.a); + assertEquals(PadLobby.ConfigureResult.OK, this.lobby.configure(this.a, 1, 1, FightMode.CHALLENGER)); + this.lobby.enter(1, this.b); + assertEquals(PadLobby.ConfigureResult.NOT_ALLOWED, this.lobby.configure(this.a, 1, 1, FightMode.OWN), "options are fixed during the countdown"); + this.run(41); + + assertEquals(List.of( + "challenger a first", + "kit b<-a", + "countdown started", + "countdown 2", + "countdown 1", + "start a vs b by a CHALLENGER" + ), this.events); + assertEquals(Map.of(this.a, this.a, this.b, this.a), this.startedKits); + assertTrue(this.lobby.isIdle()); + } + + @Test + void fullPadIsRefusedAndPlayerOnlyTakesOnePad() { + this.lobby.enter(0, this.a); + this.lobby.enter(1, this.b); + assertFalse(this.lobby.enter(1, this.c), "1v1: the blue pad is full"); + assertFalse(this.lobby.enter(0, this.b), "a player can only stand on one pad"); + } + + @Test + void unevenTeamsStartWhenBothPadsAreExactlyFull() { + this.lobby.enter(0, this.a); + assertEquals(PadLobby.ConfigureResult.OK, this.lobby.configure(this.a, 2, 3, FightMode.OWN)); + assertTrue(this.lobby.enter(0, this.b)); + assertFalse(this.lobby.enter(0, this.c), "red is full with 2"); + this.lobby.enter(1, this.c); + this.lobby.enter(1, this.d); + assertFalse(this.lobby.isCountingDown()); + this.lobby.enter(1, this.e); + assertTrue(this.lobby.isCountingDown()); + this.run(41); + assertEquals("start ab vs cde by a OWN", this.events.getLast()); + } + + @Test + void challengerKitGoesToEveryone() { + this.lobby.enter(0, this.a); + this.lobby.configure(this.a, 2, 2, FightMode.CHALLENGER); + this.lobby.enter(0, this.b); + this.lobby.enter(1, this.c); + this.lobby.enter(1, this.d); + assertEquals(List.of("challenger a first", "kit b<-a", "kit c<-a", "kit d<-a", "countdown started"), this.events); + } + + @Test + void opponentModeOnlyForOneVersusOne() { + this.lobby.enter(0, this.a); + assertNotEquals(PadLobby.ConfigureResult.OK, this.lobby.configure(this.a, 2, 1, FightMode.OPPONENT)); + assertEquals(PadLobby.ConfigureResult.OK, this.lobby.configure(this.a, 1, 1, FightMode.OPPONENT)); + this.lobby.enter(1, this.b); + assertTrue(this.events.contains("kit a<-b")); + + this.lobby.leave(this.b); + assertTrue(this.events.contains("kit a<-a"), "challenger gets the own inventory back"); + assertTrue(this.events.contains("cancelled")); + } + + @Test + void onlyChallengerMayConfigure() { + this.lobby.enter(0, this.a); + assertNotEquals(PadLobby.ConfigureResult.OK, this.lobby.configure(this.b, 2, 2, FightMode.OWN)); + assertNotEquals(PadLobby.ConfigureResult.OK, this.lobby.configure(this.a, 5, 1, FightMode.OWN)); + assertNotEquals(PadLobby.ConfigureResult.OK, this.lobby.configure(this.a, 0, 1, FightMode.OWN)); + } + + @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()); + this.run(41); + assertEquals("start a vs b by a OWN", this.events.getLast()); + } + + @Test + void leavingCancelsCountdownAndRevertsKit() { + this.lobby.enter(0, this.a); + this.lobby.configure(this.a, 1, 1, FightMode.CHALLENGER); + this.lobby.enter(1, this.b); + this.events.clear(); + + this.lobby.leave(this.b); + + assertEquals(List.of("kit b<-b", "cancelled"), this.events); + assertEquals(this.a, this.lobby.challenger()); + assertEquals(FightMode.CHALLENGER, this.lobby.mode()); + assertFalse(this.lobby.isCountingDown()); + } + + @Test + void longestWaitingTakesOverAndKeepsTeamSizes() { + this.lobby.enter(0, this.a); + this.lobby.configure(this.a, 2, 2, FightMode.CHALLENGER); + this.lobby.enter(1, this.b); + this.lobby.enter(0, this.c); + this.events.clear(); + + this.lobby.leave(this.a); + + assertEquals(List.of("kit a<-a", "kit b<-b", "kit c<-b", "challenger b"), this.events); + assertEquals(this.b, this.lobby.challenger()); + assertTrue(this.lobby.canConfigure(this.b), "the new challenger may change the options"); + assertEquals(2, this.lobby.size(0)); + assertEquals(2, this.lobby.size(1)); + } + + @Test + void handOverWhenAlone() { + this.lobby.enter(0, this.a); + this.lobby.enter(1, this.b); + this.lobby.leave(this.a); + assertEquals("challenger b", this.events.getLast()); + assertTrue(this.lobby.canConfigure(this.b)); + } + + @Test + void challengerMayChangeOptionsWhileOthersWait() { + this.lobby.enter(0, this.a); + this.lobby.configure(this.a, 2, 2, FightMode.OWN); + this.lobby.enter(0, this.b); + this.lobby.enter(1, this.c); + + assertEquals(PadLobby.ConfigureResult.OWN_TOO_SMALL, this.lobby.configure(this.a, 1, 2, FightMode.OWN), + "two already stand on the own pad"); + assertEquals(2, this.lobby.size(0)); + + this.events.clear(); + assertEquals(PadLobby.ConfigureResult.OK, this.lobby.configure(this.a, 2, 1, FightMode.CHALLENGER)); + assertEquals(List.of("kit b<-a", "kit c<-a", "countdown started"), this.events, "shrinking to the players there starts the countdown"); + assertTrue(this.lobby.isCountingDown()); + } + + @Test + void opponentSideCannotShrinkBelowPlayers() { + this.lobby.enter(0, this.a); + this.lobby.configure(this.a, 1, 3, FightMode.OWN); + this.lobby.enter(1, this.b); + this.lobby.enter(1, this.c); + assertEquals(PadLobby.ConfigureResult.OPPONENT_TOO_SMALL, this.lobby.configure(this.a, 1, 1, FightMode.OWN)); + assertEquals(PadLobby.ConfigureResult.OK, this.lobby.configure(this.a, 1, 4, FightMode.OWN)); + } + + @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 first", "kit a<-a"), this.events); + } + + @Test + void fullLobbyWaitsWhileChallengerChooses() { + this.lobby.enter(0, this.a); + this.lobby.setChoosing(this.a, true); + this.lobby.enter(1, this.b); + assertEquals("waiting", this.events.getLast()); + assertFalse(this.lobby.isCountingDown()); + assertTrue(this.lobby.isWaitingForChallenger()); + assertEquals(PadLobby.ConfigureResult.OK, this.lobby.configure(this.a, 1, 1, FightMode.CHALLENGER), "the menu stays usable"); + + this.lobby.setChoosing(this.a, false); + assertEquals("countdown started", this.events.getLast()); + assertFalse(this.lobby.isWaitingForChallenger()); + } + + @Test + void choosingTimesOut() { + this.lobby.enter(0, this.a); + this.lobby.setChoosing(this.a, true); + this.lobby.enter(1, this.b); + this.run(60); + assertFalse(this.lobby.isCountingDown()); + this.run(1); + assertTrue(this.lobby.isCountingDown()); + this.lobby.setChoosing(this.a, false); + assertEquals(1, this.events.stream().filter("countdown started"::equals).count()); + } + + @Test + void onlyChallengerChoosingBlocks() { + this.lobby.enter(0, this.a); + this.lobby.setChoosing(this.b, true); + this.lobby.enter(1, this.b); + assertTrue(this.lobby.isCountingDown()); + } + + @Test + void opponentLeavingWhileWaitingIsQuiet() { + this.lobby.enter(0, this.a); + this.lobby.setChoosing(this.a, true); + this.lobby.enter(1, this.b); + this.lobby.leave(this.b); + assertFalse(this.events.contains("cancelled")); + assertFalse(this.lobby.isWaitingForChallenger()); + } + + @Test + void newChallengerIsNotChoosing() { + this.lobby.enter(0, this.a); + this.lobby.setChoosing(this.a, true); + this.lobby.enter(1, this.b); + this.lobby.leave(this.a); + this.lobby.enter(0, this.c); + assertTrue(this.lobby.isCountingDown()); + } +} diff --git a/arena/src/test/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/RegionTest.java b/arena/src/test/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/RegionTest.java new file mode 100644 index 0000000..4fef3c1 --- /dev/null +++ b/arena/src/test/java/eu/mhsl/craftattack/spawn/arena/appliances/arena/logic/RegionTest.java @@ -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 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())); + } +} diff --git a/build.gradle b/build.gradle index 64aa638..f3b0910 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ plugins { id 'java' - id 'com.gradleup.shadow' version "8.3.5" + id 'com.gradleup.shadow' version "9.6.1" } allprojects { @@ -29,7 +29,7 @@ subprojects { java { toolchain { - languageVersion = JavaLanguageVersion.of(21) + languageVersion = JavaLanguageVersion.of(25) } } } diff --git a/common/build.gradle b/common/build.gradle index 5e7a917..62c6ef6 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -1,7 +1,7 @@ dependencies { implementation project(':core') - compileOnly 'io.papermc.paper:paper-api:1.21.10-R0.1-SNAPSHOT' + compileOnly 'io.papermc.paper:paper-api:26.2.build.129-stable' compileOnly 'org.geysermc.floodgate:api:2.2.4-SNAPSHOT' implementation 'org.apache.httpcomponents:httpclient:4.5.14' implementation 'com.sparkjava:spark-core:2.9.4' diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/chatMention/ChatMention.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/chatMention/ChatMention.java deleted file mode 100644 index 964c3d2..0000000 --- a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/chatMention/ChatMention.java +++ /dev/null @@ -1,66 +0,0 @@ -package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.chatMention; - -import eu.mhsl.craftattack.spawn.core.Main; -import eu.mhsl.craftattack.spawn.core.appliance.Appliance; -import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.settings.Settings; -import net.kyori.adventure.sound.Sound; -import org.bukkit.Bukkit; -import org.bukkit.OfflinePlayer; -import org.bukkit.event.Listener; -import org.jetbrains.annotations.NotNull; - -import java.util.Arrays; -import java.util.List; -import java.util.Objects; - -public class ChatMention extends Appliance { - private static List playerNames; - - public String formatPlayer(String name) { - return "@" + name; - } - - public List getPlayerNames() { - return playerNames; - } - - public void refreshPlayers() { - Bukkit.getScheduler().runTaskAsynchronously( - Main.instance(), - () -> playerNames = Arrays.stream(Bukkit.getOfflinePlayers()) - .map(OfflinePlayer::getName) - .filter(Objects::nonNull) - .toList() - ); - } - - public void notifyPlayers(List playerNames) { - playerNames.stream() - .distinct() - .map(Bukkit::getPlayer) - .filter(Objects::nonNull) - .filter(player -> Settings.instance() - .getSetting(player, Settings.Key.ChatMentions, ChatMentionSetting.ChatMentionConfig.class) - .notifyOnMention() - ) - .forEach(player -> player.playSound( - Sound.sound( - org.bukkit.Sound.ENTITY_EXPERIENCE_ORB_PICKUP, - Sound.Source.PLAYER, - 1.0f, - 1.0f - ) - )); - } - - @Override - public void onEnable() { - Settings.instance().declareSetting(ChatMentionSetting.class); - this.refreshPlayers(); - } - - @Override - protected @NotNull List listeners() { - return List.of(new ChatMentionListener()); - } -} diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/chatMention/ChatMentionListener.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/chatMention/ChatMentionListener.java deleted file mode 100644 index be1fcf5..0000000 --- a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/chatMention/ChatMentionListener.java +++ /dev/null @@ -1,56 +0,0 @@ -package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.chatMention; - -import eu.mhsl.craftattack.spawn.core.Main; -import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener; -import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.chatMessages.ChatMessages; -import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.settings.Settings; -import eu.mhsl.craftattack.spawn.core.util.text.ComponentUtil; -import io.papermc.paper.event.player.AsyncChatDecorateEvent; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; -import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; -import org.bukkit.event.EventHandler; -import org.bukkit.event.player.PlayerJoinEvent; - -import java.util.ArrayList; -import java.util.List; - -class ChatMentionListener extends ApplianceListener { - @SuppressWarnings("UnstableApiUsage") - @EventHandler - public void coloringEvent(AsyncChatDecorateEvent event) { - String message = PlainTextComponentSerializer.plainText().serialize(event.result()); - List words = List.of(message.split(" ")); - List mentioned = new ArrayList<>(); - - ChatMentionSetting.ChatMentionConfig config = Settings.instance() - .getSetting(event.player(), Settings.Key.ChatMentions, ChatMentionSetting.ChatMentionConfig.class); - ChatMessages chatMessages = Main.instance().getAppliance(ChatMessages.class); - - Component result = words.stream() - .map(word -> { - String wordWithoutAnnotation = word.replace("@", ""); - boolean isPlayer = this.getAppliance().getPlayerNames().contains(wordWithoutAnnotation); - if(isPlayer && config.applyMentions()) { - mentioned.add(wordWithoutAnnotation); - Component mention = Component.text( - this.getAppliance().formatPlayer(wordWithoutAnnotation), - NamedTextColor.GOLD - ); - return chatMessages.addReportActions(mention, wordWithoutAnnotation); - } else { - return Component.text(word); - } - }) - .reduce(ComponentUtil::appendWithSpace) - .orElseThrow(); - - this.getAppliance().notifyPlayers(mentioned); - event.result(result); - } - - @EventHandler - public void onJoin(PlayerJoinEvent event) { - this.getAppliance().refreshPlayers(); - } -} diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/chatMessages/ChatMessages.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/chatMessages/ChatMessages.java deleted file mode 100644 index 664a8ff..0000000 --- a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/chatMessages/ChatMessages.java +++ /dev/null @@ -1,36 +0,0 @@ -package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.chatMessages; - -import eu.mhsl.craftattack.spawn.core.appliance.Appliance; -import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.settings.Settings; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.event.ClickEvent; -import net.kyori.adventure.text.event.HoverEvent; -import net.kyori.adventure.text.format.NamedTextColor; -import org.bukkit.entity.Player; -import org.bukkit.event.Listener; -import org.jetbrains.annotations.NotNull; - -import java.util.List; - -public class ChatMessages extends Appliance { - @Override - public void onEnable() { - Settings.instance().declareSetting(ShowJoinAndLeaveMessagesSetting.class); - } - - public Component getReportablePlayerName(Player player) { - return this.addReportActions(player.displayName(), player.getName()); - } - - public Component addReportActions(Component message, String username) { - return message - .hoverEvent(HoverEvent.showText(Component.text("Klicke, um diesen Spieler zu reporten").color(NamedTextColor.GOLD))) - .clickEvent(ClickEvent.clickEvent(ClickEvent.Action.SUGGEST_COMMAND, String.format("/report %s ", username))); - } - - @Override - @NotNull - protected List listeners() { - return List.of(new ChatMessagesListener()); - } -} diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/chatMessages/ChatMessagesListener.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/chatMessages/ChatMessagesListener.java deleted file mode 100644 index 561c5bf..0000000 --- a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/chatMessages/ChatMessagesListener.java +++ /dev/null @@ -1,74 +0,0 @@ -package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.chatMessages; - -import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener; -import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.settings.Settings; -import eu.mhsl.craftattack.spawn.core.util.IteratorUtil; -import io.papermc.paper.event.player.AsyncChatEvent; -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.event.EventHandler; -import org.bukkit.event.EventPriority; -import org.bukkit.event.entity.PlayerDeathEvent; -import org.bukkit.event.player.PlayerJoinEvent; -import org.bukkit.event.player.PlayerQuitEvent; - -import java.util.Optional; - -class ChatMessagesListener extends ApplianceListener { - @EventHandler - public void onPlayerChatEvent(AsyncChatEvent event) { - event.renderer( - (source, sourceDisplayName, message, viewer) -> - Component.text("") - .append(this.getAppliance().getReportablePlayerName(source)) - .append(Component.text(" > ").color(TextColor.color(Color.GRAY.asRGB()))) - .append(message).color(TextColor.color(Color.SILVER.asRGB())) - ); - } - - @EventHandler(priority = EventPriority.HIGH) - public void onPlayerJoin(PlayerJoinEvent event) { - boolean wasHidden = event.joinMessage() == null; - event.joinMessage(null); - if(wasHidden) return; - IteratorUtil.onlinePlayers(player -> { - if(!Settings.instance().getSetting(player, Settings.Key.ShowJoinAndLeaveMessages, Boolean.class)) return; - player.sendMessage( - Component - .text(">>> ").color(NamedTextColor.GREEN) - .append(this.getAppliance().getReportablePlayerName(event.getPlayer())) - ); - }); - } - - @EventHandler - public void onPlayerLeave(PlayerQuitEvent event) { - boolean wasHidden = event.quitMessage() == null; - event.quitMessage(null); - if(wasHidden) return; - IteratorUtil.onlinePlayers(player -> { - if(!Settings.instance().getSetting(player, Settings.Key.ShowJoinAndLeaveMessages, Boolean.class)) return; - player.sendMessage( - Component - .text("<<< ").color(NamedTextColor.RED) - .append(this.getAppliance().getReportablePlayerName(event.getPlayer())) - ); - }); - } - - @EventHandler - public void onDeath(PlayerDeathEvent event) { - event.deathMessage( - Component - .text("☠ ") - .append( - Optional - .ofNullable(event.deathMessage()) - .orElse(Component.text(event.getPlayer().getName())) - ) - .color(TextColor.color(Color.SILVER.asRGB())) - ); - } -} diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/displayName/DisplayName.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/displayName/DisplayName.java index 2e9ec2d..eb05be0 100644 --- a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/displayName/DisplayName.java +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/displayName/DisplayName.java @@ -2,10 +2,12 @@ package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.displayName; import eu.mhsl.craftattack.spawn.core.Main; import eu.mhsl.craftattack.spawn.core.appliance.Appliance; +import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.networkChat.NetworkChat; import eu.mhsl.craftattack.spawn.core.util.server.Floodgate; import eu.mhsl.craftattack.spawn.core.util.text.ComponentUtil; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.ComponentBuilder; +import net.kyori.adventure.text.JoinConfiguration; import net.kyori.adventure.text.TextComponent; import net.kyori.adventure.text.event.HoverEvent; import net.kyori.adventure.text.format.NamedTextColor; @@ -49,15 +51,27 @@ public class DisplayName extends Appliance { .map(appliance -> (Prefixed) appliance) .toList(); - ComponentBuilder playerName = Component.text(); - prefixes.stream() + List prefixComponents = prefixes.stream() .map(prefixed -> prefixed.getNamePrefix(player)) .filter(Objects::nonNull) - .forEach(prefix -> playerName - .append(prefix) - .append(ComponentUtil.clearedSpace()) + .toList(); + + ComponentBuilder playerName = Component.text(); + prefixComponents.forEach(prefix -> playerName + .append(prefix) + .append(ComponentUtil.clearedSpace()) ); + // Den Chat-Namen baut Velocity selbst (Farbe, Bedrock), es braucht von hier nur die Prefixe + Main.instance().getAppliances().stream() + .filter(NetworkChat.class::isInstance) + .map(NetworkChat.class::cast) + .findFirst() + .ifPresent(networkChat -> networkChat.setPrefix( + player, + prefixComponents.isEmpty() ? null : Component.join(JoinConfiguration.separator(ComponentUtil.clearedSpace()), prefixComponents) + )); + if(Floodgate.isBedrock(player)) { playerName .append( diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/infoBars/InfoBarSetting.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/infoBars/InfoBarSetting.java index 23214ad..e264c09 100644 --- a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/infoBars/InfoBarSetting.java +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/infoBars/InfoBarSetting.java @@ -19,7 +19,8 @@ public class InfoBarSetting extends MultiBoolSetting infoBars = List.of( new TpsBar(), new MsptBar(), - new PlayerCounterBar() + new PlayerCounterBar(), + new TpsMsptPlayerCounterBar() ); public void showAllEnabled(Player player) { @@ -24,6 +26,7 @@ public class InfoBars extends Appliance { this.setVisible(player, MsptBar.name, config.mspt()); this.setVisible(player, PlayerCounterBar.name, config.playerCounter()); this.setVisible(player, TpsBar.name, config.tps()); + this.setVisible(player, TpsMsptPlayerCounterBar.name, config.tpsMsptPlayerCounter()); } public void setVisible(Player player, String bar, boolean visible) { diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/infoBars/bars/TpsMsptPlayerCounterBar.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/infoBars/bars/TpsMsptPlayerCounterBar.java new file mode 100644 index 0000000..962a066 --- /dev/null +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/infoBars/bars/TpsMsptPlayerCounterBar.java @@ -0,0 +1,103 @@ +package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.infoBars.bars; + +import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.infoBars.Bar; +import eu.mhsl.craftattack.spawn.common.appliances.tooling.playerlimit.PlayerLimit; +import eu.mhsl.craftattack.spawn.core.Main; +import eu.mhsl.craftattack.spawn.core.util.statistics.ServerMonitor; +import eu.mhsl.craftattack.spawn.core.util.text.ColorUtil; +import net.kyori.adventure.bossbar.BossBar; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextColor; +import org.bukkit.Bukkit; + +import java.time.Duration; + +public class TpsMsptPlayerCounterBar extends Bar { + public static String name = "serverStats"; + + @Override + protected Duration refresh() { + return Duration.ofSeconds(3); + } + + @Override + protected String name() { + return name; + } + + @Override + protected Component title() { + float tps = this.currentTps(); + float mspt = this.currentMspt(); + int players = this.currentPlayerCount(); + + Component separator = Component.text(" | ", NamedTextColor.DARK_GRAY); + TextColor color = ColorUtil.mapGreenToRed(players, 0, this.getMaxPlayerCount(), true); + + return Component.text() + .append(Component.text("TPS: ", NamedTextColor.GRAY)) + .append(Component.text(String.format("%.2f", tps), ColorUtil.tpsColor(tps))) + .append(separator) + .append(Component.text("MSPT: ", NamedTextColor.GRAY)) + .append(Component.text(String.format("%.2f", mspt), ColorUtil.msptColor(mspt))) + .append(separator) + .append(Component.text("Spieler: ", NamedTextColor.GRAY)) + .append(Component.text(players, color)) + .build(); + } + + @Override + protected float progress() { + return Math.clamp(this.currentTps() / 20f, 0f, 1f); + } + + @Override + protected BossBar.Color color() { + int severity = Math.max( + this.tpsSeverity(), + Math.max(this.msptSeverity(), this.playerSeverity()) + ); + + return switch (severity) { + case 2 -> BossBar.Color.RED; + case 1 -> BossBar.Color.YELLOW; + default -> BossBar.Color.GREEN; + }; + } + + @Override + protected BossBar.Overlay overlay() { + return BossBar.Overlay.PROGRESS; + } + + private int tpsSeverity() { + float tps = this.currentTps(); + return tps >= 18 ? 0 : tps >= 15 ? 1 : 2; + } + + private int msptSeverity() { + return this.currentMspt() <= 50 ? 0 : 2; + } + + private int playerSeverity() { + int freeSlots = this.getMaxPlayerCount() - this.currentPlayerCount(); + return freeSlots <= 0 ? 2 : freeSlots < 5 ? 1 : 0; + } + + private float currentTps() { + return (float) Bukkit.getTPS()[0]; + } + + private float currentMspt() { + return ServerMonitor.getServerMSPT(); + } + + private int currentPlayerCount() { + return Bukkit.getOnlinePlayers().size(); + } + + private int getMaxPlayerCount() { + return Main.instance().getAppliance(PlayerLimit.class).getLimit(); + } +} diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/chatMention/ChatMentionSetting.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/networkChat/ChatMentionSetting.java similarity index 91% rename from common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/chatMention/ChatMentionSetting.java rename to common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/networkChat/ChatMentionSetting.java index bba73b4..b74238e 100644 --- a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/chatMention/ChatMentionSetting.java +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/networkChat/ChatMentionSetting.java @@ -1,4 +1,4 @@ -package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.chatMention; +package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.networkChat; import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.settings.CategorizedSetting; import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.settings.SettingCategory; @@ -29,7 +29,7 @@ public class ChatMentionSetting extends MultiBoolSetting + * Kanal {@value #channel}, Nachrichten (UTF-Typ, dann Daten): + *
    + *
  • → Proxy {@code prefix}: Adventure-JSON oder leer
  • + *
  • → Proxy {@code settings-hello} beim Join, {@code settings-set} nach einer Änderung: Join/Leave, + * Erwähnungen hervorheben, Erwähnungston (je boolean). Velocity speichert sie; kennt es schon + * Werte, antwortet es auf das Hello mit {@code settings}.
  • + *
  • ← Proxy {@code settings}: gespeicherte Werte, werden hier übernommen
  • + *
+ */ +public class NetworkChat extends Appliance { + public static final String channel = "craftattack:chat"; + + private final Map prefixes = new ConcurrentHashMap<>(); + + @Override + public void onEnable() { + Bukkit.getMessenger().registerOutgoingPluginChannel(Main.instance(), channel); + Bukkit.getMessenger().registerIncomingPluginChannel(Main.instance(), channel, (channel, player, message) -> this.receive(player, message)); + + Settings.instance().declareSetting(ShowJoinAndLeaveMessagesSetting.class); + Settings.instance().declareSetting(ChatMentionSetting.class); + Settings.instance().addChangeListener(ShowJoinAndLeaveMessagesSetting.class, player -> this.sendSettings(player, "settings-set")); + Settings.instance().addChangeListener(ChatMentionSetting.class, player -> this.sendSettings(player, "settings-set")); + } + + public void setPrefix(Player player, @Nullable Component prefix) { + if(prefix == null) this.prefixes.remove(player.getUniqueId()); + else this.prefixes.put(player.getUniqueId(), prefix); + this.sendPrefix(player); + } + + void sendPrefix(Player player) { + Component prefix = this.prefixes.get(player.getUniqueId()); + ByteArrayDataOutput output = ByteStreams.newDataOutput(); + output.writeUTF("prefix"); + output.writeUTF(prefix == null ? "" : GsonComponentSerializer.gson().serialize(prefix)); + this.send(player, output); + } + + void sendSettings(Player player, String type) { + ChatMentionSetting.ChatMentionConfig mentions = Settings.instance() + .getSetting(player, Settings.Key.ChatMentions, ChatMentionSetting.ChatMentionConfig.class); + ByteArrayDataOutput output = ByteStreams.newDataOutput(); + output.writeUTF(type); + output.writeBoolean(Settings.instance().getSetting(player, Settings.Key.ShowJoinAndLeaveMessages, Boolean.class)); + output.writeBoolean(mentions.applyMentions()); + output.writeBoolean(mentions.notifyOnMention()); + this.send(player, output); + } + + private void receive(Player player, byte[] message) { + ByteArrayDataInput input = ByteStreams.newDataInput(message); + if(!input.readUTF().equals("settings")) return; + boolean joinAndLeave = input.readBoolean(); + boolean applyMentions = input.readBoolean(); + boolean notifyOnMention = input.readBoolean(); + Settings.instance().setSetting(player, Settings.Key.ShowJoinAndLeaveMessages, joinAndLeave); + Settings.instance().setSetting(player, Settings.Key.ChatMentions, new ChatMentionSetting.ChatMentionConfig(applyMentions, notifyOnMention)); + } + + private void send(Player player, ByteArrayDataOutput output) { + // Velocity meldet den Kanal erst kurz nach dem Join an, dann folgt PlayerRegisterChannelEvent + if(!player.getListeningPluginChannels().contains(channel)) return; + player.sendPluginMessage(Main.instance(), channel, output.toByteArray()); + } + + void forget(Player player) { + this.prefixes.remove(player.getUniqueId()); + } + + @Override + protected @NotNull List listeners() { + return List.of(new NetworkChatListener()); + } +} diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/networkChat/NetworkChatListener.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/networkChat/NetworkChatListener.java new file mode 100644 index 0000000..31fde0f --- /dev/null +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/networkChat/NetworkChatListener.java @@ -0,0 +1,74 @@ +package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.networkChat; + +import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener; +import io.papermc.paper.event.player.AsyncChatEvent; +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.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.entity.PlayerDeathEvent; +import org.bukkit.event.player.PlayerCommandPreprocessEvent; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.event.player.PlayerRegisterChannelEvent; + +import java.util.Locale; +import java.util.Optional; +import java.util.Set; + +class NetworkChatListener extends ApplianceListener { + // Vanilla-Befehle, die am Proxy vorbei in den lokalen Chat schreiben; /msg, /tell, /w fängt Velocity ab + private static final Set localChatCommands = Set.of( + "me", "minecraft:me", "teammsg", "tm", "minecraft:teammsg", "minecraft:tm", + "minecraft:msg", "minecraft:tell", "minecraft:w" + ); + + @EventHandler(priority = EventPriority.HIGHEST) + public void onChat(AsyncChatEvent event) { + // Velocity hat die Nachricht schon an alle verteilt + event.setCancelled(true); + } + + @EventHandler(priority = EventPriority.HIGHEST) + public void onCommand(PlayerCommandPreprocessEvent event) { + String label = event.getMessage().substring(1).split(" ", 2)[0].toLowerCase(Locale.ROOT); + if(!localChatCommands.contains(label)) return; + event.setCancelled(true); + event.getPlayer().sendMessage(Component.text("Dieser Befehl ist hier nicht verfügbar.", NamedTextColor.RED)); + } + + @EventHandler + public void onRegisterChannel(PlayerRegisterChannelEvent event) { + if(!event.getChannel().equals(NetworkChat.channel)) return; + this.getAppliance().sendPrefix(event.getPlayer()); + this.getAppliance().sendSettings(event.getPlayer(), "settings-hello"); + } + + @EventHandler(priority = EventPriority.HIGHEST) + public void onJoin(PlayerJoinEvent event) { + // Join- und Leave-Meldungen schreibt Velocity, beim Serverwechsel gar keine + event.joinMessage(null); + } + + @EventHandler(priority = EventPriority.HIGHEST) + public void onQuit(PlayerQuitEvent event) { + event.quitMessage(null); + this.getAppliance().forget(event.getPlayer()); + } + + @EventHandler + public void onDeath(PlayerDeathEvent event) { + event.deathMessage( + Component + .text("☠ ") + .append( + Optional + .ofNullable(event.deathMessage()) + .orElse(Component.text(event.getPlayer().getName())) + ) + .color(TextColor.color(Color.SILVER.asRGB())) + ); + } +} diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/chatMessages/ShowJoinAndLeaveMessagesSetting.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/networkChat/ShowJoinAndLeaveMessagesSetting.java similarity index 98% rename from common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/chatMessages/ShowJoinAndLeaveMessagesSetting.java rename to common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/networkChat/ShowJoinAndLeaveMessagesSetting.java index 272d491..0c9b3df 100644 --- a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/chatMessages/ShowJoinAndLeaveMessagesSetting.java +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/networkChat/ShowJoinAndLeaveMessagesSetting.java @@ -1,4 +1,4 @@ -package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.chatMessages; +package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.networkChat; import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.settings.CategorizedSetting; import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.settings.SettingCategory; diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/privateMessage/PrivateMessage.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/privateMessage/PrivateMessage.java deleted file mode 100644 index a571c53..0000000 --- a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/privateMessage/PrivateMessage.java +++ /dev/null @@ -1,194 +0,0 @@ -package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.privateMessage; - -import eu.mhsl.craftattack.spawn.core.Main; -import eu.mhsl.craftattack.spawn.core.appliance.Appliance; -import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand; -import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.chatMessages.ChatMessages; -import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.privateMessage.commands.PrivateMessageCommand; -import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.privateMessage.commands.PrivateReplyCommand; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.ComponentBuilder; -import net.kyori.adventure.text.TextComponent; -import net.kyori.adventure.text.event.ClickEvent; -import net.kyori.adventure.text.event.HoverEvent; -import net.kyori.adventure.text.format.NamedTextColor; -import org.bukkit.Bukkit; -import org.bukkit.entity.Player; -import org.jetbrains.annotations.NotNull; - -import java.util.*; -import java.util.stream.Collectors; - -public class PrivateMessage extends Appliance { - public final int targetChangeTimeoutSeconds = 30; - public final int conversationTimeoutMinutes = 30; - - private record Conversation(UUID target, Long lastSet) { - } - - private final Map> replyMapping = new WeakHashMap<>(); - - public void reply(Player sender, String message) { - this.replyMapping.computeIfAbsent(sender, player -> new ArrayList<>()); - - List replyList = this.replyMapping.get(sender); - - List tooOldConversations = replyList.stream() - .filter(conversation -> conversation.lastSet < System.currentTimeMillis() - (this.conversationTimeoutMinutes * 60 * 1000)) - .toList(); - replyList.removeAll(tooOldConversations); - - if(replyList.isEmpty()) throw new ApplianceCommand.Error("Du führst aktuell keine Konversation."); - - Component privatePrefix = Component.text("[Privat] ", NamedTextColor.LIGHT_PURPLE); - - Conversation youngestEntry = replyList.stream() - .max(Comparator.comparingLong(o -> o.lastSet)) - .orElse(replyList.getLast()); - - if(message.isBlank()) { - Player currentTargetPlayer = Bukkit.getPlayer(youngestEntry.target()); - - Component currentTargetComponent = currentTargetPlayer != null - ? Main.instance().getAppliance(ChatMessages.class).getReportablePlayerName(currentTargetPlayer) - : Component.text("niemandem."); - - sender.sendMessage( - privatePrefix - .append(Component.text("Du schreibst aktuell mit ", NamedTextColor.GRAY)) - .append(currentTargetComponent) - ); - return; - } - - List oldConversations = replyList.stream() - .filter(conversation -> conversation.lastSet < System.currentTimeMillis() - (this.targetChangeTimeoutSeconds * 1000)) - .toList(); - - if(oldConversations.contains(youngestEntry) || replyList.size() == 1) { - Player target = Bukkit.getPlayer(youngestEntry.target()); - if(target == null) - throw new ApplianceCommand.Error("Der Spieler " + Bukkit.getOfflinePlayer(youngestEntry.target()).getName() + " ist nicht mehr verfügbar."); - - replyList.clear(); - this.sendWhisper(sender, new ResolvedPmUserArguments(target, message)); - return; - } - - ComponentBuilder component = Component.text(); - - component.append( - Component.newline() - .append(privatePrefix) - .append(Component.text("Das Ziel für /r hat sich bei dir in den letzten ", NamedTextColor.RED)) - .append(Component.text(String.valueOf(this.targetChangeTimeoutSeconds), NamedTextColor.RED)) - .append(Component.text(" Sekunden geändert. Wer soll deine Nachricht erhalten? ", NamedTextColor.RED)) - .appendNewline() - .appendNewline() - ); - - if(!oldConversations.isEmpty()) { - Conversation youngestOldConversation = oldConversations.stream() - .max(Comparator.comparingLong(o -> o.lastSet)) - .orElse(oldConversations.getLast()); - replyList.removeAll(oldConversations); - replyList.add(youngestOldConversation); - } - - List playerNames = replyList.stream() - .map(conversation -> Bukkit.getOfflinePlayer(conversation.target()).getName()) - .distinct() - .toList(); - - playerNames.forEach(playerName -> component.append( - Component.text("[") - .append(Component.text(playerName, NamedTextColor.GOLD)) - .append(Component.text("]")) - .clickEvent(ClickEvent.runCommand(String.format("/msg %s %s", playerName, message))) - .hoverEvent(HoverEvent.showText(Component.text("Klicke, um diesem Spieler zu schreiben.").color(NamedTextColor.GOLD)))) - .append(Component.text(" ")) - ); - component.appendNewline(); - - sender.sendMessage(component.build()); - - } - - public void sendWhisper(Player sender, ResolvedPmUserArguments userArguments) { - Conversation newReceiverConversation = new Conversation( - sender.getUniqueId(), - System.currentTimeMillis() - ); - - if(this.replyMapping.get(userArguments.receiver) != null) { - List oldEntries = this.replyMapping.get(userArguments.receiver).stream() - .filter(conversation -> conversation.target() == sender.getUniqueId()) - .toList(); - this.replyMapping.get(userArguments.receiver).removeAll(oldEntries); - } else { - this.replyMapping.put( - userArguments.receiver, - new ArrayList<>() - ); - } - - this.replyMapping.get(userArguments.receiver).add(newReceiverConversation); - - List senderConversationList = new ArrayList<>(); - senderConversationList.add( - new Conversation( - userArguments.receiver.getUniqueId(), - System.currentTimeMillis() - ) - ); - - this.replyMapping.put( - sender, - senderConversationList - ); - - ChatMessages chatMessages = Main.instance().getAppliance(ChatMessages.class); - Component privatePrefix = Component.text("[Privat] ", NamedTextColor.LIGHT_PURPLE); - - sender.sendMessage( - Component.text() - .append(privatePrefix.clickEvent(ClickEvent.suggestCommand(String.format("/msg %s ", userArguments.receiver.getName())))) - .append(sender.displayName()) - .append(Component.text(" zu ", NamedTextColor.GRAY)) - .append(chatMessages.getReportablePlayerName(userArguments.receiver)) - .append(Component.text(" > ", NamedTextColor.GRAY)) - .append(Component.text(userArguments.message)) - ); - userArguments.receiver.sendMessage( - Component.text() - .append(privatePrefix.clickEvent(ClickEvent.suggestCommand(String.format("/msg %s ", sender.getName())))) - .append(chatMessages.getReportablePlayerName(sender)) - .append(Component.text(" zu ", NamedTextColor.GRAY)) - .append(userArguments.receiver.displayName()) - .append(Component.text(" > ", NamedTextColor.GRAY)) - .append(Component.text(userArguments.message)) - ); - } - - public record ResolvedPmUserArguments(Player receiver, String message) { - } - - public ResolvedPmUserArguments resolveImplicit(String[] args) { - if(args.length < 2) - throw new ApplianceCommand.Error("Es muss ein Spieler sowie eine Nachricht angegeben werden."); - List arguments = List.of(args); - Player targetPlayer = Bukkit.getPlayer(arguments.getFirst()); - if(targetPlayer == null) - throw new ApplianceCommand.Error(String.format("Der Spieler %s konnte nicht gefunden werden.", arguments.getFirst())); - String message = arguments.stream().skip(1).collect(Collectors.joining(" ")); - return new ResolvedPmUserArguments(targetPlayer, message); - } - - @Override - protected @NotNull List> commands() { - return List.of( - new PrivateMessageCommand(), - new PrivateReplyCommand() - ); - } -} diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/privateMessage/commands/PrivateMessageCommand.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/privateMessage/commands/PrivateMessageCommand.java deleted file mode 100644 index 35df21e..0000000 --- a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/privateMessage/commands/PrivateMessageCommand.java +++ /dev/null @@ -1,18 +0,0 @@ -package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.privateMessage.commands; - -import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand; -import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.privateMessage.PrivateMessage; -import org.bukkit.command.Command; -import org.bukkit.command.CommandSender; -import org.jetbrains.annotations.NotNull; - -public class PrivateMessageCommand extends ApplianceCommand.PlayerChecked { - public PrivateMessageCommand() { - super("msg"); - } - - @Override - protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) throws Exception { - this.getAppliance().sendWhisper(this.getPlayer(), this.getAppliance().resolveImplicit(args)); - } -} diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/privateMessage/commands/PrivateReplyCommand.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/privateMessage/commands/PrivateReplyCommand.java deleted file mode 100644 index 18f430c..0000000 --- a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/privateMessage/commands/PrivateReplyCommand.java +++ /dev/null @@ -1,18 +0,0 @@ -package eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.privateMessage.commands; - -import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand; -import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.privateMessage.PrivateMessage; -import org.bukkit.command.Command; -import org.bukkit.command.CommandSender; -import org.jetbrains.annotations.NotNull; - -public class PrivateReplyCommand extends ApplianceCommand.PlayerChecked { - public PrivateReplyCommand() { - super("r"); - } - - @Override - protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) throws Exception { - this.getAppliance().reply(this.getPlayer(), String.join(" ", args)); - } -} diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/report/Report.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/report/Report.java index f42e022..00ea524 100644 --- a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/report/Report.java +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/report/Report.java @@ -85,7 +85,7 @@ public class Report extends Appliance { .append( Component .text(createdReport.data().url(), NamedTextColor.GRAY) - .clickEvent(ClickEvent.clickEvent(ClickEvent.Action.OPEN_URL, createdReport.data().url())) + .clickEvent(ClickEvent.openUrl(createdReport.data().url())) ) .appendNewline() .appendNewline() diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/settings/Settings.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/settings/Settings.java index 2a6a0fc..e2cb94e 100644 --- a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/settings/Settings.java +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/settings/Settings.java @@ -112,6 +112,18 @@ public class Settings extends Appliance { return clazz.cast(setting.state()); } + /** Setzt einen Wert von außen (z. B. vom Proxy), ohne die Change-Listener auszulösen. */ + public void setSetting(Player player, Key key, T value) { + Setting setting = this.getSettings(player).stream() + .filter(s -> Objects.equals(s.getKey(), key)) + .findFirst() + .orElseThrow(); + + if(!setting.dataType().isInstance(value)) + throw new IllegalStateException("Tried to set Setting with Datatype " + value.getClass().getSimpleName() + " but expected " + setting.dataType().getSimpleName()); + setting.restore(player, value); + } + public void openSettings(Player player) { List> settings = this.getSettings(player); Inventory inventory = Bukkit.createInventory(null, this.calculateInvSize(settings), Component.text("Einstellungen")); diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/settings/datatypes/Setting.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/settings/datatypes/Setting.java index 24e28bb..c555a46 100644 --- a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/settings/datatypes/Setting.java +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/metaGameplay/settings/datatypes/Setting.java @@ -62,6 +62,12 @@ public abstract class Setting { Settings.instance().invokeChangeListener(p, this.getClass()); } + @SuppressWarnings("unchecked") + public void restore(Player p, Object value) { + this.state = (TDataType) value; + this.toStorage(p.getPersistentDataContainer(), this.state); + } + public ItemStack buildItem() { ItemStack stack = new ItemStack(this.icon(), 1); stack.setItemMeta(this.buildMeta(stack.getItemMeta())); diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/security/antiFormattedBook/AntiFormattedBook.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/security/antiFormattedBook/AntiFormattedBook.java index 6af6d80..09a03d2 100644 --- a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/security/antiFormattedBook/AntiFormattedBook.java +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/security/antiFormattedBook/AntiFormattedBook.java @@ -32,7 +32,7 @@ public class AntiFormattedBook extends Appliance { if (component instanceof TextComponent tc && tc.content().indexOf(SECTION) >= 0) return true; - if (component instanceof NBTComponent nbt) { + if (component instanceof NBTComponent nbt) { if (nbt.separator() != null && this.hasFormattingDeep(nbt.separator())) return true; } diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/adminChat/AdminChat.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/adminChat/AdminChat.java deleted file mode 100644 index c3786ce..0000000 --- a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/adminChat/AdminChat.java +++ /dev/null @@ -1,37 +0,0 @@ -package eu.mhsl.craftattack.spawn.common.appliances.tooling.adminChat; - -import eu.mhsl.craftattack.spawn.core.appliance.Appliance; -import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand; -import net.kyori.adventure.text.Component; -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.NotNull; - -import java.util.List; - -public class AdminChat extends Appliance { - public void sendMessage(Player sender, String message) { - Component privatePrefix = Component - .text("[Admin] ", NamedTextColor.LIGHT_PURPLE) - .clickEvent(ClickEvent.suggestCommand(AdminChatCommand.commandName)); - - Bukkit.getOnlinePlayers().stream() - .filter(player -> player.hasPermission("admin")) - .forEach(player -> { - Component formattedMessage = Component.text() - .append(privatePrefix) - .append(sender.displayName()) - .append(Component.text(" > ", NamedTextColor.DARK_GRAY)) - .append(Component.text(message)) - .build(); - player.sendMessage(formattedMessage); - }); - } - - @Override - protected @NotNull List> commands() { - return List.of(new AdminChatCommand()); - } -} diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/adminChat/AdminChatCommand.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/adminChat/AdminChatCommand.java deleted file mode 100644 index dfa9fda..0000000 --- a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/adminChat/AdminChatCommand.java +++ /dev/null @@ -1,22 +0,0 @@ -package eu.mhsl.craftattack.spawn.common.appliances.tooling.adminChat; - -import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand; -import org.bukkit.command.Command; -import org.bukkit.command.CommandSender; -import org.jetbrains.annotations.NotNull; - -class AdminChatCommand extends ApplianceCommand.PlayerChecked { - public static final String commandName = "adminchat"; - - public AdminChatCommand() { - super(commandName); - } - - @Override - protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) throws Exception { - if(!sender.hasPermission("admin")) return; - - String message = String.join(" ", args); - this.getAppliance().sendMessage(this.getPlayer(), message); - } -} diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/chatMute/ChatMute.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/chatMute/ChatMute.java deleted file mode 100644 index 0ffe686..0000000 --- a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/chatMute/ChatMute.java +++ /dev/null @@ -1,44 +0,0 @@ -package eu.mhsl.craftattack.spawn.common.appliances.tooling.chatMute; - -import eu.mhsl.craftattack.spawn.core.appliance.Appliance; -import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand; -import org.bukkit.NamespacedKey; -import org.bukkit.entity.Player; -import org.bukkit.event.Listener; -import org.bukkit.persistence.PersistentDataContainer; -import org.bukkit.persistence.PersistentDataType; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.List; -import java.util.Locale; -import java.util.Objects; - -public class ChatMute extends Appliance { - private static final String namespace = ChatMute.class.getSimpleName().toLowerCase(Locale.ROOT); - public static NamespacedKey mutedUntilKey = new NamespacedKey(namespace, "mutedUntilMillis".toLowerCase()); - - public void mutePlayer(Player player, int durationHours) { - PersistentDataContainer container = player.getPersistentDataContainer(); - long mutedUntil = System.currentTimeMillis() + (long) durationHours * 60 * 60 * 1000; - container.set(ChatMute.mutedUntilKey, PersistentDataType.LONG, mutedUntil); - } - - public @Nullable Long muteStatus(Player player) { - PersistentDataContainer container = player.getPersistentDataContainer(); - if(!container.has(mutedUntilKey)) return null; - long mutedUntil = Objects.requireNonNull(container.get(mutedUntilKey, PersistentDataType.LONG)); - if(mutedUntil < System.currentTimeMillis()) return null; - return mutedUntil; - } - - @Override - protected @NotNull List> commands() { - return List.of(new MuteCommand()); - } - - @Override - protected @NotNull List listeners() { - return List.of(new ChatMuteListener()); - } -} diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/chatMute/ChatMuteListener.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/chatMute/ChatMuteListener.java deleted file mode 100644 index 0e57ba2..0000000 --- a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/chatMute/ChatMuteListener.java +++ /dev/null @@ -1,25 +0,0 @@ -package eu.mhsl.craftattack.spawn.common.appliances.tooling.chatMute; - -import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener; -import eu.mhsl.craftattack.spawn.core.util.text.DataSizeConverter; -import io.papermc.paper.event.player.AsyncChatEvent; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; -import org.bukkit.event.EventHandler; -import org.jetbrains.annotations.Nullable; - -class ChatMuteListener extends ApplianceListener { - @EventHandler - public void onChat(AsyncChatEvent event) { - @Nullable Long muteDuration = this.getAppliance().muteStatus(event.getPlayer()); - if(muteDuration == null) return; - event.setCancelled(true); - event.getPlayer().sendMessage(Component.text( - String.format( - "Du bist für %s gestummt!", - DataSizeConverter.formatSecondsToHumanReadable((int) ((muteDuration - System.currentTimeMillis()) / 1000)) - ), - NamedTextColor.RED - )); - } -} diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/chatMute/MuteCommand.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/chatMute/MuteCommand.java deleted file mode 100644 index f9e3d86..0000000 --- a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/chatMute/MuteCommand.java +++ /dev/null @@ -1,35 +0,0 @@ -package eu.mhsl.craftattack.spawn.common.appliances.tooling.chatMute; - -import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand; -import org.bukkit.Bukkit; -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; - -import java.util.List; -import java.util.Objects; - -class MuteCommand extends ApplianceCommand { - public MuteCommand() { - super("mute"); - } - - @Override - protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) throws Exception { - if(args.length < 2) throw new Error("Syntax: mute "); - Player player = Objects.requireNonNull(Bukkit.getPlayer(args[0])); - int durationInHours = Integer.parseInt(args[1]); - this.getAppliance().mutePlayer(player, durationInHours); - sender.sendMessage(String.format("%s wurde für %d Stunden gestummt!", player.getName(), durationInHours)); - } - - @Override - public @Nullable List onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) { - if(args.length == 2) { - return List.of("1", "2", "4", "8", "24", "48"); - } - return null; - } -} diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginAttempt.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginAttempt.java new file mode 100644 index 0000000..d8e098b --- /dev/null +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginAttempt.java @@ -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); + } +} diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginLog.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginLog.java new file mode 100644 index 0000000..af4c1ca --- /dev/null +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginLog.java @@ -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 pendingAttempts = new ConcurrentHashMap<>(); + private final List 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 listeners() { + return List.of(new LoginLogListener()); + } +} diff --git a/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginLogListener.java b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginLogListener.java new file mode 100644 index 0000000..bf13108 --- /dev/null +++ b/common/src/main/java/eu/mhsl/craftattack/spawn/common/appliances/tooling/loginLog/LoginLogListener.java @@ -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 { + @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() + ); + } +} diff --git a/common/src/main/resources/migrations/loginLog/001_create_login_attempts.sql b/common/src/main/resources/migrations/loginLog/001_create_login_attempts.sql new file mode 100644 index 0000000..9440545 --- /dev/null +++ b/common/src/main/resources/migrations/loginLog/001_create_login_attempts.sql @@ -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; diff --git a/common/src/main/resources/migrations/loginLog/002_add_network.sql b/common/src/main/resources/migrations/loginLog/002_add_network.sql new file mode 100644 index 0000000..64f61c6 --- /dev/null +++ b/common/src/main/resources/migrations/loginLog/002_add_network.sql @@ -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}$'; diff --git a/common/src/main/resources/migrations/loginLog/003_add_lookup_indexes.sql b/common/src/main/resources/migrations/loginLog/003_add_lookup_indexes.sql new file mode 100644 index 0000000..3486e41 --- /dev/null +++ b/common/src/main/resources/migrations/loginLog/003_add_lookup_indexes.sql @@ -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; diff --git a/core/build.gradle b/core/build.gradle index 11e9be3..470d967 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -1,5 +1,5 @@ dependencies { - compileOnly 'io.papermc.paper:paper-api:1.21.10-R0.1-SNAPSHOT' + compileOnly 'io.papermc.paper:paper-api:26.2.build.129-stable' compileOnly 'org.geysermc.floodgate:api:2.2.4-SNAPSHOT' implementation 'org.apache.httpcomponents:httpclient:4.5.14' implementation 'com.sparkjava:spark-core:2.9.4' diff --git a/core/src/main/java/eu/mhsl/craftattack/spawn/core/Main.java b/core/src/main/java/eu/mhsl/craftattack/spawn/core/Main.java index a0ace2f..06d2470 100644 --- a/core/src/main/java/eu/mhsl/craftattack/spawn/core/Main.java +++ b/core/src/main/java/eu/mhsl/craftattack/spawn/core/Main.java @@ -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 appliances; private RepositoryLoader repositoryLoader; + private Database database; private Reflections reflections; @Override @@ -42,6 +44,8 @@ public final class Main extends JavaPlugin { Configuration.readConfig(); List 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 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; } diff --git a/core/src/main/java/eu/mhsl/craftattack/spawn/core/api/client/HttpRepository.java b/core/src/main/java/eu/mhsl/craftattack/spawn/core/api/client/HttpRepository.java index ee9eebf..849a655 100644 --- a/core/src/main/java/eu/mhsl/craftattack/spawn/core/api/client/HttpRepository.java +++ b/core/src/main/java/eu/mhsl/craftattack/spawn/core/api/client/HttpRepository.java @@ -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 ReqResp post(String command, Duration timeout, TInput data, Class 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 ReqResp put(String command, TInput data, Class 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 ReqResp get(String command, Duration timeout, Class 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 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 ReqResp execute(HttpRequest request, Class clazz, Object original) { ReqResp 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) : "", rawResponse )); return new ReqResp<>(rawResponse.status(), this.gson.fromJson(rawResponse.data(), clazz)); diff --git a/core/src/main/java/eu/mhsl/craftattack/spawn/core/api/server/HttpServer.java b/core/src/main/java/eu/mhsl/craftattack/spawn/core/api/server/HttpServer.java index 1b86556..acd284c 100644 --- a/core/src/main/java/eu/mhsl/craftattack/spawn/core/api/server/HttpServer.java +++ b/core/src/main/java/eu/mhsl/craftattack/spawn/core/api/server/HttpServer.java @@ -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); } diff --git a/core/src/main/java/eu/mhsl/craftattack/spawn/core/appliance/Appliance.java b/core/src/main/java/eu/mhsl/craftattack/spawn/core/appliance/Appliance.java index f59454b..e6abb5b 100644 --- a/core/src/main/java/eu/mhsl/craftattack/spawn/core/appliance/Appliance.java +++ b/core/src/main/java/eu/mhsl/craftattack/spawn/core/appliance/Appliance.java @@ -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//} in the resources. + */ + private String migrationsName() { + String name = this.getClass().getSimpleName(); + return Character.toLowerCase(name.charAt(0)) + name.substring(1); + } + protected T queryAppliance(Class 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); diff --git a/core/src/main/java/eu/mhsl/craftattack/spawn/core/appliance/ApplianceCommand.java b/core/src/main/java/eu/mhsl/craftattack/spawn/core/appliance/ApplianceCommand.java index 0de6055..810b547 100644 --- a/core/src/main/java/eu/mhsl/craftattack/spawn/core/appliance/ApplianceCommand.java +++ b/core/src/main/java/eu/mhsl/craftattack/spawn/core/appliance/ApplianceCommand.java @@ -30,6 +30,14 @@ public abstract class ApplianceCommand 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 { diff --git a/core/src/main/java/eu/mhsl/craftattack/spawn/core/database/Database.java b/core/src/main/java/eu/mhsl/craftattack/spawn/core/database/Database.java new file mode 100644 index 0000000..c5ef7fb --- /dev/null +++ b/core/src/main/java/eu/mhsl/craftattack/spawn/core/database/Database.java @@ -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 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 CompletableFuture query(SqlQuery query) { + CompletableFuture 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(); + } +} diff --git a/core/src/main/java/eu/mhsl/craftattack/spawn/core/database/Migrations.java b/core/src/main/java/eu/mhsl/craftattack/spawn/core/database/Migrations.java new file mode 100644 index 0000000..ac01ed3 --- /dev/null +++ b/core/src/main/java/eu/mhsl/craftattack/spawn/core/database/Migrations.java @@ -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//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> 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 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 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> findMigrations() { + if(migrationsByAppliance != null) return migrationsByAppliance; + Map> 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 appliedVersions(Connection connection, String appliance) throws SQLException { + Set 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); + } + } +} diff --git a/core/src/main/java/eu/mhsl/craftattack/spawn/core/event/LoginAttemptRecordedEvent.java b/core/src/main/java/eu/mhsl/craftattack/spawn/core/event/LoginAttemptRecordedEvent.java new file mode 100644 index 0000000..9fbf2e2 --- /dev/null +++ b/core/src/main/java/eu/mhsl/craftattack/spawn/core/event/LoginAttemptRecordedEvent.java @@ -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; + } +} diff --git a/core/src/main/java/eu/mhsl/craftattack/spawn/core/util/IteratorUtil.java b/core/src/main/java/eu/mhsl/craftattack/spawn/core/util/IteratorUtil.java index 336724c..54478e2 100644 --- a/core/src/main/java/eu/mhsl/craftattack/spawn/core/util/IteratorUtil.java +++ b/core/src/main/java/eu/mhsl/craftattack/spawn/core/util/IteratorUtil.java @@ -32,7 +32,7 @@ public class IteratorUtil { } public static void times(int times, Runnable callback) { - IntStream.range(0, times).forEach(value -> callback.run()); + IntStream.range(0, times).forEach(_ -> callback.run()); } public static void iterateListInGlobal(int sectionStart, List list, BiConsumer callback) { diff --git a/core/src/main/java/eu/mhsl/craftattack/spawn/core/util/entity/DisplayVillager.java b/core/src/main/java/eu/mhsl/craftattack/spawn/core/util/entity/DisplayVillager.java index 8970b21..e770052 100644 --- a/core/src/main/java/eu/mhsl/craftattack/spawn/core/util/entity/DisplayVillager.java +++ b/core/src/main/java/eu/mhsl/craftattack/spawn/core/util/entity/DisplayVillager.java @@ -8,6 +8,7 @@ import org.bukkit.configuration.ConfigurationSection; import org.bukkit.entity.EntityType; import org.bukkit.entity.Villager; +import java.util.Arrays; import java.util.Objects; import java.util.UUID; import java.util.function.Consumer; @@ -19,11 +20,14 @@ public class DisplayVillager { public DisplayVillager(UUID uuid, Location location, Consumer villagerCreator) { this.location = location; - try { - ChunkUtils.loadChunkAtLocation(this.location); - this.villager = (Villager) this.location.getWorld().getEntity(uuid); - Objects.requireNonNull(this.villager); - } catch(NullPointerException | IllegalArgumentException e) { + // getEntities() waits for the chunk's entities; getEntity(uuid) misses them while they are still loading + this.villager = Arrays.stream(ChunkUtils.loadChunkAtLocation(this.location).getEntities()) + .filter(entity -> entity.getUniqueId().equals(uuid)) + .filter(Villager.class::isInstance) + .map(Villager.class::cast) + .findFirst() + .orElse(null); + if(this.villager == null) { this.villager = this.getBaseVillager(); villagerCreator.accept(this.villager); } diff --git a/core/src/main/java/eu/mhsl/craftattack/spawn/core/util/network/Networks.java b/core/src/main/java/eu/mhsl/craftattack/spawn/core/util/network/Networks.java new file mode 100644 index 0000000..af47cb3 --- /dev/null +++ b/core/src/main/java/eu/mhsl/craftattack/spawn/core/util/network/Networks.java @@ -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); + } +} diff --git a/core/src/main/java/eu/mhsl/craftattack/spawn/core/util/server/Floodgate.java b/core/src/main/java/eu/mhsl/craftattack/spawn/core/util/server/Floodgate.java index b71cc09..22bc3e9 100644 --- a/core/src/main/java/eu/mhsl/craftattack/spawn/core/util/server/Floodgate.java +++ b/core/src/main/java/eu/mhsl/craftattack/spawn/core/util/server/Floodgate.java @@ -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 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 callback) { diff --git a/core/src/main/resources/config.yml b/core/src/main/resources/config.yml index f254c42..30e9431 100644 --- a/core/src/main/resources/config.yml +++ b/core/src/main/resources/config.yml @@ -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 @@ -56,9 +110,6 @@ tablist: projectTitle: "Title" interface: eth0 -outlawed: - voluntarily: [] - packselect: packs: - somepack: diff --git a/craftattack/build.gradle b/craftattack/build.gradle index a0be4ef..a70ad43 100644 --- a/craftattack/build.gradle +++ b/craftattack/build.gradle @@ -2,7 +2,7 @@ dependencies { implementation project(':core') implementation project(':common') - compileOnly 'io.papermc.paper:paper-api:1.21.10-R0.1-SNAPSHOT' + compileOnly 'io.papermc.paper:paper-api:26.2.build.129-stable' compileOnly 'org.geysermc.floodgate:api:2.2.4-SNAPSHOT' implementation 'org.apache.httpcomponents:httpclient:4.5.14' implementation 'com.sparkjava:spark-core:2.9.4' diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/api/repositories/WhitelistRepository.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/api/repositories/WhitelistRepository.java index cad137e..3e63032 100644 --- a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/api/repositories/WhitelistRepository.java +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/api/repositories/WhitelistRepository.java @@ -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 getUserData(UUID userId, Duration timeout) { + return this.get( + "users/%s".formatted(userId.toString()), + timeout, + UserData.class + ); + } } diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/newYearFirework/NewYearFirework.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/newYearFirework/NewYearFirework.java new file mode 100644 index 0000000..c02e1b4 --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/newYearFirework/NewYearFirework.java @@ -0,0 +1,48 @@ +package eu.mhsl.craftattack.spawn.craftattack.appliances.gameplay.newYearFirework; + +import eu.mhsl.craftattack.spawn.core.appliance.Appliance; +import org.bukkit.Color; +import org.bukkit.FireworkEffect; +import org.bukkit.NamespacedKey; +import org.bukkit.entity.Firework; +import org.bukkit.entity.Player; +import org.bukkit.event.Listener; +import org.bukkit.inventory.meta.FireworkMeta; +import org.bukkit.persistence.PersistentDataType; +import org.jetbrains.annotations.NotNull; + +import java.time.LocalDate; +import java.util.List; +import java.util.Locale; + +public class NewYearFirework extends Appliance { + private static final String namespace = NewYearFirework.class.getSimpleName().toLowerCase(Locale.ROOT); + public static NamespacedKey fireworkKey = new NamespacedKey(namespace, "newyear_firework".toLowerCase()); + + public void spawnFirework(Player player) { + for (int i = 0; i < 5; i++) { + Firework firework = player.getWorld().spawn(player.getLocation(), Firework.class); + firework.getPersistentDataContainer().set(fireworkKey, PersistentDataType.BOOLEAN, true); + FireworkMeta fireworkMeta = firework.getFireworkMeta(); + fireworkMeta.addEffect(FireworkEffect.builder() + .with(FireworkEffect.Type.BALL) + .withColor(Color.AQUA, Color.RED, Color.LIME, Color.ORANGE, Color.YELLOW) + .withFade(Color.FUCHSIA, Color.WHITE) + .trail(true) + .flicker(true) + .build() + ); + fireworkMeta.setPower((int) (i % 3 + 0.25)); + firework.setFireworkMeta(fireworkMeta); + } + } + + public boolean shouldSpawnFirework() { + return LocalDate.now().getDayOfYear() == 1; + } + + @Override + protected @NotNull List listeners() { + return List.of(new NewYearFireworkListener()); + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/newYearFirework/NewYearFireworkListener.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/newYearFirework/NewYearFireworkListener.java new file mode 100644 index 0000000..4a8f2ec --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/newYearFirework/NewYearFireworkListener.java @@ -0,0 +1,21 @@ +package eu.mhsl.craftattack.spawn.craftattack.appliances.gameplay.newYearFirework; + +import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener; +import org.bukkit.entity.Firework; +import org.bukkit.event.EventHandler; +import org.bukkit.event.entity.EntityDamageByEntityEvent; +import org.bukkit.event.player.PlayerJoinEvent; + +class NewYearFireworkListener extends ApplianceListener { + @EventHandler + public void onPlayerJoin(PlayerJoinEvent event) { + if(!this.getAppliance().shouldSpawnFirework()) return; + this.getAppliance().spawnFirework(event.getPlayer()); + } + @EventHandler + public void onDamage(EntityDamageByEntityEvent event) { + if(!(event.getDamager() instanceof Firework firework)) return; + if(!firework.getPersistentDataContainer().has(NewYearFirework.fireworkKey)) return; + event.setCancelled(true); + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/outlawed/OutlawChangeNotPermitted.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/outlawed/OutlawChangeNotPermitted.java deleted file mode 100644 index 605fca3..0000000 --- a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/outlawed/OutlawChangeNotPermitted.java +++ /dev/null @@ -1,7 +0,0 @@ -package eu.mhsl.craftattack.spawn.craftattack.appliances.gameplay.outlawed; - -class OutlawChangeNotPermitted extends Exception { - public OutlawChangeNotPermitted(String message) { - super(message); - } -} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/outlawed/Outlawed.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/outlawed/Outlawed.java deleted file mode 100644 index 08c31a2..0000000 --- a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/outlawed/Outlawed.java +++ /dev/null @@ -1,165 +0,0 @@ -package eu.mhsl.craftattack.spawn.craftattack.appliances.gameplay.outlawed; - -import eu.mhsl.craftattack.spawn.core.Main; -import eu.mhsl.craftattack.spawn.core.appliance.Appliance; -import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand; -import eu.mhsl.craftattack.spawn.common.appliances.metaGameplay.displayName.DisplayName; -import eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.whitelist.Whitelist; -import eu.mhsl.craftattack.spawn.core.config.Configuration; -import eu.mhsl.craftattack.spawn.core.util.text.DisconnectInfo; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.event.ClickEvent; -import net.kyori.adventure.text.event.HoverEvent; -import net.kyori.adventure.text.format.NamedTextColor; -import org.bukkit.Bukkit; -import org.bukkit.entity.Player; -import org.bukkit.event.Listener; -import org.jetbrains.annotations.NotNull; - -import java.util.*; - -public class Outlawed extends Appliance implements DisplayName.Prefixed { - public final int timeoutInMs = 1000 * 60 * 60 * 6; - private final Map timeouts = new HashMap<>(); - - public enum Status { - DISABLED, - VOLUNTARILY, - FORCED - } - - private final Map playerStatusMap = new WeakHashMap<>(); - private final String voluntarilyEntry = "voluntarily"; - - public Outlawed() { - super("outlawed"); - Bukkit.getScheduler().runTaskTimerAsynchronously( - Main.instance(), - () -> this.playerStatusMap.forEach((player, status) -> { - if(!player.isOnline()) return; - if(status != Status.FORCED) return; - try { - this.queryAppliance(Whitelist.class).fullIntegrityCheck(player); - } catch(DisconnectInfo.Throwable e) { - e.getDisconnectScreen().applyKick(player); - } - }), - 20 * 60, - 20 * 60 * 5 - ); - } - - void askForConfirmation(Player player) { - Component confirmationMessage = switch(this.getLawStatus(player)) { - case DISABLED -> Component.text("Wenn du den PVP-Modus aktivierst, darfst du von allen anderen PVP-Spielern grundlos angegriffen werden." + - " Du kannst den PVP-Modus erst wieder nach " + this.timeoutInMs / 1000 / 60 / 60 + " Stunden deaktivieren!"); - case VOLUNTARILY -> Component.text("Wenn du den PVP-Modus deaktivierst, darfst du nicht mehr grundlos von anderen Spielern angegriffen werden."); - case FORCED -> Component.text("Du darfst zurzeit deinen PVP-Modus nicht ändern, da die˝ser als Strafe auferlegt wurde!"); - }; - String command = String.format("/%s confirm", OutlawedCommand.commandName); - Component changeText = Component.text( - String.format( - "Zum ändern deines PVP-Status klicke auf diese Nachricht oder tippe '%s'", - command - ), - NamedTextColor.GOLD - ).clickEvent(ClickEvent.suggestCommand(command)); - - player.sendMessage( - Component.text() - .append(confirmationMessage.color(NamedTextColor.RED)) - .appendNewline() - .append(changeText) - ); - } - - void switchLawStatus(Player player) throws OutlawChangeNotPermitted { - if(this.getLawStatus(player).equals(Status.FORCED)) { - throw new OutlawChangeNotPermitted("Dein PVP-Status wurde als Strafe auferlegt und kann daher nicht verändert werden."); - } - - if(this.isTimeout(player)) { - throw new OutlawChangeNotPermitted("Du kannst deinen PVP-Status nicht so schnell wechseln. Bitte warte einige Stunden bevor du umschaltest!"); - } - - this.setLawStatus(player, this.isOutlawed(player) ? Status.DISABLED : Status.VOLUNTARILY); - this.setTimeout(player); - } - - public void updateForcedStatus(Player player, boolean forced) { - this.setLawStatus(player, forced ? Status.FORCED : this.getLawStatus(player) == Status.FORCED ? Status.DISABLED : this.getLawStatus(player)); - } - - public Status getLawStatus(Player player) { - return this.playerStatusMap.computeIfAbsent(player, p -> { - if(this.localConfig().getStringList(this.voluntarilyEntry).contains(p.getUniqueId().toString())) - return Status.VOLUNTARILY; - return Status.DISABLED; - }); - } - - private void setLawStatus(Player player, Status status) { - this.playerStatusMap.put(player, status); - this.queryAppliance(DisplayName.class).update(player); - - List newList = this.localConfig().getStringList(this.voluntarilyEntry); - if(status.equals(Status.VOLUNTARILY)) { - newList.add(player.getUniqueId().toString()); - } else { - newList.remove(player.getUniqueId().toString()); - } - this.localConfig().set(this.voluntarilyEntry, newList.stream().distinct().toList()); - Configuration.saveChanges(); - - } - - public boolean isOutlawed(Player player) { - return this.getLawStatus(player) != Status.DISABLED; - } - - private boolean isTimeout(Player player) { - return this.timeouts.getOrDefault(player.getUniqueId(), 0L) > System.currentTimeMillis() - this.timeoutInMs; - } - - private void setTimeout(Player player) { - this.timeouts.put(player.getUniqueId(), System.currentTimeMillis()); - } - - public Component getStatusDescription(Status status) { - return switch(status) { - case DISABLED -> Component.text("PVP-Modus inaktiv: ", NamedTextColor.GREEN) - .append(Component.text("Es gelten die normalen Regeln!", NamedTextColor.GOLD)); - - case VOLUNTARILY, FORCED -> Component.text("PVP-Modus aktiv: ", NamedTextColor.RED) - .append(Component.text( - "Du darfst von allen anderen PVP-Spielern angegriffen und getötet werden!\n" + - "Wenn du getötet wirst, müssen andere Spieler deine Items *nicht* zurückerstatten!", - NamedTextColor.GOLD - )); - }; - } - - @Override - public Component getNamePrefix(Player player) { - if(this.isOutlawed(player)) { - return Component.text("[☠]", NamedTextColor.RED) - .hoverEvent(HoverEvent.showText(Component.text( - "PVP-Modus Spieler dürfen von anderen vogelfreien Spielern ohne Grund angegriffen werden!" - ))); - } - - return null; - } - - @Override - @NotNull - protected List> commands() { - return List.of(new OutlawedCommand()); - } - - @Override - @NotNull - protected List listeners() { - return List.of(new OutlawedReminderListener()); - } -} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/outlawed/OutlawedCommand.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/outlawed/OutlawedCommand.java deleted file mode 100644 index 5c0980c..0000000 --- a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/outlawed/OutlawedCommand.java +++ /dev/null @@ -1,30 +0,0 @@ -package eu.mhsl.craftattack.spawn.craftattack.appliances.gameplay.outlawed; - -import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.format.NamedTextColor; -import org.bukkit.command.Command; -import org.bukkit.command.CommandSender; -import org.jetbrains.annotations.NotNull; - -class OutlawedCommand extends ApplianceCommand.PlayerChecked { - public static final String commandName = "pvp"; - - public OutlawedCommand() { - super(commandName); - } - - @Override - protected void execute(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) throws Exception { - if(args.length == 1 && args[0].equals("confirm")) { - try { - this.getAppliance().switchLawStatus(this.getPlayer()); - sender.sendMessage(this.getAppliance().getStatusDescription(this.getAppliance().getLawStatus(this.getPlayer()))); - } catch(OutlawChangeNotPermitted e) { - sender.sendMessage(Component.text(e.getMessage(), NamedTextColor.RED)); - } - } else { - this.getAppliance().askForConfirmation(this.getPlayer()); - } - } -} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/outlawed/OutlawedReminderListener.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/outlawed/OutlawedReminderListener.java deleted file mode 100644 index 9f71a13..0000000 --- a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/outlawed/OutlawedReminderListener.java +++ /dev/null @@ -1,14 +0,0 @@ -package eu.mhsl.craftattack.spawn.craftattack.appliances.gameplay.outlawed; - -import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener; -import org.bukkit.event.EventHandler; -import org.bukkit.event.player.PlayerJoinEvent; - -class OutlawedReminderListener extends ApplianceListener { - @EventHandler - public void onJoin(PlayerJoinEvent e) { - if(this.getAppliance().isOutlawed(e.getPlayer())) { - e.getPlayer().sendMessage(this.getAppliance().getStatusDescription(this.getAppliance().getLawStatus(e.getPlayer()))); - } - } -} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/quarzRecipe/QuarzRecipe.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/quarzRecipe/QuarzRecipe.java new file mode 100644 index 0000000..5e10e74 --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/quarzRecipe/QuarzRecipe.java @@ -0,0 +1,37 @@ +package eu.mhsl.craftattack.spawn.craftattack.appliances.gameplay.quarzRecipe; + +import eu.mhsl.craftattack.spawn.core.Main; +import eu.mhsl.craftattack.spawn.core.appliance.Appliance; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.entity.Player; +import org.bukkit.event.Listener; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.ShapelessRecipe; +import org.jetbrains.annotations.NotNull; + +import java.util.List; + +public class QuarzRecipe extends Appliance { + protected NamespacedKey recipeKey; + @Override + public void onEnable() { + this.recipeKey = new NamespacedKey(Main.instance(), "quarz_block_to_quarz"); + + ShapelessRecipe recipe = new ShapelessRecipe(this.recipeKey, new ItemStack(Material.QUARTZ, 4)); + recipe.addIngredient(Material.QUARTZ_BLOCK); + + Bukkit.addRecipe(recipe); + Bukkit.getOnlinePlayers().forEach(this::unlock); + } + + public void unlock(Player player) { + player.discoverRecipe(this.recipeKey); + } + + @Override + protected @NotNull List listeners() { + return List.of(new QuarzRecipeListener()); + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/quarzRecipe/QuarzRecipeListener.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/quarzRecipe/QuarzRecipeListener.java new file mode 100644 index 0000000..669d184 --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/quarzRecipe/QuarzRecipeListener.java @@ -0,0 +1,19 @@ +package eu.mhsl.craftattack.spawn.craftattack.appliances.gameplay.quarzRecipe; + +import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener; +import io.papermc.paper.event.player.PlayerInventorySlotChangeEvent; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.inventory.ItemStack; + +class QuarzRecipeListener extends ApplianceListener { + @EventHandler + public void shouldDiscover(PlayerInventorySlotChangeEvent event) { + ItemStack clickedItem = event.getNewItemStack(); + Player player = event.getPlayer(); + if(clickedItem.getType() != Material.QUARTZ_BLOCK) return; + if(player.hasDiscoveredRecipe(this.getAppliance().recipeKey)) return; + this.getAppliance().unlock(player); + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/metaGameplay/antiPlayerLootDespawn/AntiPlayerLootDespawn.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/metaGameplay/antiPlayerLootDespawn/AntiPlayerLootDespawn.java new file mode 100644 index 0000000..8825be4 --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/metaGameplay/antiPlayerLootDespawn/AntiPlayerLootDespawn.java @@ -0,0 +1,45 @@ +package eu.mhsl.craftattack.spawn.craftattack.appliances.metaGameplay.antiPlayerLootDespawn; + +import eu.mhsl.craftattack.spawn.core.appliance.Appliance; +import org.bukkit.NamespacedKey; +import org.bukkit.entity.Item; +import org.bukkit.event.Listener; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.jetbrains.annotations.NotNull; + +import java.util.List; +import java.util.Locale; + +public class AntiPlayerLootDespawn extends Appliance { + private static final int extraCycles = 5; + private static final String namespace = AntiPlayerLootDespawnListener.class.getSimpleName().toLowerCase(Locale.ROOT); + public static NamespacedKey cyclesKey = new NamespacedKey(namespace, "item_deaths_cycles".toLowerCase()); + + public void markItems(List item) { + item.forEach(itemStack -> + itemStack.editPersistentDataContainer(persistentDataContainer -> + persistentDataContainer.set(cyclesKey, PersistentDataType.INTEGER, extraCycles) + ) + ); + } + + public boolean isItemAboveMaxAge(Item item) { + ItemStack itemStack = item.getItemStack(); + + int cycles = itemStack.getPersistentDataContainer() + .getOrDefault(AntiPlayerLootDespawn.cyclesKey, PersistentDataType.INTEGER, 0); + + itemStack.editPersistentDataContainer(data -> + data.set(AntiPlayerLootDespawn.cyclesKey, PersistentDataType.INTEGER, cycles - 1) + ); + + item.setItemStack(itemStack); + return cycles > 0; + } + + @Override + protected @NotNull List listeners() { + return List.of(new AntiPlayerLootDespawnListener()); + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/metaGameplay/antiPlayerLootDespawn/AntiPlayerLootDespawnListener.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/metaGameplay/antiPlayerLootDespawn/AntiPlayerLootDespawnListener.java new file mode 100644 index 0000000..883702d --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/metaGameplay/antiPlayerLootDespawn/AntiPlayerLootDespawnListener.java @@ -0,0 +1,19 @@ +package eu.mhsl.craftattack.spawn.craftattack.appliances.metaGameplay.antiPlayerLootDespawn; + +import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener; +import org.bukkit.event.EventHandler; +import org.bukkit.event.entity.ItemDespawnEvent; +import org.bukkit.event.entity.PlayerDeathEvent; + +class AntiPlayerLootDespawnListener extends ApplianceListener { + + @EventHandler + public void onDeath(PlayerDeathEvent event) { + this.getAppliance().markItems(event.getDrops()); + } + + @EventHandler + public void onItemDespawn(ItemDespawnEvent event) { + event.setCancelled(this.getAppliance().isItemAboveMaxAge(event.getEntity())); + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/metaGameplay/arenaPortal/ArenaCommand.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/metaGameplay/arenaPortal/ArenaCommand.java new file mode 100644 index 0000000..e97e355 --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/metaGameplay/arenaPortal/ArenaCommand.java @@ -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 { + 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()); + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/metaGameplay/arenaPortal/ArenaPortal.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/metaGameplay/arenaPortal/ArenaPortal.java new file mode 100644 index 0000000..b9b4148 --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/metaGameplay/arenaPortal/ArenaPortal.java @@ -0,0 +1,122 @@ +package eu.mhsl.craftattack.spawn.craftattack.appliances.metaGameplay.arenaPortal; + +import eu.mhsl.craftattack.spawn.core.Main; +import eu.mhsl.craftattack.spawn.core.api.client.ReqResp; +import eu.mhsl.craftattack.spawn.core.appliance.Appliance; +import eu.mhsl.craftattack.spawn.core.appliance.ApplianceCommand; +import eu.mhsl.craftattack.spawn.core.config.ConfigUtil; +import eu.mhsl.craftattack.spawn.core.config.Configuration; +import eu.mhsl.craftattack.spawn.core.util.entity.DisplayVillager; +import eu.mhsl.craftattack.spawn.core.util.listener.DismissInventoryOpenFromHolder; +import eu.mhsl.craftattack.spawn.core.util.listener.PlayerInteractAtEntityEventListener; +import eu.mhsl.craftattack.spawn.core.util.server.PluginMessage; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.entity.Player; +import org.bukkit.entity.Villager; +import org.bukkit.event.Listener; +import org.bukkit.inventory.ItemStack; +import org.jetbrains.annotations.NotNull; + +import java.util.Base64; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.logging.Level; + +/** + * Entrance to the arena server: sends a copy of the player's inventory there and connects the player. + * The inventory on this server is never changed. + */ +public class ArenaPortal extends Appliance { + private static final Component PREFIX = Component.text("[Arena] ", NamedTextColor.GOLD); + + private DisplayVillager.ConfigBound villager; + private final Set pendingTransfers = new HashSet<>(); + + public ArenaPortal() { + super("arenaPortal"); + } + + @Override + public void onEnable() { + if(this.localConfig().getConfigurationSection("villagerLocation") == null) { + Main.logger().warning("ArenaPortal: no villagerLocation configured, villager disabled"); + return; + } + this.villager = new DisplayVillager.ConfigBound( + this.localConfig(), + villager -> { + villager.customName(Component.text("Arena", NamedTextColor.RED)); + villager.setProfession(Villager.Profession.WEAPONSMITH); + villager.setVillagerType(Villager.Type.SAVANNA); + } + ); + } + + /** + * @return false if the villager only appears after the next restart + */ + public boolean updateVillagerPosition(Location location) { + if(this.villager != null) { + this.villager.updateLocation(location); + return true; + } + ConfigUtil.Position.writeLocation(this.localConfig().createSection("villagerLocation"), location); + Configuration.saveChanges(); + return false; + } + + public void sendToArena(Player player) { + if(!this.pendingTransfers.add(player.getUniqueId())) return; + player.sendActionBar(Component.text("Du wirst zur Arena gebracht...", NamedTextColor.GRAY)); + + String items = Base64.getEncoder().encodeToString(ItemStack.serializeItemsAsBytes(player.getInventory().getContents())); + ArenaRepository.InventorySnapshot snapshot = new ArenaRepository.InventorySnapshot(player.getUniqueId(), items); + + Bukkit.getScheduler().runTaskAsynchronously(Main.instance(), () -> { + boolean success; + try { + ReqResp 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> commands() { + return List.of(new ArenaCommand(), new MoveArenaVillagerCommand()); + } + + @Override + @NotNull + protected List 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()) + ); + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/metaGameplay/arenaPortal/ArenaRepository.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/metaGameplay/arenaPortal/ArenaRepository.java new file mode 100644 index 0000000..3f25a22 --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/metaGameplay/arenaPortal/ArenaRepository.java @@ -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 sendInventory(InventorySnapshot snapshot) { + return this.post("api/arena/inventory", Duration.ofSeconds(5), snapshot, Response.class); + } + + @Override + protected boolean logRequestData() { + return false; + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/metaGameplay/arenaPortal/MoveArenaVillagerCommand.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/metaGameplay/arenaPortal/MoveArenaVillagerCommand.java new file mode 100644 index 0000000..259b21c --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/metaGameplay/arenaPortal/MoveArenaVillagerCommand.java @@ -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 { + 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)); + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/security/antiGrief/AntiGrief.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/security/antiGrief/AntiGrief.java index e638b1e..9c8b976 100644 --- a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/security/antiGrief/AntiGrief.java +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/security/antiGrief/AntiGrief.java @@ -20,7 +20,7 @@ import org.bukkit.event.Event; import org.bukkit.event.Listener; import org.jetbrains.annotations.NotNull; -import javax.annotation.Nullable; +import org.jetbrains.annotations.Nullable; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentSkipListMap; diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/AccountLinksCommand.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/AccountLinksCommand.java new file mode 100644 index 0000000..c485524 --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/AccountLinksCommand.java @@ -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 – accounts sharing a connection with the player. + * Only visible to admins. + */ +class AccountLinksCommand extends ApplianceCommand.PlayerChecked { + 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]); + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/AdminNotifier.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/AdminNotifier.java new file mode 100644 index 0000000..f579817 --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/AdminNotifier.java @@ -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 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; + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/Attempt.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/Attempt.java new file mode 100644 index 0000000..ed7b871 --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/Attempt.java @@ -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_"); + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasion.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasion.java new file mode 100644 index 0000000..9585491 --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasion.java @@ -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 load() throws Exception; + } + + /** + * Everything an evaluation needs from the database, loaded in one go. + */ + private record Context(List partners, Map households, Map> 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 query(Database.SqlQuery query) { + return this.database.query(query).join(); + } + + /** + * Blocks for HTTP requests. Only for the worker thread. + */ + Map banStates(Collection 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. + */ + void loadFor(Player admin, Loader loader, Consumer 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. + */ + void queryFor(Player admin, Database.SqlQuery query, Consumer 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 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 online) { + try { + Context context = this.query(connection -> this.loadContext(connection, attempt)); + if(context == null) return; + + Set banStateNeeded = new HashSet<>(); + banStateNeeded.add(attempt.uuid()); + context.partners().forEach(partner -> banStateNeeded.add(partner.uuid())); + context.householdMembers().values().forEach(banStateNeeded::addAll); + Map banStates = this.banStatus.getAll(banStateNeeded); + + List 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 names = new HashMap<>(); + context.partners().forEach(partner -> names.put(partner.uuid(), partner.name())); + names.put(attempt.uuid(), attempt.name()); + + List notices = this.query(connection -> { + List 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 partners = this.repository.partnerAttempts(connection, attempt, this.window); + if(partners.isEmpty()) return null; + + Set involved = new HashSet<>(); + involved.add(attempt.uuid()); + partners.forEach(partner -> involved.add(partner.uuid())); + Map 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 banStates, + Map 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 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 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 listeners() { + return List.of(new BanEvasionListener()); + } + + @Override + protected @NotNull List> commands() { + return List.of(new AccountLinksCommand()); + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasionDialogs.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasionDialogs.java new file mode 100644 index 0000000..2457b01 --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasionDialogs.java @@ -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 banStates, Map households) { + } + + private record LinksView(String name, List links, List networks, @Nullable Long household) { + } + + private record HouseholdView(BanEvasionRepository.Household household, Map 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 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 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 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 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 networks) { + List 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 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 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 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 finden."; + List 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 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 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 networks = view.networks(); + List 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 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 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 shared = details.sharedNetworks(); + List inputs = new ArrayList<>(); + List 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 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 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 lines) { + return Component.join(JoinConfiguration.newlines(), lines); + } + + private static DialogAction adminAction(BiConsumer 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 action) { + return ActionButton.builder(label).width(BUTTON_WIDTH).action(adminAction((player, response) -> action.accept(player))).build(); + } + + private static ActionButton button(String label, Consumer action) { + return button(Component.text(label), action); + } + + /** + * Button that reads the inputs of the dialog. + */ + private static ActionButton inputButton(String label, BiConsumer 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 inputs, List 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); + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasionListener.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasionListener.java new file mode 100644 index 0000000..f66d7e4 --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasionListener.java @@ -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 { + @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()); + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasionRepository.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasionRepository.java new file mode 100644 index 0000000..38d263e --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanEvasionRepository.java @@ -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 reasons, + List sharedNetworks, + List 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 members) { + } + + record Exemption(long id, String network, Instant validFrom, Instant validUntil, @Nullable String createdByName, @Nullable String note) { + } + + @FunctionalInterface + private interface RowMapper { + 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 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 householdsOf(Connection connection, Collection 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 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> householdMembers(Connection connection, Collection 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> 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 reasons, Set 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 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 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 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 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 uuids, UUID admin, @Nullable String note, Instant at) throws SQLException { + boolean autoCommit = connection.getAutoCommit(); + connection.setAutoCommit(false); + try { + List 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 listHouseholds(Connection connection) throws SQLException { + return this.households(connection, ""); + } + + @Nullable Household household(Connection connection, long id) throws SQLException { + List households = this.households(connection, "WHERE h.id = ?", id); + return households.isEmpty() ? null : households.getFirst(); + } + + private List 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 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 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 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 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 List list(Connection connection, String sql, RowMapper mapper, Object... params) throws SQLException { + try(PreparedStatement statement = connection.prepareStatement(sql)) { + bind(statement, params); + List rows = new ArrayList<>(); + try(ResultSet row = statement.executeQuery()) { + while(row.next()) rows.add(mapper.map(row)); + } + return rows; + } + } + + private static @Nullable T first(Connection connection, String sql, RowMapper mapper, Object... params) throws SQLException { + List 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); + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanState.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanState.java new file mode 100644 index 0000000..c28ccc4 --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanState.java @@ -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 +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanStatusCache.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanStatusCache.java new file mode 100644 index 0000000..796d683 --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/BanStatusCache.java @@ -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 entries = new ConcurrentHashMap<>(); + private final Function lookup; + private final Duration ttl; + private final Duration backoff; + private final Clock clock; + private volatile Instant failingUntil = Instant.MIN; + + BanStatusCache(Function 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 getAll(Collection uuids) { + Map states = new LinkedHashMap<>(); + uuids.forEach(uuid -> states.put(uuid, this.get(uuid))); + return states; + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/CaseDecision.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/CaseDecision.java new file mode 100644 index 0000000..44f53ea --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/CaseDecision.java @@ -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 reasons) { + } + + /** + * @param newReasons reasons not yet recorded for the case (all reasons for a new case) + */ + record Result(boolean reopen, Set 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 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); + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/CaseEvaluator.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/CaseEvaluator.java new file mode 100644 index 0000000..a7e9246 --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/CaseEvaluator.java @@ -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 partnerAttempts, + Map banStates, + Map households, + Map> householdMembers, + Set onlineOnSameNetwork, + Duration rejectionWindow + ) { + } + + private CaseEvaluator() { + } + + static List evaluate(Input input) { + UUID self = input.current().uuid(); + Map> byPartner = new LinkedHashMap<>(); + input.partnerAttempts().stream() + .filter(attempt -> !attempt.uuid().equals(self)) + .forEach(attempt -> byPartner.computeIfAbsent(attempt.uuid(), uuid -> new ArrayList<>()).add(attempt)); + + List 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 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 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); + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/CaseStatus.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/CaseStatus.java new file mode 100644 index 0000000..3b969ab --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/CaseStatus.java @@ -0,0 +1,6 @@ +package eu.mhsl.craftattack.spawn.craftattack.appliances.tooling.banEvasion; + +enum CaseStatus { + OPEN, + CLOSED +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/OnlineNetworks.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/OnlineNetworks.java new file mode 100644 index 0000000..b9ec6ba --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/OnlineNetworks.java @@ -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 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 onlineOn(String network) { + return this.networks.entrySet().stream() + .filter(entry -> entry.getValue().equals(network)) + .map(Map.Entry::getKey) + .collect(Collectors.toUnmodifiableSet()); + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/Outcome.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/Outcome.java new file mode 100644 index 0000000..4fa0be0 --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/Outcome.java @@ -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; + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/PairFinding.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/PairFinding.java new file mode 100644 index 0000000..5740aca --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/PairFinding.java @@ -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 reasons, boolean banStatusUnknown) { + static PairFinding of(UUID x, UUID y, Map reasons, boolean banStatusUnknown) { + boolean ordered = x.toString().compareTo(y.toString()) < 0; + EnumMap 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); + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/Reason.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/Reason.java new file mode 100644 index 0000000..c0d038d --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/Reason.java @@ -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; + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/Severity.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/Severity.java new file mode 100644 index 0000000..55a27d4 --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/banEvasion/Severity.java @@ -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; + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/projectStart/ProjectStart.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/projectStart/ProjectStart.java index cf174bd..2484b8d 100644 --- a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/projectStart/ProjectStart.java +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/tooling/projectStart/ProjectStart.java @@ -26,10 +26,8 @@ import org.bukkit.inventory.ItemStack; import org.jetbrains.annotations.NotNull; import java.util.List; -import java.util.Map; import java.util.Objects; -import static java.util.Map.entry; import static org.bukkit.Sound.MUSIC_DISC_LAVA_CHICKEN; public class ProjectStart extends Appliance { @@ -57,17 +55,23 @@ public class ProjectStart extends Appliance { ) ); - private final Map, Boolean> gameRulesAfterStart = Map.ofEntries( - entry(GameRule.DO_DAYLIGHT_CYCLE, true), - entry(GameRule.DO_INSOMNIA, true), - entry(GameRule.DISABLE_RAIDS, false), - entry(GameRule.DO_FIRE_TICK, true), - entry(GameRule.DO_ENTITY_DROPS, true), - entry(GameRule.DO_PATROL_SPAWNING, true), - entry(GameRule.DO_TRADER_SPAWNING, true), - entry(GameRule.DO_WEATHER_CYCLE, true), - entry(GameRule.FALL_DAMAGE, true), - entry(GameRule.FIRE_DAMAGE, true) + private record GameRuleSetting(GameRule rule, T before, T after) { + void apply(boolean afterStart) { + IteratorUtil.worlds(world -> world.setGameRule(this.rule, afterStart ? this.after : this.before)); + } + } + + private final List> gameRules = List.of( + new GameRuleSetting<>(GameRules.ADVANCE_TIME, false, true), + new GameRuleSetting<>(GameRules.SPAWN_PHANTOMS, false, true), + new GameRuleSetting<>(GameRules.RAIDS, false, true), + new GameRuleSetting<>(GameRules.ENTITY_DROPS, false, true), + new GameRuleSetting<>(GameRules.SPAWN_PATROLS, false, true), + new GameRuleSetting<>(GameRules.SPAWN_WANDERING_TRADERS, false, true), + new GameRuleSetting<>(GameRules.ADVANCE_WEATHER, false, true), + new GameRuleSetting<>(GameRules.FALL_DAMAGE, false, true), + new GameRuleSetting<>(GameRules.FIRE_DAMAGE, false, true), + new GameRuleSetting<>(GameRules.FIRE_SPREAD_RADIUS_AROUND_PLAYER, 0, -1) ); public ProjectStart() { @@ -76,7 +80,7 @@ public class ProjectStart extends Appliance { this.countdown.addCustomAnnouncement( new Countdown.CustomAnnouncements( counter -> counter == this.startMusicAt, - counter -> this.glassLocation + _ -> this.glassLocation .getWorld() .playSound(this.glassLocation, MUSIC_DISC_LAVA_CHICKEN, SoundCategory.RECORDS, 500f, 1f) ) @@ -121,7 +125,7 @@ public class ProjectStart extends Appliance { this.setEnabled(false); IteratorUtil.worlds(World::getWorldBorder, worldBorder -> worldBorder.setSize(worldBorder.getMaxSize())); - IteratorUtil.worlds(world -> IteratorUtil.setGameRules(this.gameRulesAfterStart, false)); + this.gameRules.forEach(setting -> setting.apply(true)); Bukkit.getWorlds().getFirst().setFullTime(0); this.netherFireLocations.forEach(location -> Objects.requireNonNull(this.startWorld).getBlockAt(location).setType(Material.FIRE)); @@ -168,7 +172,7 @@ public class ProjectStart extends Appliance { worldBorder.setDamageAmount(0); }); - IteratorUtil.worlds(world -> world, world -> IteratorUtil.setGameRules(this.gameRulesAfterStart, true)); + this.gameRules.forEach(setting -> setting.apply(false)); this.resetAdvancements(); this.blockCycle.reset(); diff --git a/craftattack/src/main/resources/migrations/banEvasion/001_create_tables.sql b/craftattack/src/main/resources/migrations/banEvasion/001_create_tables.sql new file mode 100644 index 0000000..3e2f606 --- /dev/null +++ b/craftattack/src/main/resources/migrations/banEvasion/001_create_tables.sql @@ -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; diff --git a/event/build.gradle b/event/build.gradle index fa35910..3a2556e 100644 --- a/event/build.gradle +++ b/event/build.gradle @@ -2,7 +2,7 @@ dependencies { implementation project(':common') implementation project(':core') - compileOnly 'io.papermc.paper:paper-api:1.21.10-R0.1-SNAPSHOT' + compileOnly 'io.papermc.paper:paper-api:26.2.build.129-stable' compileOnly 'org.geysermc.floodgate:api:2.2.4-SNAPSHOT' implementation 'org.apache.httpcomponents:httpclient:4.5.14' implementation 'com.sparkjava:spark-core:2.9.4' diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 1af9e09..a351597 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/local.gradle.example b/local.gradle.example index b213bb6..c5e289e 100644 --- a/local.gradle.example +++ b/local.gradle.example @@ -1,6 +1,6 @@ tasks.register('deployVaroPlugin', Copy) { dependsOn ":varo:shadowJar" - from { project(":varo").shadowJar.archivePath } + from { project(":varo").shadowJar.archiveFile } into file('path') // path to plugins folder rename { fileName -> "varo.jar" } } @@ -10,15 +10,26 @@ tasks.register("uploadVaroPlugin") { doLast { def jarFile = project(":varo").tasks.named("shadowJar").get().outputs.files.singleFile - exec { + providers.exec { commandLine "scp", "-4", "-P", "22", jarFile.absolutePath, "user@host:path/varo.jar" - } + }.result.get() } } tasks.register('deployCraftAttackPlugin', Copy) { dependsOn ":craftattack:shadowJar" - from { project(":craftattack").shadowJar.archivePath } + from { project(":craftattack").shadowJar.archiveFile } 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() + } +} diff --git a/settings.gradle b/settings.gradle index 618596b..1845a3c 100644 --- a/settings.gradle +++ b/settings.gradle @@ -5,4 +5,5 @@ include 'craftattack' include 'common' include 'varo' include 'event' +include 'arena' diff --git a/varo/build.gradle b/varo/build.gradle index acddd52..559eead 100644 --- a/varo/build.gradle +++ b/varo/build.gradle @@ -2,7 +2,7 @@ dependencies { implementation project(':core') implementation project(':common') - compileOnly 'io.papermc.paper:paper-api:1.21.10-R0.1-SNAPSHOT' + compileOnly 'io.papermc.paper:paper-api:26.2.build.129-stable' implementation 'org.apache.httpcomponents:httpclient:4.5.14' implementation 'com.sparkjava:spark-core:2.9.4' } \ No newline at end of file diff --git a/varo/src/main/java/eu/mhsl/craftattack/spawn/varo/appliances/metaGameplay/forbiddenItems/ForbiddenPotionsListener.java b/varo/src/main/java/eu/mhsl/craftattack/spawn/varo/appliances/metaGameplay/forbiddenItems/ForbiddenPotionsListener.java index e11a17c..9a3b2f3 100644 --- a/varo/src/main/java/eu/mhsl/craftattack/spawn/varo/appliances/metaGameplay/forbiddenItems/ForbiddenPotionsListener.java +++ b/varo/src/main/java/eu/mhsl/craftattack/spawn/varo/appliances/metaGameplay/forbiddenItems/ForbiddenPotionsListener.java @@ -11,7 +11,7 @@ import org.bukkit.event.player.PlayerItemConsumeEvent; import org.bukkit.inventory.meta.PotionMeta; import org.bukkit.potion.PotionEffect; -import javax.annotation.Nullable; +import org.jetbrains.annotations.Nullable; import java.util.Collection; class ForbiddenPotionsListener extends ApplianceListener {