diff --git a/README.md b/README.md
index 8319473..84f8f16 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,298 @@
-# LandsChunkInfo
+# LandsChunkInfo – Anleitung
+Dieses Plugin ist eine Erweiterung für das **Lands**-Plugin. Es zeigt Spielern:
+
+- wie viele Chunks sie schon besitzen,
+- wie viele noch frei sind,
+- **wann wieder ein Chunk frei wird** (nach dem Unclaimen oder durch den Upkeep).
+
+Du brauchst keine Programmierkenntnisse. Diese Anleitung erklärt alles Schritt für Schritt.
+
+---
+
+## 1. Was brauche ich?
+
+| Was | Pflicht? | Wofür |
+|---|---|---|
+| Ein Minecraft-Server mit **Paper** (Version 1.21 bis 26) | Ja | Das Plugin läuft darauf |
+| **Java 25** auf dem Server | Ja | Lands 8.8.0 braucht das ebenfalls |
+| Das Plugin **Lands** (Version 8.8.0) | Ja | Ohne Lands startet unser Plugin nicht |
+| **Vault** + ein Economy-Plugin (z. B. EssentialsX) | Nein | Nur für das Bezahlen mit Spielergeld (`skip`, `gift`) |
+| **PlaceholderAPI** | Nein | Nur für Platzhalter in Scoreboard, Tab-Liste usw. |
+
+Folia wird **nicht** unterstützt.
+
+---
+
+## 2. Installation (5 Schritte)
+
+1. Stoppe den Server (oder lass ihn laufen, ein Neustart ist trotzdem später nötig).
+2. Kopiere die Datei `LandsChunkInfo-1.0.0.jar` in den Ordner `plugins` deines Servers. Dort liegt auch die Lands-Jar.
+3. Starte den Server neu.
+4. Schau in die Konsole. Du solltest `[LandsChunkInfo] Enabling LandsChunkInfo` sehen. Steht dort ein roter Fehler, lies Abschnitt 9.
+5. Im Ordner `plugins/LandsChunkInfo/` wurde jetzt eine Datei `config.yml` erstellt. Dort kannst du alles einstellen (Abschnitt 5).
+
+Fertig. Spieler können ab sofort `/chunkinfo` benutzen.
+
+---
+
+## 3. Was können die Spieler? (Befehle)
+
+Der Hauptbefehl heißt `/chunkinfo`. Es gehen auch die Kurzformen `/chunks` und `/chunkslots`.
+
+### Übersicht anzeigen
+
+| Befehl | Was passiert |
+|---|---|
+| `/chunkinfo` | Zeigt alle deine Lands: Chunks belegt, Chunks frei, gesperrte Slots, wann der nächste Chunk frei wird, Upkeep |
+| `/chunkinfo MeinLand` | Zeigt nur dieses eine Land |
+| `/chunkinfo top` | Zeigt die Lands mit den meisten Chunks |
+
+**Beispiel einer Anzeige:**
+
+```
+Deine Chunks
+Von dir geclaimt: 12 / 20
+ MeinLand
+Chunks: 12 / 20 | Frei: 6 | Gesperrt: 2
+Nächster Chunk wieder frei in: 3Std 12Min (23.10. 14:30)
+Sofort freischalten: /chunkinfo skip (Preis: 80,00)
+Upkeep: 36,00 pro Zyklus, nächster Einzug in 2T 4Std
+```
+
+Was bedeuten die Wörter?
+
+- **Frei** = So viele Chunks kannst du jetzt sofort claimen.
+- **Gesperrt** = Du hast einen Chunk abgegeben (`/unclaim`). Dieser Platz ist für eine Weile blockiert. Nach der Wartezeit ist er wieder frei.
+- **Upkeep** = Die regelmäßige Gebühr, die dein Land zahlen muss, damit es seine Chunks behält.
+
+### Gesperrten Slot sofort freikaufen
+
+Du willst nicht warten? Dann kauf den Slot frei.
+
+| Befehl | Was passiert |
+|---|---|
+| `/chunkinfo skip` | Zeigt dir den Preis |
+| `/chunkinfo skip confirm` | Kauft den Slot wirklich. Ohne `confirm` wird **nichts** bezahlt |
+| `/chunkinfo gift MeinFreundLand` | Zeigt den Preis, um einen Slot in einem **fremden** Land freizukaufen |
+| `/chunkinfo gift MeinFreundLand confirm` | Kauft den Slot für das fremde Land. Die Mitglieder werden benachrichtigt |
+
+Der Preis wird günstiger, je kürzer die Wartezeit noch ist.
+
+### Chunk vormerken (Warteschlange)
+
+Der Chunk, auf dem du **gerade stehst**, ist frei, aber dein Land hat keinen freien Slot? Dann merk ihn vor. Sobald ein Slot frei wird, claimt das Plugin ihn **automatisch** für dich.
+
+| Befehl | Was passiert |
+|---|---|
+| `/chunkinfo queue` | Merkt den Chunk vor, auf dem du stehst (für dein Land) |
+| `/chunkinfo queue MeinLand` | Dasselbe, aber für ein bestimmtes Land |
+| `/chunkinfo queue list` | Zeigt deine vorgemerkten Chunks |
+| `/chunkinfo queue clear` | Löscht alle deine Vormerkungen |
+
+Wichtig: **Du musst online sein**, damit der Chunk geclaimt wird.
+
+### Fremden Chunk beobachten (Watchlist)
+
+Ein Chunk gehört jemand anderem, und du willst ihn haben, falls er frei wird? Stell dich drauf:
+
+| Befehl | Was passiert |
+|---|---|
+| `/chunkinfo watch` | Du bekommst eine Nachricht, sobald dieser Chunk frei wird |
+| `/chunkinfo watch list` | Zeigt, was du beobachtest |
+| `/chunkinfo watch clear` | Hört mit allem auf |
+
+Tipp: Wenn die Nachricht kommt, nutze danach `/chunkinfo queue`, um ihn dir zu sichern.
+
+### Chunk nur für kurze Zeit (Temp-Claim)
+
+Du brauchst einen Chunk nur für ein paar Tage (z. B. für ein Event)? Stell dich in den Chunk (er muss zu **deinem eigenen** Land gehören):
+
+| Befehl | Was passiert |
+|---|---|
+| `/chunkinfo temp 7` | Der Chunk wird nach 7 Tagen automatisch freigegeben |
+| `/chunkinfo temp list` | Zeigt deine Temp-Chunks |
+| `/chunkinfo temp cancel` | Nimmt dem Chunk, auf dem du stehst, das Ablaufdatum wieder weg |
+
+Wenn ein Temp-Chunk abläuft, wird dadurch **kein** Slot gesperrt.
+
+---
+
+## 4. Die Platzhalter (optional)
+
+Nur nötig, wenn du **PlaceholderAPI** hast und die Werte z. B. im Scoreboard oder in der Tab-Liste anzeigen willst. Die Werte beziehen sich auf das Hauptland des Spielers (sein eigenes Land, sonst das erste Land, in dem er vertraut ist).
+
+| Platzhalter | Zeigt |
+|---|---|
+| `%chunkinfo_land%` | Name des Lands |
+| `%chunkinfo_used%` | Belegte Chunks |
+| `%chunkinfo_max%` | Maximale Chunks |
+| `%chunkinfo_free%` | Freie Chunks |
+| `%chunkinfo_locked%` | Gesperrte Slots |
+| `%chunkinfo_next_free%` | Zeit, bis der nächste Slot frei wird (`-` wenn keiner gesperrt ist) |
+| `%chunkinfo_upkeep%` | Upkeep-Kosten pro Zyklus |
+| `%chunkinfo_upkeep_in%` | Zeit bis zum nächsten Upkeep |
+| `%chunkinfo_top_1_name%` | Name des Lands auf Platz 1 (statt 1 geht jede Zahl) |
+| `%chunkinfo_top_1_chunks%` | Chunks des Lands auf Platz 1 |
+
+---
+
+## 5. Einstellungen (config.yml)
+
+Die Datei findest du unter `plugins/LandsChunkInfo/config.yml`. Öffne sie mit einem Texteditor (Notepad++ oder VS Code, **nicht** mit dem Windows-Notepad, wenn du auf Umlaute achtest). Nach einer Änderung gibst du im Spiel oder in der Konsole `/chunkinfo reload` ein.
+
+**Wichtig:** Lass die Einrückungen (die Leerzeichen am Zeilenanfang) unverändert. Ändere nur die Werte hinter dem Doppelpunkt.
+
+### Wartezeit nach dem Unclaimen
+
+```yaml
+unclaim-cooldown:
+ enabled: true
+ duration: 24h
+ enforce: true
+```
+
+- `enabled` – `true` = Funktion an, `false` = aus.
+- `duration` – Wie lange ein Slot nach `/unclaim` gesperrt bleibt. Schreibweise:
+ - `30m` = 30 Minuten
+ - `12h` = 12 Stunden
+ - `2d` = 2 Tage
+ - `1d12h` = 1 Tag und 12 Stunden
+- `enforce` – `true` = Spieler können in dieser Zeit wirklich **nicht** neu claimen. `false` = es wird nur angezeigt, aber nichts blockiert.
+
+### Upkeep-Zeitpunkt
+
+```yaml
+upkeep:
+ anchor-date: ""
+```
+
+Das Plugin merkt sich automatisch, wann Lands das erste Mal Upkeep eingezogen hat, und rechnet danach weiter. **Bis dahin ist die Anzeige nur geschätzt.** Wenn du genau weißt, an welchem Tag zuletzt eingezogen wurde, trage das Datum ein, zum Beispiel `"2026-10-15"`.
+
+### Slot freikaufen
+
+```yaml
+skip:
+ enabled: true
+ payment: PLAYER
+ price-per-hour: 25.0
+ min-price: 50.0
+```
+
+- `payment` – `PLAYER` = das Geld kommt vom Spieler (Vault nötig). `LAND` = das Geld kommt aus der Landkasse (nur der Besitzer darf das). `gift` bezahlt immer der Spieler selbst.
+- `price-per-hour` – Preis pro verbleibender Stunde Wartezeit.
+- `min-price` – Mindestpreis, auch wenn nur noch wenige Minuten fehlen.
+
+Beispiel: Noch 10 Stunden Wartezeit, 25 pro Stunde ergibt 250.
+
+### Warteschlange, Watchlist, Temp-Claims
+
+```yaml
+queue:
+ max: 3
+watch:
+ max: 5
+temp-claims:
+ enabled: true
+ max-days: 30
+ max-per-land: 3
+```
+
+- `max` – Wie viele Einträge ein Spieler gleichzeitig haben darf.
+- `max-days` – Längste erlaubte Dauer für einen Temp-Chunk.
+- `max-per-land` – Wie viele Temp-Chunks ein Land gleichzeitig haben darf.
+
+### Hinweis beim Einloggen
+
+```yaml
+join-reminder:
+ enabled: true
+ upkeep-warning-hours: 48
+```
+
+Beim Einloggen sagt das Plugin dem Spieler, wie viele Slots frei sind. Wenn das Guthaben des Lands nicht für den Upkeep reicht und der Einzug in weniger als `upkeep-warning-hours` Stunden ansteht, kommt zusätzlich eine Warnung (nur für den Besitzer).
+
+### Texte ändern
+
+Unter `messages:` stehen die Meldungen. Farben schreibst du mit `&`:
+
+- `&a` = grün, `&c` = rot, `&e` = gelb, `&7` = grau
+
+`%time%` wird im Spiel durch die Restzeit ersetzt. Lass es stehen.
+
+---
+
+## 6. Rechte (Permissions)
+
+Rechte vergibst du mit einem Rechte-Plugin, z. B. LuckPerms.
+
+| Recht | Wer bekommt es standardmäßig? | Was erlaubt es? |
+|---|---|---|
+| `landschunkinfo.use` | Alle | `/chunkinfo` und `top` benutzen |
+| `landschunkinfo.skip` | Alle | Eigenen Slot freikaufen |
+| `landschunkinfo.gift` | Alle | Slot in fremdem Land freikaufen |
+| `landschunkinfo.queue` | Alle | Warteschlange benutzen |
+| `landschunkinfo.watch` | Alle | Watchlist benutzen |
+| `landschunkinfo.temp` | Alle | Temp-Claims benutzen |
+| `landschunkinfo.bypass` | Nur OPs | Wartezeit nach dem Unclaimen gilt **nicht** |
+| `landschunkinfo.admin` | Nur OPs | `/chunkinfo reload` |
+
+**Beispiel:** Du willst, dass niemand außer Teammitgliedern Slots verschenken darf. Entziehe allen das Recht `landschunkinfo.gift` und gib es nur der Team-Gruppe.
+
+---
+
+## 7. Häufige Fragen
+
+**Warum sieht ein Spieler „Gesperrt“, obwohl er nichts gemacht hat?**
+Jemand aus seinem Land hat einen Chunk abgegeben. Die Sperre gilt für das **ganze Land**, nicht für einzelne Spieler.
+
+**Warum wird bei manchen Unclaims nichts gesperrt?**
+Gesperrt wird nur, wenn ein Spieler selbst `/unclaim` benutzt. Nicht gesperrt wird, wenn das Land gelöscht wird, Chunks durch den Upkeep verloren gehen oder ein Temp-Chunk abläuft. Spieler mit dem Recht `landschunkinfo.bypass` lösen ebenfalls keine Sperre aus.
+
+**Die Upkeep-Zeit stimmt nicht.**
+Lands verrät dem Plugin nicht, wann genau der Zyklus gestartet ist. Nach dem ersten echten Einzug passt die Anzeige. Alternativ trägst du in `upkeep.anchor-date` ein bekanntes Einzugsdatum ein.
+
+**Muss ich das Plugin nach jeder Änderung neu starten?**
+Nein. Für Änderungen an der `config.yml` reicht `/chunkinfo reload`. Nur beim Austausch der Jar-Datei brauchst du einen Neustart.
+
+**Verlieren Spieler ihre Chunks, wenn ich das Plugin entferne?**
+Nein. Das Plugin ändert keine Lands-Daten außer bei zwei Dingen: Wenn die Warteschlange einen Chunk claimt oder wenn ein Temp-Chunk abläuft. Entfernst du das Plugin, bleiben alle bestehenden Chunks gleich. Die gespeicherten Sperren und Listen liegen nur in `plugins/LandsChunkInfo/data.yml`.
+
+**Kann ich einem Spieler mehr Chunk-Slots geben?**
+Nein, das geht nur in Lands selbst (`player-limits.yml`). Dieses Plugin ändert keine Limits.
+
+---
+
+## 8. Datensicherung
+
+Alles, was sich das Plugin merkt (Sperren, Warteschlange, Watchlist, Temp-Chunks, letzter Upkeep), steht in:
+
+`plugins/LandsChunkInfo/data.yml`
+
+Wenn du den Server umziehst oder ein Backup machst, kopiere diese Datei und die `config.yml` mit. **Bearbeite `data.yml` nicht von Hand, solange der Server läuft.** Das Plugin überschreibt die Datei sonst.
+
+---
+
+## 9. Wenn etwas nicht klappt
+
+| Problem | Lösung |
+|---|---|
+| Plugin startet nicht, in der Konsole steht etwas mit „Lands“ | Lands fehlt oder ist nicht aktiv. Prüfe, ob die Lands-Jar im Ordner `plugins` liegt und startet |
+| Fehler mit „Unsupported class file version“ oder „Java“ | Der Server läuft mit einem zu alten Java. Du brauchst **Java 25** |
+| `/chunkinfo` sagt „Du bist in keinem Land Mitglied“ | Der Spieler hat kein Land und ist in keinem als vertrauenswürdig eingetragen. Das ist normal |
+| `skip` oder `gift` meldet, Vault fehle | Installiere Vault und ein Economy-Plugin, oder stelle `skip.payment` auf `LAND` (dann geht nur `skip`, `gift` bleibt gesperrt) |
+| Die Platzhalter zeigen sich als Text, z. B. `%chunkinfo_used%` | PlaceholderAPI fehlt oder wurde nach unserem Plugin geladen. Starte den Server neu |
+| Änderungen an der `config.yml` wirken nicht | `/chunkinfo reload` ausführen. Falls es trotzdem nicht klappt: Auf Einrückungen und fehlende Anführungszeichen prüfen |
+| Spieler können nach dem Unclaimen trotzdem sofort claimen | Prüfe `unclaim-cooldown.enforce`, das muss `true` sein. Und prüfe, ob der Spieler das Recht `landschunkinfo.bypass` hat (OPs haben es automatisch) |
+
+Wenn du nicht weiterkommst: Kopiere die **roten Zeilen aus der Server-Konsole** (oder aus `logs/latest.log`) und gib sie weiter. Daraus lässt sich fast immer die Ursache erkennen.
+
+---
+
+## 10. Bekannte Grenzen
+
+- Das Plugin wurde noch nicht auf einem echten Server getestet. Probiere es erst auf einem Testserver aus.
+- Die Upkeep-Zeit ist bis zum ersten echten Einzug eine Schätzung.
+- Die Warteschlange und die Watchlist arbeiten nur für Spieler, die **online** sind.
+- Temp-Chunks laufen erst ab, wenn der Ersteller oder der Besitzer des Lands online ist.
+- Lands-Limits (z. B. maximale Chunks) kann das Plugin nicht verändern.
diff --git a/libs/Lands-8.8.0.jar b/libs/Lands-8.8.0.jar
new file mode 100644
index 0000000..5d1da16
Binary files /dev/null and b/libs/Lands-8.8.0.jar differ
diff --git a/libs/VaultAPI-1.7.1.jar b/libs/VaultAPI-1.7.1.jar
new file mode 100644
index 0000000..487b7af
Binary files /dev/null and b/libs/VaultAPI-1.7.1.jar differ
diff --git a/libs/placeholderapi-2.11.7.jar b/libs/placeholderapi-2.11.7.jar
new file mode 100644
index 0000000..27e1cd7
Binary files /dev/null and b/libs/placeholderapi-2.11.7.jar differ
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..f1ca64e
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,61 @@
+
+
+ 4.0.0
+ de.landschunkinfo
+ LandsChunkInfo
+ 1.0.0
+ jar
+
+
+ 25
+ UTF-8
+
+
+
+
+ papermc
+ https://repo.papermc.io/repository/maven-public/
+
+
+
+
+
+ io.papermc.paper
+ paper-api
+ 1.21.11-R0.1-SNAPSHOT
+ provided
+
+
+ me.angeschossen
+ lands
+ 8.8.0
+ system
+ ${project.basedir}/libs/Lands-8.8.0.jar
+
+
+ net.milkbowl
+ vaultapi
+ 1.7.1
+ system
+ ${project.basedir}/libs/VaultAPI-1.7.1.jar
+
+
+ me.clip
+ placeholderapi
+ 2.11.7
+ system
+ ${project.basedir}/libs/placeholderapi-2.11.7.jar
+
+
+
+
+ LandsChunkInfo-${project.version}
+
+
+ src/main/resources
+ true
+
+
+
+
diff --git a/src/main/java/de/landschunkinfo/ChunkExpansion.java b/src/main/java/de/landschunkinfo/ChunkExpansion.java
new file mode 100644
index 0000000..7c4c946
--- /dev/null
+++ b/src/main/java/de/landschunkinfo/ChunkExpansion.java
@@ -0,0 +1,84 @@
+package de.landschunkinfo;
+
+import me.angeschossen.lands.api.land.Land;
+import me.clip.placeholderapi.expansion.PlaceholderExpansion;
+import org.bukkit.OfflinePlayer;
+
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * Platzhalter (Bezug: Hauptland des Spielers, also eigenes Land oder erstes Land mit Vertrauen):
+ * %chunkinfo_land%, _used, _max, _free, _locked, _next_free, _upkeep, _upkeep_in,
+ * %chunkinfo_top__name%, %chunkinfo_top__chunks%
+ */
+final class ChunkExpansion extends PlaceholderExpansion {
+
+ private final LandsChunkInfo plugin;
+
+ ChunkExpansion(LandsChunkInfo plugin) {
+ this.plugin = plugin;
+ }
+
+ @Override
+ public String getIdentifier() {
+ return "chunkinfo";
+ }
+
+ @Override
+ public String getAuthor() {
+ return "LandsChunkInfo";
+ }
+
+ @Override
+ public String getVersion() {
+ return plugin.getPluginMeta().getVersion();
+ }
+
+ @Override
+ public boolean persist() {
+ return true;
+ }
+
+ @Override
+ public String onRequest(OfflinePlayer player, String params) {
+ String key = params.toLowerCase(Locale.ROOT);
+ if (key.startsWith("top_")) {
+ String[] p = key.split("_");
+ if (p.length != 3) {
+ return null;
+ }
+ try {
+ int rank = Integer.parseInt(p[1]);
+ List top = plugin.top(rank);
+ if (top.size() < rank) {
+ return "-";
+ }
+ Land l = top.get(rank - 1);
+ return p[2].equals("name") ? l.getName() : p[2].equals("chunks") ? String.valueOf(l.getChunksAmount()) : null;
+ } catch (NumberFormatException e) {
+ return null;
+ }
+ }
+ if (player == null) {
+ return "";
+ }
+ Land land = plugin.primaryLand(player.getUniqueId());
+ if (land == null) {
+ return key.equals("land") ? "-" : "0";
+ }
+ List locked = plugin.cooldownActive() ? plugin.store.active(LandsChunkInfo.landId(land)) : List.of();
+ long now = System.currentTimeMillis();
+ return switch (key) {
+ case "land" -> land.getName();
+ case "used" -> String.valueOf(land.getChunksAmount());
+ case "max" -> land.getMaxChunks() <= 0 ? "unbegrenzt" : String.valueOf(land.getMaxChunks());
+ case "free" -> plugin.freeSlots(land) < 0 ? "unbegrenzt" : String.valueOf(plugin.freeSlots(land));
+ case "locked" -> String.valueOf(locked.size());
+ case "next_free" -> locked.isEmpty() ? "-" : TimeUtil.format(locked.get(0) - now);
+ case "upkeep" -> String.format(Locale.GERMANY, "%.2f", land.getUpkeepCosts());
+ case "upkeep_in" -> TimeUtil.format(plugin.nextUpkeep() - now);
+ default -> null;
+ };
+ }
+}
diff --git a/src/main/java/de/landschunkinfo/InfoCommand.java b/src/main/java/de/landschunkinfo/InfoCommand.java
new file mode 100644
index 0000000..e9e92bd
--- /dev/null
+++ b/src/main/java/de/landschunkinfo/InfoCommand.java
@@ -0,0 +1,482 @@
+package de.landschunkinfo;
+
+import de.landschunkinfo.LockStore.Entry;
+import de.landschunkinfo.LockStore.Kind;
+import me.angeschossen.lands.api.framework.limit.Limit;
+import me.angeschossen.lands.api.land.Land;
+import me.angeschossen.lands.api.player.LandPlayer;
+import org.bukkit.Bukkit;
+import org.bukkit.World;
+import org.bukkit.command.Command;
+import org.bukkit.command.CommandExecutor;
+import org.bukkit.command.CommandSender;
+import org.bukkit.command.TabCompleter;
+import org.bukkit.configuration.file.YamlConfiguration;
+import org.bukkit.entity.Player;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.UUID;
+
+final class InfoCommand implements CommandExecutor, TabCompleter {
+
+ private static final List SUBS = List.of("top", "skip", "gift", "queue", "watch", "temp", "reload");
+
+ private final LandsChunkInfo plugin;
+
+ InfoCommand(LandsChunkInfo plugin) {
+ this.plugin = plugin;
+ }
+
+ // ------------------------------------------------------------ Dispatcher
+
+ @Override
+ public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
+ if (args.length >= 1 && args[0].equalsIgnoreCase("reload")) {
+ if (!sender.hasPermission("landschunkinfo.admin")) {
+ plugin.send(sender, plugin.cfgMsg("no-permission"));
+ return true;
+ }
+ plugin.reload();
+ plugin.send(sender, plugin.cfgMsg("reloaded"));
+ return true;
+ }
+ if (!(sender instanceof Player p)) {
+ plugin.send(sender, plugin.cfgMsg("players-only"));
+ return true;
+ }
+ if (!p.hasPermission("landschunkinfo.use")) {
+ plugin.send(p, plugin.cfgMsg("no-permission"));
+ return true;
+ }
+ String sub = args.length == 0 ? "" : args[0].toLowerCase(Locale.ROOT);
+ switch (sub) {
+ case "top" -> top(p);
+ case "skip" -> skip(p, args, false);
+ case "gift" -> skip(p, args, true);
+ case "queue" -> queue(p, args);
+ case "watch" -> watch(p, args);
+ case "temp" -> temp(p, args);
+ default -> info(p, args);
+ }
+ return true;
+ }
+
+ private boolean allowed(Player p, String perm) {
+ if (p.hasPermission("landschunkinfo." + perm)) {
+ return true;
+ }
+ plugin.send(p, plugin.cfgMsg("no-permission"));
+ return false;
+ }
+
+ private Land findLand(String name) {
+ for (Object o : plugin.lands.getLands()) {
+ Land l = (Land) o;
+ if (l.getName().equalsIgnoreCase(name)) {
+ return l;
+ }
+ }
+ return null;
+ }
+
+ private static boolean isMember(Land land, UUID uuid) {
+ return uuid.equals(land.getOwnerUID()) || land.isTrusted(uuid);
+ }
+
+ // ------------------------------------------------------------------ Info
+
+ private void info(Player p, String[] args) {
+ List shown = plugin.landsOf(p.getUniqueId());
+ if (args.length >= 1) {
+ String wanted = String.join(" ", args);
+ shown = shown.stream().filter(l -> l.getName().equalsIgnoreCase(wanted)).toList();
+ if (shown.isEmpty()) {
+ plugin.send(p, plugin.cfgMsg("land-not-found"));
+ return;
+ }
+ } else if (shown.isEmpty()) {
+ plugin.send(p, plugin.cfgMsg("no-land"));
+ return;
+ }
+
+ LandPlayer lp = plugin.lands.getLandPlayer(p.getUniqueId());
+ if (lp != null) {
+ Limit limit = Limit.getById("player_chunks");
+ if (limit != null) {
+ int lim = lp.getLimit(limit);
+ p.sendMessage(plugin.line("&8&m &r &aDeine Chunks &8&m "));
+ p.sendMessage(plugin.line("&7Von dir geclaimt: &e" + lp.getChunksClaimed().size()
+ + " &7/ &e" + (lim < 0 ? "unbegrenzt" : lim)));
+ }
+ }
+ for (Land land : shown) {
+ describe(p, land);
+ }
+ p.sendMessage(plugin.line("&8Mehr: &7/chunkinfo top | skip | gift | queue | watch | temp"));
+ }
+
+ private void describe(Player p, Land land) {
+ int max = land.getMaxChunks();
+ int size = land.getChunksAmount();
+ String id = LandsChunkInfo.landId(land);
+ List locked = plugin.cooldownActive() ? plugin.store.active(id) : List.of();
+ long now = System.currentTimeMillis();
+
+ p.sendMessage(plugin.line("&8&m &r &a" + land.getName() + " &8&m "));
+ if (max <= 0) {
+ p.sendMessage(plugin.line("&7Chunks: &e" + size + " &7(kein Limit)"));
+ } else {
+ p.sendMessage(plugin.line("&7Chunks: &e" + size + " &7/ &e" + max + " &8| &7Frei: &a"
+ + plugin.freeSlots(land) + (locked.isEmpty() ? "" : " &8| &7Gesperrt: &c" + locked.size())));
+ }
+ if (plugin.cooldownActive()) {
+ if (locked.isEmpty()) {
+ p.sendMessage(plugin.line("&7Gesperrte Slots: &akeine"));
+ } else {
+ long first = locked.get(0);
+ p.sendMessage(plugin.line("&7Naechster Chunk wieder frei in: &e" + TimeUtil.format(first - now)
+ + " &8(" + LandsChunkInfo.fmt(first) + ")"));
+ if (locked.size() > 1) {
+ StringBuilder sb = new StringBuilder("&7Danach: ");
+ locked.stream().skip(1).limit(4).forEach(t -> sb.append("&e")
+ .append(TimeUtil.format(t - now)).append("&7, "));
+ sb.setLength(sb.length() - 2);
+ p.sendMessage(plugin.line(sb + (locked.size() > 5 ? " ..." : "")));
+ }
+ if (plugin.getConfig().getBoolean("skip.enabled", true)) {
+ p.sendMessage(plugin.line("&7Sofort freischalten: &e/chunkinfo skip &7(Preis: &e"
+ + price(first - now) + "&7)"));
+ }
+ }
+ }
+
+ for (Entry t : plugin.store.entries(Kind.TEMP)) {
+ if (t.landId().equals(id)) {
+ p.sendMessage(plugin.line("&7Temp-Chunk &e" + t.x() + ", " + t.z() + " &7laeuft ab in &e"
+ + TimeUtil.format(t.until() - now)));
+ }
+ }
+
+ double upkeep = land.getUpkeepCosts();
+ if (upkeep > 0) {
+ long next = plugin.nextUpkeep();
+ double balance = land.getBalance();
+ p.sendMessage(plugin.line("&7Upkeep: &e" + money(upkeep) + " &7pro Zyklus, naechster Einzug in &e"
+ + TimeUtil.format(next - now) + " &8(" + LandsChunkInfo.fmt(next) + ")"));
+ if (balance < upkeep) {
+ String risk = "";
+ YamlConfiguration lc = plugin.landsConfig();
+ if (lc != null && lc.getBoolean("upkeep.unclaim", false) && lc.getDouble("upkeep.per-chunk", 0) > 0) {
+ int lost = (int) Math.min(size, Math.ceil((upkeep - balance) / lc.getDouble("upkeep.per-chunk")));
+ risk = " - " + lost + " Chunk(s) wuerden verloren gehen!";
+ }
+ p.sendMessage(plugin.line("&cGuthaben (" + money(balance) + ") reicht nicht fuer den Upkeep." + risk));
+ }
+ }
+
+ Long inactive = plugin.inactivityRemaining(land);
+ if (inactive != null && inactive >= 0) {
+ boolean ownerOnline = land.getOwnerUID() != null && Bukkit.getPlayer(land.getOwnerUID()) != null;
+ p.sendMessage(plugin.line("&7Inaktivitaet des Besitzers: " + (ownerOnline
+ ? "&aBesitzer online"
+ : "&eLand gilt in &c" + TimeUtil.format(inactive) + " &eals inaktiv")));
+ }
+ }
+
+ private static String money(double v) {
+ return String.format(Locale.GERMANY, "%.2f", v);
+ }
+
+ // ------------------------------------------------------------------- Top
+
+ private void top(Player p) {
+ int n = plugin.getConfig().getInt("top.size", 10);
+ p.sendMessage(plugin.line("&8&m &r &aTop-Lands nach Chunks &8&m "));
+ int i = 1;
+ for (Land l : plugin.top(n)) {
+ p.sendMessage(plugin.line("&e#" + i++ + " &7" + l.getName() + " &8- &a" + l.getChunksAmount() + " Chunks"));
+ }
+ }
+
+ // ------------------------------------------------------------ Skip / Gift
+
+ private String price(long remainingMs) {
+ return money(priceValue(remainingMs));
+ }
+
+ private double priceValue(long remainingMs) {
+ double perHour = plugin.getConfig().getDouble("skip.price-per-hour", 25.0);
+ double min = plugin.getConfig().getDouble("skip.min-price", 50.0);
+ return Math.max(min, remainingMs / 3_600_000.0 * perHour);
+ }
+
+ /** /chunkinfo skip [Land] [confirm] bzw. /chunkinfo gift [confirm] */
+ private void skip(Player p, String[] args, boolean gift) {
+ if (!allowed(p, gift ? "gift" : "skip")) {
+ return;
+ }
+ if (!plugin.getConfig().getBoolean("skip.enabled", true) || !plugin.cooldownActive()) {
+ plugin.send(p, "&cDiese Funktion ist deaktiviert.");
+ return;
+ }
+ boolean confirm = args.length > 1 && args[args.length - 1].equalsIgnoreCase("confirm");
+ int end = confirm ? args.length - 1 : args.length;
+ String name = end > 1 ? String.join(" ", List.of(args).subList(1, end)) : null;
+
+ Land land;
+ if (name != null) {
+ land = findLand(name);
+ } else if (gift) {
+ plugin.send(p, "&cNutzung: /chunkinfo gift [confirm]");
+ return;
+ } else {
+ land = plugin.landsOf(p.getUniqueId()).stream()
+ .filter(l -> !plugin.store.active(LandsChunkInfo.landId(l)).isEmpty()).findFirst().orElse(null);
+ if (land == null) {
+ plugin.send(p, "&aDu hast keine gesperrten Slots.");
+ return;
+ }
+ }
+ if (land == null || (!gift && !isMember(land, p.getUniqueId()))) {
+ plugin.send(p, plugin.cfgMsg("land-not-found"));
+ return;
+ }
+ List locked = plugin.store.active(LandsChunkInfo.landId(land));
+ if (locked.isEmpty()) {
+ plugin.send(p, "&e" + land.getName() + " &ahat keine gesperrten Slots.");
+ return;
+ }
+ long remaining = locked.get(0) - System.currentTimeMillis();
+ double price = priceValue(remaining);
+ boolean fromLand = !gift && "LAND".equalsIgnoreCase(plugin.getConfig().getString("skip.payment", "PLAYER"));
+
+ if (fromLand && !p.getUniqueId().equals(land.getOwnerUID())) {
+ plugin.send(p, "&cNur der Besitzer darf aus der Landkasse zahlen.");
+ return;
+ }
+ if (!fromLand && plugin.vault == null) {
+ plugin.send(p, "&cDafuer wird Vault mit einem Economy-Plugin benoetigt.");
+ return;
+ }
+ if (!confirm) {
+ plugin.send(p, "&7Slot in &e" + land.getName() + " &7(noch &e" + TimeUtil.format(remaining)
+ + "&7) freischalten fuer &e" + money(price) + (fromLand ? " &7aus der Landkasse" : "")
+ + "&7. Bestaetigen: &e/chunkinfo " + (gift ? "gift " : "skip ") + land.getName() + " confirm");
+ return;
+ }
+
+ if (fromLand) {
+ if (land.getBalance() < price) {
+ plugin.send(p, "&cDie Landkasse hat nicht genug Guthaben.");
+ return;
+ }
+ land.setBalance(land.getBalance() - price);
+ } else {
+ if (plugin.vault.balance(p) < price || !plugin.vault.withdraw(p, price)) {
+ plugin.send(p, "&cDu hast nicht genug Geld.");
+ return;
+ }
+ }
+ if (!plugin.store.removeEarliest(LandsChunkInfo.landId(land))) {
+ plugin.send(p, "&cDer Slot ist inzwischen von selbst frei geworden.");
+ return;
+ }
+ plugin.send(p, "&aSlot in &e" + land.getName() + " &afreigeschaltet (&e" + money(price) + " &abezahlt).");
+ if (gift) {
+ for (Player m : plugin.onlineMembers(land)) {
+ plugin.send(m, "&a" + p.getName() + " hat einen Chunk-Slot fuer &e" + land.getName() + " &afreigeschaltet!");
+ }
+ }
+ }
+
+ // ----------------------------------------------------------------- Queue
+
+ private void queue(Player p, String[] args) {
+ if (!allowed(p, "queue")) {
+ return;
+ }
+ String arg = args.length > 1 ? args[1].toLowerCase(Locale.ROOT) : "";
+ if (arg.equals("list")) {
+ listEntries(p, Kind.QUEUE, "Warteschlange");
+ return;
+ }
+ if (arg.equals("clear")) {
+ plugin.send(p, "&7" + plugin.store.removeAll(Kind.QUEUE, p.getUniqueId()) + " Eintraege entfernt.");
+ return;
+ }
+ Land land;
+ if (args.length > 1) {
+ land = findLand(String.join(" ", List.of(args).subList(1, args.length)));
+ } else {
+ land = plugin.primaryLand(p.getUniqueId());
+ }
+ if (land == null || !isMember(land, p.getUniqueId())) {
+ plugin.send(p, plugin.cfgMsg("land-not-found"));
+ return;
+ }
+ World w = p.getWorld();
+ int cx = p.getLocation().getBlockX() >> 4;
+ int cz = p.getLocation().getBlockZ() >> 4;
+ if (plugin.lands.getLandByUnloadedChunk(w, cx, cz) != null) {
+ plugin.send(p, "&cDieser Chunk ist schon geclaimt. Mit &e/chunkinfo watch &ckannst du ihn beobachten.");
+ return;
+ }
+ List mine = plugin.store.entries(Kind.QUEUE).stream().filter(e -> e.player().equals(p.getUniqueId())).toList();
+ if (mine.size() >= plugin.getConfig().getInt("queue.max", 3)) {
+ plugin.send(p, "&cDeine Warteschlange ist voll (&e/chunkinfo queue clear&c).");
+ return;
+ }
+ if (mine.stream().anyMatch(e -> e.at(w.getName(), cx, cz))) {
+ plugin.send(p, "&cDieser Chunk ist schon in deiner Warteschlange.");
+ return;
+ }
+ plugin.store.add(Kind.QUEUE, new Entry(p.getUniqueId(), LandsChunkInfo.landId(land), w.getName(), cx, cz, 0));
+ int free = plugin.freeSlots(land);
+ plugin.send(p, "&aChunk &e" + cx + ", " + cz + " &avorgemerkt fuer &e" + land.getName() + "&a. "
+ + (free == 0 ? "Er wird geclaimt, sobald ein Slot frei wird (du musst dafuer online sein)."
+ : "Er wird in Kuerze geclaimt."));
+ }
+
+ // ----------------------------------------------------------------- Watch
+
+ private void watch(Player p, String[] args) {
+ if (!allowed(p, "watch")) {
+ return;
+ }
+ String arg = args.length > 1 ? args[1].toLowerCase(Locale.ROOT) : "";
+ if (arg.equals("list")) {
+ listEntries(p, Kind.WATCH, "Watchlist");
+ return;
+ }
+ if (arg.equals("clear")) {
+ plugin.send(p, "&7" + plugin.store.removeAll(Kind.WATCH, p.getUniqueId()) + " Eintraege entfernt.");
+ return;
+ }
+ World w = p.getWorld();
+ int cx = p.getLocation().getBlockX() >> 4;
+ int cz = p.getLocation().getBlockZ() >> 4;
+ Land holder = plugin.lands.getLandByUnloadedChunk(w, cx, cz);
+ if (holder == null) {
+ plugin.send(p, "&cDieser Chunk ist frei - du kannst ihn direkt claimen oder mit &e/chunkinfo queue &avormerken.");
+ return;
+ }
+ List mine = plugin.store.entries(Kind.WATCH).stream().filter(e -> e.player().equals(p.getUniqueId())).toList();
+ if (mine.size() >= plugin.getConfig().getInt("watch.max", 5)) {
+ plugin.send(p, "&cDeine Watchlist ist voll (&e/chunkinfo watch clear&c).");
+ return;
+ }
+ if (mine.stream().anyMatch(e -> e.at(w.getName(), cx, cz))) {
+ plugin.send(p, "&cDen beobachtest du schon.");
+ return;
+ }
+ plugin.store.add(Kind.WATCH, new Entry(p.getUniqueId(), LandsChunkInfo.landId(holder), w.getName(), cx, cz, 0));
+ plugin.send(p, "&aDu wirst benachrichtigt, sobald Chunk &e" + cx + ", " + cz + " &a(Land &e" + holder.getName() + "&a) frei wird.");
+ }
+
+ private void listEntries(Player p, Kind kind, String title) {
+ List mine = plugin.store.entries(kind).stream().filter(e -> e.player().equals(p.getUniqueId())).toList();
+ p.sendMessage(plugin.line("&8&m &r &a" + title + " &8&m "));
+ if (mine.isEmpty()) {
+ p.sendMessage(plugin.line("&7(leer)"));
+ }
+ for (Entry e : mine) {
+ Land land = plugin.landById(e.landId());
+ p.sendMessage(plugin.line("&e" + e.world() + " " + e.x() + ", " + e.z()
+ + " &8- &7Land: &e" + (land == null ? "?" : land.getName())));
+ }
+ }
+
+ // ------------------------------------------------------------------ Temp
+
+ private void temp(Player p, String[] args) {
+ if (!allowed(p, "temp")) {
+ return;
+ }
+ if (!plugin.getConfig().getBoolean("temp-claims.enabled", true)) {
+ plugin.send(p, "&cDiese Funktion ist deaktiviert.");
+ return;
+ }
+ World w = p.getWorld();
+ int cx = p.getLocation().getBlockX() >> 4;
+ int cz = p.getLocation().getBlockZ() >> 4;
+ String arg = args.length > 1 ? args[1].toLowerCase(Locale.ROOT) : "";
+
+ if (arg.equals("list")) {
+ p.sendMessage(plugin.line("&8&m &r &aTemp-Chunks &8&m "));
+ long now = System.currentTimeMillis();
+ plugin.store.entries(Kind.TEMP).stream().filter(e -> plugin.landsOf(p.getUniqueId()).stream()
+ .anyMatch(l -> LandsChunkInfo.landId(l).equals(e.landId()))).forEach(e -> p.sendMessage(plugin.line(
+ "&e" + e.world() + " " + e.x() + ", " + e.z() + " &8- &7noch &e" + TimeUtil.format(e.until() - now))));
+ return;
+ }
+ Land land = plugin.lands.getLandByUnloadedChunk(w, cx, cz);
+ if (land == null || !p.getUniqueId().equals(land.getOwnerUID())) {
+ plugin.send(p, "&cDu musst in einem Chunk deines eigenen Lands stehen.");
+ return;
+ }
+ String id = LandsChunkInfo.landId(land);
+ if (arg.equals("cancel")) {
+ boolean removed = plugin.store.entries(Kind.TEMP).stream()
+ .filter(e -> e.landId().equals(id) && e.at(w.getName(), cx, cz))
+ .anyMatch(e -> plugin.store.remove(Kind.TEMP, e));
+ plugin.send(p, removed ? "&aDie Zeitbegrenzung fuer diesen Chunk wurde entfernt." : "&cDieser Chunk hat keine Zeitbegrenzung.");
+ return;
+ }
+ int days;
+ try {
+ days = Integer.parseInt(arg);
+ } catch (NumberFormatException ex) {
+ plugin.send(p, "&cNutzung: /chunkinfo temp | cancel | list");
+ return;
+ }
+ int maxDays = plugin.getConfig().getInt("temp-claims.max-days", 30);
+ if (days < 1 || days > maxDays) {
+ plugin.send(p, "&cErlaubt sind 1 bis " + maxDays + " Tage.");
+ return;
+ }
+ List forLand = plugin.store.entries(Kind.TEMP).stream().filter(e -> e.landId().equals(id)).toList();
+ Entry existing = forLand.stream().filter(e -> e.at(w.getName(), cx, cz)).findFirst().orElse(null);
+ if (existing == null && forLand.size() >= plugin.getConfig().getInt("temp-claims.max-per-land", 3)) {
+ plugin.send(p, "&cEs sind schon zu viele Temp-Chunks in diesem Land.");
+ return;
+ }
+ if (existing != null) {
+ plugin.store.remove(Kind.TEMP, existing);
+ }
+ long until = System.currentTimeMillis() + days * 86_400_000L;
+ plugin.store.add(Kind.TEMP, new Entry(p.getUniqueId(), id, w.getName(), cx, cz, until));
+ plugin.send(p, "&aChunk &e" + cx + ", " + cz + " &awird in &e" + days + " Tag(en) &aautomatisch freigegeben ("
+ + LandsChunkInfo.fmt(until) + ").");
+ }
+
+ // ----------------------------------------------------------------- Tab
+
+ @Override
+ public List onTabComplete(CommandSender s, Command c, String alias, String[] args) {
+ if (!(s instanceof Player p)) {
+ return List.of();
+ }
+ String last = args[args.length - 1].toLowerCase(Locale.ROOT);
+ List out = new ArrayList<>();
+ if (args.length == 1) {
+ out.addAll(SUBS);
+ plugin.landsOf(p.getUniqueId()).forEach(l -> out.add(l.getName()));
+ } else {
+ switch (args[0].toLowerCase(Locale.ROOT)) {
+ case "queue", "watch" -> out.addAll(args.length == 2 ? List.of("list", "clear") : List.of());
+ case "temp" -> out.addAll(List.of("list", "cancel", "1", "7", "14", "30"));
+ case "skip", "gift" -> {
+ out.add("confirm");
+ plugin.landsOf(p.getUniqueId()).forEach(l -> out.add(l.getName()));
+ }
+ default -> { }
+ }
+ if (args[0].equalsIgnoreCase("queue")) {
+ plugin.landsOf(p.getUniqueId()).forEach(l -> out.add(l.getName()));
+ }
+ }
+ return out.stream().filter(o -> o.toLowerCase(Locale.ROOT).startsWith(last)).toList();
+ }
+}
diff --git a/src/main/java/de/landschunkinfo/LandsChunkInfo.java b/src/main/java/de/landschunkinfo/LandsChunkInfo.java
new file mode 100644
index 0000000..f378975
--- /dev/null
+++ b/src/main/java/de/landschunkinfo/LandsChunkInfo.java
@@ -0,0 +1,513 @@
+package de.landschunkinfo;
+
+import de.landschunkinfo.LockStore.Entry;
+import de.landschunkinfo.LockStore.Kind;
+import me.angeschossen.lands.api.LandsIntegration;
+import me.angeschossen.lands.api.applicationframework.util.ULID;
+import me.angeschossen.lands.api.events.ChunkDeleteEvent;
+import me.angeschossen.lands.api.events.ChunkPreClaimEvent;
+import me.angeschossen.lands.api.events.land.DeleteReason;
+import me.angeschossen.lands.api.events.memberholder.MemberHolderUpkeepEvent;
+import me.angeschossen.lands.api.land.Land;
+import me.angeschossen.lands.api.player.LandPlayer;
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer;
+import org.bukkit.Bukkit;
+import org.bukkit.OfflinePlayer;
+import org.bukkit.Statistic;
+import org.bukkit.World;
+import org.bukkit.configuration.file.YamlConfiguration;
+import org.bukkit.entity.Player;
+import org.bukkit.event.EventHandler;
+import org.bukkit.event.EventPriority;
+import org.bukkit.event.Listener;
+import org.bukkit.event.player.PlayerJoinEvent;
+import org.bukkit.plugin.Plugin;
+import org.bukkit.plugin.java.JavaPlugin;
+
+import java.io.File;
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+
+public final class LandsChunkInfo extends JavaPlugin implements Listener {
+
+ private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd.MM. HH:mm");
+ private static final long TICK_SECONDS = 30;
+
+ LandsIntegration lands;
+ LockStore store;
+ VaultHook vault;
+
+ private long cooldownMillis;
+ private long lastTick = System.currentTimeMillis();
+ /** Unclaims, die vom Plugin selbst ausgeloest wurden (Temp-Claims) und keinen Slot sperren sollen. */
+ private final Set silent = ConcurrentHashMap.newKeySet();
+
+ @Override
+ public void onEnable() {
+ saveDefaultConfig();
+ lands = LandsIntegration.of(this);
+ store = new LockStore(getDataFolder(), getLogger());
+ reload();
+
+ if (Bukkit.getPluginManager().getPlugin("Vault") != null) {
+ vault = VaultHook.create();
+ }
+ if (vault == null) {
+ getLogger().info("Vault/Economy nicht gefunden: Bezahlung nur aus der Landkasse moeglich, /chunkinfo gift ist deaktiviert.");
+ }
+ if (Bukkit.getPluginManager().getPlugin("PlaceholderAPI") != null) {
+ new ChunkExpansion(this).register();
+ }
+
+ Bukkit.getPluginManager().registerEvents(this, this);
+ InfoCommand command = new InfoCommand(this);
+ var cmd = getCommand("chunkinfo");
+ if (cmd != null) {
+ cmd.setExecutor(command);
+ cmd.setTabCompleter(command);
+ }
+ Bukkit.getScheduler().runTaskTimer(this, this::tick, 100L, TICK_SECONDS * 20L);
+ }
+
+ @Override
+ public void onDisable() {
+ if (store != null) {
+ store.save();
+ }
+ }
+
+ void reload() {
+ reloadConfig();
+ cooldownMillis = TimeUtil.parse(getConfig().getString("unclaim-cooldown.duration", "24h"));
+ if (cooldownMillis < 0) {
+ getLogger().warning("unclaim-cooldown.duration ist ungueltig, Cooldown wird deaktiviert.");
+ }
+ }
+
+ // --------------------------------------------------------------- Helfer
+
+ boolean cooldownActive() {
+ return getConfig().getBoolean("unclaim-cooldown.enabled", true) && cooldownMillis > 0;
+ }
+
+ long cooldownMillis() {
+ return cooldownMillis;
+ }
+
+ Component line(String text) {
+ return LegacyComponentSerializer.legacyAmpersand().deserialize(text);
+ }
+
+ void send(org.bukkit.command.CommandSender to, String text) {
+ to.sendMessage(line(getConfig().getString("messages.prefix", "") + text));
+ }
+
+ String cfgMsg(String key, String... repl) {
+ String text = getConfig().getString("messages." + key, key);
+ for (int i = 0; i + 1 < repl.length; i += 2) {
+ text = text.replace(repl[i], repl[i + 1]);
+ }
+ return text;
+ }
+
+ static String landId(Land land) {
+ return land.getULID().toString();
+ }
+
+ Land landById(String id) {
+ try {
+ return lands.getLandByULID(ULID.fromString(id));
+ } catch (RuntimeException e) {
+ return null;
+ }
+ }
+
+ /** Lands, in denen die Person Besitzer oder vertraut ist. */
+ List landsOf(UUID uuid) {
+ List out = new ArrayList<>();
+ for (Object o : lands.getLands()) {
+ Land l = (Land) o;
+ if (uuid.equals(l.getOwnerUID()) || l.isTrusted(uuid)) {
+ out.add(l);
+ }
+ }
+ out.sort(Comparator.comparing((Land l) -> !uuid.equals(l.getOwnerUID())).thenComparing(Land::getName));
+ return out;
+ }
+
+ /** Hauptland: eigenes Land, sonst das erste Land, in dem man vertraut ist. */
+ Land primaryLand(UUID uuid) {
+ List l = landsOf(uuid);
+ return l.isEmpty() ? null : l.get(0);
+ }
+
+ /** Freie Slots; -1 = kein Limit. */
+ int freeSlots(Land land) {
+ int max = land.getMaxChunks();
+ if (max <= 0) {
+ return -1;
+ }
+ int locked = cooldownActive() ? store.active(landId(land)).size() : 0;
+ return Math.max(0, max - land.getChunksAmount() - locked);
+ }
+
+ List onlineMembers(Land land) {
+ List out = new ArrayList<>();
+ for (Object o : land.getOnlinePlayers()) {
+ if (o instanceof Player p) {
+ out.add(p);
+ } else if (o instanceof LandPlayer lp && lp.getPlayer() != null) {
+ out.add(lp.getPlayer());
+ }
+ }
+ return out;
+ }
+
+ /** Land-Rangliste nach Chunks, absteigend. */
+ List top(int n) {
+ List all = new ArrayList<>();
+ for (Object o : lands.getLands()) {
+ all.add((Land) o);
+ }
+ all.sort(Comparator.comparingInt(Land::getChunksAmount).reversed());
+ return all.subList(0, Math.min(n, all.size()));
+ }
+
+ static String fmt(long millis) {
+ return DATE.format(LocalDateTime.ofInstant(Instant.ofEpochMilli(millis), ZoneId.systemDefault()));
+ }
+
+ private static String chunkKey(String landId, String world, int x, int z) {
+ return landId + "|" + world + "|" + x + "|" + z;
+ }
+
+ // --------------------------------------------------------------- Events
+
+ /** Manuelles Unclaim sperrt den Slot fuer die konfigurierte Zeit. */
+ @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
+ public void onUnclaim(ChunkDeleteEvent e) {
+ String id = landId(e.getLand());
+ if (silent.remove(chunkKey(id, e.getWorld().getName(), e.getX(), e.getZ()))) {
+ return;
+ }
+ if (!cooldownActive() || e.getReason() != DeleteReason.DEFAULT
+ || "LAND_DELETION".equals(String.valueOf(e.getUnclaimType()))) {
+ return;
+ }
+ LandPlayer lp = e.getLandPlayer();
+ Player p = lp == null ? null : lp.getPlayer();
+ if (p != null && p.hasPermission("landschunkinfo.bypass")) {
+ return;
+ }
+ store.add(id, System.currentTimeMillis() + cooldownMillis);
+ }
+
+ @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
+ public void onClaim(ChunkPreClaimEvent e) {
+ if (!cooldownActive() || !getConfig().getBoolean("unclaim-cooldown.enforce", true)) {
+ return;
+ }
+ LandPlayer lp = e.getLandPlayer();
+ Player p = lp == null ? null : lp.getPlayer();
+ if (p != null && p.hasPermission("landschunkinfo.bypass")) {
+ return;
+ }
+ Land land = e.getLand();
+ int max = land.getMaxChunks();
+ if (max <= 0) {
+ return;
+ }
+ int size = land.getChunksAmount();
+ List locked = store.active(landId(land));
+ // Nur eingreifen, wenn der Platz allein wegen der Sperren fehlt; sonst uebernimmt Lands.
+ if (size < max && size + locked.size() >= max) {
+ e.setCancelled(true);
+ if (p != null) {
+ long next = locked.get(Math.max(0, size + locked.size() - max)) - System.currentTimeMillis();
+ send(p, cfgMsg("claim-blocked", "%time%", TimeUtil.format(next)));
+ }
+ }
+ }
+
+ @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
+ public void onUpkeep(MemberHolderUpkeepEvent e) {
+ long now = System.currentTimeMillis();
+ if (now - store.lastUpkeep() > 600_000L) {
+ store.lastUpkeep(now);
+ }
+ }
+
+ @EventHandler
+ public void onJoin(PlayerJoinEvent e) {
+ if (!getConfig().getBoolean("join-reminder.enabled", true)) {
+ return;
+ }
+ Player p = e.getPlayer();
+ Bukkit.getScheduler().runTaskLater(this, () -> joinSummary(p), 60L);
+ }
+
+ private void joinSummary(Player p) {
+ if (!p.isOnline() || !p.hasPermission("landschunkinfo.use")) {
+ return;
+ }
+ long now = System.currentTimeMillis();
+ long warnMs = (long) (getConfig().getDouble("join-reminder.upkeep-warning-hours", 48) * 3_600_000L);
+ for (Land land : landsOf(p.getUniqueId())) {
+ int free = freeSlots(land);
+ if (free > 0 && land.getMaxChunks() > 0) {
+ send(p, "&aIn &e" + land.getName() + " &asind &e" + free + " &afreie Chunk-Slots verfuegbar.");
+ }
+ List locked = cooldownActive() ? store.active(landId(land)) : List.of();
+ if (!locked.isEmpty()) {
+ send(p, "&7" + land.getName() + ": naechster Slot frei in &e" + TimeUtil.format(locked.get(0) - now));
+ }
+ double upkeep = land.getUpkeepCosts();
+ if (upkeep > 0 && land.getBalance() < upkeep && nextUpkeep() - now <= warnMs
+ && p.getUniqueId().equals(land.getOwnerUID())) {
+ send(p, "&cAchtung: &e" + land.getName() + " &chat zu wenig Guthaben fuer den Upkeep (Einzug in &e"
+ + TimeUtil.format(nextUpkeep() - now) + "&c).");
+ }
+ }
+ }
+
+ // --------------------------------------------------------------- Ticker
+
+ private void tick() {
+ long now = System.currentTimeMillis();
+ try {
+ notifyExpiredLocks(now);
+ processTemp(now);
+ processQueue();
+ processWatch();
+ } catch (RuntimeException ex) {
+ getLogger().warning("Fehler im Hintergrund-Task: " + ex);
+ }
+ lastTick = now;
+ }
+
+ private void notifyExpiredLocks(long now) {
+ for (String id : store.lockedLands()) {
+ Land land = landById(id);
+ if (land == null) {
+ store.clearLocks(id);
+ continue;
+ }
+ long expired = store.all(id).stream().filter(t -> t > lastTick && t <= now).count();
+ if (expired > 0) {
+ for (Player p : onlineMembers(land)) {
+ send(p, "&aIn &e" + land.getName() + " &aist wieder &e" + expired + " &aChunk-Slot frei!");
+ }
+ }
+ }
+ }
+
+ private void processQueue() {
+ Set usedThisTick = new HashSet<>();
+ for (Entry e : store.entries(Kind.QUEUE)) {
+ Player p = Bukkit.getPlayer(e.player());
+ Land land = landById(e.landId());
+ World w = Bukkit.getWorld(e.world());
+ if (land == null || w == null) {
+ store.remove(Kind.QUEUE, e);
+ continue;
+ }
+ if (p == null) {
+ continue;
+ }
+ Land holder = lands.getLandByUnloadedChunk(w, e.x(), e.z());
+ if (holder != null) {
+ if (holder.equals(land)) {
+ store.remove(Kind.QUEUE, e);
+ }
+ continue;
+ }
+ if (freeSlots(land) == 0 || !usedThisTick.add(e.landId())) {
+ continue;
+ }
+ LandPlayer lp = lands.getLandPlayer(e.player());
+ if (lp == null) {
+ continue;
+ }
+ store.remove(Kind.QUEUE, e);
+ java.util.concurrent.CompletableFuture> f = land.claimChunk(lp, w, e.x(), e.z());
+ f.whenComplete((r, ex) -> Bukkit.getScheduler().runTask(this, () -> {
+ if (!p.isOnline()) {
+ return;
+ }
+ String where = e.x() + ", " + e.z();
+ if (ex == null && land.hasChunk(w, e.x(), e.z())) {
+ send(p, "&aWarteschlange: Chunk &e" + where + " &awurde fuer &e" + land.getName() + " &ageclaimt.");
+ } else {
+ send(p, "&cWarteschlange: Chunk &e" + where + " &ckonnte nicht geclaimt werden (Eintrag entfernt).");
+ }
+ }));
+ }
+ }
+
+ private void processWatch() {
+ for (Entry e : store.entries(Kind.WATCH)) {
+ World w = Bukkit.getWorld(e.world());
+ if (w == null) {
+ store.remove(Kind.WATCH, e);
+ continue;
+ }
+ Player p = Bukkit.getPlayer(e.player());
+ if (p == null) {
+ continue;
+ }
+ if (lands.getLandByUnloadedChunk(w, e.x(), e.z()) == null) {
+ store.remove(Kind.WATCH, e);
+ send(p, "&aDer beobachtete Chunk &e" + e.x() + ", " + e.z() + " &a(" + e.world()
+ + ") ist jetzt frei! Mit &e/chunkinfo queue &akannst du ihn dir sichern.");
+ }
+ }
+ }
+
+ private void processTemp(long now) {
+ for (Entry e : store.entries(Kind.TEMP)) {
+ if (e.until() > now) {
+ continue;
+ }
+ Land land = landById(e.landId());
+ World w = Bukkit.getWorld(e.world());
+ if (land == null || w == null || !land.hasChunk(w, e.x(), e.z())) {
+ store.remove(Kind.TEMP, e);
+ continue;
+ }
+ LandPlayer lp = lands.getLandPlayer(e.player());
+ if (lp == null && land.getOwnerUID() != null) {
+ lp = lands.getLandPlayer(land.getOwnerUID());
+ }
+ if (lp == null) {
+ continue; // wartet, bis ein Verantwortlicher online ist
+ }
+ store.remove(Kind.TEMP, e);
+ String key = chunkKey(e.landId(), e.world(), e.x(), e.z());
+ silent.add(key);
+ Bukkit.getScheduler().runTaskLater(this, () -> silent.remove(key), 200L);
+ land.unclaimChunk(w, e.x(), e.z(), lp);
+ for (Player p : onlineMembers(land)) {
+ send(p, "&7Der zeitlich begrenzte Chunk &e" + e.x() + ", " + e.z() + " &7von &e" + land.getName()
+ + " &7ist abgelaufen und wurde freigegeben.");
+ }
+ }
+ }
+
+ // --------------------------------------------------------------- Upkeep
+
+ YamlConfiguration landsConfig() {
+ Plugin pl = Bukkit.getPluginManager().getPlugin("Lands");
+ File f = pl == null ? null : new File(pl.getDataFolder(), "config.yml");
+ return f != null && f.exists() ? YamlConfiguration.loadConfiguration(f) : null;
+ }
+
+ /** Naechster Upkeep-Einzug in epoch-millis, abgeleitet aus Lands economic.interval. */
+ long nextUpkeep() {
+ ZoneId zone = ZoneId.systemDefault();
+ int days = 1;
+ LocalTime time = LocalTime.MIDNIGHT;
+ YamlConfiguration lc = landsConfig();
+ if (lc != null) {
+ days = Math.max(1, lc.getInt("economic.interval.days_2", lc.getInt("economic.interval.days", 1)));
+ try {
+ time = LocalTime.parse(lc.getString("economic.interval.time", "00:00:00"));
+ } catch (Exception ignored) {
+ // Standardwert behalten
+ }
+ }
+ LocalDate anchor = null;
+ if (store.lastUpkeep() > 0) {
+ anchor = Instant.ofEpochMilli(store.lastUpkeep()).atZone(zone).toLocalDate();
+ } else {
+ try {
+ String cfg = getConfig().getString("upkeep.anchor-date", "");
+ if (!cfg.isBlank()) {
+ anchor = LocalDate.parse(cfg);
+ }
+ } catch (Exception ignored) {
+ // ungueltiges Datum -> Schaetzung
+ }
+ }
+ LocalDateTime now = LocalDateTime.now(zone);
+ if (anchor == null) {
+ LocalDateTime next = now.toLocalDate().atTime(time);
+ return (next.isAfter(now) ? next : next.plusDays(1)).atZone(zone).toInstant().toEpochMilli();
+ }
+ LocalDateTime next = anchor.atTime(time);
+ while (!next.isAfter(now)) {
+ next = next.plusDays(days);
+ }
+ return next.atZone(zone).toInstant().toEpochMilli();
+ }
+
+ // --------------------------------------------------------- Inaktivitaet
+
+ /**
+ * Restzeit bis der Besitzer laut Lands-Einstellung (expiration.owner_list) als inaktiv gilt.
+ * Gibt null zurueck, wenn die Funktion in Lands nicht aktiv ist, -1 bei "never".
+ */
+ Long inactivityRemaining(Land land) {
+ YamlConfiguration lc = landsConfig();
+ UUID owner = land.getOwnerUID();
+ if (lc == null || owner == null) {
+ return null;
+ }
+ List list = null;
+ for (String key : lc.getKeys(true)) {
+ if (key.endsWith("expiration.owner_list")) {
+ list = lc.getStringList(key);
+ break;
+ }
+ }
+ if (list == null || list.isEmpty()) {
+ return null;
+ }
+ OfflinePlayer op = Bukkit.getOfflinePlayer(owner);
+ long playMs = op.getStatistic(Statistic.PLAY_ONE_MINUTE) * 50L;
+ String chosen = null;
+ long chosenPlay = Long.MAX_VALUE;
+ String highest = null;
+ long highestPlay = -1;
+ for (String entry : list) {
+ String[] parts = entry.split(":");
+ long pt = parts.length == 2 ? TimeUtil.parse(parts[0].trim()) : -1;
+ if (pt < 0) {
+ continue;
+ }
+ if (pt >= playMs && pt < chosenPlay) {
+ chosen = parts[1].trim();
+ chosenPlay = pt;
+ }
+ if (pt > highestPlay) {
+ highest = parts[1].trim();
+ highestPlay = pt;
+ }
+ }
+ String offline = chosen != null ? chosen : highest;
+ if (offline == null) {
+ return null;
+ }
+ if (offline.equalsIgnoreCase("never")) {
+ return -1L;
+ }
+ long threshold = TimeUtil.parse(offline);
+ if (threshold < 0) {
+ return null;
+ }
+ long lastSeen = op.isOnline() ? System.currentTimeMillis() : op.getLastSeen();
+ return Math.max(0, threshold - (System.currentTimeMillis() - lastSeen));
+ }
+}
diff --git a/src/main/java/de/landschunkinfo/LockStore.java b/src/main/java/de/landschunkinfo/LockStore.java
new file mode 100644
index 0000000..419f95a
--- /dev/null
+++ b/src/main/java/de/landschunkinfo/LockStore.java
@@ -0,0 +1,185 @@
+package de.landschunkinfo;
+
+import org.bukkit.configuration.file.YamlConfiguration;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.EnumMap;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.logging.Logger;
+
+/** Persistiert Slot-Sperren, Queue, Watchlist, Temp-Claims und den letzten Upkeep-Einzug. */
+final class LockStore {
+
+ enum Kind { QUEUE, WATCH, TEMP }
+
+ /** Eintrag fuer Queue/Watch/Temp. {@code until} wird nur bei TEMP genutzt. */
+ record Entry(UUID player, String landId, String world, int x, int z, long until) {
+
+ String serialize() {
+ return player + ";" + landId + ";" + world + ";" + x + ";" + z + ";" + until;
+ }
+
+ static Entry parse(String s) {
+ String[] p = s.split(";");
+ return new Entry(UUID.fromString(p[0]), p[1], p[2], Integer.parseInt(p[3]), Integer.parseInt(p[4]),
+ Long.parseLong(p[5]));
+ }
+
+ boolean at(String world, int x, int z) {
+ return this.world.equals(world) && this.x == x && this.z == z;
+ }
+ }
+
+ private final File file;
+ private final Logger log;
+ private final Map> locks = new HashMap<>();
+ private final Map> entries = new EnumMap<>(Kind.class);
+ private long lastUpkeep;
+
+ LockStore(File dataFolder, Logger log) {
+ this.file = new File(dataFolder, "data.yml");
+ this.log = log;
+ for (Kind k : Kind.values()) {
+ entries.put(k, new ArrayList<>());
+ }
+ load();
+ }
+
+ private void load() {
+ if (!file.exists()) {
+ return;
+ }
+ YamlConfiguration yml = YamlConfiguration.loadConfiguration(file);
+ lastUpkeep = yml.getLong("last-upkeep", 0);
+ var section = yml.getConfigurationSection("locks");
+ if (section != null) {
+ for (String land : section.getKeys(false)) {
+ locks.put(land, new ArrayList<>(section.getLongList(land)));
+ }
+ }
+ for (Kind k : Kind.values()) {
+ for (String s : yml.getStringList(k.name().toLowerCase())) {
+ try {
+ entries.get(k).add(Entry.parse(s));
+ } catch (RuntimeException e) {
+ log.warning("Ungueltiger Eintrag in data.yml uebersprungen: " + s);
+ }
+ }
+ }
+ }
+
+ synchronized void save() {
+ YamlConfiguration yml = new YamlConfiguration();
+ yml.set("last-upkeep", lastUpkeep);
+ purge();
+ locks.forEach((land, list) -> yml.set("locks." + land, list));
+ for (Kind k : Kind.values()) {
+ yml.set(k.name().toLowerCase(), entries.get(k).stream().map(Entry::serialize).toList());
+ }
+ try {
+ yml.save(file);
+ } catch (IOException e) {
+ log.warning("data.yml konnte nicht gespeichert werden: " + e.getMessage());
+ }
+ }
+
+ private void purge() {
+ long now = System.currentTimeMillis();
+ locks.values().forEach(l -> l.removeIf(t -> t <= now));
+ locks.values().removeIf(List::isEmpty);
+ }
+
+ // ------------------------------------------------------------------ Sperren
+
+ synchronized void add(String landId, long until) {
+ locks.computeIfAbsent(landId, k -> new ArrayList<>()).add(until);
+ save();
+ }
+
+ /** Aktive Sperren eines Lands, aufsteigend nach Ablaufzeit. */
+ synchronized List active(String landId) {
+ long now = System.currentTimeMillis();
+ List list = locks.get(landId);
+ if (list == null) {
+ return List.of();
+ }
+ return list.stream().filter(t -> t > now).sorted().toList();
+ }
+
+ /** Alle Land-IDs, die (evtl. abgelaufene) Sperren halten. */
+ synchronized List lockedLands() {
+ return new ArrayList<>(locks.keySet());
+ }
+
+ /** Alle Sperren eines Lands inkl. abgelaufener, fuer Ablauf-Benachrichtigungen. */
+ synchronized List all(String landId) {
+ return new ArrayList<>(locks.getOrDefault(landId, List.of()));
+ }
+
+ /** Entfernt die fruehste aktive Sperre. */
+ synchronized boolean removeEarliest(String landId) {
+ List list = locks.get(landId);
+ if (list == null) {
+ return false;
+ }
+ long now = System.currentTimeMillis();
+ Long min = list.stream().filter(t -> t > now).min(Long::compare).orElse(null);
+ if (min == null) {
+ return false;
+ }
+ list.remove(min);
+ save();
+ return true;
+ }
+
+ synchronized void clearLocks(String landId) {
+ if (locks.remove(landId) != null) {
+ save();
+ }
+ }
+
+ // ------------------------------------------------------------------ Eintraege
+
+ synchronized List entries(Kind kind) {
+ return new ArrayList<>(entries.get(kind));
+ }
+
+ synchronized void add(Kind kind, Entry entry) {
+ entries.get(kind).add(entry);
+ save();
+ }
+
+ synchronized boolean remove(Kind kind, Entry entry) {
+ boolean removed = entries.get(kind).remove(entry);
+ if (removed) {
+ save();
+ }
+ return removed;
+ }
+
+ synchronized int removeAll(Kind kind, UUID player) {
+ int before = entries.get(kind).size();
+ entries.get(kind).removeIf(e -> e.player().equals(player));
+ int removed = before - entries.get(kind).size();
+ if (removed > 0) {
+ save();
+ }
+ return removed;
+ }
+
+ // ------------------------------------------------------------------ Upkeep
+
+ synchronized long lastUpkeep() {
+ return lastUpkeep;
+ }
+
+ synchronized void lastUpkeep(long time) {
+ this.lastUpkeep = time;
+ save();
+ }
+}
diff --git a/src/main/java/de/landschunkinfo/TimeUtil.java b/src/main/java/de/landschunkinfo/TimeUtil.java
new file mode 100644
index 0000000..177c9ae
--- /dev/null
+++ b/src/main/java/de/landschunkinfo/TimeUtil.java
@@ -0,0 +1,50 @@
+package de.landschunkinfo;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+final class TimeUtil {
+
+ private static final Pattern PART = Pattern.compile("(\\d+)\\s*([smhd])", Pattern.CASE_INSENSITIVE);
+
+ private TimeUtil() {
+ }
+
+ /** Parses values like "30s", "15m", "12h", "2d" or "1d12h" to milliseconds. Returns -1 if invalid. */
+ static long parse(String input) {
+ if (input == null || input.isBlank()) {
+ return -1;
+ }
+ Matcher m = PART.matcher(input);
+ long total = 0;
+ int matched = 0;
+ while (m.find()) {
+ long n = Long.parseLong(m.group(1));
+ total += n * switch (Character.toLowerCase(m.group(2).charAt(0))) {
+ case 's' -> 1_000L;
+ case 'm' -> 60_000L;
+ case 'h' -> 3_600_000L;
+ default -> 86_400_000L;
+ };
+ matched += m.group().length();
+ }
+ return matched == input.replace(" ", "").length() && total > 0 ? total : -1;
+ }
+
+ static String format(long millis) {
+ long s = Math.max(0, millis) / 1000;
+ long d = s / 86400;
+ long h = (s % 86400) / 3600;
+ long m = (s % 3600) / 60;
+ if (d > 0) {
+ return d + "T " + h + "Std " + m + "Min";
+ }
+ if (h > 0) {
+ return h + "Std " + m + "Min";
+ }
+ if (m > 0) {
+ return m + "Min " + (s % 60) + "Sek";
+ }
+ return s + "Sek";
+ }
+}
diff --git a/src/main/java/de/landschunkinfo/VaultHook.java b/src/main/java/de/landschunkinfo/VaultHook.java
new file mode 100644
index 0000000..b643158
--- /dev/null
+++ b/src/main/java/de/landschunkinfo/VaultHook.java
@@ -0,0 +1,33 @@
+package de.landschunkinfo;
+
+import net.milkbowl.vault.economy.Economy;
+import org.bukkit.Bukkit;
+import org.bukkit.OfflinePlayer;
+import org.bukkit.plugin.RegisteredServiceProvider;
+
+/** Duenne Huelle um Vault. Wird nur geladen, wenn Vault installiert ist. */
+final class VaultHook {
+
+ private final Economy economy;
+
+ private VaultHook(Economy economy) {
+ this.economy = economy;
+ }
+
+ static VaultHook create() {
+ RegisteredServiceProvider rsp = Bukkit.getServicesManager().getRegistration(Economy.class);
+ return rsp == null ? null : new VaultHook(rsp.getProvider());
+ }
+
+ double balance(OfflinePlayer p) {
+ return economy.getBalance(p);
+ }
+
+ boolean withdraw(OfflinePlayer p, double amount) {
+ return economy.withdrawPlayer(p, amount).transactionSuccess();
+ }
+
+ String format(double amount) {
+ return economy.format(amount);
+ }
+}
diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml
new file mode 100644
index 0000000..67513a2
--- /dev/null
+++ b/src/main/resources/config.yml
@@ -0,0 +1,56 @@
+# Unclaim-Cooldown: Nach einem manuellen /unclaim bleibt der Chunk-Slot des Lands
+# für diese Zeit gesperrt. Format: 30s, 15m, 12h, 2d oder kombiniert (1d12h).
+unclaim-cooldown:
+ enabled: true
+ duration: 24h
+ # true = Claimen wird blockiert, solange alle Slots belegt oder gesperrt sind.
+ # false = nur Anzeige, kein Eingriff ins Claimen.
+ enforce: true
+
+# Upkeep-Anzeige. Zeitpunkt wird aus der Lands-config.yml (economic.interval) gelesen.
+# Der letzte echte Einzug wird automatisch gespeichert. Bis dahin kann ein Startdatum
+# (Tag eines bekannten Einzugs, yyyy-MM-dd) angegeben werden, sonst wird geschätzt.
+upkeep:
+ anchor-date: ""
+
+messages:
+ prefix: "&8[&aChunks&8] &7"
+ no-permission: "&cDazu hast du keine Berechtigung."
+ players-only: "&cNur Spieler können diesen Befehl nutzen."
+ no-land: "&cDu bist in keinem Land Mitglied."
+ land-not-found: "&cLand nicht gefunden oder du bist dort kein Mitglied."
+ reloaded: "&aKonfiguration neu geladen."
+ claim-blocked: "&cAlle freien Chunk-Slots sind nach einem Unclaim noch gesperrt. Nächster Slot frei in &e%time%&c."
+
+# Gesperrten Slot sofort freischalten (/chunkinfo skip, /chunkinfo gift ).
+skip:
+ enabled: true
+ # PLAYER = Geld des Spielers (Vault), LAND = Landkasse (nur Besitzer). gift nutzt immer PLAYER.
+ payment: PLAYER
+ # Preis = Restzeit in Stunden * price-per-hour, mindestens min-price.
+ price-per-hour: 25.0
+ min-price: 50.0
+
+# Auto-Claim-Warteschlange (/chunkinfo queue): claimt vorgemerkte freie Chunks, sobald ein Slot frei ist.
+# Der Spieler muss dafuer online sein.
+queue:
+ max: 3
+
+# Watchlist (/chunkinfo watch): Nachricht, sobald ein fremder Chunk frei wird.
+watch:
+ max: 5
+
+# Zeitlich begrenzte Claims (/chunkinfo temp ): Chunk wird nach Ablauf automatisch freigegeben,
+# ohne dass dadurch ein Slot gesperrt wird.
+temp-claims:
+ enabled: true
+ max-days: 30
+ max-per-land: 3
+
+# Hinweis beim Login: freie Slots, naechster Slot, Upkeep-Warnung.
+join-reminder:
+ enabled: true
+ upkeep-warning-hours: 48
+
+top:
+ size: 10
diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml
new file mode 100644
index 0000000..01c5601
--- /dev/null
+++ b/src/main/resources/plugin.yml
@@ -0,0 +1,38 @@
+name: LandsChunkInfo
+main: de.landschunkinfo.LandsChunkInfo
+version: ${project.version}
+api-version: '1.21'
+depend: [Lands]
+softdepend: [Vault, PlaceholderAPI]
+folia-supported: false
+description: Zeigt Spielern ihre Chunks und wann wieder ein Chunk frei wird (Unclaim-Cooldown und Upkeep), mit Skip, Queue, Watchlist, Temp-Claims und Platzhaltern.
+commands:
+ chunkinfo:
+ description: Chunk-Belegung, freie Slots, Upkeep, Queue, Watchlist und mehr
+ usage: / [land|top|skip|gift|queue|watch|temp]
+ aliases: [chunks, chunkslots]
+permissions:
+ landschunkinfo.use:
+ description: /chunkinfo benutzen
+ default: true
+ landschunkinfo.skip:
+ description: Gesperrten Slot freikaufen
+ default: true
+ landschunkinfo.gift:
+ description: Slot in fremdem Land freikaufen
+ default: true
+ landschunkinfo.queue:
+ description: Auto-Claim-Warteschlange
+ default: true
+ landschunkinfo.watch:
+ description: Chunk-Watchlist
+ default: true
+ landschunkinfo.temp:
+ description: Zeitlich begrenzte Claims
+ default: true
+ landschunkinfo.bypass:
+ description: Unclaim-Cooldown ignorieren
+ default: op
+ landschunkinfo.admin:
+ description: /chunkinfo reload
+ default: op
diff --git a/target/LandsChunkInfo-1.0.0.jar b/target/LandsChunkInfo-1.0.0.jar
new file mode 100644
index 0000000..b4d706b
Binary files /dev/null and b/target/LandsChunkInfo-1.0.0.jar differ
diff --git a/target/classes/config.yml b/target/classes/config.yml
new file mode 100644
index 0000000..67513a2
--- /dev/null
+++ b/target/classes/config.yml
@@ -0,0 +1,56 @@
+# Unclaim-Cooldown: Nach einem manuellen /unclaim bleibt der Chunk-Slot des Lands
+# für diese Zeit gesperrt. Format: 30s, 15m, 12h, 2d oder kombiniert (1d12h).
+unclaim-cooldown:
+ enabled: true
+ duration: 24h
+ # true = Claimen wird blockiert, solange alle Slots belegt oder gesperrt sind.
+ # false = nur Anzeige, kein Eingriff ins Claimen.
+ enforce: true
+
+# Upkeep-Anzeige. Zeitpunkt wird aus der Lands-config.yml (economic.interval) gelesen.
+# Der letzte echte Einzug wird automatisch gespeichert. Bis dahin kann ein Startdatum
+# (Tag eines bekannten Einzugs, yyyy-MM-dd) angegeben werden, sonst wird geschätzt.
+upkeep:
+ anchor-date: ""
+
+messages:
+ prefix: "&8[&aChunks&8] &7"
+ no-permission: "&cDazu hast du keine Berechtigung."
+ players-only: "&cNur Spieler können diesen Befehl nutzen."
+ no-land: "&cDu bist in keinem Land Mitglied."
+ land-not-found: "&cLand nicht gefunden oder du bist dort kein Mitglied."
+ reloaded: "&aKonfiguration neu geladen."
+ claim-blocked: "&cAlle freien Chunk-Slots sind nach einem Unclaim noch gesperrt. Nächster Slot frei in &e%time%&c."
+
+# Gesperrten Slot sofort freischalten (/chunkinfo skip, /chunkinfo gift ).
+skip:
+ enabled: true
+ # PLAYER = Geld des Spielers (Vault), LAND = Landkasse (nur Besitzer). gift nutzt immer PLAYER.
+ payment: PLAYER
+ # Preis = Restzeit in Stunden * price-per-hour, mindestens min-price.
+ price-per-hour: 25.0
+ min-price: 50.0
+
+# Auto-Claim-Warteschlange (/chunkinfo queue): claimt vorgemerkte freie Chunks, sobald ein Slot frei ist.
+# Der Spieler muss dafuer online sein.
+queue:
+ max: 3
+
+# Watchlist (/chunkinfo watch): Nachricht, sobald ein fremder Chunk frei wird.
+watch:
+ max: 5
+
+# Zeitlich begrenzte Claims (/chunkinfo temp ): Chunk wird nach Ablauf automatisch freigegeben,
+# ohne dass dadurch ein Slot gesperrt wird.
+temp-claims:
+ enabled: true
+ max-days: 30
+ max-per-land: 3
+
+# Hinweis beim Login: freie Slots, naechster Slot, Upkeep-Warnung.
+join-reminder:
+ enabled: true
+ upkeep-warning-hours: 48
+
+top:
+ size: 10
diff --git a/target/classes/de/landschunkinfo/ChunkExpansion.class b/target/classes/de/landschunkinfo/ChunkExpansion.class
new file mode 100644
index 0000000..3714df2
Binary files /dev/null and b/target/classes/de/landschunkinfo/ChunkExpansion.class differ
diff --git a/target/classes/de/landschunkinfo/InfoCommand.class b/target/classes/de/landschunkinfo/InfoCommand.class
new file mode 100644
index 0000000..40319f5
Binary files /dev/null and b/target/classes/de/landschunkinfo/InfoCommand.class differ
diff --git a/target/classes/de/landschunkinfo/LandsChunkInfo.class b/target/classes/de/landschunkinfo/LandsChunkInfo.class
new file mode 100644
index 0000000..1c65b11
Binary files /dev/null and b/target/classes/de/landschunkinfo/LandsChunkInfo.class differ
diff --git a/target/classes/de/landschunkinfo/LockStore$Entry.class b/target/classes/de/landschunkinfo/LockStore$Entry.class
new file mode 100644
index 0000000..7afc9b5
Binary files /dev/null and b/target/classes/de/landschunkinfo/LockStore$Entry.class differ
diff --git a/target/classes/de/landschunkinfo/LockStore$Kind.class b/target/classes/de/landschunkinfo/LockStore$Kind.class
new file mode 100644
index 0000000..37e4fdf
Binary files /dev/null and b/target/classes/de/landschunkinfo/LockStore$Kind.class differ
diff --git a/target/classes/de/landschunkinfo/LockStore.class b/target/classes/de/landschunkinfo/LockStore.class
new file mode 100644
index 0000000..2c487eb
Binary files /dev/null and b/target/classes/de/landschunkinfo/LockStore.class differ
diff --git a/target/classes/de/landschunkinfo/TimeUtil.class b/target/classes/de/landschunkinfo/TimeUtil.class
new file mode 100644
index 0000000..3865dc8
Binary files /dev/null and b/target/classes/de/landschunkinfo/TimeUtil.class differ
diff --git a/target/classes/de/landschunkinfo/VaultHook.class b/target/classes/de/landschunkinfo/VaultHook.class
new file mode 100644
index 0000000..e88bd18
Binary files /dev/null and b/target/classes/de/landschunkinfo/VaultHook.class differ
diff --git a/target/classes/plugin.yml b/target/classes/plugin.yml
new file mode 100644
index 0000000..1324240
--- /dev/null
+++ b/target/classes/plugin.yml
@@ -0,0 +1,38 @@
+name: LandsChunkInfo
+main: de.landschunkinfo.LandsChunkInfo
+version: 1.0.0
+api-version: '1.21'
+depend: [Lands]
+softdepend: [Vault, PlaceholderAPI]
+folia-supported: false
+description: Zeigt Spielern ihre Chunks und wann wieder ein Chunk frei wird (Unclaim-Cooldown und Upkeep), mit Skip, Queue, Watchlist, Temp-Claims und Platzhaltern.
+commands:
+ chunkinfo:
+ description: Chunk-Belegung, freie Slots, Upkeep, Queue, Watchlist und mehr
+ usage: / [land|top|skip|gift|queue|watch|temp]
+ aliases: [chunks, chunkslots]
+permissions:
+ landschunkinfo.use:
+ description: /chunkinfo benutzen
+ default: true
+ landschunkinfo.skip:
+ description: Gesperrten Slot freikaufen
+ default: true
+ landschunkinfo.gift:
+ description: Slot in fremdem Land freikaufen
+ default: true
+ landschunkinfo.queue:
+ description: Auto-Claim-Warteschlange
+ default: true
+ landschunkinfo.watch:
+ description: Chunk-Watchlist
+ default: true
+ landschunkinfo.temp:
+ description: Zeitlich begrenzte Claims
+ default: true
+ landschunkinfo.bypass:
+ description: Unclaim-Cooldown ignorieren
+ default: op
+ landschunkinfo.admin:
+ description: /chunkinfo reload
+ default: op
diff --git a/target/maven-archiver/pom.properties b/target/maven-archiver/pom.properties
new file mode 100644
index 0000000..af117c4
--- /dev/null
+++ b/target/maven-archiver/pom.properties
@@ -0,0 +1,3 @@
+artifactId=LandsChunkInfo
+groupId=de.landschunkinfo
+version=1.0.0
diff --git a/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst b/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst
new file mode 100644
index 0000000..3d56fa0
--- /dev/null
+++ b/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst
@@ -0,0 +1,8 @@
+de\landschunkinfo\VaultHook.class
+de\landschunkinfo\LockStore$Entry.class
+de\landschunkinfo\InfoCommand.class
+de\landschunkinfo\TimeUtil.class
+de\landschunkinfo\ChunkExpansion.class
+de\landschunkinfo\LockStore$Kind.class
+de\landschunkinfo\LandsChunkInfo.class
+de\landschunkinfo\LockStore.class
diff --git a/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst b/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst
new file mode 100644
index 0000000..db38ef3
--- /dev/null
+++ b/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst
@@ -0,0 +1,6 @@
+C:\Users\Viper\Desktop\Lands-Erweiterung\src\main\java\de\landschunkinfo\ChunkExpansion.java
+C:\Users\Viper\Desktop\Lands-Erweiterung\src\main\java\de\landschunkinfo\InfoCommand.java
+C:\Users\Viper\Desktop\Lands-Erweiterung\src\main\java\de\landschunkinfo\LandsChunkInfo.java
+C:\Users\Viper\Desktop\Lands-Erweiterung\src\main\java\de\landschunkinfo\LockStore.java
+C:\Users\Viper\Desktop\Lands-Erweiterung\src\main\java\de\landschunkinfo\TimeUtil.java
+C:\Users\Viper\Desktop\Lands-Erweiterung\src\main\java\de\landschunkinfo\VaultHook.java