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)); } }