From 2d8f7cf4bf9646d708e953c0021eb1066c21c4ca Mon Sep 17 00:00:00 2001 From: M_Viper Date: Mon, 26 Oct 2026 21:35:36 +0000 Subject: [PATCH] Upload via GUI (6 Dateien) --- README.md | 100 ++++++ pom.xml | 24 ++ .../landhopperlimit/ChunkCache.java | 129 ++++++++ .../landhopperlimit/LandHopperLimit.java | 299 ++++++++++++++++++ src/main/resources/config.yml | 28 ++ src/main/resources/plugin.yml | 22 ++ 6 files changed, 602 insertions(+) create mode 100644 pom.xml create mode 100644 src/main/java/de/vipernetwork/landhopperlimit/ChunkCache.java create mode 100644 src/main/java/de/vipernetwork/landhopperlimit/LandHopperLimit.java create mode 100644 src/main/resources/config.yml create mode 100644 src/main/resources/plugin.yml diff --git a/README.md b/README.md index fa7c236..b4cd52e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,102 @@ # LandHopperLimit +Dieses Plugin begrenzt, wie viele **Trichter** (Hopper) ein **Land** haben darf. +Standard: **300 Trichter pro Land**. Ist das Limit erreicht, kann niemand mehr einen neuen Trichter in dem Land setzen. + +Bereits gesetzte Trichter bleiben immer stehen, es wird nichts gelöscht. + +--- + +## Was du brauchst + +- Einen **Paper-Server** (Version 1.21.4) +- Das Plugin **Lands** + +--- + +## Installation (5 Schritte) + +1. Lade die Datei `LandHopperLimit-1.0.0.jar` herunter. +2. Kopiere sie in den Ordner `plugins` deines Servers. +3. Starte den Server neu. +4. Gib einmal im Spiel (oder in der Konsole) diesen Befehl ein: + ``` + /lhl scan + ``` + Das zählt alle schon vorhandenen Trichter. Das dauert je nach Servergröße ein paar Minuten. Warte, bis "Scan fertig" erscheint. +5. Fertig! + +Den Scan musst du nur **einmal** machen. + +--- + +## Befehle + +| Befehl | Was er macht | Wer darf das | +|---|---|---| +| `/lhl` oder `/lhl info` | Zeigt, wie viele Trichter das Land hat, in dem du stehst | Alle | +| `/lhl scan` | Zählt alle Trichter aller Länder neu | Nur Admins | +| `/lhl reload` | Lädt die Einstellungen neu | Nur Admins | + +`/lhl` kannst du auch als `/landhopperlimit` oder `/trichterlimit` schreiben. + +--- + +## Rechte (Permissions) + +| Recht | Was es bewirkt | Standard | +|---|---|---| +| `landhopperlimit.info` | `/lhl info` benutzen | Alle | +| `landhopperlimit.admin` | `/lhl scan` und `/lhl reload` benutzen | Nur OPs | +| `landhopperlimit.bypass` | Das Limit ignorieren | Niemand | + +Wer `landhopperlimit.bypass` bekommt, darf unbegrenzt Trichter setzen (zum Beispiel für Teammitglieder). + +--- + +## Einstellungen ändern + +Öffne die Datei `plugins/LandHopperLimit/config.yml` mit einem Texteditor. + +**Limit ändern:** +```yaml +limit: 300 +``` +Ersetze `300` durch die Zahl, die du willst. + +**Nur bestimmte Welten:** +```yaml +worlds: + - world + - world_the_end +``` +Lässt du die Liste leer (`worlds: []`), gilt das Limit in **allen** Welten. + +**Nachrichten ändern:** Alle Texte stehen unter `messages:`. Farben schreibst du mit `&`, zum Beispiel `&c` für rot und `&e` für gelb. + +Nach jeder Änderung im Spiel `/lhl reload` eingeben. Ein Neustart ist nicht nötig. + +--- + +## Gut zu wissen + +- Das Limit gilt für **alle** Trichter, auch für spezielle Trichter von anderen Plugins, solange sie Trichter-Blöcke sind. +- Steht man **außerhalb eines Lands** (Wildnis), gibt es kein Limit. +- Die Zahlen werden in der Datei `plugins/LandHopperLimit/hopper-counts.yml` gespeichert. Diese Datei bitte **nicht von Hand ändern**. +- Wurden Trichter mit anderen Plugins oder Tools gesetzt (zum Beispiel WorldEdit), kann die Zahl kurz falsch sein. Mit `/lhl scan` stimmt sie wieder. + +--- + +## Hilfe, es klappt nicht! + +| Problem | Lösung | +|---|---| +| Plugin startet nicht | Prüfe, ob **Lands** installiert ist und der Server **Paper 1.21.4** ist | +| "Du stehst in keinem Land" | Stell dich in ein Land, in der Wildnis gibt es nichts zu zählen | +| Die Zahl stimmt nicht | `/lhl scan` ausführen | +| "Dafür hast du keine Rechte" | Du brauchst das Recht `landhopperlimit.admin` | +| Ein Spieler soll das Limit umgehen | Gib ihm das Recht `landhopperlimit.bypass` | + +--- + +Von Lukas (Viper Network) diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..40bc30f --- /dev/null +++ b/pom.xml @@ -0,0 +1,24 @@ + + + 4.0.0 + de.vipernetwork + LandHopperLimit + 1.0.0 + jar + + 21 + UTF-8 + + + papermchttps://repo.papermc.io/repository/maven-public/ + jitpackhttps://jitpack.io + + + io.papermc.paperpaper-api1.21.4-R0.1-SNAPSHOTprovided + com.github.angeschossenLandsAPI7.25.4provided + + + LandHopperLimit-${project.version} + src/main/resourcestrue + + diff --git a/src/main/java/de/vipernetwork/landhopperlimit/ChunkCache.java b/src/main/java/de/vipernetwork/landhopperlimit/ChunkCache.java new file mode 100644 index 0000000..b8770c0 --- /dev/null +++ b/src/main/java/de/vipernetwork/landhopperlimit/ChunkCache.java @@ -0,0 +1,129 @@ +package de.vipernetwork.landhopperlimit; + +import org.bukkit.Chunk; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.block.BlockState; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Logger; + +/** + * Speichert die Anzahl der Trichter pro Chunk, damit auch nicht geladene Chunks + * eines Lands mitgezählt werden können, ohne sie laden zu müssen. + */ +public final class ChunkCache { + + private final Map> counts = new ConcurrentHashMap<>(); + private final File file; + private final Logger logger; + private volatile boolean dirty; + + public ChunkCache(File file, Logger logger) { + this.file = file; + this.logger = logger; + } + + static long key(int x, int z) { + return ((long) x << 32) | (z & 0xFFFFFFFFL); + } + + public int get(UUID world, int x, int z) { + Map map = counts.get(world); + if (map == null) return 0; + return map.getOrDefault(key(x, z), 0); + } + + public void set(UUID world, int x, int z, int amount) { + Map map = counts.computeIfAbsent(world, w -> new ConcurrentHashMap<>()); + long k = key(x, z); + Integer old = amount <= 0 ? map.remove(k) : map.put(k, amount); + if (old == null ? amount > 0 : old != amount) dirty = true; + } + + public void add(UUID world, int x, int z, int delta) { + set(world, x, z, Math.max(0, get(world, x, z) + delta)); + } + + /** Zählt die Trichter in einem geladenen Chunk. Der Block "exclude" wird nicht mitgezählt. */ + public static int countHoppers(Chunk chunk, Block exclude) { + int n = 0; + for (BlockState state : chunk.getTileEntities(false)) { + if (state.getType() != Material.HOPPER) continue; + if (exclude != null && state.getX() == exclude.getX() && state.getY() == exclude.getY() + && state.getZ() == exclude.getZ()) continue; + n++; + } + return n; + } + + /** Zählt einen geladenen Chunk neu und speichert das Ergebnis. */ + public int recount(Chunk chunk, Block exclude) { + int n = countHoppers(chunk, exclude); + set(chunk.getWorld().getUID(), chunk.getX(), chunk.getZ(), n); + return n; + } + + public void load() { + if (!file.exists()) return; + YamlConfiguration yaml = YamlConfiguration.loadConfiguration(file); + ConfigurationSection worlds = yaml.getConfigurationSection("worlds"); + if (worlds == null) return; + for (String worldId : worlds.getKeys(false)) { + UUID uuid; + try { + uuid = UUID.fromString(worldId); + } catch (IllegalArgumentException e) { + continue; + } + Map map = counts.computeIfAbsent(uuid, w -> new ConcurrentHashMap<>()); + for (String entry : worlds.getStringList(worldId)) { + String[] p = entry.split(","); + if (p.length != 3) continue; + try { + int amount = Integer.parseInt(p[2].trim()); + if (amount > 0) map.put(key(Integer.parseInt(p[0].trim()), Integer.parseInt(p[1].trim())), amount); + } catch (NumberFormatException ignored) { + } + } + } + } + + /** Erstellt eine Kopie der Daten (auf dem Main-Thread aufrufen), die dann async gespeichert werden kann. */ + public Map> snapshot() { + Map> snap = new HashMap<>(); + counts.forEach((world, map) -> { + List list = new ArrayList<>(map.size()); + map.forEach((k, v) -> list.add((int) (k >> 32) + "," + (int) (long) k + "," + v)); + snap.put(world, list); + }); + dirty = false; + return snap; + } + + public boolean isDirty() { + return dirty; + } + + public synchronized void write(Map> snap) { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.options().setHeader(List.of("Automatisch erstellt - nicht von Hand bearbeiten.", + "Format: chunkX,chunkZ,anzahlTrichter")); + snap.forEach((world, list) -> yaml.set("worlds." + world, list)); + try { + file.getParentFile().mkdirs(); + yaml.save(file); + } catch (IOException e) { + logger.warning("Konnte " + file.getName() + " nicht speichern: " + e.getMessage()); + } + } +} diff --git a/src/main/java/de/vipernetwork/landhopperlimit/LandHopperLimit.java b/src/main/java/de/vipernetwork/landhopperlimit/LandHopperLimit.java new file mode 100644 index 0000000..44ffc5e --- /dev/null +++ b/src/main/java/de/vipernetwork/landhopperlimit/LandHopperLimit.java @@ -0,0 +1,299 @@ +package de.vipernetwork.landhopperlimit; + +import me.angeschossen.lands.api.LandsIntegration; +import me.angeschossen.lands.api.land.ChunkCoordinate; +import me.angeschossen.lands.api.land.Land; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; +import org.bukkit.Bukkit; +import org.bukkit.Chunk; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.block.BlockExplodeEvent; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.entity.EntityExplodeEvent; +import org.bukkit.event.world.ChunkLoadEvent; +import org.bukkit.event.world.ChunkUnloadEvent; +import org.bukkit.plugin.java.JavaPlugin; +import org.jetbrains.annotations.NotNull; + +import java.io.File; +import java.util.ArrayDeque; +import java.util.Collection; +import java.util.Deque; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +public final class LandHopperLimit extends JavaPlugin implements Listener, TabCompleter { + + private static final LegacyComponentSerializer LEGACY = LegacyComponentSerializer.legacyAmpersand(); + + private LandsIntegration lands; + private ChunkCache cache; + private int limit; + private Set worlds; + private boolean scanning; + + @Override + public void onEnable() { + saveDefaultConfig(); + loadSettings(); + + lands = LandsIntegration.of(this); + cache = new ChunkCache(new File(getDataFolder(), "hopper-counts.yml"), getLogger()); + cache.load(); + + // Bereits geladene Chunks direkt zählen + for (World world : Bukkit.getWorlds()) { + if (!worldEnabled(world)) continue; + for (Chunk chunk : world.getLoadedChunks()) cache.recount(chunk, null); + } + + getServer().getPluginManager().registerEvents(this, this); + var cmd = getCommand("landhopperlimit"); + if (cmd != null) { + cmd.setExecutor(this); + cmd.setTabCompleter(this); + } + + // Alle 5 Minuten speichern, falls sich etwas geändert hat + Bukkit.getScheduler().runTaskTimer(this, this::saveAsync, 20L * 300, 20L * 300); + getLogger().info("Limit: " + limit + " Trichter pro Land."); + } + + @Override + public void onDisable() { + if (cache != null) cache.write(cache.snapshot()); + } + + private void loadSettings() { + reloadConfig(); + limit = getConfig().getInt("limit", 300); + worlds = new HashSet<>(getConfig().getStringList("worlds")); + } + + private void saveAsync() { + if (!cache.isDirty()) return; + Map> snap = cache.snapshot(); + Bukkit.getScheduler().runTaskAsynchronously(this, () -> cache.write(snap)); + } + + private boolean worldEnabled(World world) { + return worlds.isEmpty() || worlds.contains(world.getName()); + } + + // ---------------------------------------------------------------- Zählen + + /** Zählt alle Trichter eines Lands. Geladene Chunks werden live gezählt, die anderen kommen aus dem Cache. */ + private int countLand(Land land, Block exclude) { + int total = 0; + for (World world : Bukkit.getWorlds()) { + Collection chunks = land.getChunks(world); + if (chunks == null || chunks.isEmpty()) continue; + UUID uid = world.getUID(); + for (ChunkCoordinate cc : chunks) { + int x = cc.getX(), z = cc.getZ(); + if (world.isChunkLoaded(x, z)) { + Block ex = exclude != null && exclude.getWorld().equals(world) + && (exclude.getX() >> 4) == x && (exclude.getZ() >> 4) == z ? exclude : null; + total += cache.recount(world.getChunkAt(x, z), ex); + } else { + total += cache.get(uid, x, z); + } + } + } + return total; + } + + // ---------------------------------------------------------------- Events + + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onPlaceCheck(BlockPlaceEvent event) { + Block block = event.getBlockPlaced(); + if (block.getType() != Material.HOPPER) return; + if (!worldEnabled(block.getWorld())) return; + + Player player = event.getPlayer(); + if (player.hasPermission("landhopperlimit.bypass")) return; + + Land land = lands.getLandByChunk(block.getWorld(), block.getX() >> 4, block.getZ() >> 4); + if (land == null) return; // Wildnis - kein Land-Limit + + int count = countLand(land, block); + if (count >= limit) { + event.setCancelled(true); + send(player, "messages.limit-reached", land.getName(), count); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onPlaced(BlockPlaceEvent event) { + Block b = event.getBlockPlaced(); + if (b.getType() == Material.HOPPER) cache.add(b.getWorld().getUID(), b.getX() >> 4, b.getZ() >> 4, 1); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onBreak(BlockBreakEvent event) { + removed(event.getBlock()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onEntityExplode(EntityExplodeEvent event) { + event.blockList().forEach(this::removed); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onBlockExplode(BlockExplodeEvent event) { + event.blockList().forEach(this::removed); + } + + private void removed(Block b) { + if (b.getType() == Material.HOPPER) cache.add(b.getWorld().getUID(), b.getX() >> 4, b.getZ() >> 4, -1); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onChunkLoad(ChunkLoadEvent event) { + if (!worldEnabled(event.getWorld())) return; + cache.recount(event.getChunk(), null); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onChunkUnload(ChunkUnloadEvent event) { + if (!worldEnabled(event.getWorld())) return; + cache.recount(event.getChunk(), null); + } + + // ---------------------------------------------------------------- Befehle + + @Override + public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, String[] args) { + String sub = args.length > 0 ? args[0].toLowerCase() : "info"; + switch (sub) { + case "info" -> { + if (!(sender instanceof Player p)) { + sender.sendMessage("Nur ingame nutzbar."); + return true; + } + if (!p.hasPermission("landhopperlimit.info")) { + send(p, "messages.no-permission", null, 0); + return true; + } + Land land = lands.getLandByChunk(p.getWorld(), p.getLocation().getBlockX() >> 4, p.getLocation().getBlockZ() >> 4); + if (land == null) { + send(p, "messages.no-land", null, 0); + return true; + } + send(p, "messages.info", land.getName(), countLand(land, null)); + } + case "scan" -> { + if (!sender.hasPermission("landhopperlimit.admin")) { + send(sender, "messages.no-permission", null, 0); + return true; + } + startScan(sender); + } + case "reload" -> { + if (!sender.hasPermission("landhopperlimit.admin")) { + send(sender, "messages.no-permission", null, 0); + return true; + } + loadSettings(); + send(sender, "messages.reloaded", null, 0); + } + default -> sender.sendMessage(LEGACY.deserialize("&7/" + label + " info &8| &7scan &8| &7reload")); + } + return true; + } + + @Override + public List onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String alias, String[] args) { + if (args.length != 1) return List.of(); + List options = sender.hasPermission("landhopperlimit.admin") ? List.of("info", "scan", "reload") : List.of("info"); + return options.stream().filter(s -> s.startsWith(args[0].toLowerCase())).toList(); + } + + /** Lädt alle Chunks aller Länder nacheinander und zählt die Trichter. Nur einmal nach der Installation nötig. */ + private void startScan(CommandSender sender) { + if (scanning) { + send(sender, "messages.scan-running", null, 0); + return; + } + Deque queue = new ArrayDeque<>(); + List worldList = Bukkit.getWorlds(); + for (Land land : lands.getLands()) { + for (int i = 0; i < worldList.size(); i++) { + Collection chunks = land.getChunks(worldList.get(i)); + if (chunks == null) continue; + for (ChunkCoordinate cc : chunks) queue.add(new long[]{i, cc.getX(), cc.getZ()}); + } + } + int total = queue.size(); + if (total == 0) { + sender.sendMessage(LEGACY.deserialize(msg("messages.scan-done").replace("{chunks}", "0"))); + return; + } + scanning = true; + sender.sendMessage(LEGACY.deserialize(msg("messages.scan-start").replace("{chunks}", String.valueOf(total)))); + + AtomicInteger done = new AtomicInteger(); + // Pro Tick ein paar Chunks async laden, damit der Server nicht laggt + Bukkit.getScheduler().runTaskTimer(this, task -> { + for (int n = 0; n < 4 && !queue.isEmpty(); n++) { + long[] e = queue.poll(); + World world = worldList.get((int) e[0]); + int x = (int) e[1], z = (int) e[2]; + if (world.isChunkLoaded(x, z)) { + cache.recount(world.getChunkAt(x, z), null); + progress(sender, done.incrementAndGet(), total); + continue; + } + world.getChunkAtAsync(x, z, false).handle((chunk, error) -> { + if (chunk != null && error == null) cache.recount(chunk, null); + progress(sender, done.incrementAndGet(), total); + return null; + }); + } + if (queue.isEmpty()) task.cancel(); + }, 1L, 1L); + } + + private void progress(CommandSender sender, int done, int total) { + if (done % 500 == 0 && done < total) { + sender.sendMessage(LEGACY.deserialize(msg("messages.scan-progress") + .replace("{done}", String.valueOf(done)).replace("{chunks}", String.valueOf(total)))); + } + if (done == total) { + scanning = false; + saveAsync(); + sender.sendMessage(LEGACY.deserialize(msg("messages.scan-done").replace("{chunks}", String.valueOf(total)))); + } + } + + // ---------------------------------------------------------------- Nachrichten + + private String msg(String path) { + return getConfig().getString("messages.prefix", "") + getConfig().getString(path, path); + } + + private void send(CommandSender to, String path, String land, int count) { + String text = msg(path) + .replace("{limit}", String.valueOf(limit)) + .replace("{count}", String.valueOf(count)) + .replace("{land}", land == null ? "-" : land); + Component c = LEGACY.deserialize(text); + to.sendMessage(c); + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml new file mode 100644 index 0000000..8dd9f64 --- /dev/null +++ b/src/main/resources/config.yml @@ -0,0 +1,28 @@ +# LandHopperLimit +# Begrenzt ALLE Trichter (normale + upgradebare) pro Land. +# Bereits gesetzte Trichter bleiben stehen. Ist ein Land schon über dem Limit, +# können dort nur keine neuen Trichter gesetzt werden. + +# Maximale Anzahl Trichter pro Land +limit: 300 + +# Welten, in denen das Limit gilt. Leer lassen = alle Welten. +# Beispiel: +# worlds: +# - world +# - world_the_end +worlds: [] + +# Farbcodes mit & (z.B. &c = rot, &e = gelb) +# Platzhalter: {limit}, {count}, {land}, {chunks}, {done} +messages: + prefix: '&8[&6Trichter&8] ' + limit-reached: '&cDas Land &e{land} &chat das Maximum von &e{limit} &cTrichtern erreicht.' + info: '&7Land &e{land}&7: &e{count}&7/&e{limit} &7Trichter' + no-land: '&cDu stehst in keinem Land.' + no-permission: '&cDafür hast du keine Rechte.' + reloaded: '&aConfig neu geladen.' + scan-start: '&7Scanne &e{chunks} &7Chunks aller Länder...' + scan-progress: '&7Scan: &e{done}&7/&e{chunks} &7Chunks' + scan-done: '&aScan fertig! &e{chunks} &aChunks gezählt.' + scan-running: '&cEin Scan läuft bereits.' diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml new file mode 100644 index 0000000..cb53341 --- /dev/null +++ b/src/main/resources/plugin.yml @@ -0,0 +1,22 @@ +name: LandHopperLimit +version: '${project.version}' +main: de.vipernetwork.landhopperlimit.LandHopperLimit +api-version: '1.20' +author: Lukas (Viper Network) +description: Begrenzt die Anzahl aller Trichter pro Lands-Land. +depend: [Lands] +commands: + landhopperlimit: + description: Trichter-Limit pro Land + usage: / [info|scan|reload] + aliases: [lhl, trichterlimit] +permissions: + landhopperlimit.info: + description: Zeigt die Trichter-Anzahl im aktuellen Land + default: true + landhopperlimit.admin: + description: Darf scan und reload nutzen + default: op + landhopperlimit.bypass: + description: Ignoriert das Limit beim Platzieren + default: false