commit c22a7d6e2436ef295f8dc289fd06df479d4fabde Author: Xelara Networks Date: Wed Jun 24 00:15:56 2026 -0400 Fresh upload diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..96dcbab --- /dev/null +++ b/pom.xml @@ -0,0 +1,74 @@ + + 4.0.0 + + com.dirtsmp + DirtSMP + 1.0.0 + jar + + DirtSMP + DirtbagMC-themed configurable world-border progression for Paper SMP servers. + + + 21 + 1.21.8-R0.1-SNAPSHOT + 2.11.6 + UTF-8 + + + + + papermc + https://repo.papermc.io/repository/maven-public/ + + + placeholderapi + https://repo.helpch.at/releases/ + + + + + + io.papermc.paper + paper-api + ${paper.version} + provided + + + me.clip + placeholderapi + ${placeholderapi.version} + provided + true + + + + + DirtSMP-${project.version} + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + ${java.version} + true + + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + + true + + + + + + + diff --git a/src/main/java/com/dirtsmp/dirtsmp/DirtSMPPlugin.java b/src/main/java/com/dirtsmp/dirtsmp/DirtSMPPlugin.java new file mode 100644 index 0000000..9de2580 --- /dev/null +++ b/src/main/java/com/dirtsmp/dirtsmp/DirtSMPPlugin.java @@ -0,0 +1,180 @@ +package com.dirtsmp.dirtsmp; + +import com.dirtsmp.dirtsmp.command.DirtSMPCommand; +import com.dirtsmp.dirtsmp.config.ConfigManager; +import com.dirtsmp.dirtsmp.config.MessageManager; +import com.dirtsmp.dirtsmp.gui.AdminGuiManager; +import com.dirtsmp.dirtsmp.hook.PlaceholderHook; +import com.dirtsmp.dirtsmp.listener.PlayerListener; +import com.dirtsmp.dirtsmp.service.BorderManager; +import com.dirtsmp.dirtsmp.service.CommandHookManager; +import com.dirtsmp.dirtsmp.service.HistoryLogger; +import com.dirtsmp.dirtsmp.service.SoftBorderManager; +import com.dirtsmp.dirtsmp.service.TriggerEvaluator; +import com.dirtsmp.dirtsmp.service.WebhookNotifier; +import com.dirtsmp.dirtsmp.state.PlayerStatsManager; +import com.dirtsmp.dirtsmp.state.StateManager; +import com.dirtsmp.dirtsmp.task.ScheduleManager; +import java.io.IOException; +import java.util.logging.Level; +import org.bukkit.Bukkit; +import org.bukkit.command.PluginCommand; +import org.bukkit.event.HandlerList; +import org.bukkit.plugin.java.JavaPlugin; + +public final class DirtSMPPlugin extends JavaPlugin { + private ConfigManager configManager; + private MessageManager messageManager; + private StateManager stateManager; + private PlayerStatsManager playerStatsManager; + private TriggerEvaluator triggerEvaluator; + private BorderManager borderManager; + private ScheduleManager scheduleManager; + private AdminGuiManager adminGuiManager; + private SoftBorderManager softBorderManager; + private PlaceholderHook placeholderHook; + + @Override + public void onEnable() { + try { + bootstrap(); + getLogger().info("DirtSMP enabled with " + configManager.worlds().size() + " configured world(s)."); + } catch (RuntimeException ex) { + getLogger().log(Level.SEVERE, "DirtSMP could not enable cleanly.", ex); + getServer().getPluginManager().disablePlugin(this); + } + } + + @Override + public void onDisable() { + if (scheduleManager != null) { + scheduleManager.stop(); + } + if (softBorderManager != null) { + softBorderManager.stop(); + } + Bukkit.getScheduler().cancelTasks(this); + if (adminGuiManager != null) { + adminGuiManager.closeOpenInventories(); + } + if (placeholderHook != null) { + placeholderHook.unregister(); + } + HandlerList.unregisterAll(this); + + if (stateManager != null) { + stateManager.saveQuietly(); + stateManager.clearRuntimeState(); + } + if (playerStatsManager != null) { + playerStatsManager.saveQuietly(); + playerStatsManager.clearRuntimeState(); + } + getLogger().info("DirtSMP disabled."); + } + + private void bootstrap() { + configManager = new ConfigManager(this); + configManager.load(); + messageManager = new MessageManager(configManager); + stateManager = new StateManager(this, configManager); + playerStatsManager = new PlayerStatsManager(this); + stateManager.load(); + playerStatsManager.load(); + + CommandHookManager commandHookManager = new CommandHookManager(configManager, messageManager); + WebhookNotifier webhookNotifier = new WebhookNotifier(this, configManager, messageManager); + HistoryLogger historyLogger = new HistoryLogger(this, configManager); + triggerEvaluator = new TriggerEvaluator(playerStatsManager); + borderManager = new BorderManager(this, configManager, messageManager, stateManager, playerStatsManager, commandHookManager, webhookNotifier, historyLogger); + scheduleManager = new ScheduleManager(this, configManager, stateManager, playerStatsManager, borderManager, triggerEvaluator, messageManager); + adminGuiManager = new AdminGuiManager(this, configManager, messageManager, stateManager, borderManager, triggerEvaluator); + softBorderManager = new SoftBorderManager(this, configManager, messageManager, stateManager); + + registerCommands(); + Bukkit.getPluginManager().registerEvents(new PlayerListener(this, playerStatsManager, borderManager, configManager), this); + Bukkit.getPluginManager().registerEvents(adminGuiManager, this); + Bukkit.getPluginManager().registerEvents(softBorderManager, this); + registerPlaceholders(); + + borderManager.applyStartupRules(); + softBorderManager.start(); + scheduleManager.start(); + } + + private void registerCommands() { + PluginCommand command = getCommand("dirtsmp"); + if (command == null) { + throw new IllegalStateException("plugin.yml is missing the dirtsmp command."); + } + DirtSMPCommand commandHandler = new DirtSMPCommand(this); + command.setExecutor(commandHandler); + command.setTabCompleter(commandHandler); + } + + private void registerPlaceholders() { + if (Bukkit.getPluginManager().isPluginEnabled("PlaceholderAPI")) { + placeholderHook = new PlaceholderHook(this); + placeholderHook.register(); + } + } + + public void reloadDirtSMP() { + if (scheduleManager != null) { + scheduleManager.stop(); + } + if (softBorderManager != null) { + softBorderManager.stop(); + } + if (stateManager != null) { + stateManager.saveQuietly(); + } + if (playerStatsManager != null) { + playerStatsManager.saveQuietly(); + } + + configManager.load(); + stateManager.load(); + playerStatsManager.load(); + borderManager.applyStartupRules(); + softBorderManager.start(); + scheduleManager.start(); + } + + public void saveAll() { + try { + stateManager.save(); + playerStatsManager.save(); + } catch (IOException ex) { + getLogger().log(Level.WARNING, "Could not save all DirtSMP data.", ex); + } + } + + public ConfigManager configManager() { + return configManager; + } + + public MessageManager messageManager() { + return messageManager; + } + + public StateManager stateManager() { + return stateManager; + } + + public PlayerStatsManager playerStatsManager() { + return playerStatsManager; + } + + public TriggerEvaluator triggerEvaluator() { + return triggerEvaluator; + } + + public BorderManager borderManager() { + return borderManager; + } + + public AdminGuiManager adminGuiManager() { + return adminGuiManager; + } +} diff --git a/src/main/java/com/dirtsmp/dirtsmp/command/DirtSMPCommand.java b/src/main/java/com/dirtsmp/dirtsmp/command/DirtSMPCommand.java new file mode 100644 index 0000000..f5ea7a3 --- /dev/null +++ b/src/main/java/com/dirtsmp/dirtsmp/command/DirtSMPCommand.java @@ -0,0 +1,592 @@ +package com.dirtsmp.dirtsmp.command; + +import com.dirtsmp.dirtsmp.DirtSMPPlugin; +import com.dirtsmp.dirtsmp.config.MessageManager; +import com.dirtsmp.dirtsmp.model.ExpansionReason; +import com.dirtsmp.dirtsmp.model.ExpansionResult; +import com.dirtsmp.dirtsmp.model.LegacyAccessReport; +import com.dirtsmp.dirtsmp.model.PhaseDefinition; +import com.dirtsmp.dirtsmp.model.WorldRule; +import com.dirtsmp.dirtsmp.service.TimeUtil; +import com.dirtsmp.dirtsmp.state.WorldProgress; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.OptionalDouble; +import java.util.UUID; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.OfflinePlayer; +import org.bukkit.Statistic; +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.entity.Player; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +public final class DirtSMPCommand implements CommandExecutor, TabCompleter { + private static final List SUBCOMMANDS = List.of("help", "reload", "status", "next", "legacy", "legacycheck", "tpborder", "tpborderside", "add", "expand", "setsize", "pause", "resume", "reset", "gui", "save"); + private static final List BORDER_SIDES = List.of("north", "south", "east", "west"); + + private final DirtSMPPlugin plugin; + + public DirtSMPCommand(DirtSMPPlugin plugin) { + this.plugin = plugin; + } + + @Override + public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) { + if (args.length == 0 || args[0].equalsIgnoreCase("help")) { + plugin.messageManager().sendList(sender, "commands.help", Map.of()); + return true; + } + + String sub = args[0].toLowerCase(Locale.ROOT); + switch (sub) { + case "reload" -> reload(sender); + case "status" -> status(sender, args); + case "next" -> next(sender, args); + case "legacy" -> legacy(sender, args); + case "legacycheck" -> legacyCheck(sender, args); + case "tpborder", "tpborderside" -> teleportBorder(sender, args); + case "add" -> add(sender, args); + case "expand" -> expand(sender, args); + case "setsize" -> setSize(sender, args); + case "pause" -> pause(sender, args); + case "resume" -> resume(sender, args); + case "reset" -> reset(sender, args); + case "gui" -> gui(sender); + case "save" -> save(sender); + default -> plugin.messageManager().sendList(sender, "commands.help", Map.of()); + } + return true; + } + + private void add(CommandSender sender, String[] args) { + if (args.length >= 4 + && args[1].equalsIgnoreCase("legacy") + && (args[2].equalsIgnoreCase("users") || args[2].equalsIgnoreCase("user")) + && args[3].equalsIgnoreCase("scan")) { + scanLegacyUsers(sender, args.length >= 5 ? args[4] : null); + return; + } + plugin.messageManager().sendList(sender, "commands.help", Map.of()); + } + + private void legacy(CommandSender sender, String[] args) { + if (args.length < 2) { + plugin.messageManager().sendList(sender, "commands.help", Map.of()); + return; + } + + switch (args[1].toLowerCase(Locale.ROOT)) { + case "scan" -> scanLegacyUsers(sender, args.length >= 3 ? args[2] : null); + case "list" -> listLegacyUsers(sender); + case "add" -> addLegacyUser(sender, args); + case "remove" -> removeLegacyUser(sender, args); + default -> plugin.messageManager().sendList(sender, "commands.help", Map.of()); + } + } + + private void scanLegacyUsers(CommandSender sender, String rawMinimum) { + if (!has(sender, "dirtsmp.legacy")) { + plugin.messageManager().send(sender, "commands.no-permission"); + return; + } + + long minimumMillis = rawMinimum == null || rawMinimum.isBlank() + ? plugin.configManager().legacyUsersMinimumPlaytimeMillis() + : TimeUtil.parseDurationMillis(rawMinimum, plugin.configManager().legacyUsersMinimumPlaytimeMillis()); + long minimumTicks = Math.max(1L, minimumMillis / 50L); + + int scanned = 0; + int matched = 0; + int added = 0; + for (OfflinePlayer player : Bukkit.getOfflinePlayers()) { + if (!player.hasPlayedBefore()) { + continue; + } + scanned++; + long playtimeTicks = playtimeTicks(player); + if (playtimeTicks < minimumTicks) { + continue; + } + matched++; + if (plugin.stateManager().addLegacyUser(player.getUniqueId(), player.getName(), playtimeTicks, "scan:" + TimeUtil.formatDuration(minimumMillis))) { + added++; + } + } + + plugin.stateManager().saveQuietly(); + plugin.messageManager().send(sender, "commands.legacy-scan", Map.of( + "scanned", String.valueOf(scanned), + "matched", String.valueOf(matched), + "added", String.valueOf(added), + "total", String.valueOf(plugin.stateManager().legacyUsers().size()), + "minimum", TimeUtil.formatDuration(minimumMillis) + )); + } + + private void listLegacyUsers(CommandSender sender) { + if (!has(sender, "dirtsmp.legacy")) { + plugin.messageManager().send(sender, "commands.no-permission"); + return; + } + + plugin.messageManager().send(sender, "commands.legacy-list-header", Map.of( + "count", String.valueOf(plugin.stateManager().legacyUsers().size()) + )); + plugin.stateManager().legacyUsers().entrySet().stream() + .sorted(Comparator.comparing(entry -> entry.getValue().name().toLowerCase(Locale.ROOT))) + .limit(20) + .forEach(entry -> plugin.messageManager().send(sender, "commands.legacy-list-line", Map.of( + "name", entry.getValue().name(), + "uuid", entry.getKey().toString(), + "playtime", TimeUtil.formatDuration(entry.getValue().playtimeTicks() * 50L), + "source", entry.getValue().source() + ))); + } + + @SuppressWarnings("deprecation") + private void addLegacyUser(CommandSender sender, String[] args) { + if (!has(sender, "dirtsmp.legacy")) { + plugin.messageManager().send(sender, "commands.no-permission"); + return; + } + if (args.length < 3) { + plugin.messageManager().sendList(sender, "commands.help", Map.of()); + return; + } + + OfflinePlayer player = Bukkit.getOfflinePlayer(args[2]); + long playtimeTicks = playtimeTicks(player); + boolean added = plugin.stateManager().addLegacyUser(player.getUniqueId(), player.getName(), playtimeTicks, "manual:" + sender.getName()); + plugin.stateManager().saveQuietly(); + plugin.messageManager().send(sender, "commands.legacy-add", Map.of( + "name", player.getName() == null ? args[2] : player.getName(), + "status", added ? "added" : "updated" + )); + } + + @SuppressWarnings("deprecation") + private void removeLegacyUser(CommandSender sender, String[] args) { + if (!has(sender, "dirtsmp.legacy")) { + plugin.messageManager().send(sender, "commands.no-permission"); + return; + } + if (args.length < 3) { + plugin.messageManager().sendList(sender, "commands.help", Map.of()); + return; + } + + UUID uuid = parseUuid(args[2]); + OfflinePlayer player = uuid == null ? Bukkit.getOfflinePlayer(args[2]) : Bukkit.getOfflinePlayer(uuid); + boolean removed = plugin.stateManager().removeLegacyUser(player.getUniqueId()); + plugin.stateManager().saveQuietly(); + plugin.messageManager().send(sender, "commands.legacy-remove", Map.of( + "name", player.getName() == null ? args[2] : player.getName(), + "status", removed ? "removed" : "not listed" + )); + } + + private long playtimeTicks(OfflinePlayer player) { + try { + return Math.max(0L, player.getStatistic(Statistic.PLAY_ONE_MINUTE)); + } catch (RuntimeException ex) { + return 0L; + } + } + + private UUID parseUuid(String value) { + try { + return UUID.fromString(value); + } catch (IllegalArgumentException ex) { + return null; + } + } + + private void reload(CommandSender sender) { + if (!has(sender, "dirtsmp.reload")) { + plugin.messageManager().send(sender, "commands.no-permission"); + return; + } + plugin.reloadDirtSMP(); + plugin.messageManager().send(sender, "commands.reload"); + } + + private void save(CommandSender sender) { + if (!has(sender, "dirtsmp.admin")) { + plugin.messageManager().send(sender, "commands.no-permission"); + return; + } + plugin.saveAll(); + plugin.messageManager().send(sender, "commands.saved"); + } + + private void status(CommandSender sender, String[] args) { + if (!has(sender, "dirtsmp.status")) { + plugin.messageManager().send(sender, "commands.no-permission"); + return; + } + if (args.length >= 2) { + WorldRule rule = rule(sender, args[1]); + if (rule != null) { + plugin.messageManager().send(sender, "commands.status-line", placeholders(rule)); + } + return; + } + + plugin.messageManager().send(sender, "commands.status-header", Map.of("count", String.valueOf(plugin.configManager().worlds().size()))); + for (WorldRule rule : plugin.configManager().worlds().values()) { + plugin.messageManager().send(sender, "commands.status-line", placeholders(rule)); + } + } + + private void next(CommandSender sender, String[] args) { + if (!has(sender, "dirtsmp.status")) { + plugin.messageManager().send(sender, "commands.no-permission"); + return; + } + if (args.length >= 2) { + WorldRule rule = rule(sender, args[1]); + if (rule != null) { + plugin.messageManager().send(sender, "commands.next-line", placeholders(rule)); + } + return; + } + for (WorldRule rule : plugin.configManager().worlds().values()) { + plugin.messageManager().send(sender, "commands.next-line", placeholders(rule)); + } + } + + private void legacyCheck(CommandSender sender, String[] args) { + if (!has(sender, "dirtsmp.status")) { + plugin.messageManager().send(sender, "commands.no-permission"); + return; + } + if (args.length < 2) { + plugin.messageManager().sendList(sender, "commands.help", Map.of()); + return; + } + WorldRule rule = rule(sender, args[1]); + if (rule == null) { + return; + } + + LegacyAccessReport report = plugin.borderManager().legacyAccessReport(rule); + double recommended = Math.max(rule.initialSize(), report.requiredSize()); + plugin.messageManager().send(sender, "commands.legacy-check", Map.of( + "world", rule.worldName(), + "locations", String.valueOf(report.includedLocations()), + "required_size", TimeUtil.formatSize(report.requiredSize()), + "recommended_size", TimeUtil.formatSize(recommended), + "initial_size", TimeUtil.formatSize(rule.initialSize()), + "farthest", report.farthestLocationName() + )); + } + + private void teleportBorder(CommandSender sender, String[] args) { + if (!has(sender, "dirtsmp.tpborder")) { + plugin.messageManager().send(sender, "commands.no-permission"); + return; + } + if (!(sender instanceof Player player)) { + plugin.messageManager().send(sender, "commands.player-only"); + return; + } + if (args.length < 2) { + plugin.messageManager().sendList(sender, "commands.help", Map.of()); + return; + } + + WorldRule rule = rule(sender, args[1]); + if (rule == null) { + return; + } + World world = Bukkit.getWorld(rule.worldName()); + if (world == null) { + plugin.messageManager().send(sender, "commands.tp-border-failed", Map.of( + "world", rule.worldName(), + "reason", "world is not loaded" + )); + return; + } + + String side = args.length >= 3 ? args[2].toLowerCase(Locale.ROOT) : nearestSide(player, rule); + if (!BORDER_SIDES.contains(side)) { + plugin.messageManager().send(sender, "commands.tp-border-failed", Map.of( + "world", rule.worldName(), + "reason", "side must be north, south, east, or west" + )); + return; + } + + Location destination = borderPreviewLocation(player, world, rule, side); + destination.setY(safeY(world, destination)); + player.teleport(destination); + plugin.messageManager().send(player, "commands.tp-border", Map.of( + "world", rule.worldName(), + "side", side, + "size", TimeUtil.formatSize(currentSize(rule)) + )); + } + + private void expand(CommandSender sender, String[] args) { + if (!has(sender, "dirtsmp.expand")) { + plugin.messageManager().send(sender, "commands.no-permission"); + return; + } + if (args.length < 2) { + plugin.messageManager().sendList(sender, "commands.help", Map.of()); + return; + } + WorldRule rule = rule(sender, args[1]); + if (rule == null) { + return; + } + ExpansionResult result = plugin.borderManager().expand(rule, ExpansionReason.MANUAL, sender.getName(), true); + if (result.success()) { + plugin.messageManager().send(sender, "commands.expanded", Map.of( + "world", rule.worldName(), + "old_size", TimeUtil.formatSize(result.oldSize()), + "new_size", TimeUtil.formatSize(result.newSize()) + )); + } else { + plugin.messageManager().send(sender, "commands.expand-failed", Map.of("world", rule.worldName(), "reason", result.reason())); + } + } + + private void setSize(CommandSender sender, String[] args) { + if (!has(sender, "dirtsmp.setsize")) { + plugin.messageManager().send(sender, "commands.no-permission"); + return; + } + if (args.length < 3) { + plugin.messageManager().sendList(sender, "commands.help", Map.of()); + return; + } + WorldRule rule = rule(sender, args[1]); + if (rule == null) { + return; + } + double size; + try { + size = Double.parseDouble(args[2].replace(",", "")); + } catch (NumberFormatException ex) { + plugin.messageManager().send(sender, "commands.invalid-number"); + return; + } + boolean force = args.length >= 4 && args[3].equalsIgnoreCase("force"); + ExpansionResult result = plugin.borderManager().setSize(rule, size, sender.getName(), force); + if (result.success()) { + plugin.messageManager().send(sender, "commands.set-size", Map.of("world", rule.worldName(), "new_size", TimeUtil.formatSize(result.newSize()))); + } else { + plugin.messageManager().send(sender, "commands.set-size-failed", Map.of("world", rule.worldName(), "reason", result.reason())); + } + } + + private void pause(CommandSender sender, String[] args) { + if (!has(sender, "dirtsmp.pause")) { + plugin.messageManager().send(sender, "commands.no-permission"); + return; + } + WorldRule rule = args.length >= 2 ? rule(sender, args[1]) : null; + if (rule == null) { + return; + } + plugin.borderManager().pause(rule); + plugin.messageManager().send(sender, "commands.paused", Map.of("world", rule.worldName())); + } + + private void resume(CommandSender sender, String[] args) { + if (!has(sender, "dirtsmp.resume")) { + plugin.messageManager().send(sender, "commands.no-permission"); + return; + } + WorldRule rule = args.length >= 2 ? rule(sender, args[1]) : null; + if (rule == null) { + return; + } + plugin.borderManager().resume(rule); + plugin.messageManager().send(sender, "commands.resumed", Map.of("world", rule.worldName())); + } + + private void reset(CommandSender sender, String[] args) { + if (!has(sender, "dirtsmp.reset")) { + plugin.messageManager().send(sender, "commands.no-permission"); + return; + } + WorldRule rule = args.length >= 2 ? rule(sender, args[1]) : null; + if (rule == null) { + return; + } + boolean force = args.length >= 3 && args[2].equalsIgnoreCase("force"); + ExpansionResult result = plugin.borderManager().reset(rule, sender.getName(), force); + if (result.success()) { + plugin.messageManager().send(sender, "commands.reset", Map.of("world", rule.worldName())); + } else { + plugin.messageManager().send(sender, "commands.set-size-failed", Map.of("world", rule.worldName(), "reason", result.reason())); + } + } + + private void gui(CommandSender sender) { + if (!has(sender, "dirtsmp.gui")) { + plugin.messageManager().send(sender, "commands.no-permission"); + return; + } + if (!(sender instanceof Player player)) { + plugin.messageManager().send(sender, "commands.player-only"); + return; + } + if (!plugin.configManager().guiEnabled()) { + plugin.messageManager().send(sender, "commands.gui-disabled"); + return; + } + plugin.adminGuiManager().open(player); + } + + private WorldRule rule(CommandSender sender, String key) { + WorldRule rule = plugin.configManager().world(key); + if (rule == null) { + plugin.messageManager().send(sender, "commands.unknown-world", Map.of("world", key)); + } + return rule; + } + + private Map placeholders(WorldRule rule) { + WorldProgress progress = plugin.stateManager().progress(rule.key()); + OptionalDouble nextSize = plugin.borderManager().nextSize(rule, progress); + PhaseDefinition phase = plugin.borderManager().currentPhase(rule, progress); + String nextTime = "manual"; + if (rule.triggers().mode().usesTime() && progress.lastExpansionAt() > 0L) { + long next = progress.lastExpansionAt() + rule.triggers().timeIntervalMillis(); + nextTime = next <= System.currentTimeMillis() ? "due" : TimeUtil.formatDuration(next - System.currentTimeMillis()); + } + return Map.ofEntries( + Map.entry("key", rule.key()), + Map.entry("world", rule.worldName()), + Map.entry("enabled", String.valueOf(rule.enabled())), + Map.entry("size", TimeUtil.formatSize(plugin.borderManager().displaySize(rule, progress))), + Map.entry("next_size", nextSize.isPresent() ? TimeUtil.formatSize(nextSize.getAsDouble()) : "max"), + Map.entry("border_mode", rule.borderMode().name()), + Map.entry("phase", phase == null ? "none" : phase.name()), + Map.entry("paused", String.valueOf(progress.paused())), + Map.entry("trigger", plugin.triggerEvaluator().describeTrigger(rule, progress)), + Map.entry("next_time", nextTime), + Map.entry("expansion_count", String.valueOf(progress.expansionCount())), + Map.entry("unique_progress", plugin.playerStatsManager().uniquePlayerCount() + "/" + (progress.uniquePlayersAtLastExpansion() + rule.triggers().uniquePlayersEvery())) + ); + } + + private Location borderPreviewLocation(Player player, World world, WorldRule rule, String side) { + double half = currentSize(rule) / 2.0; + double inset = Math.max(3.0, rule.softBorder().insideBuffer() + 1.0); + double x = clamp(player.getLocation().getX(), rule.centerX() - half + inset, rule.centerX() + half - inset); + double z = clamp(player.getLocation().getZ(), rule.centerZ() - half + inset, rule.centerZ() + half - inset); + + switch (side) { + case "north" -> z = rule.centerZ() - half + inset; + case "south" -> z = rule.centerZ() + half - inset; + case "east" -> x = rule.centerX() + half - inset; + case "west" -> x = rule.centerX() - half + inset; + default -> { + } + } + return new Location(world, x, player.getLocation().getY(), z, player.getLocation().getYaw(), player.getLocation().getPitch()); + } + + private String nearestSide(Player player, WorldRule rule) { + double half = currentSize(rule) / 2.0; + double north = Math.abs(player.getLocation().getZ() - (rule.centerZ() - half)); + double south = Math.abs(player.getLocation().getZ() - (rule.centerZ() + half)); + double east = Math.abs(player.getLocation().getX() - (rule.centerX() + half)); + double west = Math.abs(player.getLocation().getX() - (rule.centerX() - half)); + double min = Math.min(Math.min(north, south), Math.min(east, west)); + if (min == north) { + return "north"; + } + if (min == south) { + return "south"; + } + return min == east ? "east" : "west"; + } + + private double currentSize(WorldRule rule) { + WorldProgress progress = plugin.stateManager().progress(rule.key()); + return progress.currentSize() > 0.0 ? progress.currentSize() : rule.initialSize(); + } + + private double safeY(World world, Location location) { + int x = location.getBlockX(); + int z = location.getBlockZ(); + int highest = world.getHighestBlockYAt(x, z); + return Math.max(world.getMinHeight() + 1.0, Math.min(world.getMaxHeight() - 2.0, highest + 1.0)); + } + + private double clamp(double value, double min, double max) { + return Math.max(min, Math.min(max, value)); + } + + private boolean has(CommandSender sender, String permission) { + return sender.hasPermission("dirtsmp.admin") || sender.hasPermission(permission); + } + + @Override + public @Nullable List onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) { + if (args.length == 1) { + return filter(SUBCOMMANDS, args[0]); + } + + String sub = args[0].toLowerCase(Locale.ROOT); + if (args.length == 2 && List.of("status", "next", "legacycheck", "tpborder", "tpborderside", "expand", "setsize", "pause", "resume", "reset").contains(sub)) { + return filter(new ArrayList<>(plugin.configManager().worlds().keySet()), args[1]); + } + if (args.length == 3 && List.of("tpborder", "tpborderside").contains(sub)) { + return filter(BORDER_SIDES, args[2]); + } + if (sub.equals("legacy")) { + if (args.length == 2) { + return filter(List.of("scan", "list", "add", "remove"), args[1]); + } + if (args.length == 3 && args[1].equalsIgnoreCase("scan")) { + return filter(List.of("1h", "30m", "2h"), args[2]); + } + if (args.length == 3 && List.of("add", "remove").contains(args[1].toLowerCase(Locale.ROOT))) { + return filter(Bukkit.getOnlinePlayers().stream().map(Player::getName).toList(), args[2]); + } + } + if (sub.equals("add")) { + if (args.length == 2) { + return filter(List.of("legacy"), args[1]); + } + if (args.length == 3 && args[1].equalsIgnoreCase("legacy")) { + return filter(List.of("users"), args[2]); + } + if (args.length == 4 && args[1].equalsIgnoreCase("legacy") && args[2].equalsIgnoreCase("users")) { + return filter(List.of("scan"), args[3]); + } + if (args.length == 5 && args[1].equalsIgnoreCase("legacy") && args[2].equalsIgnoreCase("users") && args[3].equalsIgnoreCase("scan")) { + return filter(List.of("1h", "30m", "2h"), args[4]); + } + } + if (args.length == 4 && sub.equals("setsize")) { + return filter(List.of("force"), args[3]); + } + if (args.length == 3 && sub.equals("reset")) { + return filter(List.of("force"), args[2]); + } + return List.of(); + } + + private List filter(List values, String prefix) { + String lower = prefix.toLowerCase(Locale.ROOT); + return values.stream() + .filter(value -> value.toLowerCase(Locale.ROOT).startsWith(lower)) + .toList(); + } +} diff --git a/src/main/java/com/dirtsmp/dirtsmp/config/ConfigManager.java b/src/main/java/com/dirtsmp/dirtsmp/config/ConfigManager.java new file mode 100644 index 0000000..b82b64f --- /dev/null +++ b/src/main/java/com/dirtsmp/dirtsmp/config/ConfigManager.java @@ -0,0 +1,482 @@ +package com.dirtsmp.dirtsmp.config; + +import com.dirtsmp.dirtsmp.DirtSMPPlugin; +import com.dirtsmp.dirtsmp.model.BorderMode; +import com.dirtsmp.dirtsmp.model.CatchUpMode; +import com.dirtsmp.dirtsmp.model.GrowthMode; +import com.dirtsmp.dirtsmp.model.PhaseDefinition; +import com.dirtsmp.dirtsmp.model.TriggerMode; +import com.dirtsmp.dirtsmp.model.TriggerSettings; +import com.dirtsmp.dirtsmp.model.WorldRule; +import com.dirtsmp.dirtsmp.service.TimeUtil; +import java.io.File; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.logging.Level; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.configuration.file.YamlConfiguration; + +public final class ConfigManager { + private final DirtSMPPlugin plugin; + private FileConfiguration config; + private YamlConfiguration messages; + private Map worlds = new LinkedHashMap<>(); + private WorldRule.MilestoneRewardSettings globalMilestoneRewards = WorldRule.MilestoneRewardSettings.empty(); + + public ConfigManager(DirtSMPPlugin plugin) { + this.plugin = plugin; + } + + public void load() { + plugin.getDataFolder().mkdirs(); + plugin.saveDefaultConfig(); + saveBundledResource("messages.yml"); + + plugin.reloadConfig(); + config = plugin.getConfig(); + messages = YamlConfiguration.loadConfiguration(new File(plugin.getDataFolder(), "messages.yml")); + globalMilestoneRewards = readMilestoneRewards(config.getConfigurationSection("milestone-rewards")); + worlds = loadWorldRules(); + } + + private void saveBundledResource(String name) { + File target = new File(plugin.getDataFolder(), name); + if (!target.exists()) { + plugin.saveResource(name, false); + } + } + + private Map loadWorldRules() { + ConfigurationSection section = config.getConfigurationSection("worlds"); + if (section == null) { + plugin.getLogger().warning("No worlds section found in config.yml. DirtSMP will not manage any borders."); + return Collections.emptyMap(); + } + + Map loaded = new LinkedHashMap<>(); + for (String key : section.getKeys(false)) { + ConfigurationSection worldSection = section.getConfigurationSection(key); + if (worldSection == null) { + continue; + } + WorldRule rule = readWorldRule(key, worldSection); + loaded.put(key.toLowerCase(Locale.ROOT), rule); + } + return Collections.unmodifiableMap(loaded); + } + + private WorldRule readWorldRule(String key, ConfigurationSection section) { + boolean enabled = section.getBoolean("enabled", true); + String worldName = nonBlank(section.getString("world"), key); + double centerX = section.getDouble("center-x", 0.0); + double centerZ = section.getDouble("center-z", 0.0); + double startingSize = positiveDouble(section, "starting-size", 1000.0); + BorderMode borderMode = BorderMode.from(section.getString("border-mode", "WORLD_BORDER")); + GrowthMode growthMode = GrowthMode.from(section.getString("growth-mode", "INCREMENTAL")); + double growthAmount = positiveDouble(section, "growth-amount", 1000.0); + double maxSize = positiveDouble(section, "max-size", 59_999_968.0); + long transitionSeconds = Math.max(0L, section.getLong("transition-seconds", 60L)); + boolean manualOnly = section.getBoolean("manual-only", false); + boolean importCurrentBorder = section.getBoolean("import-current-border", false); + boolean noShrinkProtection = section.getBoolean("no-shrink-protection", true); + boolean enforceMaxSize = section.getBoolean("enforce-max-size", true); + + WorldRule.AnnouncementSettings announcements = readAnnouncements(section.getConfigurationSection("broadcasts")); + WorldRule.ReminderSettings reminders = readReminders(section.getConfigurationSection("reminders")); + TriggerSettings triggers = readTriggers(section.getConfigurationSection("triggers")); + List phases = readPhases(section); + WorldRule.MilestoneRewardSettings milestoneRewards = readMilestoneRewards(section.getConfigurationSection("milestone-rewards")); + WorldRule.LegacyAccessSettings legacyAccess = readLegacyAccess(section.getConfigurationSection("legacy-access")); + WorldRule.SoftBorderSettings softBorder = readSoftBorder(section.getConfigurationSection("soft-border")); + + if (growthMode == GrowthMode.PHASE && phases.size() < 2) { + plugin.getLogger().warning("World '" + key + "' is in PHASE mode but has fewer than two phases. It can initialize, but it has no scripted expansion path."); + } + + return new WorldRule( + key.toLowerCase(Locale.ROOT), + enabled, + worldName, + centerX, + centerZ, + startingSize, + borderMode, + growthMode, + growthAmount, + maxSize, + transitionSeconds, + manualOnly, + importCurrentBorder, + noShrinkProtection, + enforceMaxSize, + announcements, + reminders, + triggers, + phases, + milestoneRewards, + legacyAccess, + softBorder, + stringList(section, "command-hooks.before-expansion"), + stringList(section, "command-hooks.after-expansion") + ); + } + + private WorldRule.AnnouncementSettings readAnnouncements(ConfigurationSection section) { + if (section == null) { + return new WorldRule.AnnouncementSettings(true, false, "", "", "", "", "", 1.0f, 1.0f); + } + return new WorldRule.AnnouncementSettings( + section.getBoolean("enabled", true), + section.getBoolean("world-only", false), + section.getString("message", ""), + section.getString("title", ""), + section.getString("subtitle", ""), + section.getString("action-bar", ""), + section.getString("sound", ""), + (float) section.getDouble("volume", 1.0), + (float) section.getDouble("pitch", 1.0) + ); + } + + private WorldRule.ReminderSettings readReminders(ConfigurationSection section) { + if (section == null) { + return new WorldRule.ReminderSettings(false, List.of(), "", "", "", 1.0f, 1.0f); + } + List reminders = new ArrayList<>(); + for (String raw : section.getStringList("before")) { + long millis = TimeUtil.parseDurationMillis(raw, -1L); + if (millis > 0L) { + reminders.add(millis); + } else { + plugin.getLogger().warning("Ignoring invalid reminder duration '" + raw + "'."); + } + } + reminders.sort(Collections.reverseOrder()); + return new WorldRule.ReminderSettings( + section.getBoolean("enabled", false), + List.copyOf(reminders), + section.getString("message", ""), + section.getString("action-bar", ""), + section.getString("sound", ""), + (float) section.getDouble("volume", 1.0), + (float) section.getDouble("pitch", 1.0) + ); + } + + private TriggerSettings readTriggers(ConfigurationSection section) { + if (section == null) { + return new TriggerSettings(TriggerMode.MANUAL, 0L, 0, 0, 0, 0L, true, 0L, CatchUpMode.NONE); + } + + TriggerMode mode = TriggerMode.from(section.getString("mode", "MANUAL")); + long interval = TimeUtil.parseDurationMillis(section.getString("time-interval", "7d"), 604_800_000L); + long activeWindow = TimeUtil.parseDurationMillis(section.getString("active-window", "7d"), 604_800_000L); + long cooldown = TimeUtil.parseDurationMillis(section.getString("player-trigger-cooldown", "12h"), 43_200_000L); + + return new TriggerSettings( + mode, + interval, + Math.max(0, section.getInt("unique-players-every", 0)), + Math.max(0, section.getInt("online-players-threshold", 0)), + Math.max(0, section.getInt("active-players-threshold", 0)), + activeWindow, + section.getBoolean("exclude-vanished", true), + cooldown, + CatchUpMode.from(section.getString("catch-up", "ONE")) + ); + } + + private List readPhases(ConfigurationSection section) { + List phases = new ArrayList<>(); + int index = 0; + for (Map phaseMap : section.getMapList("phases")) { + Object sizeValue = phaseMap.get("size"); + double size = parseDouble(sizeValue, -1.0); + if (size <= 0.0) { + plugin.getLogger().warning("Ignoring phase with invalid size under world '" + section.getName() + "'."); + continue; + } + phases.add(new PhaseDefinition( + index, + mapString(phaseMap, "name", "Phase " + index), + size, + mapString(phaseMap, "message", ""), + mapString(phaseMap, "title", ""), + mapString(phaseMap, "subtitle", "") + )); + index++; + } + phases.sort((left, right) -> Integer.compare(left.index(), right.index())); + return List.copyOf(phases); + } + + private WorldRule.MilestoneRewardSettings readMilestoneRewards(ConfigurationSection section) { + if (section == null) { + return WorldRule.MilestoneRewardSettings.empty(); + } + + return new WorldRule.MilestoneRewardSettings( + section.getBoolean("enabled", false), + stringList(section, "every-expansion"), + integerCommandMap(section.getConfigurationSection("by-expansion-count")), + integerCommandMap(section.getConfigurationSection("by-phase-index")), + stringCommandMap(section.getConfigurationSection("by-phase-name")) + ); + } + + private WorldRule.LegacyAccessSettings readLegacyAccess(ConfigurationSection section) { + if (section == null) { + return WorldRule.LegacyAccessSettings.disabled(); + } + + List locations = new ArrayList<>(); + for (Map locationMap : section.getMapList("locations")) { + double x = parseDouble(locationMap.get("x"), Double.NaN); + double z = parseDouble(locationMap.get("z"), Double.NaN); + if (Double.isNaN(x) || Double.isNaN(z)) { + plugin.getLogger().warning("Ignoring legacy-access location with invalid x/z under world '" + section.getParent().getName() + "'."); + continue; + } + locations.add(new WorldRule.LegacyLocation( + mapString(locationMap, "name", "legacy location"), + x, + z + )); + } + + return new WorldRule.LegacyAccessSettings( + section.getBoolean("enabled", false), + section.getBoolean("include-current-border", false), + section.getBoolean("include-online-players", true), + section.getBoolean("include-offline-last-locations", true), + section.getBoolean("include-respawn-locations", true), + section.getBoolean("player-locations-require-legacy-user", false), + section.getBoolean("reconcile-on-startup", true), + section.getBoolean("allow-start-above-max-size", true), + Math.max(0.0, section.getDouble("padding", 1024.0)), + List.copyOf(locations) + ); + } + + private WorldRule.SoftBorderSettings readSoftBorder(ConfigurationSection section) { + WorldRule.SoftBorderSettings defaults = WorldRule.SoftBorderSettings.defaults(); + if (section == null) { + return defaults; + } + + return new WorldRule.SoftBorderSettings( + section.getBoolean("release-vanilla-border", defaults.releaseVanillaBorder()), + positiveDouble(section, "vanilla-border-size", defaults.vanillaBorderSize()), + section.getBoolean("ignore-creative", defaults.ignoreCreative()), + section.getBoolean("ignore-spectator", defaults.ignoreSpectator()), + nonBlank(section.getString("bypass-permission"), defaults.bypassPermission()), + Math.max(0.1, section.getDouble("inside-buffer", defaults.insideBuffer())), + Math.max(0.0, section.getDouble("bounce-strength", defaults.bounceStrength())), + Math.max(0.0, section.getDouble("vertical-boost", defaults.verticalBoost())), + section.getBoolean("protect-mounted-entities", defaults.protectMountedEntities()), + Math.max(0.0, section.getDouble("mounted-bounce-strength", defaults.mountedBounceStrength())), + Math.max(0.0, section.getDouble("mounted-vertical-boost", defaults.mountedVerticalBoost())), + Math.max(0.0, section.getDouble("max-outside-distance-before-teleport", defaults.maxOutsideDistanceBeforeTeleport())), + Math.max(0L, TimeUtil.parseDurationMillis(section.getString("cooldown", "900ms"), defaults.cooldownMillis())), + section.getString("message", defaults.message()), + section.getString("action-bar", defaults.actionBar()), + section.getString("sound", defaults.sound()), + (float) section.getDouble("volume", defaults.volume()), + (float) section.getDouble("pitch", defaults.pitch()), + section.getBoolean("particles.enabled", defaults.particlesEnabled()), + section.getString("particles.particle", defaults.particle()), + Math.max(0, section.getInt("particles.count", defaults.particleCount())), + Math.max(0.0, section.getDouble("particles.offset", defaults.particleOffset())), + Math.max(0.0, section.getDouble("particles.speed", defaults.particleSpeed())), + Math.max(1L, section.getLong("particles.interval-ticks", defaults.particleIntervalTicks())), + Math.max(8.0, section.getDouble("particles.view-distance", defaults.particleViewDistance())), + Math.max(1.0, section.getDouble("particles.spacing", defaults.particleSpacing())) + ); + } + + private Map> integerCommandMap(ConfigurationSection section) { + if (section == null) { + return Map.of(); + } + + Map> commands = new LinkedHashMap<>(); + for (String key : section.getKeys(false)) { + try { + int milestone = Integer.parseInt(key); + commands.put(milestone, List.copyOf(section.getStringList(key))); + } catch (NumberFormatException ex) { + plugin.getLogger().warning("Ignoring non-numeric milestone key '" + key + "' at " + section.getCurrentPath() + "."); + } + } + return Collections.unmodifiableMap(commands); + } + + private Map> stringCommandMap(ConfigurationSection section) { + if (section == null) { + return Map.of(); + } + + Map> commands = new LinkedHashMap<>(); + for (String key : section.getKeys(false)) { + commands.put(key.toLowerCase(Locale.ROOT), List.copyOf(section.getStringList(key))); + } + return Collections.unmodifiableMap(commands); + } + + private double positiveDouble(ConfigurationSection section, String path, double fallback) { + double value = section.getDouble(path, fallback); + if (value <= 0.0) { + plugin.getLogger().warning("Invalid non-positive value at " + section.getCurrentPath() + "." + path + "; using " + fallback + "."); + return fallback; + } + return value; + } + + private double parseDouble(Object value, double fallback) { + if (value instanceof Number number) { + return number.doubleValue(); + } + if (value == null) { + return fallback; + } + try { + return Double.parseDouble(String.valueOf(value)); + } catch (NumberFormatException ex) { + return fallback; + } + } + + private String mapString(Map values, String key, String fallback) { + Object value = values.get(key); + return value == null ? fallback : String.valueOf(value); + } + + private String nonBlank(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value; + } + + private List stringList(ConfigurationSection section, String path) { + if (section == null) { + return List.of(); + } + return List.copyOf(section.getStringList(path)); + } + + public FileConfiguration config() { + return config; + } + + public YamlConfiguration messages() { + return messages; + } + + public Map worlds() { + return worlds; + } + + public WorldRule world(String key) { + if (key == null) { + return null; + } + return worlds.get(key.toLowerCase(Locale.ROOT)); + } + + public boolean dryRun() { + return config.getBoolean("settings.dry-run", false); + } + + public boolean debug() { + return config.getBoolean("settings.debug", false); + } + + public boolean applyBordersOnStartup() { + return config.getBoolean("settings.apply-borders-on-startup", true); + } + + public long pollIntervalTicks() { + return Math.max(1L, config.getLong("settings.poll-interval-seconds", 30L)) * 20L; + } + + public int maxCatchupExpansionsPerWorld() { + return Math.max(1, config.getInt("settings.max-catchup-expansions-per-world", 20)); + } + + public String stateFileName() { + return config.getString("state.file-name", "state.yml"); + } + + public long saveIntervalTicks() { + return Math.max(20L, config.getLong("state.save-interval-seconds", 300L) * 20L); + } + + public long legacyUsersMinimumPlaytimeMillis() { + return TimeUtil.parseDurationMillis(config.getString("legacy-users.minimum-playtime", "1h"), 3_600_000L); + } + + public boolean backupStateOnSave() { + return config.getBoolean("state.backup-on-save", true); + } + + public int backupKeep() { + return Math.max(0, config.getInt("state.backup-keep", 10)); + } + + public boolean historyEnabled() { + return config.getBoolean("history.enabled", true); + } + + public String historyFileName() { + return config.getString("history.file-name", "history.log"); + } + + public boolean guiEnabled() { + return config.getBoolean("gui.enabled", true); + } + + public String guiTitle() { + return config.getString("gui.title", "A2416&lᴅA2D1B&lɪA351F&lʀA3F24&lᴛA4A2A&lʙB6B35&lᴀ&#A8873F&lɢ&#D4AF37&lᴍ&#B9C63F&lᴄ &8| &#D4AF37&lBorders"); + } + + public boolean hooksEnabled() { + return config.getBoolean("command-hooks.enabled", true); + } + + public WorldRule.MilestoneRewardSettings globalMilestoneRewards() { + return globalMilestoneRewards; + } + + public List globalBeforeCommands() { + return config.getStringList("command-hooks.before-expansion"); + } + + public List globalAfterCommands() { + return config.getStringList("command-hooks.after-expansion"); + } + + public boolean webhookEnabled() { + return config.getBoolean("webhook.enabled", false); + } + + public String webhookUrl() { + return config.getString("webhook.url", ""); + } + + public String webhookContent() { + return config.getString("webhook.content", ""); + } + + public int webhookTimeoutSeconds() { + return Math.max(1, config.getInt("webhook.timeout-seconds", 8)); + } + + public void debug(String message) { + if (debug()) { + plugin.getLogger().log(Level.INFO, "[debug] " + message); + } + } +} diff --git a/src/main/java/com/dirtsmp/dirtsmp/config/MessageManager.java b/src/main/java/com/dirtsmp/dirtsmp/config/MessageManager.java new file mode 100644 index 0000000..f1763eb --- /dev/null +++ b/src/main/java/com/dirtsmp/dirtsmp/config/MessageManager.java @@ -0,0 +1,207 @@ +package com.dirtsmp.dirtsmp.config; + +import com.dirtsmp.dirtsmp.model.ExpansionReason; +import com.dirtsmp.dirtsmp.model.PhaseDefinition; +import com.dirtsmp.dirtsmp.model.WorldRule; +import com.dirtsmp.dirtsmp.service.TimeUtil; +import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.MiniMessage; +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; +import net.kyori.adventure.title.Title; +import org.bukkit.Bukkit; +import org.bukkit.Sound; +import org.bukkit.SoundCategory; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +public final class MessageManager { + private final ConfigManager configManager; + private final MiniMessage miniMessage = MiniMessage.miniMessage(); + private final LegacyComponentSerializer legacySerializer = LegacyComponentSerializer.builder() + .character('&') + .hexColors() + .build(); + + public MessageManager(ConfigManager configManager) { + this.configManager = configManager; + } + + public void send(CommandSender sender, String path) { + send(sender, path, Map.of()); + } + + public void send(CommandSender sender, String path, Map placeholders) { + String message = configManager.messages().getString(path, ""); + if (message == null || message.isBlank()) { + return; + } + sender.sendMessage(component(message, placeholders)); + } + + public void sendRaw(CommandSender sender, String raw, Map placeholders) { + if (raw == null || raw.isBlank()) { + return; + } + sender.sendMessage(component(raw, placeholders)); + } + + public void sendList(CommandSender sender, String path, Map placeholders) { + for (String line : configManager.messages().getStringList(path)) { + sender.sendMessage(component(line, placeholders)); + } + } + + public Component component(String raw) { + return component(raw, Map.of()); + } + + public Component component(String raw, Map placeholders) { + String rendered = applyPlaceholders(raw, placeholders); + try { + if (usesLegacyFormatting(rendered)) { + return legacySerializer.deserialize(rendered); + } + return miniMessage.deserialize(rendered); + } catch (RuntimeException ex) { + return Component.text(rendered.replaceAll("<[^>]*>", "").replaceAll("&[#0-9A-Fa-fK-Ok-orR]", "")); + } + } + + public String applyPlaceholders(String raw, Map placeholders) { + String rendered = raw == null ? "" : raw; + rendered = rendered.replace("{prefix}", configManager.messages().getString("prefix", "")); + for (Map.Entry entry : placeholders.entrySet()) { + rendered = rendered.replace("{" + entry.getKey() + "}", entry.getValue() == null ? "" : entry.getValue()); + } + return rendered; + } + + public void announceExpansion(WorldRule rule, double oldSize, double newSize, ExpansionReason reason, String actor, PhaseDefinition phase) { + if (!rule.announcements().enabled()) { + return; + } + + Map placeholders = basePlaceholders(rule, oldSize, newSize, reason, actor, phase); + String message = firstNonBlank( + phase == null ? "" : phase.message(), + rule.announcements().message(), + configManager.messages().getString("expansion.default-message", "") + ); + String title = firstNonBlank( + phase == null ? "" : phase.title(), + rule.announcements().title(), + configManager.messages().getString("expansion.default-title", "") + ); + String subtitle = firstNonBlank( + phase == null ? "" : phase.subtitle(), + rule.announcements().subtitle(), + configManager.messages().getString("expansion.default-subtitle", "") + ); + String actionBar = firstNonBlank( + rule.announcements().actionBar(), + configManager.messages().getString("expansion.default-action-bar", "") + ); + + List recipients = Bukkit.getOnlinePlayers().stream() + .filter(player -> !rule.announcements().worldOnly() || player.getWorld().getName().equals(rule.worldName())) + .toList(); + + Component chat = component(message, placeholders); + for (Player player : recipients) { + player.sendMessage(chat); + if (!title.isBlank() || !subtitle.isBlank()) { + player.showTitle(Title.title( + component(title, placeholders), + component(subtitle, placeholders), + Title.Times.times(Duration.ofMillis(500), Duration.ofSeconds(4), Duration.ofMillis(800)) + )); + } + if (!actionBar.isBlank()) { + player.sendActionBar(component(actionBar, placeholders)); + } + playSound(player, rule.announcements().sound(), rule.announcements().volume(), rule.announcements().pitch()); + } + Bukkit.getConsoleSender().sendMessage(chat); + } + + public void announceReminder(WorldRule rule, long timeLeftMillis) { + if (!rule.reminders().enabled()) { + return; + } + + Map placeholders = new HashMap<>(); + placeholders.put("key", rule.key()); + placeholders.put("world", rule.worldName()); + placeholders.put("time_left", TimeUtil.formatDuration(timeLeftMillis)); + + String message = firstNonBlank( + rule.reminders().message(), + configManager.messages().getString("reminders.default-message", "") + ); + String actionBar = firstNonBlank( + rule.reminders().actionBar(), + configManager.messages().getString("reminders.default-action-bar", "") + ); + + Component chat = component(message, placeholders); + for (Player player : Bukkit.getOnlinePlayers()) { + player.sendMessage(chat); + if (!actionBar.isBlank()) { + player.sendActionBar(component(actionBar, placeholders)); + } + playSound(player, rule.reminders().sound(), rule.reminders().volume(), rule.reminders().pitch()); + } + Bukkit.getConsoleSender().sendMessage(chat); + } + + public Map basePlaceholders(WorldRule rule, double oldSize, double newSize, ExpansionReason reason, String actor, PhaseDefinition phase) { + Map placeholders = new HashMap<>(); + placeholders.put("key", rule.key()); + placeholders.put("world", rule.worldName()); + placeholders.put("old_size", TimeUtil.formatSize(oldSize)); + placeholders.put("new_size", TimeUtil.formatSize(newSize)); + placeholders.put("max_size", TimeUtil.formatSize(rule.maxSize())); + placeholders.put("reason", reason.name().toLowerCase(Locale.ROOT).replace('_', ' ')); + placeholders.put("actor", actor == null ? "console" : actor); + placeholders.put("phase", phase == null ? "none" : String.valueOf(phase.index())); + placeholders.put("phase_name", phase == null ? "none" : phase.name()); + return placeholders; + } + + public Map withBasePlaceholders(WorldRule rule, double oldSize, double newSize, ExpansionReason reason, String actor, PhaseDefinition phase, Map extra) { + Map placeholders = basePlaceholders(rule, oldSize, newSize, reason, actor, phase); + placeholders.putAll(extra); + return placeholders; + } + + @SuppressWarnings({"deprecation", "removal"}) + private void playSound(Player player, String soundName, float volume, float pitch) { + if (soundName == null || soundName.isBlank()) { + return; + } + try { + Sound sound = Sound.valueOf(soundName.trim().toUpperCase(Locale.ROOT)); + player.playSound(player.getLocation(), sound, SoundCategory.MASTER, volume, pitch); + } catch (IllegalArgumentException ignored) { + // Invalid sounds are config mistakes; they are non-fatal and already visible in config. + } + } + + private String firstNonBlank(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return ""; + } + + private boolean usesLegacyFormatting(String rendered) { + return rendered.matches("(?s).*&(?:#[0-9A-Fa-f]{6}|[0-9A-Fa-fK-Ok-orR]).*"); + } +} diff --git a/src/main/java/com/dirtsmp/dirtsmp/gui/AdminGuiManager.java b/src/main/java/com/dirtsmp/dirtsmp/gui/AdminGuiManager.java new file mode 100644 index 0000000..7dbdfd2 --- /dev/null +++ b/src/main/java/com/dirtsmp/dirtsmp/gui/AdminGuiManager.java @@ -0,0 +1,202 @@ +package com.dirtsmp.dirtsmp.gui; + +import com.dirtsmp.dirtsmp.DirtSMPPlugin; +import com.dirtsmp.dirtsmp.config.ConfigManager; +import com.dirtsmp.dirtsmp.config.MessageManager; +import com.dirtsmp.dirtsmp.model.ExpansionReason; +import com.dirtsmp.dirtsmp.model.ExpansionResult; +import com.dirtsmp.dirtsmp.model.PhaseDefinition; +import com.dirtsmp.dirtsmp.model.WorldRule; +import com.dirtsmp.dirtsmp.service.BorderManager; +import com.dirtsmp.dirtsmp.service.TimeUtil; +import com.dirtsmp.dirtsmp.service.TriggerEvaluator; +import com.dirtsmp.dirtsmp.state.StateManager; +import com.dirtsmp.dirtsmp.state.WorldProgress; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.OptionalDouble; +import net.kyori.adventure.text.Component; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; + +public final class AdminGuiManager implements Listener { + private final DirtSMPPlugin plugin; + private final ConfigManager configManager; + private final MessageManager messageManager; + private final StateManager stateManager; + private final BorderManager borderManager; + private final TriggerEvaluator triggerEvaluator; + + public AdminGuiManager(DirtSMPPlugin plugin, ConfigManager configManager, MessageManager messageManager, StateManager stateManager, BorderManager borderManager, TriggerEvaluator triggerEvaluator) { + this.plugin = plugin; + this.configManager = configManager; + this.messageManager = messageManager; + this.stateManager = stateManager; + this.borderManager = borderManager; + this.triggerEvaluator = triggerEvaluator; + } + + public void open(Player player) { + GuiHolder holder = new GuiHolder(); + Inventory inventory = Bukkit.createInventory(holder, 27, messageManager.component(configManager.guiTitle())); + holder.inventory = inventory; + + int[] slots = {10, 11, 12, 13, 14, 15, 16}; + int index = 0; + for (WorldRule rule : configManager.worlds().values()) { + if (index >= slots.length) { + break; + } + int slot = slots[index++]; + inventory.setItem(slot, worldItem(rule)); + holder.worldBySlot.put(slot, rule.key()); + } + + inventory.setItem(22, simpleItem(Material.COMPASS, configManager.messages().getString("gui.refresh", "&#B9C63FRefresh"))); + inventory.setItem(26, simpleItem(Material.BARRIER, configManager.messages().getString("gui.close", "&cClose"))); + player.openInventory(inventory); + } + + public void closeOpenInventories() { + for (Player player : Bukkit.getOnlinePlayers()) { + if (player.getOpenInventory().getTopInventory().getHolder() instanceof GuiHolder) { + player.closeInventory(); + } + } + } + + @EventHandler + public void onClick(InventoryClickEvent event) { + if (!(event.getInventory().getHolder() instanceof GuiHolder holder)) { + return; + } + event.setCancelled(true); + if (!(event.getWhoClicked() instanceof Player player)) { + return; + } + + int slot = event.getRawSlot(); + if (slot == 26) { + player.closeInventory(); + return; + } + if (slot == 22) { + open(player); + return; + } + + String key = holder.worldBySlot.get(slot); + if (key == null) { + return; + } + WorldRule rule = configManager.world(key); + if (rule == null) { + return; + } + + if (event.isLeftClick()) { + if (!player.hasPermission("dirtsmp.admin") && !player.hasPermission("dirtsmp.expand")) { + messageManager.send(player, "commands.no-permission"); + return; + } + ExpansionResult result = borderManager.expand(rule, ExpansionReason.MANUAL, player.getName(), true); + if (!result.success()) { + messageManager.send(player, "commands.expand-failed", Map.of("world", rule.worldName(), "reason", result.reason())); + } + open(player); + return; + } + + if (event.isRightClick()) { + WorldProgress progress = stateManager.progress(rule.key()); + if (progress.paused()) { + if (!player.hasPermission("dirtsmp.admin") && !player.hasPermission("dirtsmp.resume")) { + messageManager.send(player, "commands.no-permission"); + return; + } + borderManager.resume(rule); + messageManager.send(player, "commands.resumed", Map.of("world", rule.worldName())); + } else { + if (!player.hasPermission("dirtsmp.admin") && !player.hasPermission("dirtsmp.pause")) { + messageManager.send(player, "commands.no-permission"); + return; + } + borderManager.pause(rule); + messageManager.send(player, "commands.paused", Map.of("world", rule.worldName())); + } + open(player); + } + } + + private ItemStack worldItem(WorldRule rule) { + WorldProgress progress = stateManager.progress(rule.key()); + Material material = materialFor(rule, progress); + ItemStack item = new ItemStack(material); + ItemMeta meta = item.getItemMeta(); + Map placeholders = placeholders(rule, progress); + meta.displayName(messageManager.component(configManager.messages().getString("gui.world-name", "&#D4AF37&l{key}"), placeholders)); + List lore = configManager.messages().getStringList("gui.world-lore").stream() + .map(line -> messageManager.component(line, placeholders)) + .toList(); + meta.lore(lore); + item.setItemMeta(meta); + return item; + } + + private ItemStack simpleItem(Material material, String name) { + ItemStack item = new ItemStack(material); + ItemMeta meta = item.getItemMeta(); + meta.displayName(messageManager.component(name)); + item.setItemMeta(meta); + return item; + } + + private Material materialFor(WorldRule rule, WorldProgress progress) { + if (progress.paused()) { + return Material.REDSTONE_BLOCK; + } + String worldName = rule.worldName().toLowerCase(); + if (worldName.contains("the_end") || worldName.contains("end")) { + return Material.END_STONE; + } + if (worldName.contains("nether")) { + return Material.NETHERRACK; + } + return Material.GRASS_BLOCK; + } + + private Map placeholders(WorldRule rule, WorldProgress progress) { + OptionalDouble next = borderManager.nextSize(rule, progress); + PhaseDefinition phase = borderManager.currentPhase(rule, progress); + return Map.of( + "key", rule.key(), + "world", rule.worldName(), + "enabled", String.valueOf(rule.enabled()), + "size", TimeUtil.formatSize(borderManager.displaySize(rule, progress)), + "next_size", next.isPresent() ? TimeUtil.formatSize(next.getAsDouble()) : "max", + "border_mode", rule.borderMode().name(), + "trigger", triggerEvaluator.describeTrigger(rule, progress), + "phase", phase == null ? "none" : phase.name(), + "paused", String.valueOf(progress.paused()) + ); + } + + private static final class GuiHolder implements InventoryHolder { + private final Map worldBySlot = new HashMap<>(); + private Inventory inventory; + + @Override + public Inventory getInventory() { + return inventory; + } + } +} diff --git a/src/main/java/com/dirtsmp/dirtsmp/hook/DirtSMPPlaceholderExpansion.java b/src/main/java/com/dirtsmp/dirtsmp/hook/DirtSMPPlaceholderExpansion.java new file mode 100644 index 0000000..f26526e --- /dev/null +++ b/src/main/java/com/dirtsmp/dirtsmp/hook/DirtSMPPlaceholderExpansion.java @@ -0,0 +1,117 @@ +package com.dirtsmp.dirtsmp.hook; + +import com.dirtsmp.dirtsmp.DirtSMPPlugin; +import com.dirtsmp.dirtsmp.model.PhaseDefinition; +import com.dirtsmp.dirtsmp.model.WorldRule; +import com.dirtsmp.dirtsmp.service.TimeUtil; +import com.dirtsmp.dirtsmp.state.WorldProgress; +import java.util.Locale; +import java.util.OptionalDouble; +import me.clip.placeholderapi.expansion.PlaceholderExpansion; +import org.bukkit.OfflinePlayer; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@SuppressWarnings("deprecation") +public final class DirtSMPPlaceholderExpansion extends PlaceholderExpansion { + private final DirtSMPPlugin plugin; + + public DirtSMPPlaceholderExpansion(DirtSMPPlugin plugin) { + this.plugin = plugin; + } + + @Override + public @NotNull String getIdentifier() { + return "dirtsmp"; + } + + @Override + public @NotNull String getAuthor() { + return "DirtSMP"; + } + + @Override + public @NotNull String getVersion() { + return plugin.getDescription().getVersion(); + } + + @Override + public boolean persist() { + return true; + } + + @Override + public @Nullable String onRequest(OfflinePlayer player, @NotNull String params) { + String lower = params.toLowerCase(Locale.ROOT); + if (lower.startsWith("current_size")) { + WorldRule rule = ruleFor(lower, "current_size"); + return rule == null ? "" : TimeUtil.formatSize(plugin.borderManager().displaySize(rule, progress(rule))); + } + if (lower.startsWith("next_size")) { + WorldRule rule = ruleFor(lower, "next_size"); + if (rule == null) { + return ""; + } + OptionalDouble next = plugin.borderManager().nextSize(rule, progress(rule)); + return next.isPresent() ? TimeUtil.formatSize(next.getAsDouble()) : "max"; + } + if (lower.startsWith("next_expansion_time") || lower.startsWith("next_time")) { + String prefix = lower.startsWith("next_expansion_time") ? "next_expansion_time" : "next_time"; + WorldRule rule = ruleFor(lower, prefix); + if (rule == null) { + return ""; + } + WorldProgress progress = progress(rule); + if (!rule.triggers().mode().usesTime() || progress.lastExpansionAt() <= 0L) { + return "manual"; + } + long next = progress.lastExpansionAt() + rule.triggers().timeIntervalMillis(); + return next <= System.currentTimeMillis() ? "due" : TimeUtil.formatDuration(next - System.currentTimeMillis()); + } + if (lower.startsWith("phase")) { + WorldRule rule = ruleFor(lower, "phase"); + if (rule == null) { + return ""; + } + PhaseDefinition phase = plugin.borderManager().currentPhase(rule, progress(rule)); + return phase == null ? "none" : phase.name(); + } + if (lower.startsWith("expansion_count")) { + WorldRule rule = ruleFor(lower, "expansion_count"); + return rule == null ? "" : String.valueOf(progress(rule).expansionCount()); + } + if (lower.startsWith("unique_progress")) { + WorldRule rule = ruleFor(lower, "unique_progress"); + if (rule == null) { + return ""; + } + WorldProgress progress = progress(rule); + int needed = progress.uniquePlayersAtLastExpansion() + rule.triggers().uniquePlayersEvery(); + return plugin.playerStatsManager().uniquePlayerCount() + "/" + needed; + } + if (lower.startsWith("paused")) { + WorldRule rule = ruleFor(lower, "paused"); + return rule == null ? "" : String.valueOf(progress(rule).paused()); + } + if (lower.startsWith("border_mode")) { + WorldRule rule = ruleFor(lower, "border_mode"); + return rule == null ? "" : rule.borderMode().name(); + } + return null; + } + + private WorldRule ruleFor(String params, String prefix) { + if (params.equals(prefix)) { + return plugin.configManager().worlds().values().stream().findFirst().orElse(null); + } + String key = params.substring(prefix.length()); + if (key.startsWith("_")) { + key = key.substring(1); + } + return plugin.configManager().world(key); + } + + private WorldProgress progress(WorldRule rule) { + return plugin.stateManager().progress(rule.key()); + } +} diff --git a/src/main/java/com/dirtsmp/dirtsmp/hook/PlaceholderHook.java b/src/main/java/com/dirtsmp/dirtsmp/hook/PlaceholderHook.java new file mode 100644 index 0000000..5f7c1b5 --- /dev/null +++ b/src/main/java/com/dirtsmp/dirtsmp/hook/PlaceholderHook.java @@ -0,0 +1,26 @@ +package com.dirtsmp.dirtsmp.hook; + +import com.dirtsmp.dirtsmp.DirtSMPPlugin; + +public final class PlaceholderHook { + private final DirtSMPPlugin plugin; + private DirtSMPPlaceholderExpansion expansion; + + public PlaceholderHook(DirtSMPPlugin plugin) { + this.plugin = plugin; + } + + public void register() { + expansion = new DirtSMPPlaceholderExpansion(plugin); + if (expansion.register()) { + plugin.getLogger().info("Registered PlaceholderAPI expansion."); + } + } + + public void unregister() { + if (expansion != null) { + expansion.unregister(); + expansion = null; + } + } +} diff --git a/src/main/java/com/dirtsmp/dirtsmp/listener/PlayerListener.java b/src/main/java/com/dirtsmp/dirtsmp/listener/PlayerListener.java new file mode 100644 index 0000000..47efd23 --- /dev/null +++ b/src/main/java/com/dirtsmp/dirtsmp/listener/PlayerListener.java @@ -0,0 +1,47 @@ +package com.dirtsmp.dirtsmp.listener; + +import com.dirtsmp.dirtsmp.DirtSMPPlugin; +import com.dirtsmp.dirtsmp.config.ConfigManager; +import com.dirtsmp.dirtsmp.model.WorldRule; +import com.dirtsmp.dirtsmp.service.BorderManager; +import com.dirtsmp.dirtsmp.state.PlayerStatsManager; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.event.world.WorldLoadEvent; + +public final class PlayerListener implements Listener { + private final DirtSMPPlugin plugin; + private final PlayerStatsManager playerStatsManager; + private final BorderManager borderManager; + private final ConfigManager configManager; + + public PlayerListener(DirtSMPPlugin plugin, PlayerStatsManager playerStatsManager, BorderManager borderManager, ConfigManager configManager) { + this.plugin = plugin; + this.playerStatsManager = playerStatsManager; + this.borderManager = borderManager; + this.configManager = configManager; + } + + @EventHandler + public void onJoin(PlayerJoinEvent event) { + playerStatsManager.markSeen(event.getPlayer()); + } + + @EventHandler + public void onQuit(PlayerQuitEvent event) { + playerStatsManager.markSeen(event.getPlayer()); + playerStatsManager.saveQuietly(); + } + + @EventHandler + public void onWorldLoad(WorldLoadEvent event) { + for (WorldRule rule : configManager.worlds().values()) { + if (rule.enabled() && rule.worldName().equals(event.getWorld().getName())) { + plugin.getLogger().info("Applying DirtSMP border settings to newly loaded world '" + rule.worldName() + "'."); + borderManager.initializeRule(rule); + } + } + } +} diff --git a/src/main/java/com/dirtsmp/dirtsmp/model/BorderMode.java b/src/main/java/com/dirtsmp/dirtsmp/model/BorderMode.java new file mode 100644 index 0000000..60c4676 --- /dev/null +++ b/src/main/java/com/dirtsmp/dirtsmp/model/BorderMode.java @@ -0,0 +1,17 @@ +package com.dirtsmp.dirtsmp.model; + +public enum BorderMode { + WORLD_BORDER, + SOFT_BORDER; + + public static BorderMode from(String value) { + if (value == null || value.isBlank()) { + return WORLD_BORDER; + } + try { + return BorderMode.valueOf(value.trim().toUpperCase()); + } catch (IllegalArgumentException ignored) { + return WORLD_BORDER; + } + } +} diff --git a/src/main/java/com/dirtsmp/dirtsmp/model/CatchUpMode.java b/src/main/java/com/dirtsmp/dirtsmp/model/CatchUpMode.java new file mode 100644 index 0000000..e1f28c3 --- /dev/null +++ b/src/main/java/com/dirtsmp/dirtsmp/model/CatchUpMode.java @@ -0,0 +1,18 @@ +package com.dirtsmp.dirtsmp.model; + +public enum CatchUpMode { + NONE, + ONE, + ALL; + + public static CatchUpMode from(String value) { + if (value == null || value.isBlank()) { + return ONE; + } + try { + return CatchUpMode.valueOf(value.trim().toUpperCase()); + } catch (IllegalArgumentException ignored) { + return ONE; + } + } +} diff --git a/src/main/java/com/dirtsmp/dirtsmp/model/ExpansionReason.java b/src/main/java/com/dirtsmp/dirtsmp/model/ExpansionReason.java new file mode 100644 index 0000000..4c30547 --- /dev/null +++ b/src/main/java/com/dirtsmp/dirtsmp/model/ExpansionReason.java @@ -0,0 +1,22 @@ +package com.dirtsmp.dirtsmp.model; + +public enum ExpansionReason { + TIME, + UNIQUE_PLAYERS, + ONLINE_PLAYERS, + ACTIVE_PLAYERS, + HYBRID, + CATCH_UP, + MANUAL, + SET_SIZE, + RESET; + + public boolean automatic() { + return this == TIME + || this == UNIQUE_PLAYERS + || this == ONLINE_PLAYERS + || this == ACTIVE_PLAYERS + || this == HYBRID + || this == CATCH_UP; + } +} diff --git a/src/main/java/com/dirtsmp/dirtsmp/model/ExpansionResult.java b/src/main/java/com/dirtsmp/dirtsmp/model/ExpansionResult.java new file mode 100644 index 0000000..ba41f31 --- /dev/null +++ b/src/main/java/com/dirtsmp/dirtsmp/model/ExpansionResult.java @@ -0,0 +1,17 @@ +package com.dirtsmp.dirtsmp.model; + +public record ExpansionResult( + boolean success, + String reason, + double oldSize, + double newSize, + PhaseDefinition phase +) { + public static ExpansionResult success(double oldSize, double newSize, PhaseDefinition phase) { + return new ExpansionResult(true, "", oldSize, newSize, phase); + } + + public static ExpansionResult failure(String reason) { + return new ExpansionResult(false, reason, 0.0, 0.0, null); + } +} diff --git a/src/main/java/com/dirtsmp/dirtsmp/model/GrowthMode.java b/src/main/java/com/dirtsmp/dirtsmp/model/GrowthMode.java new file mode 100644 index 0000000..b21ed23 --- /dev/null +++ b/src/main/java/com/dirtsmp/dirtsmp/model/GrowthMode.java @@ -0,0 +1,17 @@ +package com.dirtsmp.dirtsmp.model; + +public enum GrowthMode { + INCREMENTAL, + PHASE; + + public static GrowthMode from(String value) { + if (value == null || value.isBlank()) { + return INCREMENTAL; + } + try { + return GrowthMode.valueOf(value.trim().toUpperCase()); + } catch (IllegalArgumentException ignored) { + return INCREMENTAL; + } + } +} diff --git a/src/main/java/com/dirtsmp/dirtsmp/model/LegacyAccessReport.java b/src/main/java/com/dirtsmp/dirtsmp/model/LegacyAccessReport.java new file mode 100644 index 0000000..62f99d3 --- /dev/null +++ b/src/main/java/com/dirtsmp/dirtsmp/model/LegacyAccessReport.java @@ -0,0 +1,11 @@ +package com.dirtsmp.dirtsmp.model; + +public record LegacyAccessReport( + double requiredSize, + int includedLocations, + String farthestLocationName +) { + public boolean hasLocations() { + return includedLocations > 0; + } +} diff --git a/src/main/java/com/dirtsmp/dirtsmp/model/PhaseDefinition.java b/src/main/java/com/dirtsmp/dirtsmp/model/PhaseDefinition.java new file mode 100644 index 0000000..1df1475 --- /dev/null +++ b/src/main/java/com/dirtsmp/dirtsmp/model/PhaseDefinition.java @@ -0,0 +1,11 @@ +package com.dirtsmp.dirtsmp.model; + +public record PhaseDefinition( + int index, + String name, + double size, + String message, + String title, + String subtitle +) { +} diff --git a/src/main/java/com/dirtsmp/dirtsmp/model/TriggerDecision.java b/src/main/java/com/dirtsmp/dirtsmp/model/TriggerDecision.java new file mode 100644 index 0000000..1ada472 --- /dev/null +++ b/src/main/java/com/dirtsmp/dirtsmp/model/TriggerDecision.java @@ -0,0 +1,11 @@ +package com.dirtsmp.dirtsmp.model; + +public record TriggerDecision( + boolean due, + ExpansionReason reason, + String description +) { + public static TriggerDecision none(String description) { + return new TriggerDecision(false, ExpansionReason.MANUAL, description); + } +} diff --git a/src/main/java/com/dirtsmp/dirtsmp/model/TriggerMode.java b/src/main/java/com/dirtsmp/dirtsmp/model/TriggerMode.java new file mode 100644 index 0000000..8d8fd1b --- /dev/null +++ b/src/main/java/com/dirtsmp/dirtsmp/model/TriggerMode.java @@ -0,0 +1,36 @@ +package com.dirtsmp.dirtsmp.model; + +public enum TriggerMode { + MANUAL, + TIME, + UNIQUE_PLAYERS, + ONLINE_PLAYERS, + ACTIVE_PLAYERS, + TIME_OR_UNIQUE_PLAYERS, + TIME_AND_UNIQUE_PLAYERS, + TIME_OR_ONLINE_PLAYERS, + TIME_AND_ONLINE_PLAYERS, + TIME_OR_ACTIVE_PLAYERS, + TIME_AND_ACTIVE_PLAYERS; + + public static TriggerMode from(String value) { + if (value == null || value.isBlank()) { + return MANUAL; + } + try { + return TriggerMode.valueOf(value.trim().toUpperCase()); + } catch (IllegalArgumentException ignored) { + return MANUAL; + } + } + + public boolean usesTime() { + return this == TIME + || this == TIME_OR_UNIQUE_PLAYERS + || this == TIME_AND_UNIQUE_PLAYERS + || this == TIME_OR_ONLINE_PLAYERS + || this == TIME_AND_ONLINE_PLAYERS + || this == TIME_OR_ACTIVE_PLAYERS + || this == TIME_AND_ACTIVE_PLAYERS; + } +} diff --git a/src/main/java/com/dirtsmp/dirtsmp/model/TriggerSettings.java b/src/main/java/com/dirtsmp/dirtsmp/model/TriggerSettings.java new file mode 100644 index 0000000..b1f9d26 --- /dev/null +++ b/src/main/java/com/dirtsmp/dirtsmp/model/TriggerSettings.java @@ -0,0 +1,17 @@ +package com.dirtsmp.dirtsmp.model; + +public record TriggerSettings( + TriggerMode mode, + long timeIntervalMillis, + int uniquePlayersEvery, + int onlinePlayersThreshold, + int activePlayersThreshold, + long activeWindowMillis, + boolean excludeVanished, + long playerTriggerCooldownMillis, + CatchUpMode catchUpMode +) { + public boolean manualOnly() { + return mode == TriggerMode.MANUAL; + } +} diff --git a/src/main/java/com/dirtsmp/dirtsmp/model/WorldRule.java b/src/main/java/com/dirtsmp/dirtsmp/model/WorldRule.java new file mode 100644 index 0000000..ceb98df --- /dev/null +++ b/src/main/java/com/dirtsmp/dirtsmp/model/WorldRule.java @@ -0,0 +1,180 @@ +package com.dirtsmp.dirtsmp.model; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public record WorldRule( + String key, + boolean enabled, + String worldName, + double centerX, + double centerZ, + double startingSize, + BorderMode borderMode, + GrowthMode growthMode, + double growthAmount, + double maxSize, + long transitionSeconds, + boolean manualOnly, + boolean importCurrentBorder, + boolean noShrinkProtection, + boolean enforceMaxSize, + AnnouncementSettings announcements, + ReminderSettings reminders, + TriggerSettings triggers, + List phases, + MilestoneRewardSettings milestoneRewards, + LegacyAccessSettings legacyAccess, + SoftBorderSettings softBorder, + List beforeCommands, + List afterCommands +) { + public double initialSize() { + if (growthMode == GrowthMode.PHASE && !phases.isEmpty()) { + return phases.getFirst().size(); + } + return startingSize; + } + + public Optional phase(int index) { + if (index < 0 || index >= phases.size()) { + return Optional.empty(); + } + return Optional.of(phases.get(index)); + } + + public Optional nextPhase(int currentIndex) { + return phase(currentIndex + 1); + } + + public int phaseIndexForSize(double size) { + int index = phases.isEmpty() ? -1 : 0; + for (PhaseDefinition phase : phases) { + if (size + 0.0001 >= phase.size()) { + index = phase.index(); + } + } + return index; + } + + public record AnnouncementSettings( + boolean enabled, + boolean worldOnly, + String message, + String title, + String subtitle, + String actionBar, + String sound, + float volume, + float pitch + ) { + } + + public record ReminderSettings( + boolean enabled, + List beforeMillis, + String message, + String actionBar, + String sound, + float volume, + float pitch + ) { + } + + public record MilestoneRewardSettings( + boolean enabled, + List everyExpansionCommands, + Map> expansionCountCommands, + Map> phaseIndexCommands, + Map> phaseNameCommands + ) { + public static MilestoneRewardSettings empty() { + return new MilestoneRewardSettings(false, List.of(), Map.of(), Map.of(), Map.of()); + } + } + + public record LegacyAccessSettings( + boolean enabled, + boolean includeCurrentBorder, + boolean includeOnlinePlayers, + boolean includeOfflineLastLocations, + boolean includeRespawnLocations, + boolean playerLocationsRequireLegacyUser, + boolean reconcileOnStartup, + boolean allowStartAboveMaxSize, + double padding, + List locations + ) { + public static LegacyAccessSettings disabled() { + return new LegacyAccessSettings(false, false, false, false, false, false, false, false, 0.0, List.of()); + } + } + + public record LegacyLocation( + String name, + double x, + double z + ) { + } + + public record SoftBorderSettings( + boolean releaseVanillaBorder, + double vanillaBorderSize, + boolean ignoreCreative, + boolean ignoreSpectator, + String bypassPermission, + double insideBuffer, + double bounceStrength, + double verticalBoost, + boolean protectMountedEntities, + double mountedBounceStrength, + double mountedVerticalBoost, + double maxOutsideDistanceBeforeTeleport, + long cooldownMillis, + String message, + String actionBar, + String sound, + float volume, + float pitch, + boolean particlesEnabled, + String particle, + int particleCount, + double particleOffset, + double particleSpeed, + long particleIntervalTicks, + double particleViewDistance, + double particleSpacing + ) { + public static SoftBorderSettings defaults() { + return new SoftBorderSettings( + true, + 59_999_968.0, + false, + true, + "dirtsmp.bypass.softborder", + 1.5, + 1.85, + 0.35, + true, + 1.15, + 0.12, + 24.0, + 900L, + "{prefix}&#D4AF37&lThe DirtbagMC border &7throws you back.", + "&#D4AF37&lBorder reached &8| &7turn back", + "ENTITY_SLIME_JUMP", + 0.65f, + 0.65f, + true, + "DUST", + 3, + 0.05, + 0.0, + 10L, + 96.0, + 3.0 + ); + } + } +} diff --git a/src/main/java/com/dirtsmp/dirtsmp/service/BorderManager.java b/src/main/java/com/dirtsmp/dirtsmp/service/BorderManager.java new file mode 100644 index 0000000..68f426f --- /dev/null +++ b/src/main/java/com/dirtsmp/dirtsmp/service/BorderManager.java @@ -0,0 +1,408 @@ +package com.dirtsmp.dirtsmp.service; + +import com.dirtsmp.dirtsmp.DirtSMPPlugin; +import com.dirtsmp.dirtsmp.config.ConfigManager; +import com.dirtsmp.dirtsmp.config.MessageManager; +import com.dirtsmp.dirtsmp.model.BorderMode; +import com.dirtsmp.dirtsmp.model.ExpansionReason; +import com.dirtsmp.dirtsmp.model.ExpansionResult; +import com.dirtsmp.dirtsmp.model.GrowthMode; +import com.dirtsmp.dirtsmp.model.LegacyAccessReport; +import com.dirtsmp.dirtsmp.model.PhaseDefinition; +import com.dirtsmp.dirtsmp.model.WorldRule; +import com.dirtsmp.dirtsmp.state.PlayerStatsManager; +import com.dirtsmp.dirtsmp.state.StateManager; +import com.dirtsmp.dirtsmp.state.WorldProgress; +import java.util.OptionalDouble; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.OfflinePlayer; +import org.bukkit.World; +import org.bukkit.WorldBorder; + +public final class BorderManager { + private static final double EPSILON = 0.0001; + + private final DirtSMPPlugin plugin; + private final ConfigManager configManager; + private final MessageManager messageManager; + private final StateManager stateManager; + private final PlayerStatsManager playerStatsManager; + private final CommandHookManager commandHookManager; + private final WebhookNotifier webhookNotifier; + private final HistoryLogger historyLogger; + + public BorderManager( + DirtSMPPlugin plugin, + ConfigManager configManager, + MessageManager messageManager, + StateManager stateManager, + PlayerStatsManager playerStatsManager, + CommandHookManager commandHookManager, + WebhookNotifier webhookNotifier, + HistoryLogger historyLogger + ) { + this.plugin = plugin; + this.configManager = configManager; + this.messageManager = messageManager; + this.stateManager = stateManager; + this.playerStatsManager = playerStatsManager; + this.commandHookManager = commandHookManager; + this.webhookNotifier = webhookNotifier; + this.historyLogger = historyLogger; + } + + public void applyStartupRules() { + if (!configManager.applyBordersOnStartup()) { + return; + } + for (WorldRule rule : configManager.worlds().values()) { + if (rule.enabled()) { + initializeRule(rule); + } + } + stateManager.saveQuietly(); + } + + public ExpansionResult initializeRule(WorldRule rule) { + World world = Bukkit.getWorld(rule.worldName()); + if (world == null) { + plugin.getLogger().warning("Configured world '" + rule.worldName() + "' for key '" + rule.key() + "' is not loaded. Skipping border initialization."); + return ExpansionResult.failure("missing world"); + } + + WorldProgress progress = stateManager.progress(rule.key()); + WorldBorder border = world.getWorldBorder(); + if (rule.borderMode() == BorderMode.WORLD_BORDER) { + border.setCenter(rule.centerX(), rule.centerZ()); + } else if (rule.softBorder().releaseVanillaBorder()) { + releaseVanillaBorder(world, rule); + } + + if (!progress.initialized()) { + double size = rule.importCurrentBorder() ? border.getSize() : rule.initialSize(); + LegacyAccessReport legacyReport = legacyAccessReport(rule); + if (legacyReport.requiredSize() > size + EPSILON) { + plugin.getLogger().warning("Legacy access increased initial border for '" + rule.key() + "' from " + TimeUtil.formatSize(size) + " to " + TimeUtil.formatSize(legacyReport.requiredSize()) + " blocks to include " + legacyReport.includedLocations() + " known beta location(s). Farthest: " + legacyReport.farthestLocationName() + "."); + size = legacyReport.requiredSize(); + } + size = enforceInitialMax(rule, size, legacyReport); + if (legacyReport.requiredSize() > size + EPSILON) { + plugin.getLogger().warning("Legacy access for '" + rule.key() + "' needs " + TimeUtil.formatSize(legacyReport.requiredSize()) + " blocks, but max-size enforcement capped startup at " + TimeUtil.formatSize(size) + ". Increase max-size or set legacy-access.allow-start-above-max-size: true."); + } + progress.initialized(true); + progress.currentSize(size); + progress.currentPhaseIndex(rule.phaseIndexForSize(size)); + progress.expansionCount(0); + progress.lastExpansionAt(System.currentTimeMillis()); + progress.uniquePlayersAtLastExpansion(playerStatsManager.uniquePlayerCount()); + progress.clearReminders(); + applyBorder(world, rule, size, 0L); + plugin.getLogger().info("Initialized '" + rule.key() + "' border at " + TimeUtil.formatSize(size) + " blocks."); + return ExpansionResult.success(border.getSize(), size, currentPhase(rule, progress)); + } + + double target = progress.currentSize() > 0.0 ? progress.currentSize() : rule.initialSize(); + target = enforceMax(rule, target); + if (rule.borderMode() == BorderMode.WORLD_BORDER && rule.noShrinkProtection() && border.getSize() > target + EPSILON) { + plugin.getLogger().warning("No-shrink protection kept '" + rule.key() + "' at current server border size " + TimeUtil.formatSize(border.getSize()) + " instead of saved size " + TimeUtil.formatSize(target) + "."); + target = border.getSize(); + progress.currentSize(target); + progress.currentPhaseIndex(rule.phaseIndexForSize(target)); + } + if (rule.legacyAccess().enabled() && rule.legacyAccess().reconcileOnStartup()) { + LegacyAccessReport legacyReport = legacyAccessReport(rule); + if (legacyReport.requiredSize() > target + EPSILON) { + double adjusted = enforceInitialMax(rule, legacyReport.requiredSize(), legacyReport); + if (adjusted > target + EPSILON) { + plugin.getLogger().warning("Legacy access reconciled saved border for '" + rule.key() + "' from " + TimeUtil.formatSize(target) + " to " + TimeUtil.formatSize(adjusted) + " blocks to include known beta locations. Farthest: " + legacyReport.farthestLocationName() + "."); + target = adjusted; + progress.currentSize(target); + progress.currentPhaseIndex(rule.phaseIndexForSize(target)); + } + } + } + applyBorder(world, rule, target, 0L); + return ExpansionResult.success(border.getSize(), target, currentPhase(rule, progress)); + } + + public ExpansionResult expand(WorldRule rule, ExpansionReason reason, String actor, boolean force) { + if (!rule.enabled()) { + return ExpansionResult.failure("world disabled"); + } + + World world = Bukkit.getWorld(rule.worldName()); + if (world == null) { + return ExpansionResult.failure(configManager.messages().getString("expansion.missing-world", "missing world")); + } + + WorldProgress progress = stateManager.progress(rule.key()); + if (!progress.initialized()) { + initializeRule(rule); + } + if (reason.automatic() && progress.paused()) { + return ExpansionResult.failure(configManager.messages().getString("expansion.paused", "paused")); + } + if (reason.automatic() && (rule.manualOnly() || rule.triggers().manualOnly())) { + return ExpansionResult.failure(configManager.messages().getString("expansion.manual-only", "manual only")); + } + if (configManager.dryRun()) { + return ExpansionResult.failure(configManager.messages().getString("expansion.dry-run", "dry-run")); + } + + double oldSize = progress.currentSize() > 0.0 ? progress.currentSize() : world.getWorldBorder().getSize(); + OptionalDouble next = nextSize(rule, progress); + if (next.isEmpty()) { + return ExpansionResult.failure(configManager.messages().getString("expansion.max-reached", "max reached")); + } + double newSize = enforceMax(rule, next.getAsDouble()); + if (newSize <= oldSize + EPSILON && !force) { + return ExpansionResult.failure(configManager.messages().getString("expansion.max-reached", "max reached")); + } + + PhaseDefinition phase = phaseAfterExpansion(rule, progress, newSize); + commandHookManager.runBefore(rule, oldSize, newSize, reason, actor, phase); + applyBorder(world, rule, newSize, rule.transitionSeconds()); + updateProgressAfterSizeChange(rule, progress, newSize, reason); + stateManager.saveQuietly(); + historyLogger.record(rule, oldSize, newSize, reason, actor, phase); + messageManager.announceExpansion(rule, oldSize, newSize, reason, actor, phase); + webhookNotifier.notifyExpansion(rule, oldSize, newSize, reason, actor, phase); + commandHookManager.runAfter(rule, oldSize, newSize, reason, actor, phase); + commandHookManager.runMilestoneRewards(rule, oldSize, newSize, reason, actor, phase, progress.expansionCount()); + return ExpansionResult.success(oldSize, newSize, phase); + } + + public ExpansionResult setSize(WorldRule rule, double size, String actor, boolean force) { + if (size <= 0.0) { + return ExpansionResult.failure("invalid size"); + } + World world = Bukkit.getWorld(rule.worldName()); + if (world == null) { + return ExpansionResult.failure(configManager.messages().getString("expansion.missing-world", "missing world")); + } + if (configManager.dryRun()) { + return ExpansionResult.failure(configManager.messages().getString("expansion.dry-run", "dry-run")); + } + + WorldProgress progress = stateManager.progress(rule.key()); + if (!progress.initialized()) { + initializeRule(rule); + } + double oldSize = progress.currentSize() > 0.0 ? progress.currentSize() : world.getWorldBorder().getSize(); + double newSize = force ? size : enforceMax(rule, size); + if (rule.noShrinkProtection() && newSize + EPSILON < oldSize && !force) { + return ExpansionResult.failure(configManager.messages().getString("expansion.no-shrink", "no shrink")); + } + + PhaseDefinition phase = phaseForSize(rule, newSize); + applyBorder(world, rule, newSize, rule.transitionSeconds()); + updateProgressAfterSizeChange(rule, progress, newSize, ExpansionReason.SET_SIZE); + stateManager.saveQuietly(); + historyLogger.record(rule, oldSize, newSize, ExpansionReason.SET_SIZE, actor, phase); + return ExpansionResult.success(oldSize, newSize, phase); + } + + public ExpansionResult reset(WorldRule rule, String actor, boolean force) { + WorldProgress progress = stateManager.progress(rule.key()); + double oldSize = progress.currentSize(); + double target = rule.initialSize(); + if (rule.noShrinkProtection() && target + EPSILON < oldSize && !force) { + return ExpansionResult.failure(configManager.messages().getString("expansion.no-shrink", "no shrink")); + } + + ExpansionResult result = setSize(rule, target, actor, true); + if (!result.success()) { + return result; + } + + progress.initialized(true); + progress.currentSize(target); + progress.currentPhaseIndex(rule.phaseIndexForSize(target)); + progress.expansionCount(0); + progress.paused(false); + progress.lastExpansionAt(System.currentTimeMillis()); + progress.uniquePlayersAtLastExpansion(playerStatsManager.uniquePlayerCount()); + progress.clearReminders(); + stateManager.saveQuietly(); + historyLogger.record(rule, oldSize, target, ExpansionReason.RESET, actor, currentPhase(rule, progress)); + return ExpansionResult.success(oldSize, target, currentPhase(rule, progress)); + } + + public void pause(WorldRule rule) { + WorldProgress progress = stateManager.progress(rule.key()); + progress.paused(true); + stateManager.saveQuietly(); + } + + public void resume(WorldRule rule) { + WorldProgress progress = stateManager.progress(rule.key()); + progress.paused(false); + stateManager.saveQuietly(); + } + + public OptionalDouble nextSize(WorldRule rule, WorldProgress progress) { + double currentSize = progress.currentSize() > 0.0 ? progress.currentSize() : rule.initialSize(); + if (rule.growthMode() == GrowthMode.PHASE) { + return rule.nextPhase(progress.currentPhaseIndex()).map(PhaseDefinition::size).map(OptionalDouble::of).orElseGet(OptionalDouble::empty); + } + return OptionalDouble.of(currentSize + rule.growthAmount()); + } + + public double displaySize(WorldRule rule, WorldProgress progress) { + return progress.currentSize() > 0.0 ? progress.currentSize() : rule.initialSize(); + } + + public String nextSizeDisplay(WorldRule rule, WorldProgress progress) { + OptionalDouble next = nextSize(rule, progress); + if (next.isEmpty()) { + return "max"; + } + return TimeUtil.formatSize(enforceMax(rule, next.getAsDouble())); + } + + public PhaseDefinition currentPhase(WorldRule rule, WorldProgress progress) { + return rule.phase(progress.currentPhaseIndex()).orElse(null); + } + + public PhaseDefinition phaseForSize(WorldRule rule, double size) { + return rule.phase(rule.phaseIndexForSize(size)).orElse(null); + } + + public LegacyAccessReport legacyAccessReport(WorldRule rule) { + World world = Bukkit.getWorld(rule.worldName()); + if (world == null || !rule.legacyAccess().enabled()) { + return new LegacyAccessReport(0.0, 0, "none"); + } + + LegacyAccumulator accumulator = new LegacyAccumulator(); + WorldRule.LegacyAccessSettings access = rule.legacyAccess(); + if (access.includeCurrentBorder()) { + accumulator.include(world.getWorldBorder().getSize(), "current world border"); + } + if (access.includeOnlinePlayers()) { + for (org.bukkit.entity.Player player : Bukkit.getOnlinePlayers()) { + if (!includePlayerLocation(rule, player)) { + continue; + } + includeLocation(rule, accumulator, player.getName() + " online location", player.getLocation()); + } + } + if (access.includeOfflineLastLocations() || access.includeRespawnLocations()) { + for (OfflinePlayer player : Bukkit.getOfflinePlayers()) { + if (!includePlayerLocation(rule, player)) { + continue; + } + String name = player.getName() == null ? player.getUniqueId().toString() : player.getName(); + if (access.includeOfflineLastLocations()) { + includeLocation(rule, accumulator, name + " last location", player.getLocation()); + } + if (access.includeRespawnLocations()) { + includeLocation(rule, accumulator, name + " respawn location", player.getRespawnLocation()); + } + } + } + for (WorldRule.LegacyLocation location : access.locations()) { + includeCoordinate(rule, accumulator, location.name(), location.x(), location.z()); + } + return new LegacyAccessReport(accumulator.requiredSize, accumulator.includedLocations, accumulator.farthestLocationName); + } + + private PhaseDefinition phaseAfterExpansion(WorldRule rule, WorldProgress progress, double newSize) { + if (rule.growthMode() == GrowthMode.PHASE) { + return rule.nextPhase(progress.currentPhaseIndex()).orElse(phaseForSize(rule, newSize)); + } + return phaseForSize(rule, newSize); + } + + private void updateProgressAfterSizeChange(WorldRule rule, WorldProgress progress, double newSize, ExpansionReason reason) { + progress.initialized(true); + progress.currentSize(newSize); + progress.currentPhaseIndex(rule.phaseIndexForSize(newSize)); + if (reason != ExpansionReason.SET_SIZE) { + progress.expansionCount(progress.expansionCount() + 1); + } + progress.lastExpansionAt(System.currentTimeMillis()); + progress.uniquePlayersAtLastExpansion(playerStatsManager.uniquePlayerCount()); + progress.clearReminders(); + } + + private double enforceMax(WorldRule rule, double size) { + if (!rule.enforceMaxSize()) { + return size; + } + return Math.min(size, rule.maxSize()); + } + + private double enforceInitialMax(WorldRule rule, double size, LegacyAccessReport legacyReport) { + if (!rule.enforceMaxSize()) { + return size; + } + if (rule.legacyAccess().enabled() + && rule.legacyAccess().allowStartAboveMaxSize() + && legacyReport.requiredSize() > rule.maxSize() + && size >= legacyReport.requiredSize() - EPSILON) { + return size; + } + return Math.min(size, rule.maxSize()); + } + + private void includeLocation(WorldRule rule, LegacyAccumulator accumulator, String name, Location location) { + if (location == null || location.getWorld() == null || !location.getWorld().getName().equals(rule.worldName())) { + return; + } + includeCoordinate(rule, accumulator, name, location.getX(), location.getZ()); + } + + private boolean includePlayerLocation(WorldRule rule, OfflinePlayer player) { + return !rule.legacyAccess().playerLocationsRequireLegacyUser() || stateManager.isLegacyUser(player.getUniqueId()); + } + + private void includeCoordinate(WorldRule rule, LegacyAccumulator accumulator, String name, double x, double z) { + double halfSize = Math.max(Math.abs(x - rule.centerX()), Math.abs(z - rule.centerZ())) + rule.legacyAccess().padding(); + accumulator.include(halfSize * 2.0, name); + } + + private void applyBorder(World world, WorldRule rule, double size, long transitionSeconds) { + if (configManager.dryRun()) { + plugin.getLogger().info("[dry-run] Would set '" + rule.key() + "' border to " + TimeUtil.formatSize(size) + " blocks."); + return; + } + if (rule.borderMode() == BorderMode.SOFT_BORDER) { + if (rule.softBorder().releaseVanillaBorder()) { + releaseVanillaBorder(world, rule); + } + plugin.getLogger().info("Set soft border state for '" + rule.key() + "' to " + TimeUtil.formatSize(size) + " blocks. Vanilla world border was not used for enforcement."); + return; + } + WorldBorder border = world.getWorldBorder(); + border.setCenter(rule.centerX(), rule.centerZ()); + if (transitionSeconds > 0L) { + border.setSize(size, transitionSeconds); + } else { + border.setSize(size); + } + } + + private void releaseVanillaBorder(World world, WorldRule rule) { + WorldBorder border = world.getWorldBorder(); + border.setCenter(rule.centerX(), rule.centerZ()); + if (border.getSize() < rule.softBorder().vanillaBorderSize() - EPSILON) { + border.setSize(rule.softBorder().vanillaBorderSize()); + } + } + + private static final class LegacyAccumulator { + private double requiredSize; + private int includedLocations; + private String farthestLocationName = "none"; + + private void include(double requiredSize, String name) { + includedLocations++; + if (requiredSize > this.requiredSize) { + this.requiredSize = requiredSize; + this.farthestLocationName = name; + } + } + } +} diff --git a/src/main/java/com/dirtsmp/dirtsmp/service/CommandHookManager.java b/src/main/java/com/dirtsmp/dirtsmp/service/CommandHookManager.java new file mode 100644 index 0000000..214175d --- /dev/null +++ b/src/main/java/com/dirtsmp/dirtsmp/service/CommandHookManager.java @@ -0,0 +1,77 @@ +package com.dirtsmp.dirtsmp.service; + +import com.dirtsmp.dirtsmp.config.ConfigManager; +import com.dirtsmp.dirtsmp.config.MessageManager; +import com.dirtsmp.dirtsmp.model.ExpansionReason; +import com.dirtsmp.dirtsmp.model.PhaseDefinition; +import com.dirtsmp.dirtsmp.model.WorldRule; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Locale; +import org.bukkit.Bukkit; + +public final class CommandHookManager { + private final ConfigManager configManager; + private final MessageManager messageManager; + + public CommandHookManager(ConfigManager configManager, MessageManager messageManager) { + this.configManager = configManager; + this.messageManager = messageManager; + } + + public void runBefore(WorldRule rule, double oldSize, double newSize, ExpansionReason reason, String actor, PhaseDefinition phase) { + runCommands(commands(configManager.globalBeforeCommands(), rule.beforeCommands()), rule, oldSize, newSize, reason, actor, phase, Map.of()); + } + + public void runAfter(WorldRule rule, double oldSize, double newSize, ExpansionReason reason, String actor, PhaseDefinition phase) { + runCommands(commands(configManager.globalAfterCommands(), rule.afterCommands()), rule, oldSize, newSize, reason, actor, phase, Map.of()); + } + + public void runMilestoneRewards(WorldRule rule, double oldSize, double newSize, ExpansionReason reason, String actor, PhaseDefinition phase, int expansionCount) { + List commands = new ArrayList<>(); + commands.addAll(rewardCommands(configManager.globalMilestoneRewards(), expansionCount, phase)); + commands.addAll(rewardCommands(rule.milestoneRewards(), expansionCount, phase)); + + runCommands(commands, rule, oldSize, newSize, reason, actor, phase, Map.of( + "expansion_count", String.valueOf(expansionCount) + )); + } + + private List commands(List global, List worldSpecific) { + List commands = new ArrayList<>(); + commands.addAll(global); + commands.addAll(worldSpecific); + return commands; + } + + private List rewardCommands(WorldRule.MilestoneRewardSettings settings, int expansionCount, PhaseDefinition phase) { + if (!settings.enabled()) { + return List.of(); + } + + List commands = new ArrayList<>(); + commands.addAll(settings.everyExpansionCommands()); + commands.addAll(settings.expansionCountCommands().getOrDefault(expansionCount, List.of())); + if (phase != null) { + commands.addAll(settings.phaseIndexCommands().getOrDefault(phase.index(), List.of())); + commands.addAll(settings.phaseNameCommands().getOrDefault(phase.name().toLowerCase(Locale.ROOT), List.of())); + } + return commands; + } + + private void runCommands(List commands, WorldRule rule, double oldSize, double newSize, ExpansionReason reason, String actor, PhaseDefinition phase, Map extraPlaceholders) { + if (!configManager.hooksEnabled() || commands.isEmpty()) { + return; + } + + Map placeholders = messageManager.withBasePlaceholders(rule, oldSize, newSize, reason, actor, phase, extraPlaceholders); + for (String command : commands) { + if (command == null || command.isBlank()) { + continue; + } + String rendered = messageManager.applyPlaceholders(command, placeholders); + Bukkit.dispatchCommand(Bukkit.getConsoleSender(), rendered.startsWith("/") ? rendered.substring(1) : rendered); + } + } +} diff --git a/src/main/java/com/dirtsmp/dirtsmp/service/HistoryLogger.java b/src/main/java/com/dirtsmp/dirtsmp/service/HistoryLogger.java new file mode 100644 index 0000000..d769263 --- /dev/null +++ b/src/main/java/com/dirtsmp/dirtsmp/service/HistoryLogger.java @@ -0,0 +1,48 @@ +package com.dirtsmp.dirtsmp.service; + +import com.dirtsmp.dirtsmp.DirtSMPPlugin; +import com.dirtsmp.dirtsmp.config.ConfigManager; +import com.dirtsmp.dirtsmp.model.ExpansionReason; +import com.dirtsmp.dirtsmp.model.PhaseDefinition; +import com.dirtsmp.dirtsmp.model.WorldRule; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.StandardOpenOption; +import java.time.Instant; +import java.util.logging.Level; + +public final class HistoryLogger { + private final DirtSMPPlugin plugin; + private final ConfigManager configManager; + + public HistoryLogger(DirtSMPPlugin plugin, ConfigManager configManager) { + this.plugin = plugin; + this.configManager = configManager; + } + + public void record(WorldRule rule, double oldSize, double newSize, ExpansionReason reason, String actor, PhaseDefinition phase) { + if (!configManager.historyEnabled()) { + return; + } + + File file = new File(plugin.getDataFolder(), configManager.historyFileName()); + String line = String.join(" | ", + Instant.now().toString(), + "world=" + rule.worldName(), + "key=" + rule.key(), + "old=" + TimeUtil.formatSize(oldSize), + "new=" + TimeUtil.formatSize(newSize), + "reason=" + reason.name(), + "actor=" + (actor == null ? "system" : actor), + "phase=" + (phase == null ? "none" : phase.name()) + ) + System.lineSeparator(); + + try { + Files.writeString(file.toPath(), line, StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.APPEND); + } catch (IOException ex) { + plugin.getLogger().log(Level.WARNING, "Could not append to expansion history.", ex); + } + } +} diff --git a/src/main/java/com/dirtsmp/dirtsmp/service/SoftBorderManager.java b/src/main/java/com/dirtsmp/dirtsmp/service/SoftBorderManager.java new file mode 100644 index 0000000..af96ff2 --- /dev/null +++ b/src/main/java/com/dirtsmp/dirtsmp/service/SoftBorderManager.java @@ -0,0 +1,434 @@ +package com.dirtsmp.dirtsmp.service; + +import com.dirtsmp.dirtsmp.DirtSMPPlugin; +import com.dirtsmp.dirtsmp.config.ConfigManager; +import com.dirtsmp.dirtsmp.config.MessageManager; +import com.dirtsmp.dirtsmp.model.BorderMode; +import com.dirtsmp.dirtsmp.model.WorldRule; +import com.dirtsmp.dirtsmp.state.StateManager; +import com.dirtsmp.dirtsmp.state.WorldProgress; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import org.bukkit.Bukkit; +import org.bukkit.Color; +import org.bukkit.GameMode; +import org.bukkit.Location; +import org.bukkit.Particle; +import org.bukkit.Sound; +import org.bukkit.SoundCategory; +import org.bukkit.World; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityTeleportEvent; +import org.bukkit.event.player.PlayerMoveEvent; +import org.bukkit.event.player.PlayerTeleportEvent; +import org.bukkit.event.vehicle.VehicleMoveEvent; +import org.bukkit.scheduler.BukkitTask; +import org.bukkit.util.Vector; + +public final class SoftBorderManager implements Listener { + private static final double EPSILON = 0.0001; + private static final long TASK_PERIOD_TICKS = 5L; + + private final DirtSMPPlugin plugin; + private final ConfigManager configManager; + private final MessageManager messageManager; + private final StateManager stateManager; + private final Map feedbackCooldowns = new HashMap<>(); + private final Set correctingEntities = new HashSet<>(); + private BukkitTask task; + private long tickCounter; + + public SoftBorderManager(DirtSMPPlugin plugin, ConfigManager configManager, MessageManager messageManager, StateManager stateManager) { + this.plugin = plugin; + this.configManager = configManager; + this.messageManager = messageManager; + this.stateManager = stateManager; + } + + public void start() { + stop(); + task = Bukkit.getScheduler().runTaskTimer(plugin, this::tick, TASK_PERIOD_TICKS, TASK_PERIOD_TICKS); + } + + public void stop() { + if (task != null) { + task.cancel(); + task = null; + } + feedbackCooldowns.clear(); + correctingEntities.clear(); + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onMove(PlayerMoveEvent event) { + Location to = event.getTo(); + if (!movedHorizontally(event.getFrom(), to)) { + return; + } + WorldRule rule = softRule(to.getWorld()); + if (rule == null || exempt(event.getPlayer(), rule)) { + return; + } + Entity target = protectedEntity(event.getPlayer(), rule); + Location targetLocation = target.getLocation(); + if (!outside(rule, targetLocation)) { + return; + } + + double outsideDistance = outsideDistance(rule, targetLocation); + if (outsideDistance >= rule.softBorder().maxOutsideDistanceBeforeTeleport()) { + teleportInside(target, event.getPlayer(), rule, targetLocation); + return; + } + bounce(target, event.getPlayer(), rule, targetLocation); + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onTeleport(PlayerTeleportEvent event) { + if (correctingEntities.remove(event.getPlayer().getUniqueId())) { + return; + } + + Location to = event.getTo(); + if (to == null) { + return; + } + WorldRule rule = softRule(to.getWorld()); + if (rule == null || exempt(event.getPlayer(), rule) || !outside(rule, to)) { + return; + } + + Location corrected = closestInside(rule, to); + event.setTo(corrected); + feedback(event.getPlayer(), rule, true); + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onEntityTeleport(EntityTeleportEvent event) { + Entity entity = event.getEntity(); + if (entity instanceof Player || correctingEntities.remove(entity.getUniqueId())) { + return; + } + + Location to = event.getTo(); + WorldRule rule = to == null ? null : softRule(to.getWorld()); + if (rule == null || !rule.softBorder().protectMountedEntities() || !outside(rule, to)) { + return; + } + Player rider = firstNonExemptPassenger(entity, rule); + if (rider == null) { + return; + } + + event.setTo(closestInside(rule, to)); + feedback(rider, rule, true); + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onVehicleMove(VehicleMoveEvent event) { + if (!movedHorizontally(event.getFrom(), event.getTo())) { + return; + } + + WorldRule rule = softRule(event.getTo().getWorld()); + if (rule == null || !rule.softBorder().protectMountedEntities() || !outside(rule, event.getTo())) { + return; + } + Player rider = firstNonExemptPassenger(event.getVehicle(), rule); + if (rider == null) { + return; + } + + double outsideDistance = outsideDistance(rule, event.getTo()); + if (outsideDistance >= rule.softBorder().maxOutsideDistanceBeforeTeleport()) { + teleportInside(event.getVehicle(), rider, rule, event.getTo()); + return; + } + bounce(event.getVehicle(), rider, rule, event.getTo()); + } + + private void tick() { + tickCounter += TASK_PERIOD_TICKS; + for (Player player : Bukkit.getOnlinePlayers()) { + WorldRule rule = softRule(player.getWorld()); + if (rule == null || exempt(player, rule)) { + continue; + } + Entity target = protectedEntity(player, rule); + Location targetLocation = target.getLocation(); + if (outside(rule, targetLocation)) { + teleportInside(target, player, rule, targetLocation); + continue; + } + if (rule.softBorder().particlesEnabled() && tickCounter % rule.softBorder().particleIntervalTicks() == 0L) { + renderParticles(player, rule); + } + } + } + + private WorldRule softRule(World world) { + if (world == null) { + return null; + } + for (WorldRule rule : configManager.worlds().values()) { + if (rule.enabled() && rule.borderMode() == BorderMode.SOFT_BORDER && rule.worldName().equals(world.getName())) { + return rule; + } + } + return null; + } + + private boolean exempt(Player player, WorldRule rule) { + WorldRule.SoftBorderSettings settings = rule.softBorder(); + if (!settings.bypassPermission().isBlank() && player.hasPermission(settings.bypassPermission())) { + return true; + } + if (settings.ignoreCreative() && player.getGameMode() == GameMode.CREATIVE) { + return true; + } + return settings.ignoreSpectator() && player.getGameMode() == GameMode.SPECTATOR; + } + + private boolean movedHorizontally(Location from, Location to) { + return to != null + && from.getWorld() == to.getWorld() + && (Math.abs(from.getX() - to.getX()) > EPSILON || Math.abs(from.getZ() - to.getZ()) > EPSILON); + } + + private Entity protectedEntity(Player player, WorldRule rule) { + if (!rule.softBorder().protectMountedEntities() || !player.isInsideVehicle()) { + return player; + } + Entity vehicle = player.getVehicle(); + if (vehicle == null || vehicle.getWorld() != player.getWorld()) { + return player; + } + return vehicle; + } + + private Player firstNonExemptPassenger(Entity entity, WorldRule rule) { + for (Entity passenger : entity.getPassengers()) { + if (passenger instanceof Player player && !exempt(player, rule)) { + return player; + } + Player nested = firstNonExemptPassenger(passenger, rule); + if (nested != null) { + return nested; + } + } + return null; + } + + private boolean outside(WorldRule rule, Location location) { + Bounds bounds = bounds(rule); + return location.getX() < bounds.minX + || location.getX() > bounds.maxX + || location.getZ() < bounds.minZ + || location.getZ() > bounds.maxZ; + } + + private double outsideDistance(WorldRule rule, Location location) { + Bounds bounds = bounds(rule); + double outsideX = Math.max(bounds.minX - location.getX(), location.getX() - bounds.maxX); + double outsideZ = Math.max(bounds.minZ - location.getZ(), location.getZ() - bounds.maxZ); + return Math.max(Math.max(0.0, outsideX), Math.max(0.0, outsideZ)); + } + + private void bounce(Entity target, Player player, WorldRule rule, Location location) { + Bounds bounds = bounds(rule); + double pushX = 0.0; + double pushZ = 0.0; + if (location.getX() < bounds.minX) { + pushX = 1.0; + } else if (location.getX() > bounds.maxX) { + pushX = -1.0; + } + if (location.getZ() < bounds.minZ) { + pushZ = 1.0; + } else if (location.getZ() > bounds.maxZ) { + pushZ = -1.0; + } + + Vector velocity = new Vector(pushX, 0.0, pushZ); + if (velocity.lengthSquared() <= EPSILON) { + velocity = location.toVector().subtract(closestInside(rule, location).toVector()).multiply(-1.0); + } + velocity.setY(0.0); + if (velocity.lengthSquared() <= EPSILON) { + velocity = new Vector(rule.centerX() - location.getX(), 0.0, rule.centerZ() - location.getZ()); + } + double strength = target instanceof Player ? rule.softBorder().bounceStrength() : rule.softBorder().mountedBounceStrength(); + double vertical = target instanceof Player ? rule.softBorder().verticalBoost() : rule.softBorder().mountedVerticalBoost(); + velocity.normalize().multiply(strength).setY(vertical); + target.setVelocity(velocity); + feedback(player, rule, false); + } + + private void teleportInside(Entity target, Player player, WorldRule rule, Location from) { + Location corrected = closestInside(rule, from); + Set passengers = new HashSet<>(target.getPassengers()); + correctingEntities.add(target.getUniqueId()); + target.teleport(corrected); + restorePassengers(target, passengers); + feedback(player, rule, true); + } + + private void restorePassengers(Entity target, Set passengers) { + if (passengers.isEmpty()) { + return; + } + Bukkit.getScheduler().runTask(plugin, () -> { + if (!target.isValid()) { + return; + } + for (Entity passenger : passengers) { + if (!passenger.isValid() || target.getPassengers().contains(passenger)) { + continue; + } + correctingEntities.add(passenger.getUniqueId()); + passenger.teleport(target.getLocation()); + target.addPassenger(passenger); + } + }); + } + + private Location closestInside(WorldRule rule, Location location) { + Bounds bounds = bounds(rule); + double buffer = rule.softBorder().insideBuffer(); + double minX = bounds.minX + buffer; + double maxX = bounds.maxX - buffer; + double minZ = bounds.minZ + buffer; + double maxZ = bounds.maxZ - buffer; + double x = Math.max(minX, Math.min(maxX, location.getX())); + double z = Math.max(minZ, Math.min(maxZ, location.getZ())); + + Location corrected = location.clone(); + corrected.setX(x); + corrected.setZ(z); + return corrected; + } + + private Bounds bounds(WorldRule rule) { + WorldProgress progress = stateManager.progress(rule.key()); + double size = progress.currentSize() > 0.0 ? progress.currentSize() : rule.initialSize(); + double half = size / 2.0; + return new Bounds(rule.centerX() - half, rule.centerX() + half, rule.centerZ() - half, rule.centerZ() + half); + } + + private void feedback(Player player, WorldRule rule, boolean forcedTeleport) { + long now = System.currentTimeMillis(); + long last = feedbackCooldowns.getOrDefault(player.getUniqueId(), 0L); + if (now - last < rule.softBorder().cooldownMillis()) { + return; + } + feedbackCooldowns.put(player.getUniqueId(), now); + + Map placeholders = Map.of( + "world", rule.worldName(), + "key", rule.key(), + "mode", forcedTeleport ? "corrected" : "bounced" + ); + messageManager.sendRaw(player, rule.softBorder().message(), placeholders); + if (!rule.softBorder().actionBar().isBlank()) { + player.sendActionBar(messageManager.component(rule.softBorder().actionBar(), placeholders)); + } + playSound(player, rule); + } + + @SuppressWarnings({"deprecation", "removal"}) + private void playSound(Player player, WorldRule rule) { + String soundName = rule.softBorder().sound(); + if (soundName == null || soundName.isBlank()) { + return; + } + try { + Sound sound = Sound.valueOf(soundName.trim().toUpperCase(Locale.ROOT)); + player.playSound(player.getLocation(), sound, SoundCategory.MASTER, rule.softBorder().volume(), rule.softBorder().pitch()); + } catch (IllegalArgumentException ignored) { + player.playSound(player.getLocation(), soundName.toLowerCase(Locale.ROOT), SoundCategory.MASTER, rule.softBorder().volume(), rule.softBorder().pitch()); + } + } + + private void renderParticles(Player player, WorldRule rule) { + Bounds bounds = bounds(rule); + Location playerLocation = player.getLocation(); + double view = rule.softBorder().particleViewDistance(); + double spacing = rule.softBorder().particleSpacing(); + double y = playerLocation.getY() + 1.1; + + if (Math.abs(playerLocation.getX() - bounds.minX) <= view) { + renderLine(player, rule, bounds.minX, clamp(playerLocation.getZ() - view, bounds.minZ, bounds.maxZ), bounds.minX, clamp(playerLocation.getZ() + view, bounds.minZ, bounds.maxZ), y, spacing); + } + if (Math.abs(playerLocation.getX() - bounds.maxX) <= view) { + renderLine(player, rule, bounds.maxX, clamp(playerLocation.getZ() - view, bounds.minZ, bounds.maxZ), bounds.maxX, clamp(playerLocation.getZ() + view, bounds.minZ, bounds.maxZ), y, spacing); + } + if (Math.abs(playerLocation.getZ() - bounds.minZ) <= view) { + renderLine(player, rule, clamp(playerLocation.getX() - view, bounds.minX, bounds.maxX), bounds.minZ, clamp(playerLocation.getX() + view, bounds.minX, bounds.maxX), bounds.minZ, y, spacing); + } + if (Math.abs(playerLocation.getZ() - bounds.maxZ) <= view) { + renderLine(player, rule, clamp(playerLocation.getX() - view, bounds.minX, bounds.maxX), bounds.maxZ, clamp(playerLocation.getX() + view, bounds.minX, bounds.maxX), bounds.maxZ, y, spacing); + } + } + + private void renderLine(Player player, WorldRule rule, double startX, double startZ, double endX, double endZ, double y, double spacing) { + double distance = Math.hypot(endX - startX, endZ - startZ); + int steps = Math.max(1, (int) Math.floor(distance / spacing)); + for (int step = 0; step <= steps; step++) { + double t = (double) step / (double) steps; + double x = startX + ((endX - startX) * t); + double z = startZ + ((endZ - startZ) * t); + spawnParticle(player, rule, x, y, z); + } + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private void spawnParticle(Player player, WorldRule rule, double x, double y, double z) { + WorldRule.SoftBorderSettings settings = rule.softBorder(); + try { + Particle particle = Particle.valueOf(settings.particle().trim().toUpperCase(Locale.ROOT)); + if (particle == Particle.DUST) { + player.spawnParticle( + Particle.DUST, + x, + y, + z, + settings.particleCount(), + settings.particleOffset(), + settings.particleOffset(), + settings.particleOffset(), + settings.particleSpeed(), + new Particle.DustOptions(Color.fromRGB(212, 175, 55), 1.25f) + ); + return; + } + player.spawnParticle( + particle, + x, + y, + z, + settings.particleCount(), + settings.particleOffset(), + settings.particleOffset(), + settings.particleOffset(), + settings.particleSpeed() + ); + } catch (IllegalArgumentException ex) { + player.spawnParticle(Particle.END_ROD, x, y, z, 1, 0.02, 0.02, 0.02, 0.0); + } + } + + private double clamp(double value, double min, double max) { + return Math.max(min, Math.min(max, value)); + } + + private record Bounds(double minX, double maxX, double minZ, double maxZ) { + } +} diff --git a/src/main/java/com/dirtsmp/dirtsmp/service/TimeUtil.java b/src/main/java/com/dirtsmp/dirtsmp/service/TimeUtil.java new file mode 100644 index 0000000..e9007cb --- /dev/null +++ b/src/main/java/com/dirtsmp/dirtsmp/service/TimeUtil.java @@ -0,0 +1,92 @@ +package com.dirtsmp.dirtsmp.service; + +import java.text.DecimalFormat; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class TimeUtil { + private static final Pattern DURATION_PART = Pattern.compile("(\\d+(?:\\.\\d+)?)(ms|s|m|h|d|w)?", Pattern.CASE_INSENSITIVE); + private static final DecimalFormat SIZE_FORMAT = new DecimalFormat("#,##0.##"); + + private TimeUtil() { + } + + public static long parseDurationMillis(String input, long fallbackMillis) { + if (input == null || input.isBlank()) { + return fallbackMillis; + } + + String compact = input.trim().toLowerCase(Locale.ROOT).replace(" ", ""); + Matcher matcher = DURATION_PART.matcher(compact); + long total = 0L; + int matched = 0; + + while (matcher.find()) { + if (matcher.start() != matched) { + return fallbackMillis; + } + double amount = Double.parseDouble(matcher.group(1)); + String unit = matcher.group(2); + total += switch (unit == null ? "s" : unit) { + case "ms" -> Math.round(amount); + case "s" -> Math.round(amount * 1000.0); + case "m" -> Math.round(amount * 60_000.0); + case "h" -> Math.round(amount * 3_600_000.0); + case "d" -> Math.round(amount * 86_400_000.0); + case "w" -> Math.round(amount * 604_800_000.0); + default -> fallbackMillis; + }; + matched = matcher.end(); + } + + return matched == compact.length() && total > 0L ? total : fallbackMillis; + } + + public static String formatDuration(long millis) { + if (millis <= 0L) { + return "now"; + } + + long seconds = millis / 1000L; + long days = seconds / 86_400L; + seconds %= 86_400L; + long hours = seconds / 3_600L; + seconds %= 3_600L; + long minutes = seconds / 60L; + seconds %= 60L; + + StringBuilder builder = new StringBuilder(); + appendPart(builder, days, "d"); + appendPart(builder, hours, "h"); + appendPart(builder, minutes, "m"); + if (builder.isEmpty()) { + appendPart(builder, seconds, "s"); + } + return builder.toString().trim(); + } + + private static void appendPart(StringBuilder builder, long value, String suffix) { + if (value <= 0L) { + return; + } + if (!builder.isEmpty()) { + builder.append(' '); + } + builder.append(value).append(suffix); + } + + public static String formatSize(double size) { + return SIZE_FORMAT.format(size); + } + + public static String formatInstant(long epochMillis) { + if (epochMillis <= 0L) { + return "unknown"; + } + return DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(Instant.ofEpochMilli(epochMillis).atZone(ZoneId.systemDefault())); + } +} diff --git a/src/main/java/com/dirtsmp/dirtsmp/service/TriggerEvaluator.java b/src/main/java/com/dirtsmp/dirtsmp/service/TriggerEvaluator.java new file mode 100644 index 0000000..7300971 --- /dev/null +++ b/src/main/java/com/dirtsmp/dirtsmp/service/TriggerEvaluator.java @@ -0,0 +1,139 @@ +package com.dirtsmp.dirtsmp.service; + +import com.dirtsmp.dirtsmp.model.ExpansionReason; +import com.dirtsmp.dirtsmp.model.TriggerDecision; +import com.dirtsmp.dirtsmp.model.TriggerMode; +import com.dirtsmp.dirtsmp.model.TriggerSettings; +import com.dirtsmp.dirtsmp.model.WorldRule; +import com.dirtsmp.dirtsmp.state.PlayerStatsManager; +import com.dirtsmp.dirtsmp.state.WorldProgress; + +public final class TriggerEvaluator { + private final PlayerStatsManager playerStatsManager; + + public TriggerEvaluator(PlayerStatsManager playerStatsManager) { + this.playerStatsManager = playerStatsManager; + } + + public TriggerDecision evaluate(WorldRule rule, WorldProgress progress) { + TriggerSettings settings = rule.triggers(); + if (!rule.enabled()) { + return TriggerDecision.none("world disabled"); + } + if (progress.paused()) { + return TriggerDecision.none("paused"); + } + if (rule.manualOnly() || settings.manualOnly()) { + return TriggerDecision.none("manual only"); + } + + boolean time = timeDue(settings, progress); + boolean unique = uniqueDue(settings, progress); + boolean online = onlineDue(settings, progress); + boolean active = activeDue(settings, progress); + + return switch (settings.mode()) { + case TIME -> decision(time, ExpansionReason.TIME, describeTrigger(rule, progress)); + case UNIQUE_PLAYERS -> decision(unique, ExpansionReason.UNIQUE_PLAYERS, describeTrigger(rule, progress)); + case ONLINE_PLAYERS -> decision(online, ExpansionReason.ONLINE_PLAYERS, describeTrigger(rule, progress)); + case ACTIVE_PLAYERS -> decision(active, ExpansionReason.ACTIVE_PLAYERS, describeTrigger(rule, progress)); + case TIME_OR_UNIQUE_PLAYERS -> decision(time || unique, time && unique ? ExpansionReason.HYBRID : (time ? ExpansionReason.TIME : ExpansionReason.UNIQUE_PLAYERS), describeTrigger(rule, progress)); + case TIME_AND_UNIQUE_PLAYERS -> decision(time && unique, ExpansionReason.HYBRID, describeTrigger(rule, progress)); + case TIME_OR_ONLINE_PLAYERS -> decision(time || online, time && online ? ExpansionReason.HYBRID : (time ? ExpansionReason.TIME : ExpansionReason.ONLINE_PLAYERS), describeTrigger(rule, progress)); + case TIME_AND_ONLINE_PLAYERS -> decision(time && online, ExpansionReason.HYBRID, describeTrigger(rule, progress)); + case TIME_OR_ACTIVE_PLAYERS -> decision(time || active, time && active ? ExpansionReason.HYBRID : (time ? ExpansionReason.TIME : ExpansionReason.ACTIVE_PLAYERS), describeTrigger(rule, progress)); + case TIME_AND_ACTIVE_PLAYERS -> decision(time && active, ExpansionReason.HYBRID, describeTrigger(rule, progress)); + case MANUAL -> TriggerDecision.none("manual only"); + }; + } + + public String describeTrigger(WorldRule rule, WorldProgress progress) { + TriggerSettings settings = rule.triggers(); + return switch (settings.mode()) { + case MANUAL -> "manual only"; + case TIME -> describeTime(settings, progress); + case UNIQUE_PLAYERS -> describeUnique(settings, progress); + case ONLINE_PLAYERS -> describeOnline(settings); + case ACTIVE_PLAYERS -> describeActive(settings); + case TIME_OR_UNIQUE_PLAYERS -> describeTime(settings, progress) + " or " + describeUnique(settings, progress); + case TIME_AND_UNIQUE_PLAYERS -> describeTime(settings, progress) + " and " + describeUnique(settings, progress); + case TIME_OR_ONLINE_PLAYERS -> describeTime(settings, progress) + " or " + describeOnline(settings); + case TIME_AND_ONLINE_PLAYERS -> describeTime(settings, progress) + " and " + describeOnline(settings); + case TIME_OR_ACTIVE_PLAYERS -> describeTime(settings, progress) + " or " + describeActive(settings); + case TIME_AND_ACTIVE_PLAYERS -> describeTime(settings, progress) + " and " + describeActive(settings); + }; + } + + public int missedTimeExpansions(WorldRule rule, WorldProgress progress) { + TriggerSettings settings = rule.triggers(); + if (!settings.mode().usesTime() || settings.timeIntervalMillis() <= 0L || progress.lastExpansionAt() <= 0L) { + return 0; + } + long elapsed = System.currentTimeMillis() - progress.lastExpansionAt(); + if (elapsed < settings.timeIntervalMillis()) { + return 0; + } + return (int) Math.max(1L, elapsed / settings.timeIntervalMillis()); + } + + private boolean timeDue(TriggerSettings settings, WorldProgress progress) { + return settings.timeIntervalMillis() > 0L + && progress.lastExpansionAt() > 0L + && System.currentTimeMillis() >= progress.lastExpansionAt() + settings.timeIntervalMillis(); + } + + private boolean uniqueDue(TriggerSettings settings, WorldProgress progress) { + if (settings.uniquePlayersEvery() <= 0) { + return false; + } + return playerStatsManager.uniquePlayerCount() >= progress.uniquePlayersAtLastExpansion() + settings.uniquePlayersEvery(); + } + + private boolean onlineDue(TriggerSettings settings, WorldProgress progress) { + if (settings.onlinePlayersThreshold() <= 0) { + return false; + } + return playerStatsManager.onlinePlayerCount(settings.excludeVanished()) >= settings.onlinePlayersThreshold() + && playerCooldownPassed(settings, progress); + } + + private boolean activeDue(TriggerSettings settings, WorldProgress progress) { + if (settings.activePlayersThreshold() <= 0) { + return false; + } + return playerStatsManager.activePlayerCount(settings.activeWindowMillis()) >= settings.activePlayersThreshold() + && playerCooldownPassed(settings, progress); + } + + private boolean playerCooldownPassed(TriggerSettings settings, WorldProgress progress) { + return settings.playerTriggerCooldownMillis() <= 0L + || progress.lastExpansionAt() <= 0L + || System.currentTimeMillis() >= progress.lastExpansionAt() + settings.playerTriggerCooldownMillis(); + } + + private TriggerDecision decision(boolean due, ExpansionReason reason, String description) { + return due ? new TriggerDecision(true, reason, description) : TriggerDecision.none(description); + } + + private String describeTime(TriggerSettings settings, WorldProgress progress) { + long next = progress.lastExpansionAt() + settings.timeIntervalMillis(); + long remaining = next - System.currentTimeMillis(); + return remaining <= 0L ? "time due" : "time in " + TimeUtil.formatDuration(remaining); + } + + private String describeUnique(TriggerSettings settings, WorldProgress progress) { + int current = playerStatsManager.uniquePlayerCount(); + int needed = progress.uniquePlayersAtLastExpansion() + settings.uniquePlayersEvery(); + return "unique players " + current + "/" + needed; + } + + private String describeOnline(TriggerSettings settings) { + int current = playerStatsManager.onlinePlayerCount(settings.excludeVanished()); + return "online players " + current + "/" + settings.onlinePlayersThreshold(); + } + + private String describeActive(TriggerSettings settings) { + int current = playerStatsManager.activePlayerCount(settings.activeWindowMillis()); + return "active players " + current + "/" + settings.activePlayersThreshold(); + } +} diff --git a/src/main/java/com/dirtsmp/dirtsmp/service/WebhookNotifier.java b/src/main/java/com/dirtsmp/dirtsmp/service/WebhookNotifier.java new file mode 100644 index 0000000..e8f5505 --- /dev/null +++ b/src/main/java/com/dirtsmp/dirtsmp/service/WebhookNotifier.java @@ -0,0 +1,67 @@ +package com.dirtsmp.dirtsmp.service; + +import com.dirtsmp.dirtsmp.DirtSMPPlugin; +import com.dirtsmp.dirtsmp.config.ConfigManager; +import com.dirtsmp.dirtsmp.config.MessageManager; +import com.dirtsmp.dirtsmp.model.ExpansionReason; +import com.dirtsmp.dirtsmp.model.PhaseDefinition; +import com.dirtsmp.dirtsmp.model.WorldRule; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Map; +import java.util.logging.Level; +import org.bukkit.Bukkit; + +public final class WebhookNotifier { + private final DirtSMPPlugin plugin; + private final ConfigManager configManager; + private final MessageManager messageManager; + + public WebhookNotifier(DirtSMPPlugin plugin, ConfigManager configManager, MessageManager messageManager) { + this.plugin = plugin; + this.configManager = configManager; + this.messageManager = messageManager; + } + + public void notifyExpansion(WorldRule rule, double oldSize, double newSize, ExpansionReason reason, String actor, PhaseDefinition phase) { + if (!configManager.webhookEnabled() || configManager.webhookUrl().isBlank()) { + return; + } + + Map placeholders = messageManager.basePlaceholders(rule, oldSize, newSize, reason, actor, phase); + String content = messageManager.applyPlaceholders(configManager.webhookContent(), placeholders); + String body = "{\"content\":\"" + escapeJson(content) + "\"}"; + String url = configManager.webhookUrl(); + int timeoutSeconds = configManager.webhookTimeoutSeconds(); + + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { + try { + HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(timeoutSeconds)) + .build(); + HttpRequest request = HttpRequest.newBuilder(URI.create(url)) + .timeout(Duration.ofSeconds(timeoutSeconds)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + plugin.getLogger().warning("Webhook returned HTTP " + response.statusCode() + "."); + } + } catch (Exception ex) { + plugin.getLogger().log(Level.WARNING, "Could not send DirtSMP webhook.", ex); + } + }); + } + + private String escapeJson(String input) { + return input + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r"); + } +} diff --git a/src/main/java/com/dirtsmp/dirtsmp/state/PlayerStatsManager.java b/src/main/java/com/dirtsmp/dirtsmp/state/PlayerStatsManager.java new file mode 100644 index 0000000..b0f719b --- /dev/null +++ b/src/main/java/com/dirtsmp/dirtsmp/state/PlayerStatsManager.java @@ -0,0 +1,144 @@ +package com.dirtsmp.dirtsmp.state; + +import com.dirtsmp.dirtsmp.DirtSMPPlugin; +import java.io.File; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.logging.Level; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.metadata.MetadataValue; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; + +public final class PlayerStatsManager { + private final DirtSMPPlugin plugin; + private final Map players = new HashMap<>(); + private File file; + + public PlayerStatsManager(DirtSMPPlugin plugin) { + this.plugin = plugin; + } + + public void load() { + file = new File(plugin.getDataFolder(), "players.yml"); + if (!file.exists()) { + try { + file.createNewFile(); + } catch (IOException ex) { + plugin.getLogger().log(Level.WARNING, "Could not create players.yml.", ex); + } + } + + players.clear(); + YamlConfiguration config = YamlConfiguration.loadConfiguration(file); + ConfigurationSection section = config.getConfigurationSection("players"); + if (section != null) { + for (String rawUuid : section.getKeys(false)) { + try { + UUID uuid = UUID.fromString(rawUuid); + String path = "players." + rawUuid + "."; + players.put(uuid, new PlayerRecord( + config.getString(path + "name", "unknown"), + config.getLong(path + "first-seen", 0L), + config.getLong(path + "last-seen", 0L) + )); + } catch (IllegalArgumentException ignored) { + plugin.getLogger().warning("Ignoring invalid UUID in players.yml: " + rawUuid); + } + } + } + + Bukkit.getOnlinePlayers().forEach(this::markSeen); + } + + public void markSeen(Player player) { + long now = System.currentTimeMillis(); + players.compute(player.getUniqueId(), (uuid, existing) -> { + if (existing == null) { + return new PlayerRecord(player.getName(), now, now); + } + existing.name = player.getName(); + existing.lastSeen = now; + if (existing.firstSeen <= 0L) { + existing.firstSeen = now; + } + return existing; + }); + } + + public int uniquePlayerCount() { + return players.size(); + } + + public int onlinePlayerCount(boolean excludeVanished) { + int count = 0; + for (Player player : Bukkit.getOnlinePlayers()) { + if (!excludeVanished || !isVanished(player)) { + count++; + } + } + return count; + } + + public int activePlayerCount(long windowMillis) { + long cutoff = System.currentTimeMillis() - Math.max(0L, windowMillis); + int count = 0; + for (PlayerRecord record : players.values()) { + if (record.lastSeen >= cutoff) { + count++; + } + } + return count; + } + + private boolean isVanished(Player player) { + for (MetadataValue value : player.getMetadata("vanished")) { + if (value.asBoolean()) { + return true; + } + } + return false; + } + + public void saveQuietly() { + try { + save(); + } catch (IOException ex) { + plugin.getLogger().log(Level.WARNING, "Could not save player stats.", ex); + } + } + + public void save() throws IOException { + if (file == null) { + file = new File(plugin.getDataFolder(), "players.yml"); + } + YamlConfiguration config = new YamlConfiguration(); + for (Map.Entry entry : players.entrySet()) { + String path = "players." + entry.getKey() + "."; + PlayerRecord record = entry.getValue(); + config.set(path + "name", record.name); + config.set(path + "first-seen", record.firstSeen); + config.set(path + "last-seen", record.lastSeen); + } + config.save(file); + } + + public void clearRuntimeState() { + players.clear(); + } + + private static final class PlayerRecord { + private String name; + private long firstSeen; + private long lastSeen; + + private PlayerRecord(String name, long firstSeen, long lastSeen) { + this.name = name; + this.firstSeen = firstSeen; + this.lastSeen = lastSeen; + } + } +} diff --git a/src/main/java/com/dirtsmp/dirtsmp/state/StateManager.java b/src/main/java/com/dirtsmp/dirtsmp/state/StateManager.java new file mode 100644 index 0000000..21fc51f --- /dev/null +++ b/src/main/java/com/dirtsmp/dirtsmp/state/StateManager.java @@ -0,0 +1,205 @@ +package com.dirtsmp.dirtsmp.state; + +import com.dirtsmp.dirtsmp.DirtSMPPlugin; +import com.dirtsmp.dirtsmp.config.ConfigManager; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; +import java.util.logging.Level; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; + +public final class StateManager { + private static final DateTimeFormatter BACKUP_FORMAT = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"); + + private final DirtSMPPlugin plugin; + private final ConfigManager configManager; + private final Map progressByWorld = new HashMap<>(); + private final Map legacyUsers = new LinkedHashMap<>(); + private File stateFile; + + public StateManager(DirtSMPPlugin plugin, ConfigManager configManager) { + this.plugin = plugin; + this.configManager = configManager; + } + + public void load() { + stateFile = new File(plugin.getDataFolder(), configManager.stateFileName()); + if (!stateFile.exists()) { + try { + stateFile.createNewFile(); + } catch (IOException ex) { + plugin.getLogger().log(Level.WARNING, "Could not create state file " + stateFile.getName(), ex); + } + } + + progressByWorld.clear(); + legacyUsers.clear(); + YamlConfiguration state = YamlConfiguration.loadConfiguration(stateFile); + ConfigurationSection worlds = state.getConfigurationSection("worlds"); + if (worlds != null) { + for (String key : worlds.getKeys(false)) { + ConfigurationSection section = worlds.getConfigurationSection(key); + if (section == null) { + continue; + } + WorldProgress progress = new WorldProgress(); + progress.initialized(section.getBoolean("initialized", false)); + progress.currentSize(section.getDouble("current-size", 0.0)); + progress.expansionCount(section.getInt("expansion-count", 0)); + progress.currentPhaseIndex(section.getInt("current-phase-index", 0)); + progress.paused(section.getBoolean("paused", false)); + progress.lastExpansionAt(section.getLong("last-expansion-at", 0L)); + progress.uniquePlayersAtLastExpansion(section.getInt("unique-players-at-last-expansion", 0)); + progress.sentReminders(new HashSet<>(section.getStringList("sent-reminders"))); + progressByWorld.put(key.toLowerCase(), progress); + } + } + + ConfigurationSection legacySection = state.getConfigurationSection("legacy-users"); + if (legacySection != null) { + for (String rawUuid : legacySection.getKeys(false)) { + try { + UUID uuid = UUID.fromString(rawUuid); + String path = "legacy-users." + rawUuid + "."; + legacyUsers.put(uuid, new LegacyUserRecord( + state.getString(path + "name", "unknown"), + state.getLong(path + "playtime-ticks", 0L), + state.getLong(path + "added-at", 0L), + state.getString(path + "source", "unknown") + )); + } catch (IllegalArgumentException ex) { + plugin.getLogger().warning("Ignoring invalid legacy user UUID in state.yml: " + rawUuid); + } + } + } + } + + public WorldProgress progress(String key) { + return progressByWorld.computeIfAbsent(key.toLowerCase(), ignored -> new WorldProgress()); + } + + public Map allProgress() { + return progressByWorld; + } + + public boolean isLegacyUser(UUID uuid) { + return legacyUsers.containsKey(uuid); + } + + public Map legacyUsers() { + return legacyUsers; + } + + public boolean addLegacyUser(UUID uuid, String name, long playtimeTicks, String source) { + boolean added = !legacyUsers.containsKey(uuid); + legacyUsers.put(uuid, new LegacyUserRecord( + name == null || name.isBlank() ? uuid.toString() : name, + Math.max(0L, playtimeTicks), + System.currentTimeMillis(), + source == null || source.isBlank() ? "manual" : source + )); + return added; + } + + public boolean removeLegacyUser(UUID uuid) { + return legacyUsers.remove(uuid) != null; + } + + public void saveQuietly() { + try { + save(); + } catch (IOException ex) { + plugin.getLogger().log(Level.WARNING, "Could not save DirtSMP state.", ex); + } + } + + public void save() throws IOException { + if (stateFile == null) { + stateFile = new File(plugin.getDataFolder(), configManager.stateFileName()); + } + if (configManager.backupStateOnSave() && stateFile.exists() && stateFile.length() > 0L) { + backupState(); + } + + YamlConfiguration state = new YamlConfiguration(); + for (Map.Entry entry : progressByWorld.entrySet()) { + String path = "worlds." + entry.getKey() + "."; + WorldProgress progress = entry.getValue(); + state.set(path + "initialized", progress.initialized()); + state.set(path + "current-size", progress.currentSize()); + state.set(path + "expansion-count", progress.expansionCount()); + state.set(path + "current-phase-index", progress.currentPhaseIndex()); + state.set(path + "paused", progress.paused()); + state.set(path + "last-expansion-at", progress.lastExpansionAt()); + state.set(path + "unique-players-at-last-expansion", progress.uniquePlayersAtLastExpansion()); + state.set(path + "sent-reminders", progress.sentReminders().stream().sorted().toList()); + } + for (Map.Entry entry : legacyUsers.entrySet()) { + String path = "legacy-users." + entry.getKey() + "."; + LegacyUserRecord record = entry.getValue(); + state.set(path + "name", record.name()); + state.set(path + "playtime-ticks", record.playtimeTicks()); + state.set(path + "added-at", record.addedAt()); + state.set(path + "source", record.source()); + } + state.save(stateFile); + } + + private void backupState() { + int keep = configManager.backupKeep(); + if (keep <= 0) { + return; + } + + File backupDirectory = new File(plugin.getDataFolder(), "backups"); + if (!backupDirectory.exists() && !backupDirectory.mkdirs()) { + plugin.getLogger().warning("Could not create backups directory for state file backups."); + return; + } + + File backup = new File(backupDirectory, "state-" + LocalDateTime.now().format(BACKUP_FORMAT) + ".yml"); + try { + Files.copy(stateFile.toPath(), backup.toPath(), StandardCopyOption.REPLACE_EXISTING); + pruneBackups(backupDirectory, keep); + } catch (IOException ex) { + plugin.getLogger().log(Level.WARNING, "Could not back up state file.", ex); + } + } + + private void pruneBackups(File backupDirectory, int keep) throws IOException { + File[] files = backupDirectory.listFiles((dir, name) -> name.startsWith("state-") && name.endsWith(".yml")); + if (files == null || files.length <= keep) { + return; + } + + for (File file : java.util.Arrays.stream(files) + .sorted(Comparator.comparingLong(File::lastModified).reversed()) + .skip(keep) + .toList()) { + Files.deleteIfExists(file.toPath()); + } + } + + public void clearRuntimeState() { + progressByWorld.clear(); + legacyUsers.clear(); + } + + public record LegacyUserRecord( + String name, + long playtimeTicks, + long addedAt, + String source + ) { + } +} diff --git a/src/main/java/com/dirtsmp/dirtsmp/state/WorldProgress.java b/src/main/java/com/dirtsmp/dirtsmp/state/WorldProgress.java new file mode 100644 index 0000000..201bd96 --- /dev/null +++ b/src/main/java/com/dirtsmp/dirtsmp/state/WorldProgress.java @@ -0,0 +1,87 @@ +package com.dirtsmp.dirtsmp.state; + +import java.util.HashSet; +import java.util.Set; + +public final class WorldProgress { + private boolean initialized; + private double currentSize; + private int expansionCount; + private int currentPhaseIndex; + private boolean paused; + private long lastExpansionAt; + private int uniquePlayersAtLastExpansion; + private Set sentReminders = new HashSet<>(); + + public boolean initialized() { + return initialized; + } + + public void initialized(boolean initialized) { + this.initialized = initialized; + } + + public double currentSize() { + return currentSize; + } + + public void currentSize(double currentSize) { + this.currentSize = currentSize; + } + + public int expansionCount() { + return expansionCount; + } + + public void expansionCount(int expansionCount) { + this.expansionCount = expansionCount; + } + + public int currentPhaseIndex() { + return currentPhaseIndex; + } + + public void currentPhaseIndex(int currentPhaseIndex) { + this.currentPhaseIndex = currentPhaseIndex; + } + + public boolean paused() { + return paused; + } + + public void paused(boolean paused) { + this.paused = paused; + } + + public long lastExpansionAt() { + return lastExpansionAt; + } + + public void lastExpansionAt(long lastExpansionAt) { + this.lastExpansionAt = lastExpansionAt; + } + + public int uniquePlayersAtLastExpansion() { + return uniquePlayersAtLastExpansion; + } + + public void uniquePlayersAtLastExpansion(int uniquePlayersAtLastExpansion) { + this.uniquePlayersAtLastExpansion = uniquePlayersAtLastExpansion; + } + + public Set sentReminders() { + return sentReminders; + } + + public void sentReminders(Set sentReminders) { + this.sentReminders = new HashSet<>(sentReminders); + } + + public boolean markReminderSent(String reminderKey) { + return sentReminders.add(reminderKey); + } + + public void clearReminders() { + sentReminders.clear(); + } +} diff --git a/src/main/java/com/dirtsmp/dirtsmp/task/ScheduleManager.java b/src/main/java/com/dirtsmp/dirtsmp/task/ScheduleManager.java new file mode 100644 index 0000000..7d2bafe --- /dev/null +++ b/src/main/java/com/dirtsmp/dirtsmp/task/ScheduleManager.java @@ -0,0 +1,144 @@ +package com.dirtsmp.dirtsmp.task; + +import com.dirtsmp.dirtsmp.DirtSMPPlugin; +import com.dirtsmp.dirtsmp.config.ConfigManager; +import com.dirtsmp.dirtsmp.config.MessageManager; +import com.dirtsmp.dirtsmp.model.CatchUpMode; +import com.dirtsmp.dirtsmp.model.ExpansionReason; +import com.dirtsmp.dirtsmp.model.TriggerDecision; +import com.dirtsmp.dirtsmp.model.TriggerMode; +import com.dirtsmp.dirtsmp.model.WorldRule; +import com.dirtsmp.dirtsmp.service.BorderManager; +import com.dirtsmp.dirtsmp.service.TriggerEvaluator; +import com.dirtsmp.dirtsmp.state.PlayerStatsManager; +import com.dirtsmp.dirtsmp.state.StateManager; +import com.dirtsmp.dirtsmp.state.WorldProgress; +import org.bukkit.Bukkit; +import org.bukkit.scheduler.BukkitTask; + +public final class ScheduleManager { + private final DirtSMPPlugin plugin; + private final ConfigManager configManager; + private final StateManager stateManager; + private final PlayerStatsManager playerStatsManager; + private final BorderManager borderManager; + private final TriggerEvaluator triggerEvaluator; + private final MessageManager messageManager; + private BukkitTask pollTask; + private BukkitTask saveTask; + + public ScheduleManager( + DirtSMPPlugin plugin, + ConfigManager configManager, + StateManager stateManager, + PlayerStatsManager playerStatsManager, + BorderManager borderManager, + TriggerEvaluator triggerEvaluator, + MessageManager messageManager + ) { + this.plugin = plugin; + this.configManager = configManager; + this.stateManager = stateManager; + this.playerStatsManager = playerStatsManager; + this.borderManager = borderManager; + this.triggerEvaluator = triggerEvaluator; + this.messageManager = messageManager; + } + + public void start() { + stop(); + applyCatchUp(); + pollTask = Bukkit.getScheduler().runTaskTimer(plugin, this::tick, configManager.pollIntervalTicks(), configManager.pollIntervalTicks()); + saveTask = Bukkit.getScheduler().runTaskTimer(plugin, () -> { + stateManager.saveQuietly(); + playerStatsManager.saveQuietly(); + }, configManager.saveIntervalTicks(), configManager.saveIntervalTicks()); + } + + public void stop() { + if (pollTask != null) { + pollTask.cancel(); + pollTask = null; + } + if (saveTask != null) { + saveTask.cancel(); + saveTask = null; + } + } + + public void tick() { + Bukkit.getOnlinePlayers().forEach(playerStatsManager::markSeen); + handleReminders(); + handleTriggers(); + } + + private void handleTriggers() { + for (WorldRule rule : configManager.worlds().values()) { + WorldProgress progress = stateManager.progress(rule.key()); + TriggerDecision decision = triggerEvaluator.evaluate(rule, progress); + if (decision.due()) { + borderManager.expand(rule, decision.reason(), "automatic", false); + } + } + } + + private void handleReminders() { + long now = System.currentTimeMillis(); + for (WorldRule rule : configManager.worlds().values()) { + if (!rule.enabled() || !rule.reminders().enabled() || !rule.triggers().mode().usesTime()) { + continue; + } + WorldProgress progress = stateManager.progress(rule.key()); + if (progress.paused() || progress.lastExpansionAt() <= 0L || rule.triggers().timeIntervalMillis() <= 0L) { + continue; + } + + long next = progress.lastExpansionAt() + rule.triggers().timeIntervalMillis(); + if (now >= next) { + continue; + } + + for (long reminderMillis : rule.reminders().beforeMillis()) { + long reminderAt = next - reminderMillis; + String key = next + ":" + reminderMillis; + if (now >= reminderAt && progress.markReminderSent(key)) { + messageManager.announceReminder(rule, next - now); + stateManager.saveQuietly(); + } + } + } + } + + private void applyCatchUp() { + for (WorldRule rule : configManager.worlds().values()) { + WorldProgress progress = stateManager.progress(rule.key()); + if (!rule.enabled() || progress.paused() || rule.manualOnly() || rule.triggers().mode() == TriggerMode.MANUAL) { + continue; + } + if (rule.triggers().catchUpMode() == CatchUpMode.NONE) { + continue; + } + + TriggerDecision decision = triggerEvaluator.evaluate(rule, progress); + if (!decision.due() || !rule.triggers().mode().usesTime()) { + continue; + } + + int missed = triggerEvaluator.missedTimeExpansions(rule, progress); + if (missed <= 0) { + continue; + } + + int amount = 1; + if (rule.triggers().catchUpMode() == CatchUpMode.ALL && rule.triggers().mode() == TriggerMode.TIME) { + amount = Math.min(missed, configManager.maxCatchupExpansionsPerWorld()); + } + + for (int index = 0; index < amount; index++) { + if (!borderManager.expand(rule, ExpansionReason.CATCH_UP, "startup", false).success()) { + break; + } + } + } + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml new file mode 100644 index 0000000..6f8e428 --- /dev/null +++ b/src/main/resources/config.yml @@ -0,0 +1,414 @@ +# DirtbagMC progression configuration +# +# Size terminology: +# Every border size in this file is a FULL DIAMETER / WIDTH in blocks, matching Bukkit/Paper's WorldBorder size. +# Example: starting-size: 50000 creates a border that is 50,000 blocks wide, roughly 25,000 blocks from center to edge. +# +# Growth modes: +# INCREMENTAL - each expansion adds growth-amount to the current full diameter. +# PHASE - each expansion moves to the next exact size listed under phases. +# +# Border modes: +# WORLD_BORDER - use Minecraft/Paper's real WorldBorder. +# SOFT_BORDER - do not enforce the vanilla border; bounce players off an invisible DirtbagMC border with effects. +# +# Trigger modes: +# MANUAL, TIME, UNIQUE_PLAYERS, ONLINE_PLAYERS, ACTIVE_PLAYERS, +# TIME_OR_UNIQUE_PLAYERS, TIME_AND_UNIQUE_PLAYERS, +# TIME_OR_ONLINE_PLAYERS, TIME_AND_ONLINE_PLAYERS, +# TIME_OR_ACTIVE_PLAYERS, TIME_AND_ACTIVE_PLAYERS. + +settings: + dry-run: false + debug: false + poll-interval-seconds: 30 + apply-borders-on-startup: true + max-catchup-expansions-per-world: 20 + +state: + file-name: state.yml + save-interval-seconds: 300 + backup-on-save: true + backup-keep: 10 + +legacy-users: + # Admin scan command: + # /dirtsmp legacy scan + # Also supported: + # /dirtsmp add legacy users scan + # Players with at least this much Minecraft playtime are added to state.yml under legacy-users. + minimum-playtime: 1h + +history: + enabled: true + file-name: history.log + +gui: + enabled: true + title: "A2416&lᴅA2D1B&lɪA351F&lʀA3F24&lᴛA4A2A&lʙB6B35&lᴀ&#A8873F&lɢ&#D4AF37&lᴍ&#B9C63F&lᴄ &8| &#D4AF37&lBorders" + +webhook: + enabled: false + url: "" + timeout-seconds: 8 + content: "**DirtbagMC progression:** `{world}` expanded from `{old_size}` to `{new_size}` blocks. Reason: `{reason}`" + +command-hooks: + enabled: true + before-expansion: + # - "say Preparing expansion for {world} to {new_size}" + after-expansion: + # Useful for tools like Chunky: + # - "chunky world {world}" + # - "chunky border" + # - "chunky start" + +milestone-rewards: + enabled: true + # Commands run from console after an expansion reaches a configured milestone. + # Placeholders: {world}, {key}, {old_size}, {new_size}, {max_size}, {reason}, {actor}, {phase}, {phase_name}, {expansion_count} + every-expansion: [] + by-expansion-count: + # Example rewards: + # 1: + # - "broadcast DirtbagMC milestone 1 reached in {world}!" + # - "crate key giveall beta 1" + 1: [] + 3: [] + 5: [] + by-phase-index: + 1: [] + by-phase-name: + Frontier Era: [] + First Ring: [] + +worlds: + overworld: + enabled: true + world: world + center-x: 0.0 + center-z: 0.0 + starting-size: 50000 + border-mode: SOFT_BORDER + growth-mode: INCREMENTAL + growth-amount: 10000 + max-size: 200000 + transition-seconds: 300 + manual-only: false + import-current-border: false + no-shrink-protection: true + enforce-max-size: true + command-hooks: + before-expansion: [] + after-expansion: [] + milestone-rewards: + enabled: true + every-expansion: [] + by-expansion-count: + 1: [] + 2: [] + 3: [] + by-phase-index: {} + by-phase-name: {} + legacy-access: + enabled: true + # This protects beta builds from being stranded when progression starts after launch. + # The plugin expands the initial/saved border enough to include known locations plus padding. + include-current-border: false + include-online-players: true + include-offline-last-locations: true + include-respawn-locations: true + # When true, player last/respawn locations only count if the player is in the scanned legacy-users list. + player-locations-require-legacy-user: true + reconcile-on-startup: true + allow-start-above-max-size: true + padding: 1024 + # Add homes, towns, bases, or Essentials homes that cannot be discovered from Paper playerdata. + # Border math uses full diameter/width, centered on center-x/center-z. + locations: [] + # Example: + # locations: + # - name: "Beta town" + # x: 31500 + # z: -22400 + soft-border: + # Premium invisible border mode. Used only when border-mode is SOFT_BORDER. + # release-vanilla-border removes any old vanilla border enforcement by setting it to vanilla-border-size. + release-vanilla-border: true + vanilla-border-size: 59999968 + ignore-creative: false + ignore-spectator: true + bypass-permission: dirtsmp.bypass.softborder + inside-buffer: 1.5 + bounce-strength: 1.85 + vertical-boost: 0.35 + protect-mounted-entities: true + # Gentler values keep horses, camels, boats, and minecarts from taking weird launch angles. + mounted-bounce-strength: 1.15 + mounted-vertical-boost: 0.12 + max-outside-distance-before-teleport: 24 + cooldown: 900ms + message: "{prefix}&#D4AF37&lThe DirtbagMC border &7throws you back." + action-bar: "&#D4AF37&lBorder reached &8| &7turn back" + sound: ENTITY_SLIME_JUMP + volume: 0.65 + pitch: 0.65 + particles: + enabled: true + # DUST uses the DirtbagMC gold color. Other Bukkit particle names also work. + particle: DUST + count: 3 + offset: 0.05 + speed: 0.0 + interval-ticks: 10 + view-distance: 96 + spacing: 3 + broadcasts: + enabled: true + world-only: false + message: "{prefix}&#B9C63FThe dirt expands. &#D4AF37&l{world} &#B9C63Fhas grown from &#A8873F{old_size} &#B9C63Fto &#D4AF37&l{new_size} &#B9C63Fblocks." + title: "A2416&lᴅA2D1B&lɪA351F&lʀA3F24&lᴛA4A2A&lʙB6B35&lᴀ&#A8873F&lɢ&#D4AF37&lᴍ&#B9C63F&lᴄ" + subtitle: "&#B9C63F{world} &8| &#D4AF37{old_size} &7-> &#D4AF37{new_size}" + action-bar: "&#D4AF37&l{world} &7border is now &#B9C63F{new_size} &7blocks wide" + sound: ENTITY_ENDER_DRAGON_GROWL + volume: 0.7 + pitch: 1.25 + reminders: + enabled: true + before: + - 1d + - 12h + - 1h + - 10m + message: "{prefix}&#D4AF37&l{world} &7expands in &#B9C63F{time_left}&7." + action-bar: "&#D4AF37&l{world} &7expands in &#B9C63F{time_left}" + sound: BLOCK_NOTE_BLOCK_PLING + volume: 0.45 + pitch: 1.4 + triggers: + mode: TIME_OR_UNIQUE_PLAYERS + time-interval: 7d + unique-players-every: 50 + online-players-threshold: 35 + active-players-threshold: 120 + active-window: 7d + exclude-vanished: true + player-trigger-cooldown: 12h + catch-up: ONE + phases: + - name: Spawn Era + size: 50000 + message: "" + - name: Frontier Era + size: 75000 + message: "" + + end: + enabled: true + world: world_the_end + center-x: 0.0 + center-z: 0.0 + starting-size: 2000 + border-mode: SOFT_BORDER + growth-mode: PHASE + growth-amount: 2000 + max-size: 20000 + transition-seconds: 180 + manual-only: false + import-current-border: false + no-shrink-protection: true + enforce-max-size: true + command-hooks: + before-expansion: [] + after-expansion: [] + milestone-rewards: + enabled: true + every-expansion: [] + by-expansion-count: + 1: [] + 2: [] + by-phase-index: + 1: [] + 2: [] + by-phase-name: + First Ring: [] + Chorus Frontier: [] + legacy-access: + enabled: true + include-current-border: false + include-online-players: true + include-offline-last-locations: true + include-respawn-locations: true + player-locations-require-legacy-user: true + reconcile-on-startup: true + allow-start-above-max-size: true + padding: 512 + locations: [] + soft-border: + release-vanilla-border: true + vanilla-border-size: 59999968 + ignore-creative: false + ignore-spectator: true + bypass-permission: dirtsmp.bypass.softborder + inside-buffer: 1.5 + bounce-strength: 2.05 + vertical-boost: 0.45 + protect-mounted-entities: true + mounted-bounce-strength: 1.2 + mounted-vertical-boost: 0.12 + max-outside-distance-before-teleport: 18 + cooldown: 900ms + message: "{prefix}A2416&lThe End border &7snaps you back." + action-bar: "A2416&lThe End border &8| &7turn back" + sound: ENTITY_ENDERMAN_TELEPORT + volume: 0.55 + pitch: 0.8 + particles: + enabled: true + particle: DUST + count: 3 + offset: 0.05 + speed: 0.0 + interval-ticks: 10 + view-distance: 72 + spacing: 2.5 + broadcasts: + enabled: true + world-only: false + message: "{prefix}A2416&lThe End &#B9C63Fhas entered &#D4AF37&l{phase_name}&#B9C63F: &#D4AF37{new_size} &#B9C63Fblocks wide." + title: "A2416&lThe End Opens" + subtitle: "&#D4AF37{phase_name} &8| &#B9C63F{new_size} blocks" + action-bar: "A2416&lThe End &7border is now &#D4AF37{new_size}" + sound: ENTITY_ENDER_DRAGON_DEATH + volume: 0.8 + pitch: 1.0 + reminders: + enabled: true + before: + - 12h + - 1h + - 10m + message: "{prefix}A2416&lThe End &7expands in &#D4AF37{time_left}&7." + action-bar: "A2416&lThe End &7expands in &#D4AF37{time_left}" + sound: BLOCK_NOTE_BLOCK_PLING + volume: 0.45 + pitch: 1.6 + triggers: + mode: TIME_AND_UNIQUE_PLAYERS + time-interval: 10d + unique-players-every: 100 + online-players-threshold: 40 + active-players-threshold: 150 + active-window: 7d + exclude-vanished: true + player-trigger-cooldown: 12h + catch-up: ONE + phases: + - name: Outer Silence + size: 2000 + message: "" + - name: First Ring + size: 4000 + message: "{prefix}A2416&lThe End &#B9C63Fstirs. &#D4AF37The first outer ring &#B9C63Fis now reachable." + - name: Chorus Frontier + size: 6000 + message: "{prefix}A2416&lThe End &#B9C63Fgrows again. &#D4AF37New islands await." + - name: Dragon's Wake + size: 8000 + message: "{prefix}A2416&lThe End &#B9C63Fborder has expanded to &#D4AF37{new_size} &#B9C63Fblocks." + + nether: + enabled: false + world: world_nether + center-x: 0.0 + center-z: 0.0 + starting-size: 10000 + border-mode: SOFT_BORDER + growth-mode: INCREMENTAL + growth-amount: 2500 + max-size: 50000 + transition-seconds: 120 + manual-only: false + import-current-border: false + no-shrink-protection: true + enforce-max-size: true + command-hooks: + before-expansion: [] + after-expansion: [] + milestone-rewards: + enabled: true + every-expansion: [] + by-expansion-count: + 1: [] + by-phase-index: {} + by-phase-name: {} + legacy-access: + enabled: false + include-current-border: false + include-online-players: true + include-offline-last-locations: true + include-respawn-locations: true + player-locations-require-legacy-user: true + reconcile-on-startup: true + allow-start-above-max-size: true + padding: 512 + locations: [] + soft-border: + release-vanilla-border: true + vanilla-border-size: 59999968 + ignore-creative: false + ignore-spectator: true + bypass-permission: dirtsmp.bypass.softborder + inside-buffer: 1.5 + bounce-strength: 1.9 + vertical-boost: 0.35 + protect-mounted-entities: true + mounted-bounce-strength: 1.15 + mounted-vertical-boost: 0.12 + max-outside-distance-before-teleport: 18 + cooldown: 900ms + message: "{prefix}A4A2A&lThe Nether border &7throws you back." + action-bar: "A4A2A&lNether border &8| &7turn back" + sound: ENTITY_BLAZE_HURT + volume: 0.55 + pitch: 0.8 + particles: + enabled: true + particle: DUST + count: 3 + offset: 0.05 + speed: 0.0 + interval-ticks: 10 + view-distance: 72 + spacing: 2.5 + broadcasts: + enabled: true + world-only: false + message: "{prefix}A4A2A&lThe Nether &#B9C63Fhas expanded to &#D4AF37{new_size} &#B9C63Fblocks." + title: "A4A2A&lNether Expanded" + subtitle: "&#A8873F{old_size} &7-> &#D4AF37{new_size}" + action-bar: "A4A2A&lThe Nether &7border is now &#D4AF37{new_size}" + sound: ENTITY_WITHER_SPAWN + volume: 0.65 + pitch: 1.1 + reminders: + enabled: true + before: + - 1h + - 10m + message: "{prefix}A4A2A&lThe Nether &7expands in &#D4AF37{time_left}&7." + action-bar: "A4A2A&lThe Nether &7expands in &#D4AF37{time_left}" + sound: BLOCK_NOTE_BLOCK_PLING + volume: 0.45 + pitch: 1.2 + triggers: + mode: MANUAL + time-interval: 7d + unique-players-every: 50 + online-players-threshold: 30 + active-players-threshold: 100 + active-window: 7d + exclude-vanished: true + player-trigger-cooldown: 12h + catch-up: NONE + phases: [] diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml new file mode 100644 index 0000000..5dc69ed --- /dev/null +++ b/src/main/resources/messages.yml @@ -0,0 +1,76 @@ +prefix: "A2416&lᴅA2D1B&lɪA351F&lʀA3F24&lᴛA4A2A&lʙB6B35&lᴀ&#A8873F&lɢ&#D4AF37&lᴍ&#B9C63F&lᴄ &8| &7" + +commands: + no-permission: "{prefix}&cYou do not have permission to do that." + player-only: "{prefix}&cOnly players can use that command." + unknown-world: "{prefix}&cUnknown configured world: &#D4AF37{world}" + invalid-number: "{prefix}&cThat size must be a positive number." + reload: "{prefix}&#B9C63FConfiguration, messages, and DirtbagMC progression rules reloaded." + saved: "{prefix}&#B9C63FState saved." + help: + - "A2416&lᴅA2D1B&lɪA351F&lʀA3F24&lᴛA4A2A&lʙB6B35&lᴀ&#A8873F&lɢ&#D4AF37&lᴍ&#B9C63F&lᴄ &8| &#D4AF37&lBorder Progression" + - "B6B35/dirtsmp status [world] &8- &7view progression" + - "B6B35/dirtsmp next [world] &8- &7view upcoming triggers" + - "B6B35/dirtsmp legacycheck &8- &7scan beta home safety" + - "B6B35/dirtsmp tpborder [side] &8- &7teleport near a border wall" + - "B6B35/dirtsmp legacy scan [1h] &8- &7mark legacy players by playtime" + - "B6B35/dirtsmp legacy list &8- &7show marked legacy players" + - "B6B35/dirtsmp expand &8- &7expand now" + - "B6B35/dirtsmp setsize [force] &8- &7set border size" + - "B6B35/dirtsmp pause &8- &7pause automatic progression" + - "B6B35/dirtsmp resume &8- &7resume automatic progression" + - "B6B35/dirtsmp reset [force] &8- &7reset progression" + - "B6B35/dirtsmp gui &8- &7open admin panel" + - "B6B35/dirtsmp reload &8- &7reload config" + status-header: "{prefix}&#B9C63FTracking &#D4AF37&l{count} &#B9C63Fconfigured world(s)." + status-line: "&8- &#D4AF37&l{key} &8(&7{world}&8) &7enabled: &#B9C63F{enabled} &7size: &#B9C63F{size} &7next: &#D4AF37{next_size} &7mode: &#A8873F{border_mode} &7phase: &#A8873F{phase} &7paused: &c{paused}" + next-line: "{prefix}&#D4AF37&l{world} &7next trigger: &#B9C63F{trigger}&7. Next size: &#D4AF37{next_size}&7." + legacy-check: "{prefix}&#D4AF37&l{world} &7legacy scan: &#B9C63F{locations} &7location(s), required size &#D4AF37{required_size}&7, recommended startup size &#B9C63F{recommended_size}&7. Farthest: &#A8873F{farthest}&7." + legacy-scan: "{prefix}&#B9C63FScanned &#D4AF37{scanned} &#B9C63Fplayer(s). Matched &#D4AF37{matched} &#B9C63Fat &#D4AF37{minimum}&#B9C63F+. Added &#D4AF37{added}&#B9C63F. Legacy total: &#D4AF37{total}&7." + legacy-list-header: "{prefix}&#B9C63FLegacy users: &#D4AF37{count}&7. Showing up to 20." + legacy-list-line: "&8- &#D4AF37{name} &7playtime: &#B9C63F{playtime} &7source: &#A8873F{source}" + legacy-add: "{prefix}&#D4AF37{name} &7was &#B9C63F{status} &7as a legacy user." + legacy-remove: "{prefix}&#D4AF37{name} &7legacy status: &#B9C63F{status}&7." + tp-border: "{prefix}&#B9C63FTeleported to the &#D4AF37{side} &#B9C63Fborder wall for &#D4AF37{world} &8(&7size {size}&8)&7." + tp-border-failed: "{prefix}&cCould not teleport to &#D4AF37{world}&c border: &7{reason}" + expanded: "{prefix}&#B9C63FExpanded &#D4AF37&l{world} &#B9C63Ffrom &#A8873F{old_size} &#B9C63Fto &#D4AF37{new_size}&7." + expand-failed: "{prefix}&cCould not expand &#D4AF37{world}&c: &7{reason}" + set-size: "{prefix}&#B9C63FSet &#D4AF37&l{world} &#B9C63Fborder size to &#D4AF37{new_size}&7." + set-size-failed: "{prefix}&cCould not set &#D4AF37{world}&c: &7{reason}" + paused: "{prefix}&#D4AF37&l{world} &7automatic progression is now paused." + resumed: "{prefix}&#B9C63F{world} automatic progression resumed." + reset: "{prefix}&#B9C63FReset progression for &#D4AF37&l{world}&7." + gui-disabled: "{prefix}&cThe admin GUI is disabled in config." + +expansion: + default-message: "{prefix}&#B9C63F{world} expanded from &#A8873F{old_size} &#B9C63Fto &#D4AF37&l{new_size} &#B9C63Fblocks!" + default-title: "A2416&lDirtbagMC" + default-subtitle: "&#B9C63F{world} &8| &#D4AF37{old_size} &7-> &#D4AF37{new_size}" + default-action-bar: "&#B9C63F{world} &7is now &#D4AF37&l{new_size} &#B9C63Fblocks wide" + max-reached: "The world is already at its configured maximum or final phase." + paused: "Automatic progression is paused." + manual-only: "This world is configured for manual-only progression." + missing-world: "The Bukkit world is not loaded or does not exist." + dry-run: "Dry-run mode is enabled; no border was changed." + no-shrink: "No-shrink protection blocked a smaller size. Add 'force' to the command to override." + +reminders: + default-message: "{prefix}&#D4AF37&l{world} &7expands in &#B9C63F{time_left}&7." + default-action-bar: "&#D4AF37&l{world} &7expands in &#B9C63F{time_left}" + +gui: + world-name: "&#D4AF37&l{key}" + world-lore: + - "&7World: &#D4AF37{world}" + - "&7Enabled: &#B9C63F{enabled}" + - "&7Size: &#B9C63F{size}" + - "&7Next: &#D4AF37{next_size}" + - "&7Mode: &#A8873F{border_mode}" + - "&7Trigger: B6B35{trigger}" + - "&7Phase: &#A8873F{phase}" + - "&7Paused: &c{paused}" + - "" + - "&#D4AF37Left-click &8- &7expand now" + - "&#D4AF37Right-click &8- &7pause/resume" + refresh: "&#B9C63FRefresh" + close: "&cClose" diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml new file mode 100644 index 0000000..28b4ec2 --- /dev/null +++ b/src/main/resources/plugin.yml @@ -0,0 +1,65 @@ +name: DirtSMP +version: 1.0.0 +main: com.dirtsmp.dirtsmp.DirtSMPPlugin +api-version: '1.21' +authors: + - DirtbagMC +description: DirtbagMC-themed configurable world-border progression for Paper SMP servers. +softdepend: + - PlaceholderAPI +commands: + dirtsmp: + description: Manage DirtbagMC world progression. + usage: /dirtsmp + aliases: + - dsmp + - dirtborder +permissions: + dirtsmp.admin: + description: Full access to every DirtbagMC progression command. + default: op + children: + dirtsmp.status: true + dirtsmp.expand: true + dirtsmp.setsize: true + dirtsmp.pause: true + dirtsmp.resume: true + dirtsmp.reload: true + dirtsmp.gui: true + dirtsmp.reset: true + dirtsmp.legacy: true + dirtsmp.tpborder: true + dirtsmp.bypass.softborder: true + dirtsmp.status: + description: View DirtbagMC progression status and legacy access scans. + default: true + dirtsmp.expand: + description: Manually expand a configured world border. + default: op + dirtsmp.setsize: + description: Set a configured world's border size. + default: op + dirtsmp.pause: + description: Pause automatic progression for a world. + default: op + dirtsmp.resume: + description: Resume automatic progression for a world. + default: op + dirtsmp.reload: + description: Reload DirtbagMC progression configuration. + default: op + dirtsmp.gui: + description: Open the DirtbagMC admin GUI. + default: op + dirtsmp.reset: + description: Reset a world's DirtbagMC progression state. + default: op + dirtsmp.legacy: + description: Scan, list, add, and remove DirtbagMC legacy users. + default: op + dirtsmp.tpborder: + description: Teleport near a configured world border for visual inspection. + default: op + dirtsmp.bypass.softborder: + description: Bypass DirtbagMC soft border bounce and correction. + default: op diff --git a/target/DirtSMP-1.0.0.jar b/target/DirtSMP-1.0.0.jar new file mode 100644 index 0000000..b957e8b Binary files /dev/null and b/target/DirtSMP-1.0.0.jar differ diff --git a/target/classes/com/dirtsmp/dirtsmp/DirtSMPPlugin.class b/target/classes/com/dirtsmp/dirtsmp/DirtSMPPlugin.class new file mode 100644 index 0000000..431edce Binary files /dev/null and b/target/classes/com/dirtsmp/dirtsmp/DirtSMPPlugin.class differ diff --git a/target/classes/com/dirtsmp/dirtsmp/command/DirtSMPCommand.class b/target/classes/com/dirtsmp/dirtsmp/command/DirtSMPCommand.class new file mode 100644 index 0000000..8c7c5dd Binary files /dev/null and b/target/classes/com/dirtsmp/dirtsmp/command/DirtSMPCommand.class differ diff --git a/target/classes/com/dirtsmp/dirtsmp/config/ConfigManager.class b/target/classes/com/dirtsmp/dirtsmp/config/ConfigManager.class new file mode 100644 index 0000000..fbf3938 Binary files /dev/null and b/target/classes/com/dirtsmp/dirtsmp/config/ConfigManager.class differ diff --git a/target/classes/com/dirtsmp/dirtsmp/config/MessageManager.class b/target/classes/com/dirtsmp/dirtsmp/config/MessageManager.class new file mode 100644 index 0000000..1b07e0c Binary files /dev/null and b/target/classes/com/dirtsmp/dirtsmp/config/MessageManager.class differ diff --git a/target/classes/com/dirtsmp/dirtsmp/gui/AdminGuiManager$GuiHolder.class b/target/classes/com/dirtsmp/dirtsmp/gui/AdminGuiManager$GuiHolder.class new file mode 100644 index 0000000..f4d425b Binary files /dev/null and b/target/classes/com/dirtsmp/dirtsmp/gui/AdminGuiManager$GuiHolder.class differ diff --git a/target/classes/com/dirtsmp/dirtsmp/gui/AdminGuiManager.class b/target/classes/com/dirtsmp/dirtsmp/gui/AdminGuiManager.class new file mode 100644 index 0000000..1115e21 Binary files /dev/null and b/target/classes/com/dirtsmp/dirtsmp/gui/AdminGuiManager.class differ diff --git a/target/classes/com/dirtsmp/dirtsmp/hook/DirtSMPPlaceholderExpansion.class b/target/classes/com/dirtsmp/dirtsmp/hook/DirtSMPPlaceholderExpansion.class new file mode 100644 index 0000000..275bd37 Binary files /dev/null and b/target/classes/com/dirtsmp/dirtsmp/hook/DirtSMPPlaceholderExpansion.class differ diff --git a/target/classes/com/dirtsmp/dirtsmp/hook/PlaceholderHook.class b/target/classes/com/dirtsmp/dirtsmp/hook/PlaceholderHook.class new file mode 100644 index 0000000..aa1d9a4 Binary files /dev/null and b/target/classes/com/dirtsmp/dirtsmp/hook/PlaceholderHook.class differ diff --git a/target/classes/com/dirtsmp/dirtsmp/listener/PlayerListener.class b/target/classes/com/dirtsmp/dirtsmp/listener/PlayerListener.class new file mode 100644 index 0000000..ac2cd81 Binary files /dev/null and b/target/classes/com/dirtsmp/dirtsmp/listener/PlayerListener.class differ diff --git a/target/classes/com/dirtsmp/dirtsmp/model/BorderMode.class b/target/classes/com/dirtsmp/dirtsmp/model/BorderMode.class new file mode 100644 index 0000000..004aae1 Binary files /dev/null and b/target/classes/com/dirtsmp/dirtsmp/model/BorderMode.class differ diff --git a/target/classes/com/dirtsmp/dirtsmp/model/CatchUpMode.class b/target/classes/com/dirtsmp/dirtsmp/model/CatchUpMode.class new file mode 100644 index 0000000..e7c34ed Binary files /dev/null and b/target/classes/com/dirtsmp/dirtsmp/model/CatchUpMode.class differ diff --git a/target/classes/com/dirtsmp/dirtsmp/model/ExpansionReason.class b/target/classes/com/dirtsmp/dirtsmp/model/ExpansionReason.class new file mode 100644 index 0000000..9e8f036 Binary files /dev/null and b/target/classes/com/dirtsmp/dirtsmp/model/ExpansionReason.class differ diff --git a/target/classes/com/dirtsmp/dirtsmp/model/ExpansionResult.class b/target/classes/com/dirtsmp/dirtsmp/model/ExpansionResult.class new file mode 100644 index 0000000..87ea97e Binary files /dev/null and b/target/classes/com/dirtsmp/dirtsmp/model/ExpansionResult.class differ diff --git a/target/classes/com/dirtsmp/dirtsmp/model/GrowthMode.class b/target/classes/com/dirtsmp/dirtsmp/model/GrowthMode.class new file mode 100644 index 0000000..457a685 Binary files /dev/null and b/target/classes/com/dirtsmp/dirtsmp/model/GrowthMode.class differ diff --git a/target/classes/com/dirtsmp/dirtsmp/model/LegacyAccessReport.class b/target/classes/com/dirtsmp/dirtsmp/model/LegacyAccessReport.class new file mode 100644 index 0000000..a3d1342 Binary files /dev/null and b/target/classes/com/dirtsmp/dirtsmp/model/LegacyAccessReport.class differ diff --git a/target/classes/com/dirtsmp/dirtsmp/model/PhaseDefinition.class b/target/classes/com/dirtsmp/dirtsmp/model/PhaseDefinition.class new file mode 100644 index 0000000..eeb17c4 Binary files /dev/null and b/target/classes/com/dirtsmp/dirtsmp/model/PhaseDefinition.class differ diff --git a/target/classes/com/dirtsmp/dirtsmp/model/TriggerDecision.class b/target/classes/com/dirtsmp/dirtsmp/model/TriggerDecision.class new file mode 100644 index 0000000..d2102a3 Binary files /dev/null and b/target/classes/com/dirtsmp/dirtsmp/model/TriggerDecision.class differ diff --git a/target/classes/com/dirtsmp/dirtsmp/model/TriggerMode.class b/target/classes/com/dirtsmp/dirtsmp/model/TriggerMode.class new file mode 100644 index 0000000..c19ec4e Binary files /dev/null and b/target/classes/com/dirtsmp/dirtsmp/model/TriggerMode.class differ diff --git a/target/classes/com/dirtsmp/dirtsmp/model/TriggerSettings.class b/target/classes/com/dirtsmp/dirtsmp/model/TriggerSettings.class new file mode 100644 index 0000000..90d9b78 Binary files /dev/null and b/target/classes/com/dirtsmp/dirtsmp/model/TriggerSettings.class differ diff --git a/target/classes/com/dirtsmp/dirtsmp/model/WorldRule$AnnouncementSettings.class b/target/classes/com/dirtsmp/dirtsmp/model/WorldRule$AnnouncementSettings.class new file mode 100644 index 0000000..e4363e6 Binary files /dev/null and b/target/classes/com/dirtsmp/dirtsmp/model/WorldRule$AnnouncementSettings.class differ diff --git a/target/classes/com/dirtsmp/dirtsmp/model/WorldRule$LegacyAccessSettings.class b/target/classes/com/dirtsmp/dirtsmp/model/WorldRule$LegacyAccessSettings.class new file mode 100644 index 0000000..7409919 Binary files /dev/null and b/target/classes/com/dirtsmp/dirtsmp/model/WorldRule$LegacyAccessSettings.class differ diff --git a/target/classes/com/dirtsmp/dirtsmp/model/WorldRule$LegacyLocation.class b/target/classes/com/dirtsmp/dirtsmp/model/WorldRule$LegacyLocation.class new file mode 100644 index 0000000..3d18e1b Binary files /dev/null and b/target/classes/com/dirtsmp/dirtsmp/model/WorldRule$LegacyLocation.class differ diff --git a/target/classes/com/dirtsmp/dirtsmp/model/WorldRule$MilestoneRewardSettings.class b/target/classes/com/dirtsmp/dirtsmp/model/WorldRule$MilestoneRewardSettings.class new file mode 100644 index 0000000..101c59e Binary files /dev/null and b/target/classes/com/dirtsmp/dirtsmp/model/WorldRule$MilestoneRewardSettings.class differ diff --git a/target/classes/com/dirtsmp/dirtsmp/model/WorldRule$ReminderSettings.class b/target/classes/com/dirtsmp/dirtsmp/model/WorldRule$ReminderSettings.class new file mode 100644 index 0000000..647251a Binary files /dev/null and b/target/classes/com/dirtsmp/dirtsmp/model/WorldRule$ReminderSettings.class differ diff --git a/target/classes/com/dirtsmp/dirtsmp/model/WorldRule$SoftBorderSettings.class b/target/classes/com/dirtsmp/dirtsmp/model/WorldRule$SoftBorderSettings.class new file mode 100644 index 0000000..cc09760 Binary files /dev/null and b/target/classes/com/dirtsmp/dirtsmp/model/WorldRule$SoftBorderSettings.class differ diff --git a/target/classes/com/dirtsmp/dirtsmp/model/WorldRule.class b/target/classes/com/dirtsmp/dirtsmp/model/WorldRule.class new file mode 100644 index 0000000..a84d932 Binary files /dev/null and b/target/classes/com/dirtsmp/dirtsmp/model/WorldRule.class differ diff --git a/target/classes/com/dirtsmp/dirtsmp/service/BorderManager$LegacyAccumulator.class b/target/classes/com/dirtsmp/dirtsmp/service/BorderManager$LegacyAccumulator.class new file mode 100644 index 0000000..6327136 Binary files /dev/null and b/target/classes/com/dirtsmp/dirtsmp/service/BorderManager$LegacyAccumulator.class differ diff --git a/target/classes/com/dirtsmp/dirtsmp/service/BorderManager.class b/target/classes/com/dirtsmp/dirtsmp/service/BorderManager.class new file mode 100644 index 0000000..efbaa51 Binary files /dev/null and b/target/classes/com/dirtsmp/dirtsmp/service/BorderManager.class differ diff --git a/target/classes/com/dirtsmp/dirtsmp/service/CommandHookManager.class b/target/classes/com/dirtsmp/dirtsmp/service/CommandHookManager.class new file mode 100644 index 0000000..d5f5ad4 Binary files /dev/null and b/target/classes/com/dirtsmp/dirtsmp/service/CommandHookManager.class differ diff --git a/target/classes/com/dirtsmp/dirtsmp/service/HistoryLogger.class b/target/classes/com/dirtsmp/dirtsmp/service/HistoryLogger.class new file mode 100644 index 0000000..cb7147c Binary files /dev/null and b/target/classes/com/dirtsmp/dirtsmp/service/HistoryLogger.class differ diff --git a/target/classes/com/dirtsmp/dirtsmp/service/SoftBorderManager$Bounds.class b/target/classes/com/dirtsmp/dirtsmp/service/SoftBorderManager$Bounds.class new file mode 100644 index 0000000..b2a60e9 Binary files /dev/null and b/target/classes/com/dirtsmp/dirtsmp/service/SoftBorderManager$Bounds.class differ diff --git a/target/classes/com/dirtsmp/dirtsmp/service/SoftBorderManager.class b/target/classes/com/dirtsmp/dirtsmp/service/SoftBorderManager.class new file mode 100644 index 0000000..a108092 Binary files /dev/null and b/target/classes/com/dirtsmp/dirtsmp/service/SoftBorderManager.class differ diff --git a/target/classes/com/dirtsmp/dirtsmp/service/TimeUtil.class b/target/classes/com/dirtsmp/dirtsmp/service/TimeUtil.class new file mode 100644 index 0000000..4abc865 Binary files /dev/null and b/target/classes/com/dirtsmp/dirtsmp/service/TimeUtil.class differ diff --git a/target/classes/com/dirtsmp/dirtsmp/service/TriggerEvaluator$1.class b/target/classes/com/dirtsmp/dirtsmp/service/TriggerEvaluator$1.class new file mode 100644 index 0000000..7b7fdb4 Binary files /dev/null and b/target/classes/com/dirtsmp/dirtsmp/service/TriggerEvaluator$1.class differ diff --git a/target/classes/com/dirtsmp/dirtsmp/service/TriggerEvaluator.class b/target/classes/com/dirtsmp/dirtsmp/service/TriggerEvaluator.class new file mode 100644 index 0000000..d1b909c Binary files /dev/null and b/target/classes/com/dirtsmp/dirtsmp/service/TriggerEvaluator.class differ diff --git a/target/classes/com/dirtsmp/dirtsmp/service/WebhookNotifier.class b/target/classes/com/dirtsmp/dirtsmp/service/WebhookNotifier.class new file mode 100644 index 0000000..3cecd85 Binary files /dev/null and b/target/classes/com/dirtsmp/dirtsmp/service/WebhookNotifier.class differ diff --git a/target/classes/com/dirtsmp/dirtsmp/state/PlayerStatsManager$PlayerRecord.class b/target/classes/com/dirtsmp/dirtsmp/state/PlayerStatsManager$PlayerRecord.class new file mode 100644 index 0000000..e6d5b6f Binary files /dev/null and b/target/classes/com/dirtsmp/dirtsmp/state/PlayerStatsManager$PlayerRecord.class differ diff --git a/target/classes/com/dirtsmp/dirtsmp/state/PlayerStatsManager.class b/target/classes/com/dirtsmp/dirtsmp/state/PlayerStatsManager.class new file mode 100644 index 0000000..284d515 Binary files /dev/null and b/target/classes/com/dirtsmp/dirtsmp/state/PlayerStatsManager.class differ diff --git a/target/classes/com/dirtsmp/dirtsmp/state/StateManager$LegacyUserRecord.class b/target/classes/com/dirtsmp/dirtsmp/state/StateManager$LegacyUserRecord.class new file mode 100644 index 0000000..379afc2 Binary files /dev/null and b/target/classes/com/dirtsmp/dirtsmp/state/StateManager$LegacyUserRecord.class differ diff --git a/target/classes/com/dirtsmp/dirtsmp/state/StateManager.class b/target/classes/com/dirtsmp/dirtsmp/state/StateManager.class new file mode 100644 index 0000000..e80ab1e Binary files /dev/null and b/target/classes/com/dirtsmp/dirtsmp/state/StateManager.class differ diff --git a/target/classes/com/dirtsmp/dirtsmp/state/WorldProgress.class b/target/classes/com/dirtsmp/dirtsmp/state/WorldProgress.class new file mode 100644 index 0000000..62e0f12 Binary files /dev/null and b/target/classes/com/dirtsmp/dirtsmp/state/WorldProgress.class differ diff --git a/target/classes/com/dirtsmp/dirtsmp/task/ScheduleManager.class b/target/classes/com/dirtsmp/dirtsmp/task/ScheduleManager.class new file mode 100644 index 0000000..293cada Binary files /dev/null and b/target/classes/com/dirtsmp/dirtsmp/task/ScheduleManager.class differ diff --git a/target/classes/config.yml b/target/classes/config.yml new file mode 100644 index 0000000..6f8e428 --- /dev/null +++ b/target/classes/config.yml @@ -0,0 +1,414 @@ +# DirtbagMC progression configuration +# +# Size terminology: +# Every border size in this file is a FULL DIAMETER / WIDTH in blocks, matching Bukkit/Paper's WorldBorder size. +# Example: starting-size: 50000 creates a border that is 50,000 blocks wide, roughly 25,000 blocks from center to edge. +# +# Growth modes: +# INCREMENTAL - each expansion adds growth-amount to the current full diameter. +# PHASE - each expansion moves to the next exact size listed under phases. +# +# Border modes: +# WORLD_BORDER - use Minecraft/Paper's real WorldBorder. +# SOFT_BORDER - do not enforce the vanilla border; bounce players off an invisible DirtbagMC border with effects. +# +# Trigger modes: +# MANUAL, TIME, UNIQUE_PLAYERS, ONLINE_PLAYERS, ACTIVE_PLAYERS, +# TIME_OR_UNIQUE_PLAYERS, TIME_AND_UNIQUE_PLAYERS, +# TIME_OR_ONLINE_PLAYERS, TIME_AND_ONLINE_PLAYERS, +# TIME_OR_ACTIVE_PLAYERS, TIME_AND_ACTIVE_PLAYERS. + +settings: + dry-run: false + debug: false + poll-interval-seconds: 30 + apply-borders-on-startup: true + max-catchup-expansions-per-world: 20 + +state: + file-name: state.yml + save-interval-seconds: 300 + backup-on-save: true + backup-keep: 10 + +legacy-users: + # Admin scan command: + # /dirtsmp legacy scan + # Also supported: + # /dirtsmp add legacy users scan + # Players with at least this much Minecraft playtime are added to state.yml under legacy-users. + minimum-playtime: 1h + +history: + enabled: true + file-name: history.log + +gui: + enabled: true + title: "A2416&lᴅA2D1B&lɪA351F&lʀA3F24&lᴛA4A2A&lʙB6B35&lᴀ&#A8873F&lɢ&#D4AF37&lᴍ&#B9C63F&lᴄ &8| &#D4AF37&lBorders" + +webhook: + enabled: false + url: "" + timeout-seconds: 8 + content: "**DirtbagMC progression:** `{world}` expanded from `{old_size}` to `{new_size}` blocks. Reason: `{reason}`" + +command-hooks: + enabled: true + before-expansion: + # - "say Preparing expansion for {world} to {new_size}" + after-expansion: + # Useful for tools like Chunky: + # - "chunky world {world}" + # - "chunky border" + # - "chunky start" + +milestone-rewards: + enabled: true + # Commands run from console after an expansion reaches a configured milestone. + # Placeholders: {world}, {key}, {old_size}, {new_size}, {max_size}, {reason}, {actor}, {phase}, {phase_name}, {expansion_count} + every-expansion: [] + by-expansion-count: + # Example rewards: + # 1: + # - "broadcast DirtbagMC milestone 1 reached in {world}!" + # - "crate key giveall beta 1" + 1: [] + 3: [] + 5: [] + by-phase-index: + 1: [] + by-phase-name: + Frontier Era: [] + First Ring: [] + +worlds: + overworld: + enabled: true + world: world + center-x: 0.0 + center-z: 0.0 + starting-size: 50000 + border-mode: SOFT_BORDER + growth-mode: INCREMENTAL + growth-amount: 10000 + max-size: 200000 + transition-seconds: 300 + manual-only: false + import-current-border: false + no-shrink-protection: true + enforce-max-size: true + command-hooks: + before-expansion: [] + after-expansion: [] + milestone-rewards: + enabled: true + every-expansion: [] + by-expansion-count: + 1: [] + 2: [] + 3: [] + by-phase-index: {} + by-phase-name: {} + legacy-access: + enabled: true + # This protects beta builds from being stranded when progression starts after launch. + # The plugin expands the initial/saved border enough to include known locations plus padding. + include-current-border: false + include-online-players: true + include-offline-last-locations: true + include-respawn-locations: true + # When true, player last/respawn locations only count if the player is in the scanned legacy-users list. + player-locations-require-legacy-user: true + reconcile-on-startup: true + allow-start-above-max-size: true + padding: 1024 + # Add homes, towns, bases, or Essentials homes that cannot be discovered from Paper playerdata. + # Border math uses full diameter/width, centered on center-x/center-z. + locations: [] + # Example: + # locations: + # - name: "Beta town" + # x: 31500 + # z: -22400 + soft-border: + # Premium invisible border mode. Used only when border-mode is SOFT_BORDER. + # release-vanilla-border removes any old vanilla border enforcement by setting it to vanilla-border-size. + release-vanilla-border: true + vanilla-border-size: 59999968 + ignore-creative: false + ignore-spectator: true + bypass-permission: dirtsmp.bypass.softborder + inside-buffer: 1.5 + bounce-strength: 1.85 + vertical-boost: 0.35 + protect-mounted-entities: true + # Gentler values keep horses, camels, boats, and minecarts from taking weird launch angles. + mounted-bounce-strength: 1.15 + mounted-vertical-boost: 0.12 + max-outside-distance-before-teleport: 24 + cooldown: 900ms + message: "{prefix}&#D4AF37&lThe DirtbagMC border &7throws you back." + action-bar: "&#D4AF37&lBorder reached &8| &7turn back" + sound: ENTITY_SLIME_JUMP + volume: 0.65 + pitch: 0.65 + particles: + enabled: true + # DUST uses the DirtbagMC gold color. Other Bukkit particle names also work. + particle: DUST + count: 3 + offset: 0.05 + speed: 0.0 + interval-ticks: 10 + view-distance: 96 + spacing: 3 + broadcasts: + enabled: true + world-only: false + message: "{prefix}&#B9C63FThe dirt expands. &#D4AF37&l{world} &#B9C63Fhas grown from &#A8873F{old_size} &#B9C63Fto &#D4AF37&l{new_size} &#B9C63Fblocks." + title: "A2416&lᴅA2D1B&lɪA351F&lʀA3F24&lᴛA4A2A&lʙB6B35&lᴀ&#A8873F&lɢ&#D4AF37&lᴍ&#B9C63F&lᴄ" + subtitle: "&#B9C63F{world} &8| &#D4AF37{old_size} &7-> &#D4AF37{new_size}" + action-bar: "&#D4AF37&l{world} &7border is now &#B9C63F{new_size} &7blocks wide" + sound: ENTITY_ENDER_DRAGON_GROWL + volume: 0.7 + pitch: 1.25 + reminders: + enabled: true + before: + - 1d + - 12h + - 1h + - 10m + message: "{prefix}&#D4AF37&l{world} &7expands in &#B9C63F{time_left}&7." + action-bar: "&#D4AF37&l{world} &7expands in &#B9C63F{time_left}" + sound: BLOCK_NOTE_BLOCK_PLING + volume: 0.45 + pitch: 1.4 + triggers: + mode: TIME_OR_UNIQUE_PLAYERS + time-interval: 7d + unique-players-every: 50 + online-players-threshold: 35 + active-players-threshold: 120 + active-window: 7d + exclude-vanished: true + player-trigger-cooldown: 12h + catch-up: ONE + phases: + - name: Spawn Era + size: 50000 + message: "" + - name: Frontier Era + size: 75000 + message: "" + + end: + enabled: true + world: world_the_end + center-x: 0.0 + center-z: 0.0 + starting-size: 2000 + border-mode: SOFT_BORDER + growth-mode: PHASE + growth-amount: 2000 + max-size: 20000 + transition-seconds: 180 + manual-only: false + import-current-border: false + no-shrink-protection: true + enforce-max-size: true + command-hooks: + before-expansion: [] + after-expansion: [] + milestone-rewards: + enabled: true + every-expansion: [] + by-expansion-count: + 1: [] + 2: [] + by-phase-index: + 1: [] + 2: [] + by-phase-name: + First Ring: [] + Chorus Frontier: [] + legacy-access: + enabled: true + include-current-border: false + include-online-players: true + include-offline-last-locations: true + include-respawn-locations: true + player-locations-require-legacy-user: true + reconcile-on-startup: true + allow-start-above-max-size: true + padding: 512 + locations: [] + soft-border: + release-vanilla-border: true + vanilla-border-size: 59999968 + ignore-creative: false + ignore-spectator: true + bypass-permission: dirtsmp.bypass.softborder + inside-buffer: 1.5 + bounce-strength: 2.05 + vertical-boost: 0.45 + protect-mounted-entities: true + mounted-bounce-strength: 1.2 + mounted-vertical-boost: 0.12 + max-outside-distance-before-teleport: 18 + cooldown: 900ms + message: "{prefix}A2416&lThe End border &7snaps you back." + action-bar: "A2416&lThe End border &8| &7turn back" + sound: ENTITY_ENDERMAN_TELEPORT + volume: 0.55 + pitch: 0.8 + particles: + enabled: true + particle: DUST + count: 3 + offset: 0.05 + speed: 0.0 + interval-ticks: 10 + view-distance: 72 + spacing: 2.5 + broadcasts: + enabled: true + world-only: false + message: "{prefix}A2416&lThe End &#B9C63Fhas entered &#D4AF37&l{phase_name}&#B9C63F: &#D4AF37{new_size} &#B9C63Fblocks wide." + title: "A2416&lThe End Opens" + subtitle: "&#D4AF37{phase_name} &8| &#B9C63F{new_size} blocks" + action-bar: "A2416&lThe End &7border is now &#D4AF37{new_size}" + sound: ENTITY_ENDER_DRAGON_DEATH + volume: 0.8 + pitch: 1.0 + reminders: + enabled: true + before: + - 12h + - 1h + - 10m + message: "{prefix}A2416&lThe End &7expands in &#D4AF37{time_left}&7." + action-bar: "A2416&lThe End &7expands in &#D4AF37{time_left}" + sound: BLOCK_NOTE_BLOCK_PLING + volume: 0.45 + pitch: 1.6 + triggers: + mode: TIME_AND_UNIQUE_PLAYERS + time-interval: 10d + unique-players-every: 100 + online-players-threshold: 40 + active-players-threshold: 150 + active-window: 7d + exclude-vanished: true + player-trigger-cooldown: 12h + catch-up: ONE + phases: + - name: Outer Silence + size: 2000 + message: "" + - name: First Ring + size: 4000 + message: "{prefix}A2416&lThe End &#B9C63Fstirs. &#D4AF37The first outer ring &#B9C63Fis now reachable." + - name: Chorus Frontier + size: 6000 + message: "{prefix}A2416&lThe End &#B9C63Fgrows again. &#D4AF37New islands await." + - name: Dragon's Wake + size: 8000 + message: "{prefix}A2416&lThe End &#B9C63Fborder has expanded to &#D4AF37{new_size} &#B9C63Fblocks." + + nether: + enabled: false + world: world_nether + center-x: 0.0 + center-z: 0.0 + starting-size: 10000 + border-mode: SOFT_BORDER + growth-mode: INCREMENTAL + growth-amount: 2500 + max-size: 50000 + transition-seconds: 120 + manual-only: false + import-current-border: false + no-shrink-protection: true + enforce-max-size: true + command-hooks: + before-expansion: [] + after-expansion: [] + milestone-rewards: + enabled: true + every-expansion: [] + by-expansion-count: + 1: [] + by-phase-index: {} + by-phase-name: {} + legacy-access: + enabled: false + include-current-border: false + include-online-players: true + include-offline-last-locations: true + include-respawn-locations: true + player-locations-require-legacy-user: true + reconcile-on-startup: true + allow-start-above-max-size: true + padding: 512 + locations: [] + soft-border: + release-vanilla-border: true + vanilla-border-size: 59999968 + ignore-creative: false + ignore-spectator: true + bypass-permission: dirtsmp.bypass.softborder + inside-buffer: 1.5 + bounce-strength: 1.9 + vertical-boost: 0.35 + protect-mounted-entities: true + mounted-bounce-strength: 1.15 + mounted-vertical-boost: 0.12 + max-outside-distance-before-teleport: 18 + cooldown: 900ms + message: "{prefix}A4A2A&lThe Nether border &7throws you back." + action-bar: "A4A2A&lNether border &8| &7turn back" + sound: ENTITY_BLAZE_HURT + volume: 0.55 + pitch: 0.8 + particles: + enabled: true + particle: DUST + count: 3 + offset: 0.05 + speed: 0.0 + interval-ticks: 10 + view-distance: 72 + spacing: 2.5 + broadcasts: + enabled: true + world-only: false + message: "{prefix}A4A2A&lThe Nether &#B9C63Fhas expanded to &#D4AF37{new_size} &#B9C63Fblocks." + title: "A4A2A&lNether Expanded" + subtitle: "&#A8873F{old_size} &7-> &#D4AF37{new_size}" + action-bar: "A4A2A&lThe Nether &7border is now &#D4AF37{new_size}" + sound: ENTITY_WITHER_SPAWN + volume: 0.65 + pitch: 1.1 + reminders: + enabled: true + before: + - 1h + - 10m + message: "{prefix}A4A2A&lThe Nether &7expands in &#D4AF37{time_left}&7." + action-bar: "A4A2A&lThe Nether &7expands in &#D4AF37{time_left}" + sound: BLOCK_NOTE_BLOCK_PLING + volume: 0.45 + pitch: 1.2 + triggers: + mode: MANUAL + time-interval: 7d + unique-players-every: 50 + online-players-threshold: 30 + active-players-threshold: 100 + active-window: 7d + exclude-vanished: true + player-trigger-cooldown: 12h + catch-up: NONE + phases: [] diff --git a/target/classes/messages.yml b/target/classes/messages.yml new file mode 100644 index 0000000..5dc69ed --- /dev/null +++ b/target/classes/messages.yml @@ -0,0 +1,76 @@ +prefix: "A2416&lᴅA2D1B&lɪA351F&lʀA3F24&lᴛA4A2A&lʙB6B35&lᴀ&#A8873F&lɢ&#D4AF37&lᴍ&#B9C63F&lᴄ &8| &7" + +commands: + no-permission: "{prefix}&cYou do not have permission to do that." + player-only: "{prefix}&cOnly players can use that command." + unknown-world: "{prefix}&cUnknown configured world: &#D4AF37{world}" + invalid-number: "{prefix}&cThat size must be a positive number." + reload: "{prefix}&#B9C63FConfiguration, messages, and DirtbagMC progression rules reloaded." + saved: "{prefix}&#B9C63FState saved." + help: + - "A2416&lᴅA2D1B&lɪA351F&lʀA3F24&lᴛA4A2A&lʙB6B35&lᴀ&#A8873F&lɢ&#D4AF37&lᴍ&#B9C63F&lᴄ &8| &#D4AF37&lBorder Progression" + - "B6B35/dirtsmp status [world] &8- &7view progression" + - "B6B35/dirtsmp next [world] &8- &7view upcoming triggers" + - "B6B35/dirtsmp legacycheck &8- &7scan beta home safety" + - "B6B35/dirtsmp tpborder [side] &8- &7teleport near a border wall" + - "B6B35/dirtsmp legacy scan [1h] &8- &7mark legacy players by playtime" + - "B6B35/dirtsmp legacy list &8- &7show marked legacy players" + - "B6B35/dirtsmp expand &8- &7expand now" + - "B6B35/dirtsmp setsize [force] &8- &7set border size" + - "B6B35/dirtsmp pause &8- &7pause automatic progression" + - "B6B35/dirtsmp resume &8- &7resume automatic progression" + - "B6B35/dirtsmp reset [force] &8- &7reset progression" + - "B6B35/dirtsmp gui &8- &7open admin panel" + - "B6B35/dirtsmp reload &8- &7reload config" + status-header: "{prefix}&#B9C63FTracking &#D4AF37&l{count} &#B9C63Fconfigured world(s)." + status-line: "&8- &#D4AF37&l{key} &8(&7{world}&8) &7enabled: &#B9C63F{enabled} &7size: &#B9C63F{size} &7next: &#D4AF37{next_size} &7mode: &#A8873F{border_mode} &7phase: &#A8873F{phase} &7paused: &c{paused}" + next-line: "{prefix}&#D4AF37&l{world} &7next trigger: &#B9C63F{trigger}&7. Next size: &#D4AF37{next_size}&7." + legacy-check: "{prefix}&#D4AF37&l{world} &7legacy scan: &#B9C63F{locations} &7location(s), required size &#D4AF37{required_size}&7, recommended startup size &#B9C63F{recommended_size}&7. Farthest: &#A8873F{farthest}&7." + legacy-scan: "{prefix}&#B9C63FScanned &#D4AF37{scanned} &#B9C63Fplayer(s). Matched &#D4AF37{matched} &#B9C63Fat &#D4AF37{minimum}&#B9C63F+. Added &#D4AF37{added}&#B9C63F. Legacy total: &#D4AF37{total}&7." + legacy-list-header: "{prefix}&#B9C63FLegacy users: &#D4AF37{count}&7. Showing up to 20." + legacy-list-line: "&8- &#D4AF37{name} &7playtime: &#B9C63F{playtime} &7source: &#A8873F{source}" + legacy-add: "{prefix}&#D4AF37{name} &7was &#B9C63F{status} &7as a legacy user." + legacy-remove: "{prefix}&#D4AF37{name} &7legacy status: &#B9C63F{status}&7." + tp-border: "{prefix}&#B9C63FTeleported to the &#D4AF37{side} &#B9C63Fborder wall for &#D4AF37{world} &8(&7size {size}&8)&7." + tp-border-failed: "{prefix}&cCould not teleport to &#D4AF37{world}&c border: &7{reason}" + expanded: "{prefix}&#B9C63FExpanded &#D4AF37&l{world} &#B9C63Ffrom &#A8873F{old_size} &#B9C63Fto &#D4AF37{new_size}&7." + expand-failed: "{prefix}&cCould not expand &#D4AF37{world}&c: &7{reason}" + set-size: "{prefix}&#B9C63FSet &#D4AF37&l{world} &#B9C63Fborder size to &#D4AF37{new_size}&7." + set-size-failed: "{prefix}&cCould not set &#D4AF37{world}&c: &7{reason}" + paused: "{prefix}&#D4AF37&l{world} &7automatic progression is now paused." + resumed: "{prefix}&#B9C63F{world} automatic progression resumed." + reset: "{prefix}&#B9C63FReset progression for &#D4AF37&l{world}&7." + gui-disabled: "{prefix}&cThe admin GUI is disabled in config." + +expansion: + default-message: "{prefix}&#B9C63F{world} expanded from &#A8873F{old_size} &#B9C63Fto &#D4AF37&l{new_size} &#B9C63Fblocks!" + default-title: "A2416&lDirtbagMC" + default-subtitle: "&#B9C63F{world} &8| &#D4AF37{old_size} &7-> &#D4AF37{new_size}" + default-action-bar: "&#B9C63F{world} &7is now &#D4AF37&l{new_size} &#B9C63Fblocks wide" + max-reached: "The world is already at its configured maximum or final phase." + paused: "Automatic progression is paused." + manual-only: "This world is configured for manual-only progression." + missing-world: "The Bukkit world is not loaded or does not exist." + dry-run: "Dry-run mode is enabled; no border was changed." + no-shrink: "No-shrink protection blocked a smaller size. Add 'force' to the command to override." + +reminders: + default-message: "{prefix}&#D4AF37&l{world} &7expands in &#B9C63F{time_left}&7." + default-action-bar: "&#D4AF37&l{world} &7expands in &#B9C63F{time_left}" + +gui: + world-name: "&#D4AF37&l{key}" + world-lore: + - "&7World: &#D4AF37{world}" + - "&7Enabled: &#B9C63F{enabled}" + - "&7Size: &#B9C63F{size}" + - "&7Next: &#D4AF37{next_size}" + - "&7Mode: &#A8873F{border_mode}" + - "&7Trigger: B6B35{trigger}" + - "&7Phase: &#A8873F{phase}" + - "&7Paused: &c{paused}" + - "" + - "&#D4AF37Left-click &8- &7expand now" + - "&#D4AF37Right-click &8- &7pause/resume" + refresh: "&#B9C63FRefresh" + close: "&cClose" diff --git a/target/classes/plugin.yml b/target/classes/plugin.yml new file mode 100644 index 0000000..28b4ec2 --- /dev/null +++ b/target/classes/plugin.yml @@ -0,0 +1,65 @@ +name: DirtSMP +version: 1.0.0 +main: com.dirtsmp.dirtsmp.DirtSMPPlugin +api-version: '1.21' +authors: + - DirtbagMC +description: DirtbagMC-themed configurable world-border progression for Paper SMP servers. +softdepend: + - PlaceholderAPI +commands: + dirtsmp: + description: Manage DirtbagMC world progression. + usage: /dirtsmp + aliases: + - dsmp + - dirtborder +permissions: + dirtsmp.admin: + description: Full access to every DirtbagMC progression command. + default: op + children: + dirtsmp.status: true + dirtsmp.expand: true + dirtsmp.setsize: true + dirtsmp.pause: true + dirtsmp.resume: true + dirtsmp.reload: true + dirtsmp.gui: true + dirtsmp.reset: true + dirtsmp.legacy: true + dirtsmp.tpborder: true + dirtsmp.bypass.softborder: true + dirtsmp.status: + description: View DirtbagMC progression status and legacy access scans. + default: true + dirtsmp.expand: + description: Manually expand a configured world border. + default: op + dirtsmp.setsize: + description: Set a configured world's border size. + default: op + dirtsmp.pause: + description: Pause automatic progression for a world. + default: op + dirtsmp.resume: + description: Resume automatic progression for a world. + default: op + dirtsmp.reload: + description: Reload DirtbagMC progression configuration. + default: op + dirtsmp.gui: + description: Open the DirtbagMC admin GUI. + default: op + dirtsmp.reset: + description: Reset a world's DirtbagMC progression state. + default: op + dirtsmp.legacy: + description: Scan, list, add, and remove DirtbagMC legacy users. + default: op + dirtsmp.tpborder: + description: Teleport near a configured world border for visual inspection. + default: op + dirtsmp.bypass.softborder: + description: Bypass DirtbagMC soft border bounce and correction. + default: op diff --git a/target/maven-archiver/pom.properties b/target/maven-archiver/pom.properties new file mode 100644 index 0000000..2479a7f --- /dev/null +++ b/target/maven-archiver/pom.properties @@ -0,0 +1,3 @@ +artifactId=DirtSMP +groupId=com.dirtsmp +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..78c10b0 --- /dev/null +++ b/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst @@ -0,0 +1,42 @@ +com/dirtsmp/dirtsmp/service/TimeUtil.class +com/dirtsmp/dirtsmp/model/TriggerMode.class +com/dirtsmp/dirtsmp/config/MessageManager.class +com/dirtsmp/dirtsmp/model/ExpansionReason.class +com/dirtsmp/dirtsmp/service/HistoryLogger.class +com/dirtsmp/dirtsmp/service/TriggerEvaluator$1.class +com/dirtsmp/dirtsmp/task/ScheduleManager.class +com/dirtsmp/dirtsmp/state/PlayerStatsManager.class +com/dirtsmp/dirtsmp/config/ConfigManager.class +com/dirtsmp/dirtsmp/service/SoftBorderManager.class +com/dirtsmp/dirtsmp/model/TriggerSettings.class +com/dirtsmp/dirtsmp/service/SoftBorderManager$Bounds.class +com/dirtsmp/dirtsmp/model/WorldRule$MilestoneRewardSettings.class +com/dirtsmp/dirtsmp/hook/PlaceholderHook.class +com/dirtsmp/dirtsmp/model/GrowthMode.class +com/dirtsmp/dirtsmp/model/WorldRule$LegacyLocation.class +com/dirtsmp/dirtsmp/service/TriggerEvaluator.class +com/dirtsmp/dirtsmp/state/StateManager.class +com/dirtsmp/dirtsmp/model/WorldRule.class +com/dirtsmp/dirtsmp/gui/AdminGuiManager.class +com/dirtsmp/dirtsmp/model/WorldRule$LegacyAccessSettings.class +com/dirtsmp/dirtsmp/model/WorldRule$AnnouncementSettings.class +com/dirtsmp/dirtsmp/state/StateManager$LegacyUserRecord.class +com/dirtsmp/dirtsmp/service/WebhookNotifier.class +com/dirtsmp/dirtsmp/listener/PlayerListener.class +com/dirtsmp/dirtsmp/hook/DirtSMPPlaceholderExpansion.class +com/dirtsmp/dirtsmp/command/DirtSMPCommand.class +com/dirtsmp/dirtsmp/DirtSMPPlugin.class +com/dirtsmp/dirtsmp/model/PhaseDefinition.class +com/dirtsmp/dirtsmp/service/BorderManager.class +com/dirtsmp/dirtsmp/model/WorldRule$ReminderSettings.class +com/dirtsmp/dirtsmp/model/ExpansionResult.class +com/dirtsmp/dirtsmp/model/LegacyAccessReport.class +com/dirtsmp/dirtsmp/state/PlayerStatsManager$PlayerRecord.class +com/dirtsmp/dirtsmp/state/WorldProgress.class +com/dirtsmp/dirtsmp/service/CommandHookManager.class +com/dirtsmp/dirtsmp/model/BorderMode.class +com/dirtsmp/dirtsmp/service/BorderManager$LegacyAccumulator.class +com/dirtsmp/dirtsmp/model/WorldRule$SoftBorderSettings.class +com/dirtsmp/dirtsmp/model/TriggerDecision.class +com/dirtsmp/dirtsmp/model/CatchUpMode.class +com/dirtsmp/dirtsmp/gui/AdminGuiManager$GuiHolder.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..6e072c4 --- /dev/null +++ b/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst @@ -0,0 +1,30 @@ +/home/bitnix/Desktop/DirtSMP/src/main/java/com/dirtsmp/dirtsmp/DirtSMPPlugin.java +/home/bitnix/Desktop/DirtSMP/src/main/java/com/dirtsmp/dirtsmp/command/DirtSMPCommand.java +/home/bitnix/Desktop/DirtSMP/src/main/java/com/dirtsmp/dirtsmp/config/ConfigManager.java +/home/bitnix/Desktop/DirtSMP/src/main/java/com/dirtsmp/dirtsmp/config/MessageManager.java +/home/bitnix/Desktop/DirtSMP/src/main/java/com/dirtsmp/dirtsmp/gui/AdminGuiManager.java +/home/bitnix/Desktop/DirtSMP/src/main/java/com/dirtsmp/dirtsmp/hook/DirtSMPPlaceholderExpansion.java +/home/bitnix/Desktop/DirtSMP/src/main/java/com/dirtsmp/dirtsmp/hook/PlaceholderHook.java +/home/bitnix/Desktop/DirtSMP/src/main/java/com/dirtsmp/dirtsmp/listener/PlayerListener.java +/home/bitnix/Desktop/DirtSMP/src/main/java/com/dirtsmp/dirtsmp/model/BorderMode.java +/home/bitnix/Desktop/DirtSMP/src/main/java/com/dirtsmp/dirtsmp/model/CatchUpMode.java +/home/bitnix/Desktop/DirtSMP/src/main/java/com/dirtsmp/dirtsmp/model/ExpansionReason.java +/home/bitnix/Desktop/DirtSMP/src/main/java/com/dirtsmp/dirtsmp/model/ExpansionResult.java +/home/bitnix/Desktop/DirtSMP/src/main/java/com/dirtsmp/dirtsmp/model/GrowthMode.java +/home/bitnix/Desktop/DirtSMP/src/main/java/com/dirtsmp/dirtsmp/model/LegacyAccessReport.java +/home/bitnix/Desktop/DirtSMP/src/main/java/com/dirtsmp/dirtsmp/model/PhaseDefinition.java +/home/bitnix/Desktop/DirtSMP/src/main/java/com/dirtsmp/dirtsmp/model/TriggerDecision.java +/home/bitnix/Desktop/DirtSMP/src/main/java/com/dirtsmp/dirtsmp/model/TriggerMode.java +/home/bitnix/Desktop/DirtSMP/src/main/java/com/dirtsmp/dirtsmp/model/TriggerSettings.java +/home/bitnix/Desktop/DirtSMP/src/main/java/com/dirtsmp/dirtsmp/model/WorldRule.java +/home/bitnix/Desktop/DirtSMP/src/main/java/com/dirtsmp/dirtsmp/service/BorderManager.java +/home/bitnix/Desktop/DirtSMP/src/main/java/com/dirtsmp/dirtsmp/service/CommandHookManager.java +/home/bitnix/Desktop/DirtSMP/src/main/java/com/dirtsmp/dirtsmp/service/HistoryLogger.java +/home/bitnix/Desktop/DirtSMP/src/main/java/com/dirtsmp/dirtsmp/service/SoftBorderManager.java +/home/bitnix/Desktop/DirtSMP/src/main/java/com/dirtsmp/dirtsmp/service/TimeUtil.java +/home/bitnix/Desktop/DirtSMP/src/main/java/com/dirtsmp/dirtsmp/service/TriggerEvaluator.java +/home/bitnix/Desktop/DirtSMP/src/main/java/com/dirtsmp/dirtsmp/service/WebhookNotifier.java +/home/bitnix/Desktop/DirtSMP/src/main/java/com/dirtsmp/dirtsmp/state/PlayerStatsManager.java +/home/bitnix/Desktop/DirtSMP/src/main/java/com/dirtsmp/dirtsmp/state/StateManager.java +/home/bitnix/Desktop/DirtSMP/src/main/java/com/dirtsmp/dirtsmp/state/WorldProgress.java +/home/bitnix/Desktop/DirtSMP/src/main/java/com/dirtsmp/dirtsmp/task/ScheduleManager.java