Files
LandsChunkInfo/src/main/java/de/landschunkinfo/LandsChunkInfo.java
T
2026-10-22 19:06:23 +00:00

514 lines
19 KiB
Java

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<String> 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<Land> landsOf(UUID uuid) {
List<Land> 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<Land> 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<Player> onlineMembers(Land land) {
List<Player> 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<Land> top(int n) {
List<Land> 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<Long> 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<Long> 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<String> 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<String> 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));
}
}