commit c5c83aa216428a867e138417c884a953e07d3f66 Author: Xelara Networks Date: Wed Jun 24 18:38:15 2026 -0400 first 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..9717efe --- /dev/null +++ b/pom.xml @@ -0,0 +1,82 @@ + + + 4.0.0 + + com.dirtbagmc + DirtBounties + 1.0.0 + jar + + DirtBounties + Premium Paper bounty system for DirtbagMC. + https://dirtbagmc.com + + + 21 + UTF-8 + 1.21.8-R0.1-SNAPSHOT + 2.11.6 + + + + + papermc + https://repo.papermc.io/repository/maven-public/ + + + placeholderapi + https://repo.extendedclip.com/content/repositories/placeholderapi/ + + + + + + io.papermc.paper + paper-api + ${paper.version} + provided + + + me.clip + placeholderapi + ${placeholderapi.version} + provided + true + + + + + DirtBounties-${project.version} + + + src/main/resources + false + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + ${java.version} + ${project.build.sourceEncoding} + + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + + true + + + + + + + diff --git a/src/main/java/com/dirtbagmc/dirtbounties/DirtBountiesPlugin.java b/src/main/java/com/dirtbagmc/dirtbounties/DirtBountiesPlugin.java new file mode 100644 index 0000000..d1b09f2 --- /dev/null +++ b/src/main/java/com/dirtbagmc/dirtbounties/DirtBountiesPlugin.java @@ -0,0 +1,204 @@ +package com.dirtbagmc.dirtbounties; + +import com.dirtbagmc.dirtbounties.command.BountyAdminCommand; +import com.dirtbagmc.dirtbounties.command.BountyCommand; +import com.dirtbagmc.dirtbounties.config.ConfigService; +import com.dirtbagmc.dirtbounties.economy.EconomyService; +import com.dirtbagmc.dirtbounties.gui.GuiManager; +import com.dirtbagmc.dirtbounties.hook.DirtBountiesExpansion; +import com.dirtbagmc.dirtbounties.listener.BountyListener; +import com.dirtbagmc.dirtbounties.service.AntiAbuseService; +import com.dirtbagmc.dirtbounties.service.BountyService; +import com.dirtbagmc.dirtbounties.service.CombatTracker; +import com.dirtbagmc.dirtbounties.service.HistoryService; +import com.dirtbagmc.dirtbounties.service.MessageService; +import com.dirtbagmc.dirtbounties.service.PlayerCacheService; +import com.dirtbagmc.dirtbounties.storage.StorageManager; +import com.dirtbagmc.dirtbounties.util.TimeUtil; +import com.dirtbagmc.dirtbounties.webhook.WebhookService; +import org.bukkit.Bukkit; +import org.bukkit.command.PluginCommand; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitTask; + +import java.util.Objects; +import java.util.logging.Level; + +public final class DirtBountiesPlugin extends JavaPlugin { + private ConfigService configService; + private MessageService messageService; + private StorageManager storageManager; + private EconomyService economyService; + private HistoryService historyService; + private PlayerCacheService playerCacheService; + private CombatTracker combatTracker; + private AntiAbuseService antiAbuseService; + private WebhookService webhookService; + private BountyService bountyService; + private GuiManager guiManager; + private BukkitTask cleanupTask; + private BukkitTask autosaveTask; + private Object placeholderExpansion; + + @Override + public void onEnable() { + try { + bootstrap(); + registerCommands(); + registerEvents(); + registerPlaceholders(); + scheduleTasks(); + getLogger().info("DirtBounties enabled with " + bountyService.activeBounties().size() + " active bounties."); + } catch (RuntimeException ex) { + getLogger().log(Level.SEVERE, "DirtBounties failed to enable safely.", ex); + Bukkit.getPluginManager().disablePlugin(this); + } + } + + @Override + public void onDisable() { + Bukkit.getScheduler().cancelTasks(this); + if (bountyService != null) { + bountyService.save(); + } + if (historyService != null) { + historyService.save(); + } + if (playerCacheService != null) { + playerCacheService.save(); + } + if (guiManager != null) { + guiManager.clear(); + } + if (combatTracker != null) { + combatTracker.clear(); + } + if (antiAbuseService != null) { + antiAbuseService.clear(); + } + if (bountyService != null) { + bountyService.clearRuntimeState(); + } + cleanupTask = null; + autosaveTask = null; + getLogger().info("DirtBounties disabled cleanly."); + } + + public void reloadDirtBounties() { + Bukkit.getScheduler().cancelTasks(this); + if (bountyService != null) { + bountyService.save(); + } + if (historyService != null) { + historyService.save(); + } + if (playerCacheService != null) { + playerCacheService.save(); + } + + configService.load(); + economyService.initialize(); + webhookService.initialize(); + historyService.load(); + playerCacheService.load(); + bountyService.load(); + guiManager.clear(); + combatTracker.clear(); + antiAbuseService.clear(); + scheduleTasks(); + } + + public BountyService bountyService() { + return bountyService; + } + + public HistoryService historyService() { + return historyService; + } + + private void bootstrap() { + configService = new ConfigService(this); + configService.load(); + messageService = new MessageService(configService); + storageManager = new StorageManager(this); + storageManager.initialize(); + economyService = new EconomyService(this, configService); + economyService.initialize(); + if (!economyService.isReady() && configService.main().getBoolean("economy.fail-if-missing", false)) { + throw new IllegalStateException("Configured to fail when no Vault-compatible economy is available."); + } + + historyService = new HistoryService(configService, storageManager); + historyService.load(); + playerCacheService = new PlayerCacheService(configService, storageManager); + playerCacheService.load(); + combatTracker = new CombatTracker(configService); + antiAbuseService = new AntiAbuseService(configService, historyService); + webhookService = new WebhookService(this, configService, messageService); + webhookService.initialize(); + bountyService = new BountyService(this, configService, messageService, economyService, storageManager, + historyService, playerCacheService, combatTracker, antiAbuseService, webhookService); + bountyService.load(); + guiManager = new GuiManager(this, configService, messageService, bountyService, historyService); + } + + private void registerCommands() { + BountyCommand bountyCommand = new BountyCommand(configService, messageService, bountyService, guiManager); + PluginCommand bounty = Objects.requireNonNull(getCommand("bounty"), "bounty command missing from plugin.yml"); + bounty.setExecutor(bountyCommand); + bounty.setTabCompleter(bountyCommand); + + BountyAdminCommand adminCommand = new BountyAdminCommand(this, configService, messageService, + bountyService, historyService, guiManager); + PluginCommand admin = Objects.requireNonNull(getCommand("bountyadmin"), "bountyadmin command missing from plugin.yml"); + admin.setExecutor(adminCommand); + admin.setTabCompleter(adminCommand); + } + + private void registerEvents() { + Bukkit.getPluginManager().registerEvents( + new BountyListener(this, guiManager, bountyService, playerCacheService, combatTracker), this); + } + + private void registerPlaceholders() { + if (Bukkit.getPluginManager().getPlugin("PlaceholderAPI") == null) { + return; + } + try { + placeholderExpansion = new DirtBountiesExpansion(bountyService, historyService, getDescription().getVersion()); + ((DirtBountiesExpansion) placeholderExpansion).register(); + getLogger().info("Registered PlaceholderAPI placeholders."); + } catch (NoClassDefFoundError | RuntimeException ex) { + getLogger().log(Level.WARNING, "PlaceholderAPI was present but DirtBounties could not register placeholders.", ex); + } + } + + private void scheduleTasks() { + long cleanupTicks = ticks(configService.main().getString("storage.cleanup-expired-interval"), 600_000L); + if (cleanupTicks > 0L) { + cleanupTask = Bukkit.getScheduler().runTaskTimer(this, () -> { + int removed = bountyService.cleanupExpired(); + if (removed > 0 && configService.main().getBoolean("logging.console.admin-actions", true)) { + getLogger().info("Cleaned up " + removed + " expired bounties."); + } + }, cleanupTicks, cleanupTicks); + } + + long autosaveTicks = ticks(configService.main().getString("storage.autosave-interval"), 300_000L); + if (autosaveTicks > 0L) { + autosaveTask = Bukkit.getScheduler().runTaskTimer(this, () -> { + bountyService.save(); + historyService.save(); + playerCacheService.save(); + }, autosaveTicks, autosaveTicks); + } + } + + private long ticks(String duration, long fallbackMillis) { + long millis = TimeUtil.parseMillis(duration, fallbackMillis); + if (millis <= 0L) { + return 0L; + } + return Math.max(20L, millis / 50L); + } +} diff --git a/src/main/java/com/dirtbagmc/dirtbounties/command/BountyAdminCommand.java b/src/main/java/com/dirtbagmc/dirtbounties/command/BountyAdminCommand.java new file mode 100644 index 0000000..52a36d7 --- /dev/null +++ b/src/main/java/com/dirtbagmc/dirtbounties/command/BountyAdminCommand.java @@ -0,0 +1,193 @@ +package com.dirtbagmc.dirtbounties.command; + +import com.dirtbagmc.dirtbounties.DirtBountiesPlugin; +import com.dirtbagmc.dirtbounties.config.ConfigService; +import com.dirtbagmc.dirtbounties.gui.GuiManager; +import com.dirtbagmc.dirtbounties.model.BountyHistoryRecord; +import com.dirtbagmc.dirtbounties.model.SuspiciousActivity; +import com.dirtbagmc.dirtbounties.service.BountyService; +import com.dirtbagmc.dirtbounties.service.HistoryService; +import com.dirtbagmc.dirtbounties.service.MessageService; +import com.dirtbagmc.dirtbounties.util.TimeUtil; +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabExecutor; +import org.bukkit.entity.Player; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +public final class BountyAdminCommand implements TabExecutor { + private final DirtBountiesPlugin plugin; + private final ConfigService configService; + private final MessageService messages; + private final BountyService bountyService; + private final HistoryService historyService; + private final GuiManager guiManager; + + public BountyAdminCommand(DirtBountiesPlugin plugin, ConfigService configService, MessageService messages, + BountyService bountyService, HistoryService historyService, GuiManager guiManager) { + this.plugin = plugin; + this.configService = configService; + this.messages = messages; + this.bountyService = bountyService; + this.historyService = historyService; + this.guiManager = guiManager; + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (!sender.hasPermission("dirtbounties.admin")) { + messages.send(sender, "no-permission"); + return true; + } + if (args.length == 0 || args[0].equalsIgnoreCase("help")) { + messages.sendLines(sender, "admin-help", Map.of()); + return true; + } + + switch (args[0].toLowerCase(Locale.ROOT)) { + case "reload" -> { + plugin.reloadDirtBounties(); + messages.send(sender, "reload-complete"); + } + case "remove" -> remove(sender, args); + case "clearall" -> clearAll(sender, args); + case "set" -> set(sender, args); + case "expire" -> expire(sender, args); + case "history" -> history(sender, args); + case "suspicious" -> suspicious(sender); + case "gui" -> { + if (sender instanceof Player player) { + guiManager.openAdminMain(player); + } else { + messages.send(sender, "player-only"); + } + } + default -> messages.sendLines(sender, "admin-help", Map.of()); + } + return true; + } + + private void remove(CommandSender sender, String[] args) { + if (args.length < 2) { + messages.send(sender, "invalid-player"); + return; + } + OfflinePlayer target = bountyService.resolveTarget(args[1]); + double refund = configService.main().getDouble("refunds.on-admin-remove-percent", 100.0); + bountyService.adminRemove(sender, target, "ADMIN_REMOVE", refund); + } + + private void clearAll(CommandSender sender, String[] args) { + if (args.length < 2 || !args[1].equalsIgnoreCase("confirm")) { + messages.send(sender, "bounty.clearall-warning"); + return; + } + int count = bountyService.clearAll(sender); + messages.send(sender, "bounty.clearall-done", Map.of("count", String.valueOf(count))); + } + + private void set(CommandSender sender, String[] args) { + if (args.length < 3) { + messages.send(sender, "invalid-number"); + return; + } + OfflinePlayer target = bountyService.resolveTarget(args[1]); + double amount; + try { + amount = Double.parseDouble(args[2].replace(",", "")); + } catch (NumberFormatException ex) { + messages.send(sender, "invalid-number"); + return; + } + bountyService.adminSet(sender, target, amount); + } + + private void expire(CommandSender sender, String[] args) { + if (args.length < 2) { + messages.send(sender, "invalid-player"); + return; + } + OfflinePlayer target = bountyService.resolveTarget(args[1]); + double refund = configService.main().getDouble("refunds.on-expire-percent", 50.0); + bountyService.adminRemove(sender, target, "EXPIRED", refund); + } + + private void history(CommandSender sender, String[] args) { + if (args.length < 2) { + messages.send(sender, "invalid-player"); + return; + } + OfflinePlayer target = bountyService.resolveTarget(args[1]); + if (target == null) { + messages.send(sender, "invalid-player"); + return; + } + int limit = configService.main().getInt("history.max-records-per-player-command", 10); + List records = historyService.forPlayer(target.getUniqueId(), limit); + if (records.isEmpty()) { + messages.send(sender, "admin.history-empty", Map.of("target", target.getName() == null ? args[1] : target.getName())); + return; + } + for (BountyHistoryRecord record : records) { + messages.send(sender, "admin.history-line", Map.of( + "time", TimeUtil.formatTimestamp(record.createdAt()), + "type", record.type(), + "amount", bountyService.formatAmount(record.amount()), + "note", record.note() + )); + } + } + + private void suspicious(CommandSender sender) { + List records = historyService.suspiciousRecent(10); + if (records.isEmpty()) { + messages.send(sender, "admin.suspicious-empty"); + return; + } + for (SuspiciousActivity record : records) { + messages.send(sender, "admin.suspicious-line", Map.of( + "time", TimeUtil.formatTimestamp(record.createdAt()), + "type", record.type(), + "details", record.details() + )); + } + } + + @Override + public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { + if (!sender.hasPermission("dirtbounties.admin")) { + return List.of(); + } + if (args.length == 1) { + return filter(args[0], List.of("reload", "remove", "clearall", "set", "expire", "history", "suspicious", "gui", "help")); + } + if (args.length == 2 && List.of("remove", "set", "expire", "history").contains(args[0].toLowerCase(Locale.ROOT))) { + List names = new ArrayList<>(); + Bukkit.getOnlinePlayers().forEach(player -> names.add(player.getName())); + bountyService.activeBounties().forEach(bounty -> names.add(bounty.targetName())); + return filter(args[1], names); + } + if (args.length == 2 && args[0].equalsIgnoreCase("clearall")) { + return filter(args[1], List.of("confirm")); + } + if (args.length == 3 && args[0].equalsIgnoreCase("set")) { + return filter(args[2], List.of("100", "1000", "5000", "10000")); + } + return List.of(); + } + + private List filter(String input, List options) { + String lower = input.toLowerCase(Locale.ROOT); + return options.stream() + .distinct() + .filter(option -> option.toLowerCase(Locale.ROOT).startsWith(lower)) + .sorted(String.CASE_INSENSITIVE_ORDER) + .toList(); + } +} diff --git a/src/main/java/com/dirtbagmc/dirtbounties/command/BountyCommand.java b/src/main/java/com/dirtbagmc/dirtbounties/command/BountyCommand.java new file mode 100644 index 0000000..cb416f3 --- /dev/null +++ b/src/main/java/com/dirtbagmc/dirtbounties/command/BountyCommand.java @@ -0,0 +1,223 @@ +package com.dirtbagmc.dirtbounties.command; + +import com.dirtbagmc.dirtbounties.config.ConfigService; +import com.dirtbagmc.dirtbounties.gui.GuiManager; +import com.dirtbagmc.dirtbounties.model.Bounty; +import com.dirtbagmc.dirtbounties.service.BountyService; +import com.dirtbagmc.dirtbounties.service.MessageService; +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabExecutor; +import org.bukkit.entity.Player; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +public final class BountyCommand implements TabExecutor { + private final ConfigService configService; + private final MessageService messages; + private final BountyService bountyService; + private final GuiManager guiManager; + + public BountyCommand(ConfigService configService, MessageService messages, + BountyService bountyService, GuiManager guiManager) { + this.configService = configService; + this.messages = messages; + this.bountyService = bountyService; + this.guiManager = guiManager; + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (!sender.hasPermission("dirtbounties.use")) { + messages.send(sender, "no-permission"); + return true; + } + if (args.length == 0 || args[0].equalsIgnoreCase("gui")) { + if (!(sender instanceof Player player)) { + messages.send(sender, "player-only"); + return true; + } + guiManager.openMain(player, 0); + return true; + } + + switch (args[0].toLowerCase(Locale.ROOT)) { + case "help" -> messages.sendLines(sender, "help", Map.of()); + case "place" -> place(sender, args, false); + case "add", "increase" -> place(sender, args, true); + case "list" -> list(sender); + case "top" -> top(sender); + case "view" -> view(sender, args); + case "claiminfo" -> claimInfo(sender, args); + default -> messages.send(sender, "unknown-command"); + } + return true; + } + + private void place(CommandSender sender, String[] args, boolean increase) { + if (!(sender instanceof Player player)) { + messages.send(sender, "player-only"); + return; + } + if (!player.hasPermission(increase ? "dirtbounties.add" : "dirtbounties.place")) { + messages.send(player, "no-permission"); + return; + } + if (args.length < 3) { + messages.sendLines(player, "help", Map.of()); + return; + } + OfflinePlayer target = bountyService.resolveTarget(args[1]); + double amount; + try { + amount = Double.parseDouble(args[2].replace(",", "")); + } catch (NumberFormatException ex) { + messages.send(player, "invalid-number"); + return; + } + ReasonInput reason = parseReason(args, 3); + bountyService.placeBounty(player, target, amount, reason.reason(), reason.anonymous(), increase); + } + + private void list(CommandSender sender) { + if (!sender.hasPermission("dirtbounties.view")) { + messages.send(sender, "no-permission"); + return; + } + List bounties = bountyService.activeSorted(); + if (bounties.isEmpty()) { + messages.send(sender, "no-active-bounties"); + return; + } + int limit = Math.min(10, bounties.size()); + for (int i = 0; i < limit; i++) { + Bounty bounty = bounties.get(i); + messages.send(sender, "bounty.list-line", bountyService.bountyPlaceholders(bounty)); + } + } + + private void top(CommandSender sender) { + if (!sender.hasPermission("dirtbounties.top")) { + messages.send(sender, "no-permission"); + return; + } + List bounties = bountyService.topBounties(10); + if (bounties.isEmpty()) { + messages.send(sender, "no-active-bounties"); + return; + } + for (int i = 0; i < bounties.size(); i++) { + Bounty bounty = bounties.get(i); + Map placeholders = bountyService.bountyPlaceholders(bounty); + placeholders.put("rank", String.valueOf(i + 1)); + messages.send(sender, "bounty.top-line", placeholders); + } + } + + private void view(CommandSender sender, String[] args) { + if (!sender.hasPermission("dirtbounties.view")) { + messages.send(sender, "no-permission"); + return; + } + if (args.length < 2) { + messages.send(sender, "invalid-player"); + return; + } + OfflinePlayer target = bountyService.resolveTarget(args[1]); + if (target == null) { + messages.send(sender, "invalid-player"); + return; + } + Bounty bounty = bountyService.bounty(target.getUniqueId()).orElse(null); + if (bounty == null) { + messages.send(sender, "bounty-not-found", Map.of("target", target.getName() == null ? args[1] : target.getName())); + return; + } + messages.sendLines(sender, "bounty.view", bountyService.bountyPlaceholders(bounty)); + if (sender instanceof Player player) { + guiManager.openDetail(player, target.getUniqueId()); + } + } + + private void claimInfo(CommandSender sender, String[] args) { + if (!sender.hasPermission("dirtbounties.view")) { + messages.send(sender, "no-permission"); + return; + } + if (args.length < 2) { + messages.send(sender, "invalid-player"); + return; + } + OfflinePlayer target = bountyService.resolveTarget(args[1]); + if (target == null) { + messages.send(sender, "invalid-player"); + return; + } + Map placeholders = new HashMap<>(); + placeholders.put("target", target.getName() == null ? args[1] : target.getName()); + placeholders.put("pvp", yes(configService.main().getBoolean("claim-rules.require-pvp-kill", true))); + placeholders.put("same_ip", yes(configService.main().getBoolean("claim-rules.prevent-same-ip-claims", true))); + placeholders.put("worlds", configService.main().getString("claim-rules.worlds.mode", "blacklist") + + " " + configService.main().getStringList("claim-rules.worlds.list")); + placeholders.put("combat", yes(configService.main().getBoolean("claim-rules.combat.enabled", true))); + messages.sendLines(sender, "bounty.claiminfo", placeholders); + } + + private ReasonInput parseReason(String[] args, int start) { + boolean anonymous = false; + List parts = new ArrayList<>(); + for (int i = start; i < args.length; i++) { + if (args[i].equalsIgnoreCase("--anonymous") || args[i].equalsIgnoreCase("-a")) { + anonymous = true; + } else { + parts.add(args[i]); + } + } + String reason = parts.isEmpty() + ? configService.main().getString("bounties.default-reason", "No reason given.") + : String.join(" ", parts); + return new ReasonInput(reason, anonymous); + } + + @Override + public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { + if (args.length == 1) { + return filter(args[0], List.of("help", "place", "add", "list", "top", "view", "claiminfo", "gui")); + } + if (args.length == 2 && List.of("place", "add", "increase", "view", "claiminfo").contains(args[0].toLowerCase(Locale.ROOT))) { + List names = new ArrayList<>(); + Bukkit.getOnlinePlayers().forEach(player -> names.add(player.getName())); + bountyService.activeBounties().forEach(bounty -> names.add(bounty.targetName())); + return filter(args[1], names); + } + if (args.length == 3 && List.of("place", "add", "increase").contains(args[0].toLowerCase(Locale.ROOT))) { + return filter(args[2], List.of("100", "500", "1000", "5000", "10000")); + } + if (args.length >= 4 && List.of("place", "add", "increase").contains(args[0].toLowerCase(Locale.ROOT))) { + return filter(args[args.length - 1], List.of("--anonymous")); + } + return List.of(); + } + + private List filter(String input, List options) { + String lower = input.toLowerCase(Locale.ROOT); + return options.stream() + .distinct() + .filter(option -> option.toLowerCase(Locale.ROOT).startsWith(lower)) + .sorted(String.CASE_INSENSITIVE_ORDER) + .toList(); + } + + private String yes(boolean value) { + return value ? "yes" : "no"; + } + + private record ReasonInput(String reason, boolean anonymous) { + } +} diff --git a/src/main/java/com/dirtbagmc/dirtbounties/config/ConfigService.java b/src/main/java/com/dirtbagmc/dirtbounties/config/ConfigService.java new file mode 100644 index 0000000..9ccbbc5 --- /dev/null +++ b/src/main/java/com/dirtbagmc/dirtbounties/config/ConfigService.java @@ -0,0 +1,62 @@ +package com.dirtbagmc.dirtbounties.config; + +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.plugin.java.JavaPlugin; + +import java.io.File; + +public final class ConfigService { + private final JavaPlugin plugin; + private FileConfiguration mainConfig; + private YamlConfiguration messagesConfig; + private YamlConfiguration guiConfig; + private File messagesFile; + private File guiFile; + + public ConfigService(JavaPlugin plugin) { + this.plugin = plugin; + } + + public void load() { + plugin.getDataFolder().mkdirs(); + plugin.saveDefaultConfig(); + saveResourceIfMissing("messages.yml"); + saveResourceIfMissing("gui.yml"); + + plugin.reloadConfig(); + mainConfig = plugin.getConfig(); + messagesFile = new File(plugin.getDataFolder(), "messages.yml"); + guiFile = new File(plugin.getDataFolder(), "gui.yml"); + messagesConfig = YamlConfiguration.loadConfiguration(messagesFile); + guiConfig = YamlConfiguration.loadConfiguration(guiFile); + } + + public FileConfiguration main() { + return mainConfig; + } + + public YamlConfiguration messages() { + return messagesConfig; + } + + public YamlConfiguration gui() { + return guiConfig; + } + + public boolean debug() { + return mainConfig.getBoolean("server.debug", false); + } + + public ConfigurationSection guiSection(String path) { + return guiConfig.getConfigurationSection(path); + } + + private void saveResourceIfMissing(String name) { + File file = new File(plugin.getDataFolder(), name); + if (!file.exists()) { + plugin.saveResource(name, false); + } + } +} diff --git a/src/main/java/com/dirtbagmc/dirtbounties/economy/EconomyService.java b/src/main/java/com/dirtbagmc/dirtbounties/economy/EconomyService.java new file mode 100644 index 0000000..9464022 --- /dev/null +++ b/src/main/java/com/dirtbagmc/dirtbounties/economy/EconomyService.java @@ -0,0 +1,148 @@ +package com.dirtbagmc.dirtbounties.economy; + +import com.dirtbagmc.dirtbounties.config.ConfigService; +import com.dirtbagmc.dirtbounties.util.NumberUtil; +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.bukkit.plugin.RegisteredServiceProvider; +import org.bukkit.plugin.java.JavaPlugin; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.logging.Level; + +public final class EconomyService { + private final JavaPlugin plugin; + private final ConfigService configService; + private Class economyClass; + private Object provider; + private boolean ready; + private String providerName = "None"; + + public EconomyService(JavaPlugin plugin, ConfigService configService) { + this.plugin = plugin; + this.configService = configService; + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + public void initialize() { + ready = false; + provider = null; + providerName = "None"; + if (!configService.main().getBoolean("economy.enabled", true)) { + plugin.getLogger().info("Economy integration is disabled in config.yml."); + return; + } + + try { + economyClass = Class.forName("net.milkbowl.vault.economy.Economy", false, plugin.getClass().getClassLoader()); + RegisteredServiceProvider registration = Bukkit.getServicesManager().getRegistration((Class) economyClass); + if (registration == null || registration.getProvider() == null) { + logMissingProvider(); + return; + } + provider = registration.getProvider(); + providerName = provider.getClass().getSimpleName(); + ready = true; + if (configService.main().getBoolean("economy.provider-log-on-enable", true)) { + plugin.getLogger().info("Hooked economy provider through Vault services: " + providerName); + } + } catch (ClassNotFoundException | LinkageError ex) { + logMissingProvider(); + } catch (RuntimeException ex) { + plugin.getLogger().log(Level.WARNING, "Could not hook an economy provider.", ex); + } + } + + public boolean isReady() { + return ready && provider != null; + } + + public String providerName() { + return providerName; + } + + public double balance(OfflinePlayer player) { + if (!isReady()) { + return 0.0; + } + Object result = invoke("getBalance", new Class[]{OfflinePlayer.class}, player); + return result instanceof Number number ? number.doubleValue() : 0.0; + } + + public EconomyResult withdraw(OfflinePlayer player, double amount) { + return transaction("withdrawPlayer", player, amount); + } + + public EconomyResult deposit(OfflinePlayer player, double amount) { + return transaction("depositPlayer", player, amount); + } + + public String format(double amount) { + double clamped = NumberUtil.clampMoney(amount); + if (isReady()) { + Object result = invoke("format", new Class[]{double.class}, clamped); + if (result instanceof String formatted && !formatted.isBlank()) { + return formatted; + } + } + return configService.main().getString("economy.currency-format", "${amount}") + .replace("{amount}", NumberUtil.compact(clamped)); + } + + private EconomyResult transaction(String method, OfflinePlayer player, double amount) { + if (!isReady()) { + return EconomyResult.failure("Economy provider is missing."); + } + if (amount <= 0.0) { + return EconomyResult.ok(); + } + Object response = invoke(method, new Class[]{OfflinePlayer.class, double.class}, player, NumberUtil.clampMoney(amount)); + if (response == null) { + return EconomyResult.failure("Economy provider returned no response."); + } + try { + Method success = response.getClass().getMethod("transactionSuccess"); + boolean ok = Boolean.TRUE.equals(success.invoke(response)); + if (ok) { + return EconomyResult.ok(); + } + Method errorMessage = response.getClass().getMethod("errorMessage"); + Object message = errorMessage.invoke(response); + return EconomyResult.failure(message == null ? "Economy transaction failed." : message.toString()); + } catch (ReflectiveOperationException ex) { + return EconomyResult.failure("Could not read economy transaction response."); + } + } + + private Object invoke(String methodName, Class[] parameterTypes, Object... args) { + try { + Method method = provider.getClass().getMethod(methodName, parameterTypes); + return method.invoke(provider, args); + } catch (NoSuchMethodException ex) { + plugin.getLogger().warning("Economy provider does not support method: " + methodName); + } catch (IllegalAccessException | InvocationTargetException ex) { + plugin.getLogger().log(Level.WARNING, "Economy method failed: " + methodName, ex); + } + return null; + } + + private void logMissingProvider() { + String message = "No Vault-compatible economy provider is registered. Bounty economy actions will be blocked."; + if (configService.main().getBoolean("economy.fail-if-missing", false)) { + plugin.getLogger().severe(message); + } else if (configService.main().getBoolean("logging.console.economy-status", true)) { + plugin.getLogger().warning(message + " This also covers missing CMI Vault injector setup."); + } + } + + public record EconomyResult(boolean success, String message) { + public static EconomyResult ok() { + return new EconomyResult(true, ""); + } + + public static EconomyResult failure(String message) { + return new EconomyResult(false, message); + } + } +} diff --git a/src/main/java/com/dirtbagmc/dirtbounties/gui/GuiHolder.java b/src/main/java/com/dirtbagmc/dirtbounties/gui/GuiHolder.java new file mode 100644 index 0000000..ff4a8dc --- /dev/null +++ b/src/main/java/com/dirtbagmc/dirtbounties/gui/GuiHolder.java @@ -0,0 +1,40 @@ +package com.dirtbagmc.dirtbounties.gui; + +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; + +import java.util.UUID; + +public final class GuiHolder implements InventoryHolder { + private final GuiType type; + private final int page; + private final UUID targetUuid; + private Inventory inventory; + + public GuiHolder(GuiType type, int page, UUID targetUuid) { + this.type = type; + this.page = page; + this.targetUuid = targetUuid; + } + + @Override + public Inventory getInventory() { + return inventory; + } + + public void inventory(Inventory inventory) { + this.inventory = inventory; + } + + public GuiType type() { + return type; + } + + public int page() { + return page; + } + + public UUID targetUuid() { + return targetUuid; + } +} diff --git a/src/main/java/com/dirtbagmc/dirtbounties/gui/GuiManager.java b/src/main/java/com/dirtbagmc/dirtbounties/gui/GuiManager.java new file mode 100644 index 0000000..95401c5 --- /dev/null +++ b/src/main/java/com/dirtbagmc/dirtbounties/gui/GuiManager.java @@ -0,0 +1,589 @@ +package com.dirtbagmc.dirtbounties.gui; + +import com.dirtbagmc.dirtbounties.config.ConfigService; +import com.dirtbagmc.dirtbounties.model.Bounty; +import com.dirtbagmc.dirtbounties.model.BountyContribution; +import com.dirtbagmc.dirtbounties.model.BountyHistoryRecord; +import com.dirtbagmc.dirtbounties.model.SuspiciousActivity; +import com.dirtbagmc.dirtbounties.service.BountyService; +import com.dirtbagmc.dirtbounties.service.HistoryService; +import com.dirtbagmc.dirtbounties.service.MessageService; +import com.dirtbagmc.dirtbounties.util.ItemBuilder; +import com.dirtbagmc.dirtbounties.util.NumberUtil; +import com.dirtbagmc.dirtbounties.util.TimeUtil; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.OfflinePlayer; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.plugin.java.JavaPlugin; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; + +public final class GuiManager { + private final JavaPlugin plugin; + private final ConfigService configService; + private final MessageService messages; + private final BountyService bountyService; + private final HistoryService historyService; + private final Map pendingInputs = new HashMap<>(); + + public GuiManager(JavaPlugin plugin, ConfigService configService, MessageService messages, + BountyService bountyService, HistoryService historyService) { + this.plugin = plugin; + this.configService = configService; + this.messages = messages; + this.bountyService = bountyService; + this.historyService = historyService; + } + + public void openMain(Player player, int page) { + if (!enabled(player)) { + return; + } + List bounties = bountyService.activeSorted(); + Inventory inventory = create(GuiType.MAIN, page, null, "titles.main", Map.of()); + decorate(inventory); + fillBounties(inventory, bounties, page, false); + addMainButtons(inventory, page, bounties.size(), GuiType.MAIN, player); + player.openInventory(inventory); + messages.play(player, "gui.open-sound"); + } + + public void openTop(Player player, int page) { + if (!enabled(player)) { + return; + } + List bounties = bountyService.activeSorted(); + Inventory inventory = create(GuiType.TOP, page, null, "titles.top", Map.of()); + decorate(inventory); + fillBounties(inventory, bounties, page, true); + addMainButtons(inventory, page, bounties.size(), GuiType.TOP, player); + player.openInventory(inventory); + messages.play(player, "gui.open-sound"); + } + + public void openDetail(Player player, UUID targetUuid) { + Bounty bounty = bountyService.bounty(targetUuid).orElse(null); + if (bounty == null) { + messages.send(player, "bounty-not-found", Map.of("target", "Unknown")); + openMain(player, 0); + return; + } + Map placeholders = bountyService.bountyPlaceholders(bounty); + placeholders.put("reason_lines", reasonLines(bounty)); + placeholders.putAll(claimRulePlaceholders()); + + Inventory inventory = create(GuiType.DETAIL, 0, targetUuid, "titles.detail", placeholders); + decorate(inventory); + OfflinePlayer owner = Bukkit.getOfflinePlayer(targetUuid); + inventory.setItem(13, item("items.detail-head", placeholders, owner)); + inventory.setItem(22, item("items.claim-info", placeholders, null)); + inventory.setItem(30, item("items.add", placeholders, null)); + inventory.setItem(slot("layout.back-slot", 46), item("items.back", placeholders, null)); + inventory.setItem(slot("layout.refresh-slot", 49), item("items.refresh", placeholders, null)); + player.openInventory(inventory); + messages.play(player, "gui.open-sound"); + } + + public void openConfirm(Player player, PendingBountyInput input) { + Map placeholders = confirmPlaceholders(input); + Inventory inventory = create(GuiType.CONFIRM, 0, input.targetUuid(), "titles.confirm", placeholders); + decorate(inventory); + OfflinePlayer owner = input.targetUuid() == null ? null : Bukkit.getOfflinePlayer(input.targetUuid()); + inventory.setItem(13, ItemBuilder.simple(Material.PLAYER_HEAD, "&c&l{target}", + List.of("&7Amount: &f{amount}", "&7Total cost: &f{cost}", "&7Reason: &f{reason}"), + messages, placeholders)); + if (owner != null && inventory.getItem(13) != null) { + inventory.setItem(13, item("items.bounty", placeholders, owner)); + } + inventory.setItem(29, item("items.confirm", placeholders, owner)); + inventory.setItem(31, anonymousToggle(input)); + inventory.setItem(33, item("items.cancel", placeholders, null)); + player.openInventory(inventory); + messages.play(player, "gui.open-sound"); + } + + public void openAdminMain(Player player) { + if (!player.hasPermission("dirtbounties.admin")) { + messages.send(player, "no-permission"); + return; + } + Inventory inventory = create(GuiType.ADMIN_MAIN, 0, null, "titles.admin-main", Map.of()); + decorate(inventory); + inventory.setItem(20, item("items.admin-active", Map.of(), null)); + inventory.setItem(22, item("items.admin-history", Map.of(), null)); + inventory.setItem(24, item("items.admin-suspicious", Map.of(), null)); + inventory.setItem(slot("layout.back-slot", 46), item("items.back", Map.of(), null)); + player.openInventory(inventory); + messages.send(player, "gui.admin-opened"); + messages.play(player, "gui.open-sound"); + } + + public void openAdminHistory(Player player, int page) { + Inventory inventory = create(GuiType.ADMIN_HISTORY, page, null, "titles.admin-history", Map.of()); + decorate(inventory); + List slots = contentSlots(); + List records = historyService.recent(500); + int start = page * slots.size(); + for (int i = 0; i < slots.size() && start + i < records.size(); i++) { + BountyHistoryRecord record = records.get(start + i); + Map placeholders = new HashMap<>(); + placeholders.put("type", record.type()); + placeholders.put("target", record.targetName()); + placeholders.put("actor", record.actorName()); + placeholders.put("amount", bountyService.formatAmount(record.amount())); + placeholders.put("time", TimeUtil.formatTimestamp(record.createdAt())); + placeholders.put("note", record.note()); + inventory.setItem(slots.get(i), ItemBuilder.simple(Material.PAPER, "&e{type} &8| &f{target}", + List.of("&7Actor: &f{actor}", "&7Amount: &f{amount}", "&7When: &f{time}", "&7Note: &f{note}"), + messages, placeholders)); + } + addPageButtons(inventory, page, records.size(), GuiType.ADMIN_HISTORY); + inventory.setItem(slot("layout.back-slot", 46), item("items.back", Map.of(), null)); + player.openInventory(inventory); + } + + public void openAdminSuspicious(Player player, int page) { + Inventory inventory = create(GuiType.ADMIN_SUSPICIOUS, page, null, "titles.admin-suspicious", Map.of()); + decorate(inventory); + List slots = contentSlots(); + List records = historyService.suspiciousRecent(500); + int start = page * slots.size(); + for (int i = 0; i < slots.size() && start + i < records.size(); i++) { + SuspiciousActivity record = records.get(start + i); + Map placeholders = new HashMap<>(); + placeholders.put("type", record.type()); + placeholders.put("killer", record.killerName()); + placeholders.put("target", record.targetName()); + placeholders.put("severity", String.valueOf(record.severity())); + placeholders.put("time", TimeUtil.formatTimestamp(record.createdAt())); + placeholders.put("details", record.details()); + inventory.setItem(slots.get(i), ItemBuilder.simple(Material.REDSTONE_TORCH, "&c{type} &8| &fSeverity {severity}", + List.of("&7Killer: &f{killer}", "&7Target: &f{target}", "&7When: &f{time}", "&7Details: &f{details}"), + messages, placeholders)); + } + addPageButtons(inventory, page, records.size(), GuiType.ADMIN_SUSPICIOUS); + inventory.setItem(slot("layout.back-slot", 46), item("items.back", Map.of(), null)); + player.openInventory(inventory); + } + + public void handleClick(InventoryClickEvent event) { + if (!(event.getInventory().getHolder() instanceof GuiHolder holder)) { + return; + } + event.setCancelled(true); + if (!(event.getWhoClicked() instanceof Player player)) { + return; + } + if (event.getRawSlot() >= event.getView().getTopInventory().getSize()) { + return; + } + messages.play(player, "gui.click-sound"); + int slot = event.getRawSlot(); + switch (holder.type()) { + case MAIN, TOP -> handleBountyListClick(player, holder, slot); + case DETAIL -> handleDetailClick(player, holder, slot); + case CONFIRM -> handleConfirmClick(player, slot); + case ADMIN_MAIN -> handleAdminMainClick(player, slot); + case ADMIN_HISTORY -> handlePagedAdminClick(player, holder, slot, GuiType.ADMIN_HISTORY); + case ADMIN_SUSPICIOUS -> handlePagedAdminClick(player, holder, slot, GuiType.ADMIN_SUSPICIOUS); + } + } + + public boolean hasPendingInput(UUID playerUuid) { + return pendingInputs.containsKey(playerUuid); + } + + public void startPlaceFlow(Player player) { + if (!enabled(player)) { + return; + } + PendingBountyInput input = new PendingBountyInput(player.getUniqueId(), PendingBountyInput.Stage.TARGET, inputExpiresAt()); + pendingInputs.put(player.getUniqueId(), input); + player.closeInventory(); + messages.send(player, "gui.prompt-target"); + } + + public void startAddFlow(Player player, Bounty bounty) { + PendingBountyInput input = new PendingBountyInput(player.getUniqueId(), PendingBountyInput.Stage.AMOUNT, inputExpiresAt()); + input.targetUuid(bounty.targetUuid()); + input.targetName(bounty.targetName()); + input.increase(true); + pendingInputs.put(player.getUniqueId(), input); + player.closeInventory(); + messages.send(player, "gui.prompt-amount", Map.of("target", bounty.targetName())); + } + + public void acceptChatInput(Player player, String message) { + PendingBountyInput input = pendingInputs.get(player.getUniqueId()); + if (input == null) { + return; + } + if (System.currentTimeMillis() > input.expiresAt()) { + pendingInputs.remove(player.getUniqueId()); + messages.send(player, "gui.input-expired"); + return; + } + if (message.equalsIgnoreCase("cancel")) { + pendingInputs.remove(player.getUniqueId()); + messages.send(player, "gui.input-cancelled"); + return; + } + + switch (input.stage()) { + case TARGET -> acceptTarget(player, input, message); + case AMOUNT -> acceptAmount(player, input, message); + case REASON -> acceptReason(player, input, message); + case CONFIRM -> openConfirm(player, input); + } + } + + public void clear() { + pendingInputs.clear(); + } + + private void acceptTarget(Player player, PendingBountyInput input, String message) { + OfflinePlayer target = bountyService.resolveTarget(message); + if (target == null) { + messages.send(player, "invalid-player"); + messages.send(player, "gui.prompt-target"); + return; + } + input.targetUuid(target.getUniqueId()); + input.targetName(target.getName() == null ? message : target.getName()); + input.stage(PendingBountyInput.Stage.AMOUNT); + input.expiresAt(inputExpiresAt()); + messages.send(player, "gui.prompt-amount", Map.of("target", input.targetName())); + } + + private void acceptAmount(Player player, PendingBountyInput input, String message) { + double amount; + try { + amount = NumberUtil.clampMoney(Double.parseDouble(message.replace(",", ""))); + } catch (NumberFormatException ex) { + messages.send(player, "invalid-number"); + messages.send(player, "gui.prompt-amount", Map.of("target", input.targetName())); + return; + } + input.amount(amount); + input.expiresAt(inputExpiresAt()); + if (configService.main().getBoolean("bounties.allow-reasons", true)) { + input.stage(PendingBountyInput.Stage.REASON); + messages.send(player, "gui.prompt-reason", Map.of("target", input.targetName())); + } else { + input.reason(configService.main().getString("bounties.default-reason", "No reason given.")); + input.stage(PendingBountyInput.Stage.CONFIRM); + openConfirm(player, input); + } + } + + private void acceptReason(Player player, PendingBountyInput input, String message) { + input.reason(message.equals("-") ? configService.main().getString("bounties.default-reason", "No reason given.") : message); + input.stage(PendingBountyInput.Stage.CONFIRM); + input.expiresAt(inputExpiresAt()); + messages.send(player, "gui.confirm-opened"); + openConfirm(player, input); + } + + private void handleBountyListClick(Player player, GuiHolder holder, int slot) { + if (slot == slot("layout.place-slot", 48)) { + startPlaceFlow(player); + return; + } + if (slot == slot("layout.top-slot", 50)) { + if (holder.type() == GuiType.TOP) { + openMain(player, 0); + } else { + openTop(player, 0); + } + return; + } + if (slot == slot("layout.refresh-slot", 49)) { + if (holder.type() == GuiType.TOP) { + openTop(player, holder.page()); + } else { + openMain(player, holder.page()); + } + return; + } + if (slot == slot("layout.previous-slot", 45) && holder.page() > 0) { + if (holder.type() == GuiType.TOP) { + openTop(player, holder.page() - 1); + } else { + openMain(player, holder.page() - 1); + } + return; + } + if (slot == slot("layout.next-slot", 53)) { + if (holder.type() == GuiType.TOP) { + openTop(player, holder.page() + 1); + } else { + openMain(player, holder.page() + 1); + } + return; + } + if (slot == slot("layout.admin-slot", 52) && player.hasPermission("dirtbounties.admin")) { + openAdminMain(player); + return; + } + Bounty clicked = bountyAt(holder.page(), slot); + if (clicked != null) { + openDetail(player, clicked.targetUuid()); + } + } + + private void handleDetailClick(Player player, GuiHolder holder, int slot) { + if (slot == slot("layout.back-slot", 46)) { + openMain(player, 0); + return; + } + if (slot == slot("layout.refresh-slot", 49)) { + openDetail(player, holder.targetUuid()); + return; + } + if (slot == 30) { + Bounty bounty = bountyService.bounty(holder.targetUuid()).orElse(null); + if (bounty != null) { + startAddFlow(player, bounty); + } + } + } + + private void handleConfirmClick(Player player, int slot) { + PendingBountyInput input = pendingInputs.get(player.getUniqueId()); + if (input == null) { + player.closeInventory(); + return; + } + if (slot == 31) { + if (!configService.main().getBoolean("bounties.allow-anonymous", true) + || (configService.main().getBoolean("bounties.anonymous-requires-permission", true) + && !player.hasPermission("dirtbounties.anonymous"))) { + messages.send(player, "no-permission"); + messages.play(player, "gui.error-sound"); + return; + } + input.anonymous(!input.anonymous()); + openConfirm(player, input); + return; + } + if (slot == 33) { + pendingInputs.remove(player.getUniqueId()); + messages.send(player, "gui.input-cancelled"); + player.closeInventory(); + return; + } + if (slot == 29) { + OfflinePlayer target = input.targetUuid() == null ? bountyService.resolveTarget(input.targetName()) : Bukkit.getOfflinePlayer(input.targetUuid()); + boolean success = bountyService.placeBounty(player, target, input.amount(), input.reason(), input.anonymous(), input.increase()); + if (success) { + pendingInputs.remove(player.getUniqueId()); + messages.play(player, "gui.success-sound"); + if (configService.main().getBoolean("gui.close-on-confirm", true)) { + player.closeInventory(); + } else { + openMain(player, 0); + } + } else { + messages.play(player, "gui.error-sound"); + } + } + } + + private void handleAdminMainClick(Player player, int slot) { + if (slot == 20) { + openMain(player, 0); + } else if (slot == 22) { + openAdminHistory(player, 0); + } else if (slot == 24) { + openAdminSuspicious(player, 0); + } else if (slot == slot("layout.back-slot", 46)) { + openMain(player, 0); + } + } + + private void handlePagedAdminClick(Player player, GuiHolder holder, int slot, GuiType type) { + if (slot == slot("layout.back-slot", 46)) { + openAdminMain(player); + return; + } + if (slot == slot("layout.previous-slot", 45) && holder.page() > 0) { + if (type == GuiType.ADMIN_HISTORY) { + openAdminHistory(player, holder.page() - 1); + } else { + openAdminSuspicious(player, holder.page() - 1); + } + return; + } + if (slot == slot("layout.next-slot", 53)) { + if (type == GuiType.ADMIN_HISTORY) { + openAdminHistory(player, holder.page() + 1); + } else { + openAdminSuspicious(player, holder.page() + 1); + } + } + } + + private void fillBounties(Inventory inventory, List bounties, int page, boolean top) { + List slots = contentSlots(); + int start = page * slots.size(); + if (bounties.isEmpty()) { + inventory.setItem(22, item("items.empty", Map.of(), null)); + return; + } + for (int i = 0; i < slots.size() && start + i < bounties.size(); i++) { + Bounty bounty = bounties.get(start + i); + Map placeholders = bountyService.bountyPlaceholders(bounty); + placeholders.put("rank", String.valueOf(start + i + 1)); + ConfigurationSection section = configService.guiSection(top ? "items.top-bounty" : "items.bounty"); + inventory.setItem(slots.get(i), ItemBuilder.fromSection(section, messages, placeholders, Bukkit.getOfflinePlayer(bounty.targetUuid()))); + } + } + + private Bounty bountyAt(int page, int slot) { + List slots = contentSlots(); + int index = slots.indexOf(slot); + if (index < 0) { + return null; + } + int global = page * slots.size() + index; + List bounties = bountyService.activeSorted(); + return global >= 0 && global < bounties.size() ? bounties.get(global) : null; + } + + private void addMainButtons(Inventory inventory, int page, int totalItems, GuiType type, Player player) { + addPageButtons(inventory, page, totalItems, type); + inventory.setItem(slot("layout.place-slot", 48), item("items.place", Map.of(), null)); + inventory.setItem(slot("layout.top-slot", 50), item(type == GuiType.TOP ? "items.back" : "items.top", Map.of(), null)); + inventory.setItem(slot("layout.refresh-slot", 49), item("items.refresh", Map.of(), null)); + if (player.hasPermission("dirtbounties.admin")) { + inventory.setItem(slot("layout.admin-slot", 52), item("items.admin-active", Map.of(), null)); + } + } + + private void addPageButtons(Inventory inventory, int page, int totalItems, GuiType type) { + List slots = contentSlots(); + if (page > 0) { + inventory.setItem(slot("layout.previous-slot", 45), item("items.previous", Map.of(), null)); + } + if ((page + 1) * slots.size() < totalItems) { + inventory.setItem(slot("layout.next-slot", 53), item("items.next", Map.of(), null)); + } + if (type == GuiType.ADMIN_HISTORY || type == GuiType.ADMIN_SUSPICIOUS) { + inventory.setItem(slot("layout.refresh-slot", 49), item("items.refresh", Map.of(), null)); + } + } + + private Inventory create(GuiType type, int page, UUID targetUuid, String titlePath, Map placeholders) { + int size = Math.max(9, configService.gui().getInt("layout.size", 54)); + size = Math.min(54, ((size + 8) / 9) * 9); + GuiHolder holder = new GuiHolder(type, Math.max(0, page), targetUuid); + String title = configService.gui().getString(titlePath, "DirtBounties"); + Inventory inventory = Bukkit.createInventory(holder, size, messages.legacy(title, placeholders)); + holder.inventory(inventory); + return inventory; + } + + private void decorate(Inventory inventory) { + ItemStack filler = item("items.filler", Map.of(), null); + ItemStack border = item("items.border", Map.of(), null); + for (int i = 0; i < inventory.getSize(); i++) { + inventory.setItem(i, filler); + } + for (int i = 0; i < inventory.getSize(); i++) { + boolean isBorder = i < 9 || i >= inventory.getSize() - 9 || i % 9 == 0 || i % 9 == 8; + if (isBorder) { + inventory.setItem(i, border); + } + } + } + + private ItemStack item(String path, Map placeholders, OfflinePlayer owner) { + return ItemBuilder.fromSection(configService.guiSection(path), messages, placeholders, owner); + } + + private ItemStack anonymousToggle(PendingBountyInput input) { + String state = input.anonymous() ? "&aEnabled" : "&cDisabled"; + return ItemBuilder.simple(Material.NAME_TAG, "&6Anonymous", List.of("&7Current: " + state, "", "&eClick to toggle."), + messages, Map.of()); + } + + private Map confirmPlaceholders(PendingBountyInput input) { + BountyService.Fee fee = bountyService.previewFee(input.amount(), input.increase()); + Map placeholders = new HashMap<>(); + placeholders.put("target", input.targetName() == null ? "Unknown" : input.targetName()); + placeholders.put("amount", bountyService.formatAmount(fee.bountyAmount())); + placeholders.put("fee", bountyService.formatAmount(fee.feeAmount())); + placeholders.put("cost", bountyService.formatAmount(fee.totalCost())); + placeholders.put("reason", input.reason() == null || input.reason().isBlank() + ? configService.main().getString("bounties.default-reason", "No reason given.") + : input.reason()); + return placeholders; + } + + private Map claimRulePlaceholders() { + Map placeholders = new HashMap<>(); + placeholders.put("pvp", bool(configService.main().getBoolean("claim-rules.require-pvp-kill", true))); + placeholders.put("same_ip", bool(configService.main().getBoolean("claim-rules.prevent-same-ip-claims", true))); + placeholders.put("world_mode", configService.main().getString("claim-rules.worlds.mode", "blacklist")); + placeholders.put("combat", bool(configService.main().getBoolean("claim-rules.combat.enabled", true))); + return placeholders; + } + + private String reasonLines(Bounty bounty) { + List lines = new ArrayList<>(); + List contributions = bounty.contributions(); + int start = Math.max(0, contributions.size() - 5); + for (int i = contributions.size() - 1; i >= start; i--) { + BountyContribution contribution = contributions.get(i); + String placer = contribution.anonymous() ? "Anonymous" : contribution.placerName(); + lines.add("&8- &7" + placer + ": &f" + contribution.reason()); + } + if (lines.isEmpty()) { + lines.add("&8- &7No contribution notes."); + } + return String.join("\n", lines); + } + + private List contentSlots() { + List slots = configService.gui().getIntegerList("layout.content-slots"); + if (!slots.isEmpty()) { + return slots; + } + return List.of(10, 11, 12, 13, 14, 15, 16, 19, 20, 21, 22, 23, 24, 25, 28, 29, 30, 31, 32, 33, 34, 37, 38, 39, 40, 41, 42, 43); + } + + private int slot(String path, int fallback) { + return configService.gui().getInt(path, fallback); + } + + private boolean enabled(Player player) { + if (!configService.main().getBoolean("gui.enabled", true)) { + messages.send(player, "gui.unavailable"); + return false; + } + if (!player.hasPermission("dirtbounties.use")) { + messages.send(player, "no-permission"); + return false; + } + return true; + } + + private String bool(boolean value) { + return value ? "yes" : "no"; + } + + private long inputExpiresAt() { + long timeout = TimeUtil.parseMillis(configService.main().getString("gui.chat-input-timeout"), 60_000L); + return System.currentTimeMillis() + Math.max(5_000L, timeout); + } +} diff --git a/src/main/java/com/dirtbagmc/dirtbounties/gui/GuiType.java b/src/main/java/com/dirtbagmc/dirtbounties/gui/GuiType.java new file mode 100644 index 0000000..0be474e --- /dev/null +++ b/src/main/java/com/dirtbagmc/dirtbounties/gui/GuiType.java @@ -0,0 +1,11 @@ +package com.dirtbagmc.dirtbounties.gui; + +public enum GuiType { + MAIN, + TOP, + DETAIL, + CONFIRM, + ADMIN_MAIN, + ADMIN_HISTORY, + ADMIN_SUSPICIOUS +} diff --git a/src/main/java/com/dirtbagmc/dirtbounties/gui/PendingBountyInput.java b/src/main/java/com/dirtbagmc/dirtbounties/gui/PendingBountyInput.java new file mode 100644 index 0000000..2e36131 --- /dev/null +++ b/src/main/java/com/dirtbagmc/dirtbounties/gui/PendingBountyInput.java @@ -0,0 +1,96 @@ +package com.dirtbagmc.dirtbounties.gui; + +import java.util.UUID; + +public final class PendingBountyInput { + private final UUID playerUuid; + private UUID targetUuid; + private String targetName; + private double amount; + private String reason = ""; + private boolean anonymous; + private boolean increase; + private Stage stage; + private long expiresAt; + + public PendingBountyInput(UUID playerUuid, Stage stage, long expiresAt) { + this.playerUuid = playerUuid; + this.stage = stage; + this.expiresAt = expiresAt; + } + + public UUID playerUuid() { + return playerUuid; + } + + public UUID targetUuid() { + return targetUuid; + } + + public void targetUuid(UUID targetUuid) { + this.targetUuid = targetUuid; + } + + public String targetName() { + return targetName; + } + + public void targetName(String targetName) { + this.targetName = targetName; + } + + public double amount() { + return amount; + } + + public void amount(double amount) { + this.amount = amount; + } + + public String reason() { + return reason; + } + + public void reason(String reason) { + this.reason = reason; + } + + public boolean anonymous() { + return anonymous; + } + + public void anonymous(boolean anonymous) { + this.anonymous = anonymous; + } + + public boolean increase() { + return increase; + } + + public void increase(boolean increase) { + this.increase = increase; + } + + public Stage stage() { + return stage; + } + + public void stage(Stage stage) { + this.stage = stage; + } + + public long expiresAt() { + return expiresAt; + } + + public void expiresAt(long expiresAt) { + this.expiresAt = expiresAt; + } + + public enum Stage { + TARGET, + AMOUNT, + REASON, + CONFIRM + } +} diff --git a/src/main/java/com/dirtbagmc/dirtbounties/hook/DirtBountiesExpansion.java b/src/main/java/com/dirtbagmc/dirtbounties/hook/DirtBountiesExpansion.java new file mode 100644 index 0000000..41301dc --- /dev/null +++ b/src/main/java/com/dirtbagmc/dirtbounties/hook/DirtBountiesExpansion.java @@ -0,0 +1,95 @@ +package com.dirtbagmc.dirtbounties.hook; + +import com.dirtbagmc.dirtbounties.model.Bounty; +import com.dirtbagmc.dirtbounties.service.BountyService; +import com.dirtbagmc.dirtbounties.service.HistoryService; +import me.clip.placeholderapi.expansion.PlaceholderExpansion; +import org.bukkit.OfflinePlayer; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; + +import java.util.Comparator; + +public final class DirtBountiesExpansion extends PlaceholderExpansion { + private final BountyService bountyService; + private final HistoryService historyService; + private final String version; + + public DirtBountiesExpansion(BountyService bountyService, HistoryService historyService, String version) { + this.bountyService = bountyService; + this.historyService = historyService; + this.version = version; + } + + @Override + public @NotNull String getIdentifier() { + return "dirtbounties"; + } + + @Override + public @NotNull String getAuthor() { + return "DirtbagMC"; + } + + @Override + public @NotNull String getVersion() { + return version; + } + + @Override + public boolean persist() { + return true; + } + + @Override + public String onRequest(OfflinePlayer player, @NotNull String params) { + if (params.equalsIgnoreCase("total_active_bounties")) { + return String.valueOf(bountyService.activeBounties().size()); + } + if (params.equalsIgnoreCase("total_active_value")) { + return bountyService.formatAmount(bountyService.totalActiveValue()); + } + if (params.equalsIgnoreCase("top_target")) { + return bountyService.activeSorted().stream().findFirst().map(Bounty::targetName).orElse(""); + } + if (params.equalsIgnoreCase("top_amount")) { + return bountyService.activeSorted().stream().findFirst().map(bounty -> bountyService.formatAmount(bounty.amount())).orElse("0"); + } + if (player != null) { + if (params.equalsIgnoreCase("current_player_bounty") || params.equalsIgnoreCase("player_bounty")) { + return bountyService.bounty(player.getUniqueId()) + .map(bounty -> bountyService.formatAmount(bounty.amount())) + .orElse("0"); + } + if (params.equalsIgnoreCase("has_bounty")) { + return bountyService.bounty(player.getUniqueId()).isPresent() ? "yes" : "no"; + } + if (params.equalsIgnoreCase("claimed_count")) { + return String.valueOf(historyService.claimedCountBy(player.getUniqueId())); + } + } + if (params.startsWith("rank_")) { + int rank; + try { + rank = Integer.parseInt(params.substring("rank_".length())); + } catch (NumberFormatException ex) { + return ""; + } + if (rank <= 0) { + return ""; + } + return bountyService.activeSorted().stream() + .sorted(Comparator.comparingDouble(Bounty::amount).reversed()) + .skip(rank - 1L) + .findFirst() + .map(bounty -> bounty.targetName() + ":" + bountyService.formatAmount(bounty.amount())) + .orElse(""); + } + return null; + } + + @Override + public String onPlaceholderRequest(Player player, @NotNull String params) { + return onRequest(player, params); + } +} diff --git a/src/main/java/com/dirtbagmc/dirtbounties/listener/BountyListener.java b/src/main/java/com/dirtbagmc/dirtbounties/listener/BountyListener.java new file mode 100644 index 0000000..d0461f5 --- /dev/null +++ b/src/main/java/com/dirtbagmc/dirtbounties/listener/BountyListener.java @@ -0,0 +1,89 @@ +package com.dirtbagmc.dirtbounties.listener; + +import com.dirtbagmc.dirtbounties.gui.GuiManager; +import com.dirtbagmc.dirtbounties.service.BountyService; +import com.dirtbagmc.dirtbounties.service.CombatTracker; +import com.dirtbagmc.dirtbounties.service.PlayerCacheService; +import io.papermc.paper.event.player.AsyncChatEvent; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.bukkit.Bukkit; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.bukkit.entity.Projectile; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityDamageByEntityEvent; +import org.bukkit.event.entity.PlayerDeathEvent; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.projectiles.ProjectileSource; + +public final class BountyListener implements Listener { + private final JavaPlugin plugin; + private final GuiManager guiManager; + private final BountyService bountyService; + private final PlayerCacheService playerCacheService; + private final CombatTracker combatTracker; + + public BountyListener(JavaPlugin plugin, GuiManager guiManager, BountyService bountyService, + PlayerCacheService playerCacheService, CombatTracker combatTracker) { + this.plugin = plugin; + this.guiManager = guiManager; + this.bountyService = bountyService; + this.playerCacheService = playerCacheService; + this.combatTracker = combatTracker; + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onJoin(PlayerJoinEvent event) { + playerCacheService.update(event.getPlayer()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onDamage(EntityDamageByEntityEvent event) { + if (!(event.getEntity() instanceof Player victim)) { + return; + } + Player attacker = attacker(event.getDamager()); + if (attacker == null) { + return; + } + combatTracker.record(attacker, victim, event.getFinalDamage()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onDeath(PlayerDeathEvent event) { + bountyService.handleDeath(event.getEntity(), event.getEntity().getKiller()); + } + + @EventHandler(priority = EventPriority.HIGHEST) + public void onInventoryClick(InventoryClickEvent event) { + guiManager.handleClick(event); + } + + @EventHandler(priority = EventPriority.HIGHEST) + public void onChat(AsyncChatEvent event) { + Player player = event.getPlayer(); + if (!guiManager.hasPendingInput(player.getUniqueId())) { + return; + } + String plain = PlainTextComponentSerializer.plainText().serialize(event.message()).trim(); + event.setCancelled(true); + Bukkit.getScheduler().runTask(plugin, () -> guiManager.acceptChatInput(player, plain)); + } + + private Player attacker(Entity damager) { + if (damager instanceof Player player) { + return player; + } + if (damager instanceof Projectile projectile) { + ProjectileSource shooter = projectile.getShooter(); + if (shooter instanceof Player player) { + return player; + } + } + return null; + } +} diff --git a/src/main/java/com/dirtbagmc/dirtbounties/model/Bounty.java b/src/main/java/com/dirtbagmc/dirtbounties/model/Bounty.java new file mode 100644 index 0000000..eb1a885 --- /dev/null +++ b/src/main/java/com/dirtbagmc/dirtbounties/model/Bounty.java @@ -0,0 +1,96 @@ +package com.dirtbagmc.dirtbounties.model; + +import com.dirtbagmc.dirtbounties.util.NumberUtil; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +public final class Bounty { + private final UUID id; + private final UUID targetUuid; + private String targetName; + private double amount; + private final long createdAt; + private long updatedAt; + private long expiresAt; + private final List contributions; + + public Bounty(UUID id, UUID targetUuid, String targetName, double amount, long createdAt, + long updatedAt, long expiresAt, List contributions) { + this.id = id; + this.targetUuid = targetUuid; + this.targetName = targetName; + this.amount = NumberUtil.clampMoney(amount); + this.createdAt = createdAt; + this.updatedAt = updatedAt; + this.expiresAt = expiresAt; + this.contributions = new ArrayList<>(contributions == null ? List.of() : contributions); + } + + public UUID id() { + return id; + } + + public UUID targetUuid() { + return targetUuid; + } + + public String targetName() { + return targetName; + } + + public void targetName(String targetName) { + this.targetName = targetName; + } + + public double amount() { + return amount; + } + + public long createdAt() { + return createdAt; + } + + public long updatedAt() { + return updatedAt; + } + + public long expiresAt() { + return expiresAt; + } + + public void expiresAt(long expiresAt) { + this.expiresAt = expiresAt; + } + + public List contributions() { + return Collections.unmodifiableList(contributions); + } + + public void addContribution(BountyContribution contribution) { + contributions.add(contribution); + amount = NumberUtil.clampMoney(amount + contribution.amount()); + updatedAt = System.currentTimeMillis(); + } + + public void setAmount(double amount) { + this.amount = NumberUtil.clampMoney(amount); + this.updatedAt = System.currentTimeMillis(); + } + + public boolean isExpired(long now) { + return expiresAt > 0L && now >= expiresAt; + } + + public String topReason(String fallback) { + for (int i = contributions.size() - 1; i >= 0; i--) { + String reason = contributions.get(i).reason(); + if (reason != null && !reason.isBlank()) { + return reason; + } + } + return fallback; + } +} diff --git a/src/main/java/com/dirtbagmc/dirtbounties/model/BountyContribution.java b/src/main/java/com/dirtbagmc/dirtbounties/model/BountyContribution.java new file mode 100644 index 0000000..94584f8 --- /dev/null +++ b/src/main/java/com/dirtbagmc/dirtbounties/model/BountyContribution.java @@ -0,0 +1,58 @@ +package com.dirtbagmc.dirtbounties.model; + +import java.util.UUID; + +public final class BountyContribution { + private final UUID id; + private final UUID placerUuid; + private final String placerName; + private final double amount; + private final double fee; + private final String reason; + private final boolean anonymous; + private final long createdAt; + + public BountyContribution(UUID id, UUID placerUuid, String placerName, double amount, double fee, + String reason, boolean anonymous, long createdAt) { + this.id = id; + this.placerUuid = placerUuid; + this.placerName = placerName; + this.amount = amount; + this.fee = fee; + this.reason = reason; + this.anonymous = anonymous; + this.createdAt = createdAt; + } + + public UUID id() { + return id; + } + + public UUID placerUuid() { + return placerUuid; + } + + public String placerName() { + return placerName; + } + + public double amount() { + return amount; + } + + public double fee() { + return fee; + } + + public String reason() { + return reason; + } + + public boolean anonymous() { + return anonymous; + } + + public long createdAt() { + return createdAt; + } +} diff --git a/src/main/java/com/dirtbagmc/dirtbounties/model/BountyHistoryRecord.java b/src/main/java/com/dirtbagmc/dirtbounties/model/BountyHistoryRecord.java new file mode 100644 index 0000000..8eaa309 --- /dev/null +++ b/src/main/java/com/dirtbagmc/dirtbounties/model/BountyHistoryRecord.java @@ -0,0 +1,64 @@ +package com.dirtbagmc.dirtbounties.model; + +import java.util.UUID; + +public final class BountyHistoryRecord { + private final UUID id; + private final String type; + private final UUID targetUuid; + private final String targetName; + private final UUID actorUuid; + private final String actorName; + private final double amount; + private final String note; + private final long createdAt; + + public BountyHistoryRecord(UUID id, String type, UUID targetUuid, String targetName, UUID actorUuid, + String actorName, double amount, String note, long createdAt) { + this.id = id; + this.type = type; + this.targetUuid = targetUuid; + this.targetName = targetName; + this.actorUuid = actorUuid; + this.actorName = actorName; + this.amount = amount; + this.note = note; + this.createdAt = createdAt; + } + + public UUID id() { + return id; + } + + public String type() { + return type; + } + + public UUID targetUuid() { + return targetUuid; + } + + public String targetName() { + return targetName; + } + + public UUID actorUuid() { + return actorUuid; + } + + public String actorName() { + return actorName; + } + + public double amount() { + return amount; + } + + public String note() { + return note; + } + + public long createdAt() { + return createdAt; + } +} diff --git a/src/main/java/com/dirtbagmc/dirtbounties/model/ClaimValidation.java b/src/main/java/com/dirtbagmc/dirtbounties/model/ClaimValidation.java new file mode 100644 index 0000000..8647b20 --- /dev/null +++ b/src/main/java/com/dirtbagmc/dirtbounties/model/ClaimValidation.java @@ -0,0 +1,11 @@ +package com.dirtbagmc.dirtbounties.model; + +public record ClaimValidation(boolean allowed, String messageKey, String reason) { + public static ClaimValidation allow() { + return new ClaimValidation(true, "", ""); + } + + public static ClaimValidation denied(String messageKey, String reason) { + return new ClaimValidation(false, messageKey, reason); + } +} diff --git a/src/main/java/com/dirtbagmc/dirtbounties/model/SuspiciousActivity.java b/src/main/java/com/dirtbagmc/dirtbounties/model/SuspiciousActivity.java new file mode 100644 index 0000000..e02474a --- /dev/null +++ b/src/main/java/com/dirtbagmc/dirtbounties/model/SuspiciousActivity.java @@ -0,0 +1,64 @@ +package com.dirtbagmc.dirtbounties.model; + +import java.util.UUID; + +public final class SuspiciousActivity { + private final UUID id; + private final String type; + private final UUID killerUuid; + private final String killerName; + private final UUID targetUuid; + private final String targetName; + private final String details; + private final int severity; + private final long createdAt; + + public SuspiciousActivity(UUID id, String type, UUID killerUuid, String killerName, UUID targetUuid, + String targetName, String details, int severity, long createdAt) { + this.id = id; + this.type = type; + this.killerUuid = killerUuid; + this.killerName = killerName; + this.targetUuid = targetUuid; + this.targetName = targetName; + this.details = details; + this.severity = severity; + this.createdAt = createdAt; + } + + public UUID id() { + return id; + } + + public String type() { + return type; + } + + public UUID killerUuid() { + return killerUuid; + } + + public String killerName() { + return killerName; + } + + public UUID targetUuid() { + return targetUuid; + } + + public String targetName() { + return targetName; + } + + public String details() { + return details; + } + + public int severity() { + return severity; + } + + public long createdAt() { + return createdAt; + } +} diff --git a/src/main/java/com/dirtbagmc/dirtbounties/service/AntiAbuseService.java b/src/main/java/com/dirtbagmc/dirtbounties/service/AntiAbuseService.java new file mode 100644 index 0000000..07b8d95 --- /dev/null +++ b/src/main/java/com/dirtbagmc/dirtbounties/service/AntiAbuseService.java @@ -0,0 +1,78 @@ +package com.dirtbagmc.dirtbounties.service; + +import com.dirtbagmc.dirtbounties.config.ConfigService; +import com.dirtbagmc.dirtbounties.model.ClaimValidation; +import com.dirtbagmc.dirtbounties.util.TimeUtil; +import org.bukkit.entity.Player; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +public final class AntiAbuseService { + private final ConfigService configService; + private final HistoryService historyService; + private final Map runtimeKillerClaims = new HashMap<>(); + private final Map runtimeTargetClaims = new HashMap<>(); + private final Map runtimePairClaims = new HashMap<>(); + + public AntiAbuseService(ConfigService configService, HistoryService historyService) { + this.configService = configService; + this.historyService = historyService; + } + + public ClaimValidation validate(Player killer, Player target) { + if (!configService.main().getBoolean("anti-abuse.enabled", true) + || killer.hasPermission("dirtbounties.bypass.cooldowns")) { + return ClaimValidation.allow(); + } + long now = System.currentTimeMillis(); + long killerCooldown = TimeUtil.parseMillis(configService.main().getString("anti-abuse.killer-claim-cooldown"), 120_000L); + long targetCooldown = TimeUtil.parseMillis(configService.main().getString("anti-abuse.target-claim-cooldown"), 120_000L); + long pairCooldown = TimeUtil.parseMillis(configService.main().getString("anti-abuse.killer-target-pair-cooldown"), 43_200_000L); + + long lastKiller = Math.max(runtimeKillerClaims.getOrDefault(killer.getUniqueId(), 0L), + historyService.lastClaimByKiller(killer.getUniqueId())); + if (killerCooldown > 0L && now - lastKiller < killerCooldown) { + return ClaimValidation.denied("claim-denied.cooldown", "Killer claim cooldown"); + } + + long lastTarget = Math.max(runtimeTargetClaims.getOrDefault(target.getUniqueId(), 0L), + historyService.lastClaimOnTarget(target.getUniqueId())); + if (targetCooldown > 0L && now - lastTarget < targetCooldown) { + return ClaimValidation.denied("claim-denied.cooldown", "Target claim cooldown"); + } + + String pair = pair(killer.getUniqueId(), target.getUniqueId()); + long lastPair = Math.max(runtimePairClaims.getOrDefault(pair, 0L), + historyService.lastPairClaim(killer.getUniqueId(), target.getUniqueId())); + if (pairCooldown > 0L && now - lastPair < pairCooldown) { + return ClaimValidation.denied("claim-denied.cooldown", "Killer-target pair cooldown"); + } + + long window = TimeUtil.parseMillis(configService.main().getString("anti-abuse.pair-window"), 604_800_000L); + int max = configService.main().getInt("anti-abuse.max-pair-claims-in-window", 2); + if (max > 0 && historyService.claimCount(killer.getUniqueId(), target.getUniqueId(), now - window) >= max) { + return ClaimValidation.denied("claim-denied.pair-limit", "Killer-target pair claim limit"); + } + + return ClaimValidation.allow(); + } + + public void recordClaim(Player killer, Player target) { + long now = System.currentTimeMillis(); + runtimeKillerClaims.put(killer.getUniqueId(), now); + runtimeTargetClaims.put(target.getUniqueId(), now); + runtimePairClaims.put(pair(killer.getUniqueId(), target.getUniqueId()), now); + } + + public void clear() { + runtimeKillerClaims.clear(); + runtimeTargetClaims.clear(); + runtimePairClaims.clear(); + } + + private String pair(UUID killer, UUID target) { + return killer + ":" + target; + } +} diff --git a/src/main/java/com/dirtbagmc/dirtbounties/service/BountyService.java b/src/main/java/com/dirtbagmc/dirtbounties/service/BountyService.java new file mode 100644 index 0000000..3a071ae --- /dev/null +++ b/src/main/java/com/dirtbagmc/dirtbounties/service/BountyService.java @@ -0,0 +1,641 @@ +package com.dirtbagmc.dirtbounties.service; + +import com.dirtbagmc.dirtbounties.config.ConfigService; +import com.dirtbagmc.dirtbounties.economy.EconomyService; +import com.dirtbagmc.dirtbounties.model.Bounty; +import com.dirtbagmc.dirtbounties.model.BountyContribution; +import com.dirtbagmc.dirtbounties.model.ClaimValidation; +import com.dirtbagmc.dirtbounties.storage.StorageManager; +import com.dirtbagmc.dirtbounties.util.NumberUtil; +import com.dirtbagmc.dirtbounties.util.TimeUtil; +import com.dirtbagmc.dirtbounties.webhook.WebhookService; +import org.bukkit.Bukkit; +import org.bukkit.GameMode; +import org.bukkit.OfflinePlayer; +import org.bukkit.World; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.regex.Pattern; + +public final class BountyService { + private static final Pattern VALID_PLAYER_NAME = Pattern.compile("^[A-Za-z0-9_]{3,16}$"); + + private final JavaPlugin plugin; + private final ConfigService configService; + private final MessageService messages; + private final EconomyService economy; + private final StorageManager storage; + private final HistoryService history; + private final PlayerCacheService playerCache; + private final CombatTracker combatTracker; + private final AntiAbuseService antiAbuse; + private final WebhookService webhooks; + private final Map bounties = new HashMap<>(); + private final Map placementCooldowns = new HashMap<>(); + + public BountyService(JavaPlugin plugin, ConfigService configService, MessageService messages, EconomyService economy, + StorageManager storage, HistoryService history, PlayerCacheService playerCache, + CombatTracker combatTracker, AntiAbuseService antiAbuse, WebhookService webhooks) { + this.plugin = plugin; + this.configService = configService; + this.messages = messages; + this.economy = economy; + this.storage = storage; + this.history = history; + this.playerCache = playerCache; + this.combatTracker = combatTracker; + this.antiAbuse = antiAbuse; + this.webhooks = webhooks; + } + + public void load() { + bounties.clear(); + bounties.putAll(storage.loadBounties()); + } + + public void save() { + storage.saveBounties(bounties.values()); + } + + public void clearRuntimeState() { + placementCooldowns.clear(); + bounties.clear(); + } + + public Optional bounty(UUID targetUuid) { + return Optional.ofNullable(bounties.get(targetUuid)); + } + + public Collection activeBounties() { + return List.copyOf(bounties.values()); + } + + public List activeSorted() { + return bounties.values().stream() + .sorted(Comparator.comparingDouble(Bounty::amount).reversed() + .thenComparing(Bounty::updatedAt, Comparator.reverseOrder())) + .toList(); + } + + public List topBounties(int limit) { + return activeSorted().stream().limit(limit).toList(); + } + + public double totalActiveValue() { + return bounties.values().stream().mapToDouble(Bounty::amount).sum(); + } + + public OfflinePlayer resolveTarget(String name) { + if (name == null || !VALID_PLAYER_NAME.matcher(name).matches()) { + return null; + } + + Player exact = Bukkit.getPlayerExact(name); + if (exact != null) { + return exact; + } + for (Player online : Bukkit.getOnlinePlayers()) { + if (online.getName().equalsIgnoreCase(name)) { + return online; + } + } + for (OfflinePlayer offline : Bukkit.getOfflinePlayers()) { + if (offline.getName() != null && offline.getName().equalsIgnoreCase(name)) { + return offline; + } + } + if (configService.main().getBoolean("bounties.allow-never-joined-targets", false)) { + return Bukkit.getOfflinePlayer(name); + } + return null; + } + + public boolean placeBounty(Player placer, OfflinePlayer target, double requestedAmount, String reason, + boolean anonymous, boolean increaseOnly) { + String permission = increaseOnly ? "dirtbounties.add" : "dirtbounties.place"; + if (!placer.hasPermission(permission)) { + messages.send(placer, "no-permission"); + return false; + } + if (!validateTargetForPlacement(placer, target)) { + return false; + } + if (!economy.isReady()) { + messages.send(placer, "economy-missing"); + return false; + } + + requestedAmount = NumberUtil.clampMoney(requestedAmount); + double min = configService.main().getDouble("bounties.min-amount", 100.0); + double max = configService.main().getDouble("bounties.max-amount", 1_000_000.0); + if (requestedAmount < min) { + messages.send(placer, "amount-too-low", Map.of("min", economy.format(min))); + return false; + } + if (requestedAmount > max) { + messages.send(placer, "amount-too-high", Map.of("max", economy.format(max))); + return false; + } + + long cooldown = TimeUtil.parseMillis(configService.main().getString("bounties.placement-cooldown"), 30_000L); + long lastPlacement = placementCooldowns.getOrDefault(placer.getUniqueId(), 0L); + if (cooldown > 0L && !placer.hasPermission("dirtbounties.bypass.cooldowns") + && System.currentTimeMillis() - lastPlacement < cooldown) { + messages.send(placer, "cooldown", Map.of("time", TimeUtil.formatDuration(cooldown - (System.currentTimeMillis() - lastPlacement)))); + return false; + } + + reason = cleanReason(placer, reason); + if (reason == null) { + return false; + } + anonymous = sanitizeAnonymous(placer, anonymous); + + Bounty existing = bounties.get(target.getUniqueId()); + if (increaseOnly && existing == null) { + messages.send(placer, "bounty-not-found", Map.of("target", safeName(target))); + return false; + } + if (existing != null && !configService.main().getBoolean("bounties.stack-existing", true)) { + messages.send(placer, "amount-would-exceed-max", Map.of("max", economy.format(max))); + return false; + } + + Fee fee = calculateFee(requestedAmount, increaseOnly); + double previousTotal = existing == null ? 0.0 : existing.amount(); + if (previousTotal + fee.bountyAmount() > max) { + messages.send(placer, "amount-would-exceed-max", Map.of("max", economy.format(max))); + return false; + } + + double minimumBalanceAfter = configService.main().getDouble("economy.minimum-balance-after-withdraw", 0.0); + if (economy.balance(placer) - fee.totalCost() < minimumBalanceAfter) { + messages.send(placer, "not-enough-money", Map.of("cost", economy.format(fee.totalCost()))); + return false; + } + EconomyService.EconomyResult withdraw = economy.withdraw(placer, fee.totalCost()); + if (!withdraw.success()) { + messages.send(placer, "not-enough-money", Map.of("cost", economy.format(fee.totalCost()))); + return false; + } + + long now = System.currentTimeMillis(); + String targetName = safeName(target); + Bounty bounty = existing == null + ? new Bounty(UUID.randomUUID(), target.getUniqueId(), targetName, 0.0, now, now, defaultExpiresAt(now), List.of()) + : existing; + bounty.targetName(targetName); + bounty.addContribution(new BountyContribution(UUID.randomUUID(), placer.getUniqueId(), placer.getName(), + fee.bountyAmount(), fee.feeAmount(), reason, anonymous, now)); + bounties.put(target.getUniqueId(), bounty); + placementCooldowns.put(placer.getUniqueId(), now); + save(); + + String type = existing == null ? "PLACED" : "INCREASED"; + history.record(type, target.getUniqueId(), targetName, placer.getUniqueId(), placer.getName(), + fee.bountyAmount(), reason + (anonymous ? " (anonymous)" : "")); + + Map placeholders = bountyPlaceholders(bounty); + placeholders.put("placer", anonymous ? "Anonymous" : placer.getName()); + placeholders.put("amount", economy.format(fee.bountyAmount())); + placeholders.put("fee", economy.format(fee.feeAmount())); + placeholders.put("cost", economy.format(fee.totalCost())); + + messages.send(placer, existing == null ? "bounty.placed" : "bounty.added", placeholders); + maybeBroadcastPlacement(existing == null, previousTotal, bounty, placeholders); + if (configService.main().getBoolean("logging.console.placements", true)) { + plugin.getLogger().info(placer.getName() + " placed/increased bounty on " + targetName + " for " + fee.bountyAmount()); + } + webhooks.placement(placer.getName(), targetName, bounty.amount(), reason, existing != null); + return true; + } + + public boolean adminSet(CommandSender sender, OfflinePlayer target, double amount) { + if (target == null) { + messages.send(sender, "invalid-player"); + return false; + } + amount = NumberUtil.clampMoney(amount); + if (amount <= 0.0) { + messages.send(sender, "invalid-number"); + return false; + } + long now = System.currentTimeMillis(); + Bounty bounty = new Bounty(UUID.randomUUID(), target.getUniqueId(), safeName(target), amount, + now, now, defaultExpiresAt(now), List.of()); + bounties.put(target.getUniqueId(), bounty); + save(); + history.record("ADMIN_SET", target.getUniqueId(), safeName(target), actorUuid(sender), sender.getName(), amount, "Admin set"); + messages.send(sender, "bounty.set", bountyPlaceholders(bounty)); + webhooks.admin(sender.getName(), "Set bounty on " + safeName(target) + " to " + economy.format(amount)); + return true; + } + + public boolean adminRemove(CommandSender sender, OfflinePlayer target, String type, double refundPercent) { + if (target == null) { + messages.send(sender, "invalid-player"); + return false; + } + Bounty bounty = bounties.remove(target.getUniqueId()); + if (bounty == null) { + messages.send(sender, "bounty-not-found", Map.of("target", safeName(target))); + return false; + } + refundContributions(bounty, refundPercent, configService.main().getBoolean("refunds.refund-fees", false), sender); + save(); + history.record(type, bounty.targetUuid(), bounty.targetName(), actorUuid(sender), sender.getName(), bounty.amount(), "Admin action"); + messages.send(sender, "bounty.removed", bountyPlaceholders(bounty)); + webhooks.admin(sender.getName(), type + " bounty on " + bounty.targetName() + " (" + economy.format(bounty.amount()) + ")"); + return true; + } + + public int clearAll(CommandSender sender) { + int count = bounties.size(); + double refund = configService.main().getDouble("refunds.on-admin-remove-percent", 100.0); + for (Bounty bounty : new ArrayList<>(bounties.values())) { + refundContributions(bounty, refund, configService.main().getBoolean("refunds.refund-fees", false), sender); + history.record("ADMIN_REMOVE", bounty.targetUuid(), bounty.targetName(), actorUuid(sender), sender.getName(), bounty.amount(), "Clear all"); + } + bounties.clear(); + save(); + webhooks.admin(sender.getName(), "Cleared all active bounties: " + count); + return count; + } + + public int cleanupExpired() { + if (!configService.main().getBoolean("expiration.enabled", true)) { + return 0; + } + long now = System.currentTimeMillis(); + int removed = 0; + Iterator> iterator = bounties.entrySet().iterator(); + while (iterator.hasNext()) { + Bounty bounty = iterator.next().getValue(); + if (bounty.isExpired(now) || shouldExpireForTargetState(bounty)) { + iterator.remove(); + double refund = configService.main().getDouble("refunds.on-expire-percent", 50.0); + refundContributions(bounty, refund, configService.main().getBoolean("refunds.refund-fees", false), Bukkit.getConsoleSender()); + history.record("EXPIRED", bounty.targetUuid(), bounty.targetName(), null, "Console", bounty.amount(), "Expired"); + if (configService.main().getBoolean("logging.console.admin-actions", true)) { + plugin.getLogger().info("Expired bounty on " + bounty.targetName() + " worth " + bounty.amount()); + } + removed++; + } + } + if (removed > 0) { + save(); + } + return removed; + } + + public void handleDeath(Player victim, Player killer) { + Bounty bounty = bounties.get(victim.getUniqueId()); + if (bounty == null) { + return; + } + if (killer == null) { + if (configService.main().getBoolean("claim-rules.block-environmental-deaths", true) + && configService.main().getBoolean("anti-abuse.log-failed-claims", true)) { + history.suspicious("ENVIRONMENTAL_DEATH", null, victim, "Bounty target died without a player killer.", 1); + } + return; + } + + ClaimValidation validation = validateClaim(killer, victim, bounty); + if (!validation.allowed()) { + Map placeholders = new HashMap<>(); + placeholders.put("reason", messages.raw(validation.messageKey(), validation.reason())); + messages.send(killer, "claim-denied-format", placeholders); + if (configService.main().getBoolean("anti-abuse.log-failed-claims", true)) { + history.suspicious("CLAIM_DENIED", killer, victim, validation.reason(), severity(validation.reason())); + runSuspiciousCommands(killer, victim, validation.reason()); + } + return; + } + + if (!economy.isReady()) { + messages.send(killer, "economy-missing"); + plugin.getLogger().warning("Could not pay bounty claim because economy is missing."); + return; + } + + double payout = claimPayout(bounty.amount()); + EconomyService.EconomyResult deposit = economy.deposit(killer, payout); + if (!deposit.success()) { + plugin.getLogger().warning("Could not pay bounty claim to " + killer.getName() + ": " + deposit.message()); + return; + } + + bounties.remove(victim.getUniqueId()); + save(); + antiAbuse.recordClaim(killer, victim); + history.record("CLAIMED", victim.getUniqueId(), victim.getName(), killer.getUniqueId(), killer.getName(), + payout, "Killed bounty target"); + + Map placeholders = bountyPlaceholders(bounty); + placeholders.put("killer", killer.getName()); + placeholders.put("payout", economy.format(payout)); + messages.send(killer, "bounty.claimed", placeholders); + if (configService.main().getBoolean("economy.broadcasts.enabled", true) + && payout >= configService.main().getDouble("economy.broadcasts.claimed-threshold", 5000.0)) { + messages.broadcast("bounty.claim-broadcast", placeholders); + } + if (configService.main().getBoolean("logging.console.claims", true)) { + plugin.getLogger().info(killer.getName() + " claimed bounty on " + victim.getName() + " for " + payout); + } + webhooks.claim(killer.getName(), victim.getName(), payout); + } + + public ClaimValidation validateClaim(Player killer, Player target, Bounty bounty) { + if (configService.main().getBoolean("claim-rules.require-permission", true) + && !killer.hasPermission("dirtbounties.claim")) { + return ClaimValidation.denied("claim-denied.no-permission", "No claim permission"); + } + if (killer.getUniqueId().equals(target.getUniqueId())) { + return ClaimValidation.denied("claim-denied.self", "Self claim"); + } + + boolean bypass = configService.main().getBoolean("claim-rules.allow-bypass-permission", true) + && killer.hasPermission("dirtbounties.bypass.claimrules"); + if (bypass) { + return ClaimValidation.allow(); + } + + if (blockedWorld(target.getWorld())) { + return ClaimValidation.denied("claim-denied.world", "World blocked"); + } + if (blockedGameMode(killer.getGameMode())) { + return ClaimValidation.denied("claim-denied.gamemode", "Blocked game mode"); + } + if (configService.main().getBoolean("claim-rules.prevent-same-ip-claims", true) + && playerCache.sameLastIp(killer.getUniqueId(), target.getUniqueId())) { + return ClaimValidation.denied("claim-denied.same-ip", "Same last IP"); + } + if (configService.main().getBoolean("claim-rules.prevent-shared-known-ip-claims", true) + && playerCache.sharedKnownIp(killer.getUniqueId(), target.getUniqueId())) { + return ClaimValidation.denied("claim-denied.shared-known-ip", "Shared known IP"); + } + if (!combatTracker.meetsRequirement(killer, target)) { + return ClaimValidation.denied("claim-denied.combat", "Combat requirement failed"); + } + + ClaimValidation abuse = antiAbuse.validate(killer, target); + if (!abuse.allowed()) { + return abuse; + } + + return bounty == null + ? ClaimValidation.denied("claim-denied.no-bounty", "No bounty") + : ClaimValidation.allow(); + } + + public Map bountyPlaceholders(Bounty bounty) { + Map placeholders = new HashMap<>(); + placeholders.put("target", bounty.targetName()); + placeholders.put("amount", economy.format(bounty.amount())); + placeholders.put("total", economy.format(bounty.amount())); + placeholders.put("contributors", String.valueOf(bounty.contributions().size())); + placeholders.put("reason", bounty.topReason(configService.main().getString("bounties.default-reason", "No reason given."))); + placeholders.put("expires", expiresText(bounty)); + return placeholders; + } + + public String expiresText(Bounty bounty) { + if (bounty.expiresAt() <= 0L) { + return "never"; + } + long remaining = bounty.expiresAt() - System.currentTimeMillis(); + return remaining <= 0L ? "expired" : TimeUtil.formatDuration(remaining); + } + + public String formatAmount(double amount) { + return economy.format(amount); + } + + public Fee previewFee(double amount, boolean increase) { + return calculateFee(amount, increase); + } + + private boolean validateTargetForPlacement(Player placer, OfflinePlayer target) { + if (target == null) { + messages.send(placer, "invalid-player"); + return false; + } + if (!configService.main().getBoolean("bounties.allow-offline-targets", true) && !target.isOnline()) { + messages.send(placer, "invalid-player"); + return false; + } + if (!configService.main().getBoolean("bounties.allow-never-joined-targets", false) + && !target.hasPlayedBefore() + && !target.isOnline()) { + messages.send(placer, "invalid-player"); + return false; + } + if (!configService.main().getBoolean("bounties.allow-self-target", false) + && placer.getUniqueId().equals(target.getUniqueId())) { + messages.send(placer, "target-self"); + return false; + } + if (!configService.main().getBoolean("bounties.allow-banned-targets", false) && target.isBanned()) { + messages.send(placer, "target-banned"); + return false; + } + return true; + } + + private String cleanReason(Player player, String reason) { + if (!configService.main().getBoolean("bounties.allow-reasons", true)) { + return configService.main().getString("bounties.default-reason", "No reason given."); + } + String cleaned = reason == null || reason.isBlank() + ? configService.main().getString("bounties.default-reason", "No reason given.") + : reason.trim(); + int max = configService.main().getInt("bounties.max-reason-length", 80); + if (cleaned.length() > max) { + messages.send(player, "reason-too-long", Map.of("max", String.valueOf(max))); + return null; + } + return cleaned; + } + + private boolean sanitizeAnonymous(Player player, boolean requested) { + if (!requested || !configService.main().getBoolean("bounties.allow-anonymous", true)) { + return false; + } + if (configService.main().getBoolean("bounties.anonymous-requires-permission", true) + && !player.hasPermission("dirtbounties.anonymous")) { + return false; + } + return true; + } + + private Fee calculateFee(double amount, boolean increase) { + double percent = configService.main().getDouble(increase ? "economy.fees.add-percent" : "economy.fees.placement-percent", 5.0); + String mode = configService.main().getString(increase ? "economy.fees.add-mode" : "economy.fees.placement-mode", "extra"); + double fee = NumberUtil.clampMoney(amount * Math.max(0.0, percent) / 100.0); + double bountyAmount = "deduct".equalsIgnoreCase(mode) ? NumberUtil.clampMoney(amount - fee) : amount; + bountyAmount = Math.max(0.0, bountyAmount); + double totalCost = "deduct".equalsIgnoreCase(mode) ? amount : NumberUtil.clampMoney(amount + fee); + return new Fee(NumberUtil.clampMoney(bountyAmount), fee, NumberUtil.clampMoney(totalCost)); + } + + private void maybeBroadcastPlacement(boolean newBounty, double previousTotal, Bounty bounty, Map placeholders) { + if (!configService.main().getBoolean("economy.broadcasts.enabled", true)) { + return; + } + double threshold = configService.main().getDouble("economy.broadcasts.placed-threshold", 5000.0); + if (bounty.amount() >= threshold) { + messages.broadcast(newBounty ? "bounty.broadcast-placed" : "bounty.broadcast-added", placeholders); + } + for (Object object : configService.main().getList("economy.broadcasts.milestone-thresholds", List.of())) { + double milestone = parseDouble(object); + if (milestone > 0.0 && previousTotal < milestone && bounty.amount() >= milestone) { + messages.broadcast("bounty.milestone", placeholders); + } + } + } + + private void refundContributions(Bounty bounty, double percent, boolean includeFees, CommandSender actor) { + if (!economy.isReady() || percent <= 0.0) { + return; + } + boolean refundOffline = configService.main().getBoolean("refunds.refund-offline-players", true); + double minimumRefund = configService.main().getDouble("refunds.minimum-refund", 0.01); + for (BountyContribution contribution : bounty.contributions()) { + if (contribution.placerUuid() == null) { + continue; + } + OfflinePlayer player = Bukkit.getOfflinePlayer(contribution.placerUuid()); + if (!refundOffline && !player.isOnline()) { + continue; + } + double refund = NumberUtil.clampMoney((contribution.amount() + (includeFees ? contribution.fee() : 0.0)) * percent / 100.0); + if (refund < minimumRefund) { + continue; + } + EconomyService.EconomyResult result = economy.deposit(player, refund); + if (result.success()) { + history.record("REFUND", bounty.targetUuid(), bounty.targetName(), contribution.placerUuid(), + player.getName() == null ? contribution.placerName() : player.getName(), refund, "Refunded by " + actor.getName()); + } else { + plugin.getLogger().warning("Failed bounty refund to " + contribution.placerName() + ": " + result.message()); + } + } + } + + private long defaultExpiresAt(long now) { + if (!configService.main().getBoolean("expiration.enabled", true)) { + return 0L; + } + long duration = TimeUtil.parseMillis(configService.main().getString("expiration.default-duration"), 1_209_600_000L); + long max = TimeUtil.parseMillis(configService.main().getString("expiration.max-duration"), 2_592_000_000L); + if (max > 0L) { + duration = Math.min(duration, max); + } + return duration <= 0L ? 0L : now + duration; + } + + private boolean shouldExpireForTargetState(Bounty bounty) { + OfflinePlayer target = Bukkit.getOfflinePlayer(bounty.targetUuid()); + if ("expire".equalsIgnoreCase(configService.main().getString("expiration.banned-player-action", "keep")) + && target.isBanned()) { + return true; + } + return "expire".equalsIgnoreCase(configService.main().getString("expiration.deleted-player-action", "keep")) + && !target.hasPlayedBefore() + && !target.isOnline(); + } + + private boolean blockedWorld(World world) { + String mode = configService.main().getString("claim-rules.worlds.mode", "blacklist"); + if (mode == null || mode.equalsIgnoreCase("disabled")) { + return false; + } + boolean listed = configService.main().getStringList("claim-rules.worlds.list").stream() + .anyMatch(name -> name.equalsIgnoreCase(world.getName())); + if (mode.equalsIgnoreCase("whitelist")) { + return !listed; + } + if (mode.equalsIgnoreCase("blacklist")) { + return listed; + } + return false; + } + + private boolean blockedGameMode(GameMode mode) { + for (String configured : configService.main().getStringList("claim-rules.blocked-killer-game-modes")) { + if (configured.equalsIgnoreCase(mode.name())) { + return true; + } + } + return false; + } + + private double claimPayout(double amount) { + double tax = Math.max(0.0, configService.main().getDouble("economy.fees.claim-tax-percent", 0.0)); + double sink = Math.max(0.0, configService.main().getDouble("economy.fees.claim-sink-percent", 0.0)); + double removed = Math.min(100.0, tax + sink); + return NumberUtil.clampMoney(amount * (100.0 - removed) / 100.0); + } + + private int severity(String reason) { + String lower = reason == null ? "" : reason.toLowerCase(Locale.ROOT); + if (lower.contains("ip")) { + return 5; + } + if (lower.contains("cooldown") || lower.contains("limit")) { + return 3; + } + return 1; + } + + private void runSuspiciousCommands(Player killer, Player target, String reason) { + if (!configService.main().getBoolean("anti-abuse.run-console-commands-on-suspicious", false)) { + return; + } + for (String command : configService.main().getStringList("anti-abuse.suspicious-commands")) { + String parsed = command + .replace("{killer}", killer.getName()) + .replace("{target}", target.getName()) + .replace("{reason}", reason == null ? "" : reason); + Bukkit.dispatchCommand(Bukkit.getConsoleSender(), parsed); + } + } + + private UUID actorUuid(CommandSender sender) { + return sender instanceof Player player ? player.getUniqueId() : null; + } + + private String safeName(OfflinePlayer player) { + return player.getName() == null ? player.getUniqueId().toString() : player.getName(); + } + + private double parseDouble(Object object) { + if (object instanceof Number number) { + return number.doubleValue(); + } + if (object instanceof String string) { + try { + return Double.parseDouble(string); + } catch (NumberFormatException ignored) { + return 0.0; + } + } + return 0.0; + } + + public record Fee(double bountyAmount, double feeAmount, double totalCost) { + } +} diff --git a/src/main/java/com/dirtbagmc/dirtbounties/service/CombatTracker.java b/src/main/java/com/dirtbagmc/dirtbounties/service/CombatTracker.java new file mode 100644 index 0000000..2f9714d --- /dev/null +++ b/src/main/java/com/dirtbagmc/dirtbounties/service/CombatTracker.java @@ -0,0 +1,112 @@ +package com.dirtbagmc.dirtbounties.service; + +import com.dirtbagmc.dirtbounties.config.ConfigService; +import com.dirtbagmc.dirtbounties.util.TimeUtil; +import org.bukkit.entity.Player; + +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; +import java.util.UUID; + +public final class CombatTracker { + private final ConfigService configService; + private final Map records = new HashMap<>(); + + public CombatTracker(ConfigService configService) { + this.configService = configService; + } + + public void record(Player attacker, Player victim, double damage) { + if (attacker.getUniqueId().equals(victim.getUniqueId())) { + return; + } + long now = System.currentTimeMillis(); + String key = key(attacker.getUniqueId(), victim.getUniqueId()); + CombatRecord record = records.computeIfAbsent(key, ignored -> new CombatRecord(now)); + record.lastAt(now); + record.damage(record.damage() + Math.max(0.0, damage)); + record.hits(record.hits() + 1); + cleanup(now); + } + + public boolean meetsRequirement(Player attacker, Player victim) { + if (!configService.main().getBoolean("claim-rules.combat.enabled", true)) { + return true; + } + long now = System.currentTimeMillis(); + cleanup(now); + CombatRecord record = records.get(key(attacker.getUniqueId(), victim.getUniqueId())); + if (record == null) { + return false; + } + long window = TimeUtil.parseMillis(configService.main().getString("claim-rules.combat.window"), 30_000L); + if (now - record.lastAt() > window) { + return false; + } + double minDamage = configService.main().getDouble("claim-rules.combat.min-damage", 4.0); + int minHits = configService.main().getInt("claim-rules.combat.min-hits", 1); + long minDuration = TimeUtil.parseMillis(configService.main().getString("claim-rules.combat.min-combat-duration"), 0L); + return record.damage() >= minDamage + && record.hits() >= minHits + && record.lastAt() - record.firstAt() >= minDuration; + } + + public void clear() { + records.clear(); + } + + private void cleanup(long now) { + long window = TimeUtil.parseMillis(configService.main().getString("claim-rules.combat.window"), 30_000L); + Iterator> iterator = records.entrySet().iterator(); + while (iterator.hasNext()) { + if (now - iterator.next().getValue().lastAt() > window) { + iterator.remove(); + } + } + } + + private String key(UUID attacker, UUID victim) { + return attacker + ":" + victim; + } + + private static final class CombatRecord { + private final long firstAt; + private long lastAt; + private double damage; + private int hits; + + private CombatRecord(long firstAt) { + this.firstAt = firstAt; + this.lastAt = firstAt; + } + + public long firstAt() { + return firstAt; + } + + public long lastAt() { + return lastAt; + } + + public void lastAt(long lastAt) { + this.lastAt = lastAt; + } + + public double damage() { + return damage; + } + + public void damage(double damage) { + this.damage = damage; + } + + public int hits() { + return hits; + } + + public void hits(int hits) { + this.hits = hits; + } + } +} diff --git a/src/main/java/com/dirtbagmc/dirtbounties/service/HistoryService.java b/src/main/java/com/dirtbagmc/dirtbounties/service/HistoryService.java new file mode 100644 index 0000000..24eb4d1 --- /dev/null +++ b/src/main/java/com/dirtbagmc/dirtbounties/service/HistoryService.java @@ -0,0 +1,163 @@ +package com.dirtbagmc.dirtbounties.service; + +import com.dirtbagmc.dirtbounties.config.ConfigService; +import com.dirtbagmc.dirtbounties.model.BountyHistoryRecord; +import com.dirtbagmc.dirtbounties.model.SuspiciousActivity; +import com.dirtbagmc.dirtbounties.storage.StorageManager; +import com.dirtbagmc.dirtbounties.util.TimeUtil; +import org.bukkit.OfflinePlayer; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Iterator; +import java.util.List; +import java.util.UUID; + +public final class HistoryService { + private final ConfigService configService; + private final StorageManager storageManager; + private final List records = new ArrayList<>(); + private final List suspicious = new ArrayList<>(); + + public HistoryService(ConfigService configService, StorageManager storageManager) { + this.configService = configService; + this.storageManager = storageManager; + } + + public void load() { + records.clear(); + suspicious.clear(); + records.addAll(storageManager.loadHistory()); + suspicious.addAll(storageManager.loadSuspicious()); + prune(); + } + + public void save() { + storageManager.saveHistory(records); + storageManager.saveSuspicious(suspicious); + } + + public void record(String type, UUID targetUuid, String targetName, UUID actorUuid, String actorName, + double amount, String note) { + if (!configService.main().getBoolean("history.enabled", true)) { + return; + } + records.add(0, new BountyHistoryRecord(UUID.randomUUID(), type, targetUuid, targetName, + actorUuid, actorName, amount, note, System.currentTimeMillis())); + prune(); + if (configService.main().getBoolean("storage.write-history-to-disk-immediately", true)) { + storageManager.saveHistory(records); + } + } + + public void suspicious(String type, OfflinePlayer killer, OfflinePlayer target, String details, int severity) { + suspicious.add(0, new SuspiciousActivity(UUID.randomUUID(), type, + killer == null ? null : killer.getUniqueId(), + killer == null ? "Unknown" : safeName(killer), + target == null ? null : target.getUniqueId(), + target == null ? "Unknown" : safeName(target), + details, + severity, + System.currentTimeMillis())); + prune(); + if (configService.main().getBoolean("storage.write-history-to-disk-immediately", true)) { + storageManager.saveSuspicious(suspicious); + } + } + + public List forPlayer(UUID uuid, int limit) { + return records.stream() + .filter(record -> uuid.equals(record.targetUuid()) || uuid.equals(record.actorUuid())) + .sorted(Comparator.comparingLong(BountyHistoryRecord::createdAt).reversed()) + .limit(limit) + .toList(); + } + + public List recent(int limit) { + return records.stream() + .sorted(Comparator.comparingLong(BountyHistoryRecord::createdAt).reversed()) + .limit(limit) + .toList(); + } + + public List suspiciousRecent(int limit) { + return suspicious.stream() + .sorted(Comparator.comparingLong(SuspiciousActivity::createdAt).reversed()) + .limit(limit) + .toList(); + } + + public int claimCount(UUID killer, UUID target, long since) { + int count = 0; + for (BountyHistoryRecord record : records) { + if ("CLAIMED".equalsIgnoreCase(record.type()) + && killer.equals(record.actorUuid()) + && target.equals(record.targetUuid()) + && record.createdAt() >= since) { + count++; + } + } + return count; + } + + public long lastClaimByKiller(UUID killer) { + return records.stream() + .filter(record -> "CLAIMED".equalsIgnoreCase(record.type()) && killer.equals(record.actorUuid())) + .mapToLong(BountyHistoryRecord::createdAt) + .max() + .orElse(0L); + } + + public long lastClaimOnTarget(UUID target) { + return records.stream() + .filter(record -> "CLAIMED".equalsIgnoreCase(record.type()) && target.equals(record.targetUuid())) + .mapToLong(BountyHistoryRecord::createdAt) + .max() + .orElse(0L); + } + + public long lastPairClaim(UUID killer, UUID target) { + return records.stream() + .filter(record -> "CLAIMED".equalsIgnoreCase(record.type()) + && killer.equals(record.actorUuid()) + && target.equals(record.targetUuid())) + .mapToLong(BountyHistoryRecord::createdAt) + .max() + .orElse(0L); + } + + public long claimedCountBy(UUID killer) { + return records.stream() + .filter(record -> "CLAIMED".equalsIgnoreCase(record.type()) && killer.equals(record.actorUuid())) + .count(); + } + + private void prune() { + long olderThan = TimeUtil.parseMillis(configService.main().getString("history.prune-older-than"), 7_776_000_000L); + if (olderThan > 0L) { + long cutoff = System.currentTimeMillis() - olderThan; + records.removeIf(record -> record.createdAt() < cutoff); + suspicious.removeIf(record -> record.createdAt() < cutoff); + } + + int maxRecords = configService.main().getInt("history.max-records", 2000); + trim(records, maxRecords); + + int maxSuspicious = configService.main().getInt("anti-abuse.suspicious-history-limit", 500); + trim(suspicious, maxSuspicious); + } + + private void trim(List list, int max) { + if (max <= 0) { + list.clear(); + return; + } + while (list.size() > max) { + list.remove(list.size() - 1); + } + } + + private String safeName(OfflinePlayer player) { + return player.getName() == null ? player.getUniqueId().toString() : player.getName(); + } +} diff --git a/src/main/java/com/dirtbagmc/dirtbounties/service/MessageService.java b/src/main/java/com/dirtbagmc/dirtbounties/service/MessageService.java new file mode 100644 index 0000000..ad2d7e4 --- /dev/null +++ b/src/main/java/com/dirtbagmc/dirtbounties/service/MessageService.java @@ -0,0 +1,103 @@ +package com.dirtbagmc.dirtbounties.service; + +import com.dirtbagmc.dirtbounties.config.ConfigService; +import com.dirtbagmc.dirtbounties.util.ColorUtil; +import net.kyori.adventure.text.Component; +import org.bukkit.Bukkit; +import org.bukkit.NamespacedKey; +import org.bukkit.Registry; +import org.bukkit.Sound; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +public final class MessageService { + private final ConfigService configService; + + public MessageService(ConfigService configService) { + this.configService = configService; + } + + public void send(CommandSender sender, String path) { + send(sender, path, Map.of()); + } + + public void send(CommandSender sender, String path, Map placeholders) { + String raw = configService.messages().getString(path); + if (raw == null || raw.isBlank()) { + return; + } + sender.sendMessage(component(raw, placeholders)); + } + + public void sendLines(CommandSender sender, String path, Map placeholders) { + List lines = configService.messages().getStringList(path); + if (lines.isEmpty()) { + send(sender, path, placeholders); + return; + } + for (String line : lines) { + sender.sendMessage(component(line, placeholders)); + } + } + + public void broadcast(String path, Map placeholders) { + String raw = configService.messages().getString(path); + if (raw == null || raw.isBlank()) { + return; + } + Component component = component(raw, placeholders); + Bukkit.getOnlinePlayers().forEach(player -> player.sendMessage(component)); + Bukkit.getConsoleSender().sendMessage(component); + } + + public Component component(String raw, Map placeholders) { + return ColorUtil.component(apply(raw, placeholders)); + } + + public String legacy(String raw, Map placeholders) { + return ColorUtil.legacySection(apply(raw, placeholders)); + } + + public List components(List lines, Map placeholders) { + return lines.stream().map(line -> component(line, placeholders)).toList(); + } + + public String raw(String path, String fallback) { + return configService.messages().getString(path, fallback); + } + + public void play(Player player, String configPath) { + String soundName = configService.main().getString(configPath, ""); + if (soundName == null || soundName.isBlank() || soundName.equalsIgnoreCase("none")) { + return; + } + Sound sound = resolveSound(soundName); + if (sound != null) { + player.playSound(player.getLocation(), sound, 1.0f, 1.0f); + } else if (configService.debug()) { + Bukkit.getLogger().warning("[DirtBounties] Unknown sound in config: " + soundName); + } + } + + private Sound resolveSound(String soundName) { + String normalized = soundName.toLowerCase(Locale.ROOT); + NamespacedKey key = normalized.contains(":") + ? NamespacedKey.fromString(normalized) + : NamespacedKey.minecraft(normalized.replace('_', '.')); + return key == null ? null : Registry.SOUNDS.get(key); + } + + private String apply(String raw, Map placeholders) { + Map merged = new HashMap<>(); + merged.put("prefix", configService.messages().getString("prefix", "")); + if (placeholders != null) { + merged.putAll(placeholders); + } + return ColorUtil.replace(raw, merged); + } +} diff --git a/src/main/java/com/dirtbagmc/dirtbounties/service/PlayerCacheService.java b/src/main/java/com/dirtbagmc/dirtbounties/service/PlayerCacheService.java new file mode 100644 index 0000000..ecc2060 --- /dev/null +++ b/src/main/java/com/dirtbagmc/dirtbounties/service/PlayerCacheService.java @@ -0,0 +1,126 @@ +package com.dirtbagmc.dirtbounties.service; + +import com.dirtbagmc.dirtbounties.config.ConfigService; +import com.dirtbagmc.dirtbounties.storage.StorageManager; +import org.bukkit.entity.Player; + +import java.net.InetSocketAddress; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +public final class PlayerCacheService { + private final ConfigService configService; + private final StorageManager storageManager; + private final Map cache = new HashMap<>(); + + public PlayerCacheService(ConfigService configService, StorageManager storageManager) { + this.configService = configService; + this.storageManager = storageManager; + } + + public void load() { + cache.clear(); + storageManager.loadPlayerCache().forEach((uuid, data) -> + cache.put(uuid, new CacheEntry(data.name(), data.lastIp(), new HashSet<>(data.knownIps()), data.lastSeen()))); + } + + public void save() { + if (!configService.main().getBoolean("storage.save-player-ip-cache", true)) { + return; + } + Map data = new HashMap<>(); + cache.forEach((uuid, entry) -> + data.put(uuid, new StorageManager.PlayerCacheData(entry.name(), entry.lastIp(), entry.knownIps(), entry.lastSeen()))); + storageManager.savePlayerCache(data); + } + + public void update(Player player) { + String ip = ip(player); + CacheEntry entry = cache.computeIfAbsent(player.getUniqueId(), + ignored -> new CacheEntry(player.getName(), "", new HashSet<>(), 0L)); + entry.name(player.getName()); + entry.lastSeen(System.currentTimeMillis()); + if (!ip.isBlank()) { + entry.lastIp(ip); + entry.knownIps().add(ip); + } + } + + public boolean sameLastIp(UUID first, UUID second) { + CacheEntry left = cache.get(first); + CacheEntry right = cache.get(second); + return left != null && right != null && !left.lastIp().isBlank() && left.lastIp().equals(right.lastIp()); + } + + public boolean sharedKnownIp(UUID first, UUID second) { + CacheEntry left = cache.get(first); + CacheEntry right = cache.get(second); + if (left == null || right == null) { + return false; + } + for (String ip : left.knownIps()) { + if (!ip.isBlank() && right.knownIps().contains(ip)) { + return true; + } + } + return false; + } + + public String cachedName(UUID uuid, String fallback) { + CacheEntry entry = cache.get(uuid); + return entry == null || entry.name().isBlank() ? fallback : entry.name(); + } + + private String ip(Player player) { + InetSocketAddress address = player.getAddress(); + if (address == null || address.getAddress() == null) { + return ""; + } + return address.getAddress().getHostAddress(); + } + + private static final class CacheEntry { + private String name; + private String lastIp; + private final Set knownIps; + private long lastSeen; + + private CacheEntry(String name, String lastIp, Set knownIps, long lastSeen) { + this.name = name; + this.lastIp = lastIp == null ? "" : lastIp; + this.knownIps = knownIps; + this.lastSeen = lastSeen; + } + + public String name() { + return name; + } + + public void name(String name) { + this.name = name; + } + + public String lastIp() { + return lastIp; + } + + public void lastIp(String lastIp) { + this.lastIp = lastIp; + } + + public Set knownIps() { + return knownIps; + } + + public long lastSeen() { + return lastSeen; + } + + public void lastSeen(long lastSeen) { + this.lastSeen = lastSeen; + } + } +} diff --git a/src/main/java/com/dirtbagmc/dirtbounties/storage/StorageManager.java b/src/main/java/com/dirtbagmc/dirtbounties/storage/StorageManager.java new file mode 100644 index 0000000..3c424bf --- /dev/null +++ b/src/main/java/com/dirtbagmc/dirtbounties/storage/StorageManager.java @@ -0,0 +1,296 @@ +package com.dirtbagmc.dirtbounties.storage; + +import com.dirtbagmc.dirtbounties.model.Bounty; +import com.dirtbagmc.dirtbounties.model.BountyContribution; +import com.dirtbagmc.dirtbounties.model.BountyHistoryRecord; +import com.dirtbagmc.dirtbounties.model.SuspiciousActivity; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.plugin.java.JavaPlugin; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.logging.Level; + +public final class StorageManager { + private final JavaPlugin plugin; + private File bountiesFile; + private File historyFile; + private File suspiciousFile; + private File playerCacheFile; + + public StorageManager(JavaPlugin plugin) { + this.plugin = plugin; + } + + public void initialize() { + plugin.getDataFolder().mkdirs(); + bountiesFile = ensureFile("bounties.yml"); + historyFile = ensureFile("history.yml"); + suspiciousFile = ensureFile("suspicious.yml"); + playerCacheFile = ensureFile("player-cache.yml"); + } + + public Map loadBounties() { + Map loaded = new HashMap<>(); + YamlConfiguration yaml = YamlConfiguration.loadConfiguration(bountiesFile); + ConfigurationSection root = yaml.getConfigurationSection("bounties"); + if (root == null) { + return loaded; + } + + for (String key : root.getKeys(false)) { + ConfigurationSection section = root.getConfigurationSection(key); + if (section == null) { + continue; + } + try { + UUID targetUuid = UUID.fromString(section.getString("target-uuid", key)); + UUID bountyId = UUID.fromString(section.getString("id", UUID.randomUUID().toString())); + List contributions = new ArrayList<>(); + ConfigurationSection contributionsSection = section.getConfigurationSection("contributions"); + if (contributionsSection != null) { + for (String contributionKey : contributionsSection.getKeys(false)) { + ConfigurationSection contribution = contributionsSection.getConfigurationSection(contributionKey); + if (contribution == null) { + continue; + } + UUID placerUuid = uuidOrNull(contribution.getString("placer-uuid")); + contributions.add(new BountyContribution( + UUID.fromString(contribution.getString("id", contributionKey)), + placerUuid, + contribution.getString("placer-name", "Console"), + contribution.getDouble("amount"), + contribution.getDouble("fee"), + contribution.getString("reason", ""), + contribution.getBoolean("anonymous"), + contribution.getLong("created-at") + )); + } + } + Bounty bounty = new Bounty( + bountyId, + targetUuid, + section.getString("target-name", "Unknown"), + section.getDouble("amount"), + section.getLong("created-at"), + section.getLong("updated-at"), + section.getLong("expires-at"), + contributions + ); + loaded.put(targetUuid, bounty); + } catch (RuntimeException ex) { + plugin.getLogger().log(Level.WARNING, "Skipping invalid bounty entry " + key, ex); + } + } + return loaded; + } + + public void saveBounties(Collection bounties) { + YamlConfiguration yaml = new YamlConfiguration(); + for (Bounty bounty : bounties) { + String path = "bounties." + bounty.targetUuid(); + yaml.set(path + ".id", bounty.id().toString()); + yaml.set(path + ".target-uuid", bounty.targetUuid().toString()); + yaml.set(path + ".target-name", bounty.targetName()); + yaml.set(path + ".amount", bounty.amount()); + yaml.set(path + ".created-at", bounty.createdAt()); + yaml.set(path + ".updated-at", bounty.updatedAt()); + yaml.set(path + ".expires-at", bounty.expiresAt()); + for (BountyContribution contribution : bounty.contributions()) { + String contributionPath = path + ".contributions." + contribution.id(); + yaml.set(contributionPath + ".id", contribution.id().toString()); + yaml.set(contributionPath + ".placer-uuid", contribution.placerUuid() == null ? null : contribution.placerUuid().toString()); + yaml.set(contributionPath + ".placer-name", contribution.placerName()); + yaml.set(contributionPath + ".amount", contribution.amount()); + yaml.set(contributionPath + ".fee", contribution.fee()); + yaml.set(contributionPath + ".reason", contribution.reason()); + yaml.set(contributionPath + ".anonymous", contribution.anonymous()); + yaml.set(contributionPath + ".created-at", contribution.createdAt()); + } + } + save(yaml, bountiesFile); + } + + public List loadHistory() { + List records = new ArrayList<>(); + YamlConfiguration yaml = YamlConfiguration.loadConfiguration(historyFile); + ConfigurationSection root = yaml.getConfigurationSection("records"); + if (root == null) { + return records; + } + for (String key : root.getKeys(false)) { + ConfigurationSection section = root.getConfigurationSection(key); + if (section == null) { + continue; + } + try { + records.add(new BountyHistoryRecord( + UUID.fromString(section.getString("id", key)), + section.getString("type", "UNKNOWN"), + uuidOrNull(section.getString("target-uuid")), + section.getString("target-name", "Unknown"), + uuidOrNull(section.getString("actor-uuid")), + section.getString("actor-name", "Console"), + section.getDouble("amount"), + section.getString("note", ""), + section.getLong("created-at") + )); + } catch (RuntimeException ex) { + plugin.getLogger().log(Level.WARNING, "Skipping invalid history entry " + key, ex); + } + } + records.sort((left, right) -> Long.compare(right.createdAt(), left.createdAt())); + return records; + } + + public void saveHistory(Collection records) { + YamlConfiguration yaml = new YamlConfiguration(); + for (BountyHistoryRecord record : records) { + String path = "records." + record.id(); + yaml.set(path + ".id", record.id().toString()); + yaml.set(path + ".type", record.type()); + yaml.set(path + ".target-uuid", record.targetUuid() == null ? null : record.targetUuid().toString()); + yaml.set(path + ".target-name", record.targetName()); + yaml.set(path + ".actor-uuid", record.actorUuid() == null ? null : record.actorUuid().toString()); + yaml.set(path + ".actor-name", record.actorName()); + yaml.set(path + ".amount", record.amount()); + yaml.set(path + ".note", record.note()); + yaml.set(path + ".created-at", record.createdAt()); + } + save(yaml, historyFile); + } + + public List loadSuspicious() { + List records = new ArrayList<>(); + YamlConfiguration yaml = YamlConfiguration.loadConfiguration(suspiciousFile); + ConfigurationSection root = yaml.getConfigurationSection("records"); + if (root == null) { + return records; + } + for (String key : root.getKeys(false)) { + ConfigurationSection section = root.getConfigurationSection(key); + if (section == null) { + continue; + } + try { + records.add(new SuspiciousActivity( + UUID.fromString(section.getString("id", key)), + section.getString("type", "UNKNOWN"), + uuidOrNull(section.getString("killer-uuid")), + section.getString("killer-name", "Unknown"), + uuidOrNull(section.getString("target-uuid")), + section.getString("target-name", "Unknown"), + section.getString("details", ""), + section.getInt("severity", 1), + section.getLong("created-at") + )); + } catch (RuntimeException ex) { + plugin.getLogger().log(Level.WARNING, "Skipping invalid suspicious entry " + key, ex); + } + } + records.sort((left, right) -> Long.compare(right.createdAt(), left.createdAt())); + return records; + } + + public void saveSuspicious(Collection records) { + YamlConfiguration yaml = new YamlConfiguration(); + for (SuspiciousActivity record : records) { + String path = "records." + record.id(); + yaml.set(path + ".id", record.id().toString()); + yaml.set(path + ".type", record.type()); + yaml.set(path + ".killer-uuid", record.killerUuid() == null ? null : record.killerUuid().toString()); + yaml.set(path + ".killer-name", record.killerName()); + yaml.set(path + ".target-uuid", record.targetUuid() == null ? null : record.targetUuid().toString()); + yaml.set(path + ".target-name", record.targetName()); + yaml.set(path + ".details", record.details()); + yaml.set(path + ".severity", record.severity()); + yaml.set(path + ".created-at", record.createdAt()); + } + save(yaml, suspiciousFile); + } + + public Map loadPlayerCache() { + Map cache = new HashMap<>(); + YamlConfiguration yaml = YamlConfiguration.loadConfiguration(playerCacheFile); + ConfigurationSection root = yaml.getConfigurationSection("players"); + if (root == null) { + return cache; + } + for (String key : root.getKeys(false)) { + ConfigurationSection section = root.getConfigurationSection(key); + if (section == null) { + continue; + } + try { + UUID uuid = UUID.fromString(key); + cache.put(uuid, new PlayerCacheData( + section.getString("name", "Unknown"), + section.getString("last-ip", ""), + new HashSet<>(section.getStringList("known-ips")), + section.getLong("last-seen") + )); + } catch (RuntimeException ex) { + plugin.getLogger().log(Level.WARNING, "Skipping invalid player cache entry " + key, ex); + } + } + return cache; + } + + public void savePlayerCache(Map cache) { + YamlConfiguration yaml = new YamlConfiguration(); + for (Map.Entry entry : cache.entrySet()) { + String path = "players." + entry.getKey(); + PlayerCacheData data = entry.getValue(); + yaml.set(path + ".name", data.name()); + yaml.set(path + ".last-ip", data.lastIp()); + yaml.set(path + ".known-ips", new ArrayList<>(data.knownIps())); + yaml.set(path + ".last-seen", data.lastSeen()); + } + save(yaml, playerCacheFile); + } + + private File ensureFile(String name) { + File file = new File(plugin.getDataFolder(), name); + if (!file.exists()) { + try { + if (file.createNewFile()) { + plugin.getLogger().fine("Created " + name); + } + } catch (IOException ex) { + plugin.getLogger().log(Level.SEVERE, "Could not create " + name, ex); + } + } + return file; + } + + private void save(YamlConfiguration yaml, File file) { + try { + yaml.save(file); + } catch (IOException ex) { + plugin.getLogger().log(Level.SEVERE, "Could not save " + file.getName(), ex); + } + } + + private UUID uuidOrNull(String input) { + if (input == null || input.isBlank()) { + return null; + } + return UUID.fromString(input); + } + + public record PlayerCacheData(String name, String lastIp, Set knownIps, long lastSeen) { + public PlayerCacheData { + knownIps = knownIps == null ? Set.of() : Set.copyOf(knownIps); + } + } +} diff --git a/src/main/java/com/dirtbagmc/dirtbounties/util/ColorUtil.java b/src/main/java/com/dirtbagmc/dirtbounties/util/ColorUtil.java new file mode 100644 index 0000000..049d026 --- /dev/null +++ b/src/main/java/com/dirtbagmc/dirtbounties/util/ColorUtil.java @@ -0,0 +1,63 @@ +package com.dirtbagmc.dirtbounties.util; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; + +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class ColorUtil { + private static final Pattern HEX_PATTERN = Pattern.compile("&#([A-Fa-f0-9]{6})"); + private static final LegacyComponentSerializer LEGACY = LegacyComponentSerializer.builder() + .character('&') + .hexColors() + .build(); + + private ColorUtil() { + } + + public static Component component(String input) { + return LEGACY.deserialize(translateHex(input == null ? "" : input)); + } + + public static String legacySection(String input) { + String translated = translateHex(input == null ? "" : input); + StringBuilder builder = new StringBuilder(translated.length()); + for (int i = 0; i < translated.length(); i++) { + char current = translated.charAt(i); + if (current == '&' && i + 1 < translated.length()) { + builder.append('§'); + } else { + builder.append(current); + } + } + return builder.toString(); + } + + public static String replace(String input, Map placeholders) { + String value = input == null ? "" : input; + if (placeholders == null || placeholders.isEmpty()) { + return value; + } + for (Map.Entry entry : placeholders.entrySet()) { + value = value.replace("{" + entry.getKey() + "}", entry.getValue() == null ? "" : entry.getValue()); + } + return value; + } + + private static String translateHex(String input) { + Matcher matcher = HEX_PATTERN.matcher(input); + StringBuilder builder = new StringBuilder(); + while (matcher.find()) { + String hex = matcher.group(1); + StringBuilder replacement = new StringBuilder("&x"); + for (char c : hex.toCharArray()) { + replacement.append('&').append(c); + } + matcher.appendReplacement(builder, Matcher.quoteReplacement(replacement.toString())); + } + matcher.appendTail(builder); + return builder.toString(); + } +} diff --git a/src/main/java/com/dirtbagmc/dirtbounties/util/ItemBuilder.java b/src/main/java/com/dirtbagmc/dirtbounties/util/ItemBuilder.java new file mode 100644 index 0000000..62543ad --- /dev/null +++ b/src/main/java/com/dirtbagmc/dirtbounties/util/ItemBuilder.java @@ -0,0 +1,86 @@ +package com.dirtbagmc.dirtbounties.util; + +import com.dirtbagmc.dirtbounties.service.MessageService; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.OfflinePlayer; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.inventory.ItemFlag; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.inventory.meta.SkullMeta; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +public final class ItemBuilder { + private ItemBuilder() { + } + + public static ItemStack fromSection(ConfigurationSection section, MessageService messages, + Map placeholders, OfflinePlayer skullOwner) { + if (section == null) { + return new ItemStack(Material.STONE); + } + Material material = material(section.getString("material", "STONE")); + ItemStack item = new ItemStack(material); + ItemMeta meta = item.getItemMeta(); + if (meta == null) { + return item; + } + + if (meta instanceof SkullMeta skullMeta && skullOwner != null) { + skullMeta.setOwningPlayer(skullOwner); + } + + String name = section.getString("name", ""); + if (!name.isBlank()) { + meta.displayName(messages.component(name, placeholders)); + } + + List lore = new ArrayList<>(); + for (String line : section.getStringList("lore")) { + String expanded = ColorUtil.replace(line, placeholders); + if (expanded.contains("\n")) { + lore.addAll(List.of(expanded.split("\\n", -1))); + } else { + lore.add(expanded); + } + } + if (!lore.isEmpty()) { + meta.lore(lore.stream().map(ColorUtil::component).toList()); + } + + meta.addItemFlags(ItemFlag.HIDE_ATTRIBUTES, ItemFlag.HIDE_ENCHANTS, ItemFlag.HIDE_ADDITIONAL_TOOLTIP); + item.setItemMeta(meta); + return item; + } + + public static ItemStack simple(Material material, String name, List lore, MessageService messages, + Map placeholders) { + ItemStack item = new ItemStack(material); + ItemMeta meta = item.getItemMeta(); + if (meta == null) { + return item; + } + meta.displayName(messages.component(name, placeholders)); + meta.lore(lore.stream().map(line -> messages.component(line, placeholders)).toList()); + meta.addItemFlags(ItemFlag.HIDE_ATTRIBUTES, ItemFlag.HIDE_ENCHANTS, ItemFlag.HIDE_ADDITIONAL_TOOLTIP); + item.setItemMeta(meta); + return item; + } + + private static Material material(String name) { + if (name == null || name.isBlank()) { + return Material.STONE; + } + Material material = Material.matchMaterial(name.toUpperCase(Locale.ROOT)); + if (material == null) { + Bukkit.getLogger().warning("[DirtBounties] Unknown material in GUI config: " + name); + return Material.STONE; + } + return material; + } +} diff --git a/src/main/java/com/dirtbagmc/dirtbounties/util/NumberUtil.java b/src/main/java/com/dirtbagmc/dirtbounties/util/NumberUtil.java new file mode 100644 index 0000000..386564e --- /dev/null +++ b/src/main/java/com/dirtbagmc/dirtbounties/util/NumberUtil.java @@ -0,0 +1,21 @@ +package com.dirtbagmc.dirtbounties.util; + +import java.text.DecimalFormat; + +public final class NumberUtil { + private static final DecimalFormat MONEY = new DecimalFormat("#,##0.##"); + + private NumberUtil() { + } + + public static double clampMoney(double value) { + if (Double.isNaN(value) || Double.isInfinite(value)) { + return 0.0; + } + return Math.round(value * 100.0) / 100.0; + } + + public static String compact(double value) { + return MONEY.format(clampMoney(value)); + } +} diff --git a/src/main/java/com/dirtbagmc/dirtbounties/util/TimeUtil.java b/src/main/java/com/dirtbagmc/dirtbounties/util/TimeUtil.java new file mode 100644 index 0000000..483adfa --- /dev/null +++ b/src/main/java/com/dirtbagmc/dirtbounties/util/TimeUtil.java @@ -0,0 +1,79 @@ +package com.dirtbagmc.dirtbounties.util; + +import java.time.Duration; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class TimeUtil { + private static final Pattern TOKEN = Pattern.compile("(\\d+(?:\\.\\d+)?)(ms|s|m|h|d|w)", Pattern.CASE_INSENSITIVE); + + private TimeUtil() { + } + + public static long parseMillis(String input, long fallbackMillis) { + if (input == null || input.isBlank()) { + return fallbackMillis; + } + String normalized = input.trim().toLowerCase(Locale.ROOT); + if (normalized.equals("0") || normalized.equals("none") || normalized.equals("off") || normalized.equals("disabled")) { + return 0L; + } + if (normalized.matches("\\d+")) { + return Long.parseLong(normalized) * 1000L; + } + + Matcher matcher = TOKEN.matcher(normalized.replace(" ", "")); + double total = 0.0; + int matches = 0; + while (matcher.find()) { + double value = Double.parseDouble(matcher.group(1)); + String unit = matcher.group(2).toLowerCase(Locale.ROOT); + total += switch (unit) { + case "ms" -> value; + case "s" -> value * 1000.0; + case "m" -> value * 60_000.0; + case "h" -> value * 3_600_000.0; + case "d" -> value * 86_400_000.0; + case "w" -> value * 604_800_000.0; + default -> 0.0; + }; + matches++; + } + return matches == 0 ? fallbackMillis : Math.max(0L, Math.round(total)); + } + + public static String formatDuration(long millis) { + if (millis <= 0L) { + return "never"; + } + Duration duration = Duration.ofMillis(millis); + long days = duration.toDays(); + duration = duration.minusDays(days); + long hours = duration.toHours(); + duration = duration.minusHours(hours); + long minutes = duration.toMinutes(); + duration = duration.minusMinutes(minutes); + long seconds = Math.max(0L, duration.toSeconds()); + + if (days > 0) { + return days + "d " + hours + "h"; + } + if (hours > 0) { + return hours + "h " + minutes + "m"; + } + if (minutes > 0) { + return minutes + "m " + seconds + "s"; + } + return seconds + "s"; + } + + public static String formatTimestamp(long epochMillis) { + if (epochMillis <= 0L) { + return "never"; + } + return java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") + .withZone(java.time.ZoneId.systemDefault()) + .format(java.time.Instant.ofEpochMilli(epochMillis)); + } +} diff --git a/src/main/java/com/dirtbagmc/dirtbounties/webhook/WebhookService.java b/src/main/java/com/dirtbagmc/dirtbounties/webhook/WebhookService.java new file mode 100644 index 0000000..65d305b --- /dev/null +++ b/src/main/java/com/dirtbagmc/dirtbounties/webhook/WebhookService.java @@ -0,0 +1,100 @@ +package com.dirtbagmc.dirtbounties.webhook; + +import com.dirtbagmc.dirtbounties.config.ConfigService; +import com.dirtbagmc.dirtbounties.service.MessageService; +import org.bukkit.Bukkit; +import org.bukkit.plugin.java.JavaPlugin; + +import java.io.IOException; +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.logging.Level; + +public final class WebhookService { + private final JavaPlugin plugin; + private final ConfigService configService; + private final MessageService messages; + private HttpClient client; + + public WebhookService(JavaPlugin plugin, ConfigService configService, MessageService messages) { + this.plugin = plugin; + this.configService = configService; + this.messages = messages; + } + + public void initialize() { + client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(Math.max(1, configService.main().getInt("webhooks.timeout-seconds", 8)))) + .build(); + } + + public void placement(String placer, String target, double total, String reason, boolean increased) { + if (!configService.main().getBoolean("webhooks.notify-placements", true) + || total < configService.main().getDouble("webhooks.large-bounty-threshold", 25_000.0)) { + return; + } + String title = messages.raw("webhook.placement-title", "New bounty placed"); + String content = title + "\n" + placer + (increased ? " increased " : " placed ") + + "a bounty on " + target + " worth " + total + ". Reason: " + reason; + send(content); + } + + public void claim(String killer, String target, double payout) { + if (!configService.main().getBoolean("webhooks.notify-claims", true) + || payout < configService.main().getDouble("webhooks.large-claim-threshold", 25_000.0)) { + return; + } + String title = messages.raw("webhook.claim-title", "Bounty claimed"); + send(title + "\n" + killer + " claimed " + payout + " for killing " + target + "."); + } + + public void admin(String actor, String action) { + if (!configService.main().getBoolean("webhooks.notify-admin-actions", true)) { + return; + } + String title = messages.raw("webhook.admin-title", "Bounty admin action"); + send(title + "\n" + actor + ": " + action); + } + + private void send(String content) { + if (!configService.main().getBoolean("webhooks.enabled", false)) { + return; + } + String url = configService.main().getString("webhooks.url", ""); + if (url == null || url.isBlank()) { + return; + } + String username = configService.main().getString("webhooks.username", "DirtBounties"); + String body = "{\"username\":\"" + escape(username) + "\",\"content\":\"" + escape(content) + "\"}"; + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofSeconds(Math.max(1, configService.main().getInt("webhooks.timeout-seconds", 8)))) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() >= 400) { + plugin.getLogger().warning("Webhook returned HTTP " + response.statusCode()); + } + } catch (IllegalArgumentException | IOException | InterruptedException ex) { + if (ex instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + plugin.getLogger().log(Level.WARNING, "Could not send DirtBounties webhook.", ex); + } + }); + } + + private String escape(String input) { + return (input == null ? "" : input) + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", ""); + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml new file mode 100644 index 0000000..8c8acb0 --- /dev/null +++ b/src/main/resources/config.yml @@ -0,0 +1,154 @@ +# DirtBounties main configuration +# Paper 1.21.x, Java 21 +# +# Color formatting supports normal ampersand colors, hex colors like &#D4AF37, +# and the DirtbagMC gradient style shown below. + +server: + brand-name: "DirtbagMC" + brand-gradient: "A2416&lᴅA2D1B&lɪA351F&lʀA3F24&lᴛA4A2A&lʙB6B35&lᴀ&#A8873F&lɢ&#D4AF37&lᴍ&#B9C63F&lᴄ" + debug: false + +storage: + # Active bounty data is saved after important mutations and on this autosave interval. + autosave-interval: "5m" + cleanup-expired-interval: "10m" + save-player-ip-cache: true + write-history-to-disk-immediately: true + +economy: + enabled: true + # DirtBounties uses Bukkit ServicesManager economy registration. + # This supports normal Vault and CMI's Vault injector. + fail-if-missing: false + provider-log-on-enable: true + minimum-balance-after-withdraw: 0.0 + currency-format: "${amount}" + fees: + # Placement fee can be charged as extra money or deducted from the bounty value. + placement-percent: 5.0 + placement-mode: "extra" # extra, deduct + add-percent: 5.0 + add-mode: "extra" # extra, deduct + claim-tax-percent: 0.0 + claim-sink-percent: 0.0 + broadcasts: + enabled: true + placed-threshold: 5000.0 + claimed-threshold: 5000.0 + milestone-thresholds: + - 10000.0 + - 25000.0 + - 50000.0 + +bounties: + min-amount: 100.0 + max-amount: 1000000.0 + stack-existing: true + allow-self-target: false + allow-offline-targets: true + # If false, targets must be online or have joined before. + # If true, Bukkit may create an OfflinePlayer profile for unknown names. + allow-never-joined-targets: false + allow-banned-targets: false + allow-anonymous: true + anonymous-requires-permission: true + allow-reasons: true + max-reason-length: 80 + default-reason: "No reason given." + placement-cooldown: "30s" + require-confirmation-gui: true + +expiration: + enabled: true + default-duration: "14d" + max-duration: "30d" + # If true, a bounty without contributions gets removed during cleanup. + remove-empty-bounties: true + banned-player-action: "keep" # keep, expire + deleted-player-action: "keep" # keep, expire + +refunds: + # Refund percentage is based on the active contribution value, not placement fees. + on-admin-remove-percent: 100.0 + on-expire-percent: 50.0 + on-invalid-percent: 100.0 + refund-fees: false + refund-offline-players: true + minimum-refund: 0.01 + +claim-rules: + require-permission: true + require-pvp-kill: true + block-environmental-deaths: true + prevent-self-claims: true + prevent-same-ip-claims: true + prevent-shared-known-ip-claims: true + allow-bypass-permission: true + require-target-online-at-death: true + blocked-killer-game-modes: + - CREATIVE + - SPECTATOR + worlds: + mode: "blacklist" # disabled, whitelist, blacklist + list: + - spawn + - events + combat: + enabled: true + window: "30s" + min-damage: 4.0 + min-hits: 1 + min-combat-duration: "0s" + +anti-abuse: + enabled: true + log-failed-claims: true + log-same-ip-attempts: true + killer-claim-cooldown: "2m" + target-claim-cooldown: "2m" + killer-target-pair-cooldown: "12h" + same-victim-cooldown: "10m" + pair-window: "7d" + max-pair-claims-in-window: 2 + suspicious-history-limit: 500 + run-console-commands-on-suspicious: false + suspicious-commands: + - "staffmsg Suspicious bounty claim: {killer} -> {target}: {reason}" + +history: + enabled: true + max-records: 2000 + max-records-per-player-command: 10 + prune-older-than: "90d" + +gui: + enabled: true + open-sound: "BLOCK_BARREL_OPEN" + click-sound: "UI_BUTTON_CLICK" + success-sound: "ENTITY_PLAYER_LEVELUP" + error-sound: "ENTITY_VILLAGER_NO" + items-per-page: 28 + refresh-after-action: true + close-on-confirm: true + chat-input-timeout: "60s" + +webhooks: + enabled: false + url: "" + username: "DirtBounties" + large-bounty-threshold: 25000.0 + large-claim-threshold: 25000.0 + notify-placements: true + notify-claims: true + notify-admin-actions: true + timeout-seconds: 8 + +logging: + console: + economy-status: true + placements: true + claims: true + admin-actions: true + suspicious: true + file-history: true diff --git a/src/main/resources/gui.yml b/src/main/resources/gui.yml new file mode 100644 index 0000000..35dcff8 --- /dev/null +++ b/src/main/resources/gui.yml @@ -0,0 +1,177 @@ +titles: + main: "A2416&lDirtBounties &8| &6Active" + top: "A2416&lDirtBounties &8| &6Top" + detail: "A2416&lDirtBounties &8| &c{target}" + confirm: "A2416&lDirtBounties &8| &aConfirm" + admin-main: "A2416&lDirtBounties &8| &4Admin" + admin-history: "A2416&lDirtBounties &8| &4History" + admin-suspicious: "A2416&lDirtBounties &8| &4Suspicious" + +layout: + size: 54 + content-slots: + - 10 + - 11 + - 12 + - 13 + - 14 + - 15 + - 16 + - 19 + - 20 + - 21 + - 22 + - 23 + - 24 + - 25 + - 28 + - 29 + - 30 + - 31 + - 32 + - 33 + - 34 + - 37 + - 38 + - 39 + - 40 + - 41 + - 42 + - 43 + previous-slot: 45 + back-slot: 46 + refresh-slot: 49 + next-slot: 53 + place-slot: 48 + top-slot: 50 + admin-slot: 52 + +items: + filler: + material: BLACK_STAINED_GLASS_PANE + name: " " + lore: [] + border: + material: BROWN_STAINED_GLASS_PANE + name: " " + lore: [] + empty: + material: BARRIER + name: "&cNo bounties" + lore: + - "&7Nobody has a price on their head." + bounty: + material: PLAYER_HEAD + name: "&c&l{target}" + lore: + - "&7Bounty: &f{amount}" + - "&7Contributors: &f{contributors}" + - "&7Top reason: &f{reason}" + - "&7Expires: &f{expires}" + - "" + - "&eClick to view details." + top-bounty: + material: PLAYER_HEAD + name: "&6#{rank} &c&l{target}" + lore: + - "&7Bounty: &f{amount}" + - "&7Contributors: &f{contributors}" + - "" + - "&eClick to inspect." + detail-head: + material: PLAYER_HEAD + name: "&c&l{target}" + lore: + - "&7Total bounty: &f{amount}" + - "&7Contributors: &f{contributors}" + - "&7Expires: &f{expires}" + - "" + - "&6Top reasons:" + - "{reason_lines}" + claim-info: + material: BOOK + name: "&6Claim Conditions" + lore: + - "&7PvP required: &f{pvp}" + - "&7Same IP blocked: &f{same_ip}" + - "&7World mode: &f{world_mode}" + - "&7Combat: &f{combat}" + - "" + - "&8Claims are checked automatically on kill." + place: + material: GOLD_INGOT + name: "&6Place Bounty" + lore: + - "&7Start a guided bounty placement." + - "" + - "&eClick to begin." + add: + material: ANVIL + name: "&6Increase Bounty" + lore: + - "&7Add money to this target's bounty." + - "" + - "&eClick to continue." + top: + material: NETHER_STAR + name: "&6Top Bounties" + lore: + - "&7Sort by highest active value." + - "" + - "&eClick to view." + refresh: + material: SUNFLOWER + name: "&eRefresh" + lore: + - "&7Reload this view." + previous: + material: ARROW + name: "&ePrevious Page" + lore: + - "&7Go back one page." + next: + material: ARROW + name: "&eNext Page" + lore: + - "&7Go forward one page." + back: + material: OAK_DOOR + name: "&eBack" + lore: + - "&7Return to the previous view." + close: + material: BARRIER + name: "&cClose" + lore: + - "&7Close this menu." + confirm: + material: LIME_CONCRETE + name: "&aConfirm Bounty" + lore: + - "&7Target: &f{target}" + - "&7Amount: &f{amount}" + - "&7Fee: &f{fee}" + - "&7Total cost: &f{cost}" + - "&7Reason: &f{reason}" + - "" + - "&aClick to confirm." + cancel: + material: RED_CONCRETE + name: "&cCancel" + lore: + - "&7Return without placing this bounty." + admin-active: + material: CHEST + name: "&4Active Bounties" + lore: + - "&7Review and inspect all active bounties." + admin-history: + material: WRITABLE_BOOK + name: "&4Recent Claims and Changes" + lore: + - "&7Review bounty history records." + admin-suspicious: + material: REDSTONE_TORCH + name: "&4Suspicious Activity" + lore: + - "&7Review blocked and suspicious claims." diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml new file mode 100644 index 0000000..956abf4 --- /dev/null +++ b/src/main/resources/messages.yml @@ -0,0 +1,105 @@ +prefix: "A2416&lᴅA2D1B&lɪA351F&lʀA3F24&lᴛA4A2A&lʙB6B35&lᴀ&#A8873F&lɢ&#D4AF37&lᴍ&#B9C63F&lᴄ &8» " +no-permission: "{prefix}&cYou do not have permission to do that." +player-only: "{prefix}&cOnly players can use that command." +unknown-command: "{prefix}&cUnknown bounty command. Use &f/bounty help&c." +reload-complete: "{prefix}&aDirtBounties reloaded." +invalid-number: "{prefix}&cThat amount is not valid." +invalid-player: "{prefix}&cCould not find that player." +invalid-world: "{prefix}&cBounties cannot be claimed in this world." +economy-missing: "{prefix}&cEconomy is not available. Ask staff to check Vault or CMI's Vault injector." +not-enough-money: "{prefix}&cYou need &f{cost}&c, including fees, to place that bounty." +cooldown: "{prefix}&cSlow down. Try again in &f{time}&c." +reason-too-long: "{prefix}&cThat reason is too long. Maximum: &f{max}&c characters." +target-self: "{prefix}&cYou cannot place or claim a bounty on yourself." +target-banned: "{prefix}&cThat player cannot receive bounties while banned." +amount-too-low: "{prefix}&cMinimum bounty amount is &f{min}&c." +amount-too-high: "{prefix}&cMaximum bounty amount is &f{max}&c." +amount-would-exceed-max: "{prefix}&cThat would put the bounty above the maximum of &f{max}&c." +no-active-bounties: "{prefix}&7There are no active bounties right now." +bounty-not-found: "{prefix}&cThere is no active bounty on &f{target}&c." +claim-denied-format: "{prefix}&cBounty claim denied: &f{reason}" + +help: + - "{prefix}&6&lDirtBounties Commands" + - "&8- &e/bounty &7Open the bounty GUI." + - "&8- &e/bounty place [reason] &7Place a bounty." + - "&8- &e/bounty add [reason] &7Increase a bounty." + - "&8- &e/bounty list &7List active bounties." + - "&8- &e/bounty top &7View top bounties." + - "&8- &e/bounty view &7View bounty details." + - "&8- &e/bounty claiminfo &7View claim rules." + +admin-help: + - "{prefix}&6&lDirtBounties Admin" + - "&8- &e/bountyadmin reload &7Reload configs and storage." + - "&8- &e/bountyadmin remove &7Remove a bounty and refund by config." + - "&8- &e/bountyadmin clearall confirm &7Remove every active bounty." + - "&8- &e/bountyadmin set &7Set a bounty value." + - "&8- &e/bountyadmin expire &7Expire a bounty." + - "&8- &e/bountyadmin history &7Show history." + - "&8- &e/bountyadmin suspicious &7Show suspicious activity." + - "&8- &e/bountyadmin gui &7Open admin GUI." + +bounty: + placed: "{prefix}&aPlaced a bounty of &f{amount}&a on &f{target}&a. Fee: &f{fee}&a." + added: "{prefix}&aAdded &f{amount}&a to &f{target}&a's bounty. New total: &f{total}&a." + broadcast-placed: "{prefix}&6{placer}&e placed a bounty of &f{amount}&e on &c{target}&e." + broadcast-added: "{prefix}&6{placer}&e increased &c{target}&e's bounty to &f{total}&e." + milestone: "{prefix}&c{target}&6's bounty has reached &f{total}&6." + claimed: "{prefix}&aYou claimed &f{payout}&a from &c{target}&a's bounty." + claim-broadcast: "{prefix}&c{killer}&6 claimed &f{payout}&6 for killing &c{target}&6." + expired: "{prefix}&7The bounty on &f{target}&7 expired." + removed: "{prefix}&aRemoved the bounty on &f{target}&a." + set: "{prefix}&aSet &f{target}&a's bounty to &f{amount}&a." + clearall-warning: "{prefix}&cUse &f/bountyadmin clearall confirm &cto remove all active bounties." + clearall-done: "{prefix}&aRemoved &f{count}&a active bounties." + list-line: "&8- &c{target} &7» &f{amount} &8({contributors} contributors)" + top-line: "&6#{rank} &c{target} &7» &f{amount}" + view: + - "{prefix}&6&lBounty: &c{target}" + - "&7Amount: &f{amount}" + - "&7Contributors: &f{contributors}" + - "&7Expires: &f{expires}" + - "&7Reason: &f{reason}" + claiminfo: + - "{prefix}&6&lClaim Rules for &c{target}" + - "&8- &7PvP kill required: &f{pvp}" + - "&8- &7Same-IP claims blocked: &f{same_ip}" + - "&8- &7Allowed worlds: &f{worlds}" + - "&8- &7Combat requirement: &f{combat}" + +claim-denied: + no-bounty: "No active bounty exists." + no-permission: "You do not have permission to claim bounties." + self: "Self-claims are blocked." + same-ip: "Same-IP bounty claims are blocked." + shared-known-ip: "Shared known-IP bounty claims are blocked." + world: "Claims are disabled in this world." + gamemode: "Your game mode cannot claim bounties." + combat: "Combat requirements were not met." + cooldown: "A claim cooldown is active." + pair-limit: "This killer-target pair has too many recent claims." + target-offline: "The target must be online at death." + +gui: + unavailable: "{prefix}&cThe bounty GUI is disabled." + prompt-target: "{prefix}&eType the target player's name in chat, or type &fcancel&e." + prompt-amount: "{prefix}&eType the bounty amount for &f{target}&e, or type &fcancel&e." + prompt-reason: "{prefix}&eType a short reason for &f{target}&e, use &f-&e for none, or type &fcancel&e." + input-cancelled: "{prefix}&7Bounty input cancelled." + input-expired: "{prefix}&cYour bounty input expired." + confirm-opened: "{prefix}&7Review and confirm the bounty." + admin-opened: "{prefix}&7Opened the admin bounty view." + +admin: + history-empty: "{prefix}&7No bounty history found for &f{target}&7." + suspicious-empty: "{prefix}&7No suspicious bounty activity has been logged." + suspicious-line: "&8- &c{time} &7{type}: &f{details}" + history-line: "&8- &e{time} &7{type}: &f{amount} &8({note})" + refunded: "{prefix}&aRefunded &f{amount}&a to &f{player}&a." + action-logged: "{prefix}&7Admin action logged." + +webhook: + placement-title: "New bounty placed" + claim-title: "Bounty claimed" + admin-title: "Bounty admin action" diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml new file mode 100644 index 0000000..a99dc65 --- /dev/null +++ b/src/main/resources/plugin.yml @@ -0,0 +1,67 @@ +name: DirtBounties +version: 1.0.0 +main: com.dirtbagmc.dirtbounties.DirtBountiesPlugin +api-version: '1.21' +author: DirtbagMC +website: https://dirtbagmc.com +description: Premium bounty system for Paper SMP and anarchy servers. +softdepend: + - Vault + - CMI + - PlaceholderAPI + +commands: + bounty: + description: Open and manage player bounties. + usage: /bounty help + aliases: + - bounties + - dbounty + bountyadmin: + description: Admin controls for DirtBounties. + usage: /bountyadmin help + aliases: + - dbountyadmin + - ba + +permissions: + dirtbounties.use: + description: Allows opening the bounty GUI and using basic commands. + default: true + dirtbounties.place: + description: Allows placing new bounties. + default: true + dirtbounties.add: + description: Allows increasing existing bounties. + default: true + dirtbounties.view: + description: Allows viewing bounty details. + default: true + dirtbounties.top: + description: Allows viewing top bounties. + default: true + dirtbounties.claim: + description: Allows claiming bounties by killing targets. + default: true + dirtbounties.anonymous: + description: Allows placing anonymous bounties when enabled. + default: op + dirtbounties.bypass.cooldowns: + description: Bypasses placement and claim cooldown checks. + default: op + dirtbounties.bypass.claimrules: + description: Bypasses configured claim restrictions. + default: op + dirtbounties.admin: + description: Full DirtBounties administration access. + default: op + children: + dirtbounties.use: true + dirtbounties.place: true + dirtbounties.add: true + dirtbounties.view: true + dirtbounties.top: true + dirtbounties.claim: true + dirtbounties.anonymous: true + dirtbounties.bypass.cooldowns: true + dirtbounties.bypass.claimrules: true diff --git a/target/DirtBounties-1.0.0.jar b/target/DirtBounties-1.0.0.jar new file mode 100644 index 0000000..3abc3e4 Binary files /dev/null and b/target/DirtBounties-1.0.0.jar differ diff --git a/target/classes/com/dirtbagmc/dirtbounties/DirtBountiesPlugin.class b/target/classes/com/dirtbagmc/dirtbounties/DirtBountiesPlugin.class new file mode 100644 index 0000000..6f07149 Binary files /dev/null and b/target/classes/com/dirtbagmc/dirtbounties/DirtBountiesPlugin.class differ diff --git a/target/classes/com/dirtbagmc/dirtbounties/command/BountyAdminCommand.class b/target/classes/com/dirtbagmc/dirtbounties/command/BountyAdminCommand.class new file mode 100644 index 0000000..7f09903 Binary files /dev/null and b/target/classes/com/dirtbagmc/dirtbounties/command/BountyAdminCommand.class differ diff --git a/target/classes/com/dirtbagmc/dirtbounties/command/BountyCommand$ReasonInput.class b/target/classes/com/dirtbagmc/dirtbounties/command/BountyCommand$ReasonInput.class new file mode 100644 index 0000000..ade4fed Binary files /dev/null and b/target/classes/com/dirtbagmc/dirtbounties/command/BountyCommand$ReasonInput.class differ diff --git a/target/classes/com/dirtbagmc/dirtbounties/command/BountyCommand.class b/target/classes/com/dirtbagmc/dirtbounties/command/BountyCommand.class new file mode 100644 index 0000000..94abad9 Binary files /dev/null and b/target/classes/com/dirtbagmc/dirtbounties/command/BountyCommand.class differ diff --git a/target/classes/com/dirtbagmc/dirtbounties/config/ConfigService.class b/target/classes/com/dirtbagmc/dirtbounties/config/ConfigService.class new file mode 100644 index 0000000..0b3b768 Binary files /dev/null and b/target/classes/com/dirtbagmc/dirtbounties/config/ConfigService.class differ diff --git a/target/classes/com/dirtbagmc/dirtbounties/economy/EconomyService$EconomyResult.class b/target/classes/com/dirtbagmc/dirtbounties/economy/EconomyService$EconomyResult.class new file mode 100644 index 0000000..efa2a94 Binary files /dev/null and b/target/classes/com/dirtbagmc/dirtbounties/economy/EconomyService$EconomyResult.class differ diff --git a/target/classes/com/dirtbagmc/dirtbounties/economy/EconomyService.class b/target/classes/com/dirtbagmc/dirtbounties/economy/EconomyService.class new file mode 100644 index 0000000..ae26541 Binary files /dev/null and b/target/classes/com/dirtbagmc/dirtbounties/economy/EconomyService.class differ diff --git a/target/classes/com/dirtbagmc/dirtbounties/gui/GuiHolder.class b/target/classes/com/dirtbagmc/dirtbounties/gui/GuiHolder.class new file mode 100644 index 0000000..0e95706 Binary files /dev/null and b/target/classes/com/dirtbagmc/dirtbounties/gui/GuiHolder.class differ diff --git a/target/classes/com/dirtbagmc/dirtbounties/gui/GuiManager$1.class b/target/classes/com/dirtbagmc/dirtbounties/gui/GuiManager$1.class new file mode 100644 index 0000000..53ba859 Binary files /dev/null and b/target/classes/com/dirtbagmc/dirtbounties/gui/GuiManager$1.class differ diff --git a/target/classes/com/dirtbagmc/dirtbounties/gui/GuiManager.class b/target/classes/com/dirtbagmc/dirtbounties/gui/GuiManager.class new file mode 100644 index 0000000..1c2965b Binary files /dev/null and b/target/classes/com/dirtbagmc/dirtbounties/gui/GuiManager.class differ diff --git a/target/classes/com/dirtbagmc/dirtbounties/gui/GuiType.class b/target/classes/com/dirtbagmc/dirtbounties/gui/GuiType.class new file mode 100644 index 0000000..3ed9537 Binary files /dev/null and b/target/classes/com/dirtbagmc/dirtbounties/gui/GuiType.class differ diff --git a/target/classes/com/dirtbagmc/dirtbounties/gui/PendingBountyInput$Stage.class b/target/classes/com/dirtbagmc/dirtbounties/gui/PendingBountyInput$Stage.class new file mode 100644 index 0000000..12f33d4 Binary files /dev/null and b/target/classes/com/dirtbagmc/dirtbounties/gui/PendingBountyInput$Stage.class differ diff --git a/target/classes/com/dirtbagmc/dirtbounties/gui/PendingBountyInput.class b/target/classes/com/dirtbagmc/dirtbounties/gui/PendingBountyInput.class new file mode 100644 index 0000000..9d2c2e9 Binary files /dev/null and b/target/classes/com/dirtbagmc/dirtbounties/gui/PendingBountyInput.class differ diff --git a/target/classes/com/dirtbagmc/dirtbounties/hook/DirtBountiesExpansion.class b/target/classes/com/dirtbagmc/dirtbounties/hook/DirtBountiesExpansion.class new file mode 100644 index 0000000..12ca90f Binary files /dev/null and b/target/classes/com/dirtbagmc/dirtbounties/hook/DirtBountiesExpansion.class differ diff --git a/target/classes/com/dirtbagmc/dirtbounties/listener/BountyListener.class b/target/classes/com/dirtbagmc/dirtbounties/listener/BountyListener.class new file mode 100644 index 0000000..09fbe66 Binary files /dev/null and b/target/classes/com/dirtbagmc/dirtbounties/listener/BountyListener.class differ diff --git a/target/classes/com/dirtbagmc/dirtbounties/model/Bounty.class b/target/classes/com/dirtbagmc/dirtbounties/model/Bounty.class new file mode 100644 index 0000000..9cf2cc5 Binary files /dev/null and b/target/classes/com/dirtbagmc/dirtbounties/model/Bounty.class differ diff --git a/target/classes/com/dirtbagmc/dirtbounties/model/BountyContribution.class b/target/classes/com/dirtbagmc/dirtbounties/model/BountyContribution.class new file mode 100644 index 0000000..b00fb99 Binary files /dev/null and b/target/classes/com/dirtbagmc/dirtbounties/model/BountyContribution.class differ diff --git a/target/classes/com/dirtbagmc/dirtbounties/model/BountyHistoryRecord.class b/target/classes/com/dirtbagmc/dirtbounties/model/BountyHistoryRecord.class new file mode 100644 index 0000000..16323ad Binary files /dev/null and b/target/classes/com/dirtbagmc/dirtbounties/model/BountyHistoryRecord.class differ diff --git a/target/classes/com/dirtbagmc/dirtbounties/model/ClaimValidation.class b/target/classes/com/dirtbagmc/dirtbounties/model/ClaimValidation.class new file mode 100644 index 0000000..be4934c Binary files /dev/null and b/target/classes/com/dirtbagmc/dirtbounties/model/ClaimValidation.class differ diff --git a/target/classes/com/dirtbagmc/dirtbounties/model/SuspiciousActivity.class b/target/classes/com/dirtbagmc/dirtbounties/model/SuspiciousActivity.class new file mode 100644 index 0000000..df2a92e Binary files /dev/null and b/target/classes/com/dirtbagmc/dirtbounties/model/SuspiciousActivity.class differ diff --git a/target/classes/com/dirtbagmc/dirtbounties/service/AntiAbuseService.class b/target/classes/com/dirtbagmc/dirtbounties/service/AntiAbuseService.class new file mode 100644 index 0000000..da922b7 Binary files /dev/null and b/target/classes/com/dirtbagmc/dirtbounties/service/AntiAbuseService.class differ diff --git a/target/classes/com/dirtbagmc/dirtbounties/service/BountyService$Fee.class b/target/classes/com/dirtbagmc/dirtbounties/service/BountyService$Fee.class new file mode 100644 index 0000000..1012ec6 Binary files /dev/null and b/target/classes/com/dirtbagmc/dirtbounties/service/BountyService$Fee.class differ diff --git a/target/classes/com/dirtbagmc/dirtbounties/service/BountyService.class b/target/classes/com/dirtbagmc/dirtbounties/service/BountyService.class new file mode 100644 index 0000000..32e88cc Binary files /dev/null and b/target/classes/com/dirtbagmc/dirtbounties/service/BountyService.class differ diff --git a/target/classes/com/dirtbagmc/dirtbounties/service/CombatTracker$CombatRecord.class b/target/classes/com/dirtbagmc/dirtbounties/service/CombatTracker$CombatRecord.class new file mode 100644 index 0000000..10a1cb8 Binary files /dev/null and b/target/classes/com/dirtbagmc/dirtbounties/service/CombatTracker$CombatRecord.class differ diff --git a/target/classes/com/dirtbagmc/dirtbounties/service/CombatTracker.class b/target/classes/com/dirtbagmc/dirtbounties/service/CombatTracker.class new file mode 100644 index 0000000..8265f07 Binary files /dev/null and b/target/classes/com/dirtbagmc/dirtbounties/service/CombatTracker.class differ diff --git a/target/classes/com/dirtbagmc/dirtbounties/service/HistoryService.class b/target/classes/com/dirtbagmc/dirtbounties/service/HistoryService.class new file mode 100644 index 0000000..73d1424 Binary files /dev/null and b/target/classes/com/dirtbagmc/dirtbounties/service/HistoryService.class differ diff --git a/target/classes/com/dirtbagmc/dirtbounties/service/MessageService.class b/target/classes/com/dirtbagmc/dirtbounties/service/MessageService.class new file mode 100644 index 0000000..918af76 Binary files /dev/null and b/target/classes/com/dirtbagmc/dirtbounties/service/MessageService.class differ diff --git a/target/classes/com/dirtbagmc/dirtbounties/service/PlayerCacheService$CacheEntry.class b/target/classes/com/dirtbagmc/dirtbounties/service/PlayerCacheService$CacheEntry.class new file mode 100644 index 0000000..dfede24 Binary files /dev/null and b/target/classes/com/dirtbagmc/dirtbounties/service/PlayerCacheService$CacheEntry.class differ diff --git a/target/classes/com/dirtbagmc/dirtbounties/service/PlayerCacheService.class b/target/classes/com/dirtbagmc/dirtbounties/service/PlayerCacheService.class new file mode 100644 index 0000000..ce6766d Binary files /dev/null and b/target/classes/com/dirtbagmc/dirtbounties/service/PlayerCacheService.class differ diff --git a/target/classes/com/dirtbagmc/dirtbounties/storage/StorageManager$PlayerCacheData.class b/target/classes/com/dirtbagmc/dirtbounties/storage/StorageManager$PlayerCacheData.class new file mode 100644 index 0000000..e7a469d Binary files /dev/null and b/target/classes/com/dirtbagmc/dirtbounties/storage/StorageManager$PlayerCacheData.class differ diff --git a/target/classes/com/dirtbagmc/dirtbounties/storage/StorageManager.class b/target/classes/com/dirtbagmc/dirtbounties/storage/StorageManager.class new file mode 100644 index 0000000..f5192c4 Binary files /dev/null and b/target/classes/com/dirtbagmc/dirtbounties/storage/StorageManager.class differ diff --git a/target/classes/com/dirtbagmc/dirtbounties/util/ColorUtil.class b/target/classes/com/dirtbagmc/dirtbounties/util/ColorUtil.class new file mode 100644 index 0000000..bc2e226 Binary files /dev/null and b/target/classes/com/dirtbagmc/dirtbounties/util/ColorUtil.class differ diff --git a/target/classes/com/dirtbagmc/dirtbounties/util/ItemBuilder.class b/target/classes/com/dirtbagmc/dirtbounties/util/ItemBuilder.class new file mode 100644 index 0000000..782bb29 Binary files /dev/null and b/target/classes/com/dirtbagmc/dirtbounties/util/ItemBuilder.class differ diff --git a/target/classes/com/dirtbagmc/dirtbounties/util/NumberUtil.class b/target/classes/com/dirtbagmc/dirtbounties/util/NumberUtil.class new file mode 100644 index 0000000..9fb4e6a Binary files /dev/null and b/target/classes/com/dirtbagmc/dirtbounties/util/NumberUtil.class differ diff --git a/target/classes/com/dirtbagmc/dirtbounties/util/TimeUtil.class b/target/classes/com/dirtbagmc/dirtbounties/util/TimeUtil.class new file mode 100644 index 0000000..8e61745 Binary files /dev/null and b/target/classes/com/dirtbagmc/dirtbounties/util/TimeUtil.class differ diff --git a/target/classes/com/dirtbagmc/dirtbounties/webhook/WebhookService.class b/target/classes/com/dirtbagmc/dirtbounties/webhook/WebhookService.class new file mode 100644 index 0000000..148ea1d Binary files /dev/null and b/target/classes/com/dirtbagmc/dirtbounties/webhook/WebhookService.class differ diff --git a/target/classes/config.yml b/target/classes/config.yml new file mode 100644 index 0000000..8c8acb0 --- /dev/null +++ b/target/classes/config.yml @@ -0,0 +1,154 @@ +# DirtBounties main configuration +# Paper 1.21.x, Java 21 +# +# Color formatting supports normal ampersand colors, hex colors like &#D4AF37, +# and the DirtbagMC gradient style shown below. + +server: + brand-name: "DirtbagMC" + brand-gradient: "A2416&lᴅA2D1B&lɪA351F&lʀA3F24&lᴛA4A2A&lʙB6B35&lᴀ&#A8873F&lɢ&#D4AF37&lᴍ&#B9C63F&lᴄ" + debug: false + +storage: + # Active bounty data is saved after important mutations and on this autosave interval. + autosave-interval: "5m" + cleanup-expired-interval: "10m" + save-player-ip-cache: true + write-history-to-disk-immediately: true + +economy: + enabled: true + # DirtBounties uses Bukkit ServicesManager economy registration. + # This supports normal Vault and CMI's Vault injector. + fail-if-missing: false + provider-log-on-enable: true + minimum-balance-after-withdraw: 0.0 + currency-format: "${amount}" + fees: + # Placement fee can be charged as extra money or deducted from the bounty value. + placement-percent: 5.0 + placement-mode: "extra" # extra, deduct + add-percent: 5.0 + add-mode: "extra" # extra, deduct + claim-tax-percent: 0.0 + claim-sink-percent: 0.0 + broadcasts: + enabled: true + placed-threshold: 5000.0 + claimed-threshold: 5000.0 + milestone-thresholds: + - 10000.0 + - 25000.0 + - 50000.0 + +bounties: + min-amount: 100.0 + max-amount: 1000000.0 + stack-existing: true + allow-self-target: false + allow-offline-targets: true + # If false, targets must be online or have joined before. + # If true, Bukkit may create an OfflinePlayer profile for unknown names. + allow-never-joined-targets: false + allow-banned-targets: false + allow-anonymous: true + anonymous-requires-permission: true + allow-reasons: true + max-reason-length: 80 + default-reason: "No reason given." + placement-cooldown: "30s" + require-confirmation-gui: true + +expiration: + enabled: true + default-duration: "14d" + max-duration: "30d" + # If true, a bounty without contributions gets removed during cleanup. + remove-empty-bounties: true + banned-player-action: "keep" # keep, expire + deleted-player-action: "keep" # keep, expire + +refunds: + # Refund percentage is based on the active contribution value, not placement fees. + on-admin-remove-percent: 100.0 + on-expire-percent: 50.0 + on-invalid-percent: 100.0 + refund-fees: false + refund-offline-players: true + minimum-refund: 0.01 + +claim-rules: + require-permission: true + require-pvp-kill: true + block-environmental-deaths: true + prevent-self-claims: true + prevent-same-ip-claims: true + prevent-shared-known-ip-claims: true + allow-bypass-permission: true + require-target-online-at-death: true + blocked-killer-game-modes: + - CREATIVE + - SPECTATOR + worlds: + mode: "blacklist" # disabled, whitelist, blacklist + list: + - spawn + - events + combat: + enabled: true + window: "30s" + min-damage: 4.0 + min-hits: 1 + min-combat-duration: "0s" + +anti-abuse: + enabled: true + log-failed-claims: true + log-same-ip-attempts: true + killer-claim-cooldown: "2m" + target-claim-cooldown: "2m" + killer-target-pair-cooldown: "12h" + same-victim-cooldown: "10m" + pair-window: "7d" + max-pair-claims-in-window: 2 + suspicious-history-limit: 500 + run-console-commands-on-suspicious: false + suspicious-commands: + - "staffmsg Suspicious bounty claim: {killer} -> {target}: {reason}" + +history: + enabled: true + max-records: 2000 + max-records-per-player-command: 10 + prune-older-than: "90d" + +gui: + enabled: true + open-sound: "BLOCK_BARREL_OPEN" + click-sound: "UI_BUTTON_CLICK" + success-sound: "ENTITY_PLAYER_LEVELUP" + error-sound: "ENTITY_VILLAGER_NO" + items-per-page: 28 + refresh-after-action: true + close-on-confirm: true + chat-input-timeout: "60s" + +webhooks: + enabled: false + url: "" + username: "DirtBounties" + large-bounty-threshold: 25000.0 + large-claim-threshold: 25000.0 + notify-placements: true + notify-claims: true + notify-admin-actions: true + timeout-seconds: 8 + +logging: + console: + economy-status: true + placements: true + claims: true + admin-actions: true + suspicious: true + file-history: true diff --git a/target/classes/gui.yml b/target/classes/gui.yml new file mode 100644 index 0000000..35dcff8 --- /dev/null +++ b/target/classes/gui.yml @@ -0,0 +1,177 @@ +titles: + main: "A2416&lDirtBounties &8| &6Active" + top: "A2416&lDirtBounties &8| &6Top" + detail: "A2416&lDirtBounties &8| &c{target}" + confirm: "A2416&lDirtBounties &8| &aConfirm" + admin-main: "A2416&lDirtBounties &8| &4Admin" + admin-history: "A2416&lDirtBounties &8| &4History" + admin-suspicious: "A2416&lDirtBounties &8| &4Suspicious" + +layout: + size: 54 + content-slots: + - 10 + - 11 + - 12 + - 13 + - 14 + - 15 + - 16 + - 19 + - 20 + - 21 + - 22 + - 23 + - 24 + - 25 + - 28 + - 29 + - 30 + - 31 + - 32 + - 33 + - 34 + - 37 + - 38 + - 39 + - 40 + - 41 + - 42 + - 43 + previous-slot: 45 + back-slot: 46 + refresh-slot: 49 + next-slot: 53 + place-slot: 48 + top-slot: 50 + admin-slot: 52 + +items: + filler: + material: BLACK_STAINED_GLASS_PANE + name: " " + lore: [] + border: + material: BROWN_STAINED_GLASS_PANE + name: " " + lore: [] + empty: + material: BARRIER + name: "&cNo bounties" + lore: + - "&7Nobody has a price on their head." + bounty: + material: PLAYER_HEAD + name: "&c&l{target}" + lore: + - "&7Bounty: &f{amount}" + - "&7Contributors: &f{contributors}" + - "&7Top reason: &f{reason}" + - "&7Expires: &f{expires}" + - "" + - "&eClick to view details." + top-bounty: + material: PLAYER_HEAD + name: "&6#{rank} &c&l{target}" + lore: + - "&7Bounty: &f{amount}" + - "&7Contributors: &f{contributors}" + - "" + - "&eClick to inspect." + detail-head: + material: PLAYER_HEAD + name: "&c&l{target}" + lore: + - "&7Total bounty: &f{amount}" + - "&7Contributors: &f{contributors}" + - "&7Expires: &f{expires}" + - "" + - "&6Top reasons:" + - "{reason_lines}" + claim-info: + material: BOOK + name: "&6Claim Conditions" + lore: + - "&7PvP required: &f{pvp}" + - "&7Same IP blocked: &f{same_ip}" + - "&7World mode: &f{world_mode}" + - "&7Combat: &f{combat}" + - "" + - "&8Claims are checked automatically on kill." + place: + material: GOLD_INGOT + name: "&6Place Bounty" + lore: + - "&7Start a guided bounty placement." + - "" + - "&eClick to begin." + add: + material: ANVIL + name: "&6Increase Bounty" + lore: + - "&7Add money to this target's bounty." + - "" + - "&eClick to continue." + top: + material: NETHER_STAR + name: "&6Top Bounties" + lore: + - "&7Sort by highest active value." + - "" + - "&eClick to view." + refresh: + material: SUNFLOWER + name: "&eRefresh" + lore: + - "&7Reload this view." + previous: + material: ARROW + name: "&ePrevious Page" + lore: + - "&7Go back one page." + next: + material: ARROW + name: "&eNext Page" + lore: + - "&7Go forward one page." + back: + material: OAK_DOOR + name: "&eBack" + lore: + - "&7Return to the previous view." + close: + material: BARRIER + name: "&cClose" + lore: + - "&7Close this menu." + confirm: + material: LIME_CONCRETE + name: "&aConfirm Bounty" + lore: + - "&7Target: &f{target}" + - "&7Amount: &f{amount}" + - "&7Fee: &f{fee}" + - "&7Total cost: &f{cost}" + - "&7Reason: &f{reason}" + - "" + - "&aClick to confirm." + cancel: + material: RED_CONCRETE + name: "&cCancel" + lore: + - "&7Return without placing this bounty." + admin-active: + material: CHEST + name: "&4Active Bounties" + lore: + - "&7Review and inspect all active bounties." + admin-history: + material: WRITABLE_BOOK + name: "&4Recent Claims and Changes" + lore: + - "&7Review bounty history records." + admin-suspicious: + material: REDSTONE_TORCH + name: "&4Suspicious Activity" + lore: + - "&7Review blocked and suspicious claims." diff --git a/target/classes/messages.yml b/target/classes/messages.yml new file mode 100644 index 0000000..956abf4 --- /dev/null +++ b/target/classes/messages.yml @@ -0,0 +1,105 @@ +prefix: "A2416&lᴅA2D1B&lɪA351F&lʀA3F24&lᴛA4A2A&lʙB6B35&lᴀ&#A8873F&lɢ&#D4AF37&lᴍ&#B9C63F&lᴄ &8» " +no-permission: "{prefix}&cYou do not have permission to do that." +player-only: "{prefix}&cOnly players can use that command." +unknown-command: "{prefix}&cUnknown bounty command. Use &f/bounty help&c." +reload-complete: "{prefix}&aDirtBounties reloaded." +invalid-number: "{prefix}&cThat amount is not valid." +invalid-player: "{prefix}&cCould not find that player." +invalid-world: "{prefix}&cBounties cannot be claimed in this world." +economy-missing: "{prefix}&cEconomy is not available. Ask staff to check Vault or CMI's Vault injector." +not-enough-money: "{prefix}&cYou need &f{cost}&c, including fees, to place that bounty." +cooldown: "{prefix}&cSlow down. Try again in &f{time}&c." +reason-too-long: "{prefix}&cThat reason is too long. Maximum: &f{max}&c characters." +target-self: "{prefix}&cYou cannot place or claim a bounty on yourself." +target-banned: "{prefix}&cThat player cannot receive bounties while banned." +amount-too-low: "{prefix}&cMinimum bounty amount is &f{min}&c." +amount-too-high: "{prefix}&cMaximum bounty amount is &f{max}&c." +amount-would-exceed-max: "{prefix}&cThat would put the bounty above the maximum of &f{max}&c." +no-active-bounties: "{prefix}&7There are no active bounties right now." +bounty-not-found: "{prefix}&cThere is no active bounty on &f{target}&c." +claim-denied-format: "{prefix}&cBounty claim denied: &f{reason}" + +help: + - "{prefix}&6&lDirtBounties Commands" + - "&8- &e/bounty &7Open the bounty GUI." + - "&8- &e/bounty place [reason] &7Place a bounty." + - "&8- &e/bounty add [reason] &7Increase a bounty." + - "&8- &e/bounty list &7List active bounties." + - "&8- &e/bounty top &7View top bounties." + - "&8- &e/bounty view &7View bounty details." + - "&8- &e/bounty claiminfo &7View claim rules." + +admin-help: + - "{prefix}&6&lDirtBounties Admin" + - "&8- &e/bountyadmin reload &7Reload configs and storage." + - "&8- &e/bountyadmin remove &7Remove a bounty and refund by config." + - "&8- &e/bountyadmin clearall confirm &7Remove every active bounty." + - "&8- &e/bountyadmin set &7Set a bounty value." + - "&8- &e/bountyadmin expire &7Expire a bounty." + - "&8- &e/bountyadmin history &7Show history." + - "&8- &e/bountyadmin suspicious &7Show suspicious activity." + - "&8- &e/bountyadmin gui &7Open admin GUI." + +bounty: + placed: "{prefix}&aPlaced a bounty of &f{amount}&a on &f{target}&a. Fee: &f{fee}&a." + added: "{prefix}&aAdded &f{amount}&a to &f{target}&a's bounty. New total: &f{total}&a." + broadcast-placed: "{prefix}&6{placer}&e placed a bounty of &f{amount}&e on &c{target}&e." + broadcast-added: "{prefix}&6{placer}&e increased &c{target}&e's bounty to &f{total}&e." + milestone: "{prefix}&c{target}&6's bounty has reached &f{total}&6." + claimed: "{prefix}&aYou claimed &f{payout}&a from &c{target}&a's bounty." + claim-broadcast: "{prefix}&c{killer}&6 claimed &f{payout}&6 for killing &c{target}&6." + expired: "{prefix}&7The bounty on &f{target}&7 expired." + removed: "{prefix}&aRemoved the bounty on &f{target}&a." + set: "{prefix}&aSet &f{target}&a's bounty to &f{amount}&a." + clearall-warning: "{prefix}&cUse &f/bountyadmin clearall confirm &cto remove all active bounties." + clearall-done: "{prefix}&aRemoved &f{count}&a active bounties." + list-line: "&8- &c{target} &7» &f{amount} &8({contributors} contributors)" + top-line: "&6#{rank} &c{target} &7» &f{amount}" + view: + - "{prefix}&6&lBounty: &c{target}" + - "&7Amount: &f{amount}" + - "&7Contributors: &f{contributors}" + - "&7Expires: &f{expires}" + - "&7Reason: &f{reason}" + claiminfo: + - "{prefix}&6&lClaim Rules for &c{target}" + - "&8- &7PvP kill required: &f{pvp}" + - "&8- &7Same-IP claims blocked: &f{same_ip}" + - "&8- &7Allowed worlds: &f{worlds}" + - "&8- &7Combat requirement: &f{combat}" + +claim-denied: + no-bounty: "No active bounty exists." + no-permission: "You do not have permission to claim bounties." + self: "Self-claims are blocked." + same-ip: "Same-IP bounty claims are blocked." + shared-known-ip: "Shared known-IP bounty claims are blocked." + world: "Claims are disabled in this world." + gamemode: "Your game mode cannot claim bounties." + combat: "Combat requirements were not met." + cooldown: "A claim cooldown is active." + pair-limit: "This killer-target pair has too many recent claims." + target-offline: "The target must be online at death." + +gui: + unavailable: "{prefix}&cThe bounty GUI is disabled." + prompt-target: "{prefix}&eType the target player's name in chat, or type &fcancel&e." + prompt-amount: "{prefix}&eType the bounty amount for &f{target}&e, or type &fcancel&e." + prompt-reason: "{prefix}&eType a short reason for &f{target}&e, use &f-&e for none, or type &fcancel&e." + input-cancelled: "{prefix}&7Bounty input cancelled." + input-expired: "{prefix}&cYour bounty input expired." + confirm-opened: "{prefix}&7Review and confirm the bounty." + admin-opened: "{prefix}&7Opened the admin bounty view." + +admin: + history-empty: "{prefix}&7No bounty history found for &f{target}&7." + suspicious-empty: "{prefix}&7No suspicious bounty activity has been logged." + suspicious-line: "&8- &c{time} &7{type}: &f{details}" + history-line: "&8- &e{time} &7{type}: &f{amount} &8({note})" + refunded: "{prefix}&aRefunded &f{amount}&a to &f{player}&a." + action-logged: "{prefix}&7Admin action logged." + +webhook: + placement-title: "New bounty placed" + claim-title: "Bounty claimed" + admin-title: "Bounty admin action" diff --git a/target/classes/plugin.yml b/target/classes/plugin.yml new file mode 100644 index 0000000..a99dc65 --- /dev/null +++ b/target/classes/plugin.yml @@ -0,0 +1,67 @@ +name: DirtBounties +version: 1.0.0 +main: com.dirtbagmc.dirtbounties.DirtBountiesPlugin +api-version: '1.21' +author: DirtbagMC +website: https://dirtbagmc.com +description: Premium bounty system for Paper SMP and anarchy servers. +softdepend: + - Vault + - CMI + - PlaceholderAPI + +commands: + bounty: + description: Open and manage player bounties. + usage: /bounty help + aliases: + - bounties + - dbounty + bountyadmin: + description: Admin controls for DirtBounties. + usage: /bountyadmin help + aliases: + - dbountyadmin + - ba + +permissions: + dirtbounties.use: + description: Allows opening the bounty GUI and using basic commands. + default: true + dirtbounties.place: + description: Allows placing new bounties. + default: true + dirtbounties.add: + description: Allows increasing existing bounties. + default: true + dirtbounties.view: + description: Allows viewing bounty details. + default: true + dirtbounties.top: + description: Allows viewing top bounties. + default: true + dirtbounties.claim: + description: Allows claiming bounties by killing targets. + default: true + dirtbounties.anonymous: + description: Allows placing anonymous bounties when enabled. + default: op + dirtbounties.bypass.cooldowns: + description: Bypasses placement and claim cooldown checks. + default: op + dirtbounties.bypass.claimrules: + description: Bypasses configured claim restrictions. + default: op + dirtbounties.admin: + description: Full DirtBounties administration access. + default: op + children: + dirtbounties.use: true + dirtbounties.place: true + dirtbounties.add: true + dirtbounties.view: true + dirtbounties.top: true + dirtbounties.claim: true + dirtbounties.anonymous: true + dirtbounties.bypass.cooldowns: true + dirtbounties.bypass.claimrules: true diff --git a/target/maven-archiver/pom.properties b/target/maven-archiver/pom.properties new file mode 100644 index 0000000..7a8f606 --- /dev/null +++ b/target/maven-archiver/pom.properties @@ -0,0 +1,3 @@ +artifactId=DirtBounties +groupId=com.dirtbagmc +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..fbd6f2b --- /dev/null +++ b/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst @@ -0,0 +1,36 @@ +com/dirtbagmc/dirtbounties/util/NumberUtil.class +com/dirtbagmc/dirtbounties/service/BountyService.class +com/dirtbagmc/dirtbounties/listener/BountyListener.class +com/dirtbagmc/dirtbounties/service/PlayerCacheService.class +com/dirtbagmc/dirtbounties/command/BountyAdminCommand.class +com/dirtbagmc/dirtbounties/command/BountyCommand.class +com/dirtbagmc/dirtbounties/service/HistoryService.class +com/dirtbagmc/dirtbounties/service/CombatTracker.class +com/dirtbagmc/dirtbounties/service/BountyService$Fee.class +com/dirtbagmc/dirtbounties/webhook/WebhookService.class +com/dirtbagmc/dirtbounties/gui/GuiManager$1.class +com/dirtbagmc/dirtbounties/service/AntiAbuseService.class +com/dirtbagmc/dirtbounties/model/BountyContribution.class +com/dirtbagmc/dirtbounties/gui/GuiHolder.class +com/dirtbagmc/dirtbounties/gui/GuiType.class +com/dirtbagmc/dirtbounties/util/TimeUtil.class +com/dirtbagmc/dirtbounties/DirtBountiesPlugin.class +com/dirtbagmc/dirtbounties/hook/DirtBountiesExpansion.class +com/dirtbagmc/dirtbounties/service/CombatTracker$CombatRecord.class +com/dirtbagmc/dirtbounties/economy/EconomyService$EconomyResult.class +com/dirtbagmc/dirtbounties/storage/StorageManager.class +com/dirtbagmc/dirtbounties/util/ColorUtil.class +com/dirtbagmc/dirtbounties/service/MessageService.class +com/dirtbagmc/dirtbounties/model/Bounty.class +com/dirtbagmc/dirtbounties/service/PlayerCacheService$CacheEntry.class +com/dirtbagmc/dirtbounties/model/ClaimValidation.class +com/dirtbagmc/dirtbounties/storage/StorageManager$PlayerCacheData.class +com/dirtbagmc/dirtbounties/util/ItemBuilder.class +com/dirtbagmc/dirtbounties/model/BountyHistoryRecord.class +com/dirtbagmc/dirtbounties/command/BountyCommand$ReasonInput.class +com/dirtbagmc/dirtbounties/gui/PendingBountyInput.class +com/dirtbagmc/dirtbounties/gui/PendingBountyInput$Stage.class +com/dirtbagmc/dirtbounties/config/ConfigService.class +com/dirtbagmc/dirtbounties/economy/EconomyService.class +com/dirtbagmc/dirtbounties/model/SuspiciousActivity.class +com/dirtbagmc/dirtbounties/gui/GuiManager.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..4a8ecb7 --- /dev/null +++ b/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst @@ -0,0 +1,28 @@ +/home/bitnix/Desktop/DirtBounties/src/main/java/com/dirtbagmc/dirtbounties/DirtBountiesPlugin.java +/home/bitnix/Desktop/DirtBounties/src/main/java/com/dirtbagmc/dirtbounties/command/BountyAdminCommand.java +/home/bitnix/Desktop/DirtBounties/src/main/java/com/dirtbagmc/dirtbounties/command/BountyCommand.java +/home/bitnix/Desktop/DirtBounties/src/main/java/com/dirtbagmc/dirtbounties/config/ConfigService.java +/home/bitnix/Desktop/DirtBounties/src/main/java/com/dirtbagmc/dirtbounties/economy/EconomyService.java +/home/bitnix/Desktop/DirtBounties/src/main/java/com/dirtbagmc/dirtbounties/gui/GuiHolder.java +/home/bitnix/Desktop/DirtBounties/src/main/java/com/dirtbagmc/dirtbounties/gui/GuiManager.java +/home/bitnix/Desktop/DirtBounties/src/main/java/com/dirtbagmc/dirtbounties/gui/GuiType.java +/home/bitnix/Desktop/DirtBounties/src/main/java/com/dirtbagmc/dirtbounties/gui/PendingBountyInput.java +/home/bitnix/Desktop/DirtBounties/src/main/java/com/dirtbagmc/dirtbounties/hook/DirtBountiesExpansion.java +/home/bitnix/Desktop/DirtBounties/src/main/java/com/dirtbagmc/dirtbounties/listener/BountyListener.java +/home/bitnix/Desktop/DirtBounties/src/main/java/com/dirtbagmc/dirtbounties/model/Bounty.java +/home/bitnix/Desktop/DirtBounties/src/main/java/com/dirtbagmc/dirtbounties/model/BountyContribution.java +/home/bitnix/Desktop/DirtBounties/src/main/java/com/dirtbagmc/dirtbounties/model/BountyHistoryRecord.java +/home/bitnix/Desktop/DirtBounties/src/main/java/com/dirtbagmc/dirtbounties/model/ClaimValidation.java +/home/bitnix/Desktop/DirtBounties/src/main/java/com/dirtbagmc/dirtbounties/model/SuspiciousActivity.java +/home/bitnix/Desktop/DirtBounties/src/main/java/com/dirtbagmc/dirtbounties/service/AntiAbuseService.java +/home/bitnix/Desktop/DirtBounties/src/main/java/com/dirtbagmc/dirtbounties/service/BountyService.java +/home/bitnix/Desktop/DirtBounties/src/main/java/com/dirtbagmc/dirtbounties/service/CombatTracker.java +/home/bitnix/Desktop/DirtBounties/src/main/java/com/dirtbagmc/dirtbounties/service/HistoryService.java +/home/bitnix/Desktop/DirtBounties/src/main/java/com/dirtbagmc/dirtbounties/service/MessageService.java +/home/bitnix/Desktop/DirtBounties/src/main/java/com/dirtbagmc/dirtbounties/service/PlayerCacheService.java +/home/bitnix/Desktop/DirtBounties/src/main/java/com/dirtbagmc/dirtbounties/storage/StorageManager.java +/home/bitnix/Desktop/DirtBounties/src/main/java/com/dirtbagmc/dirtbounties/util/ColorUtil.java +/home/bitnix/Desktop/DirtBounties/src/main/java/com/dirtbagmc/dirtbounties/util/ItemBuilder.java +/home/bitnix/Desktop/DirtBounties/src/main/java/com/dirtbagmc/dirtbounties/util/NumberUtil.java +/home/bitnix/Desktop/DirtBounties/src/main/java/com/dirtbagmc/dirtbounties/util/TimeUtil.java +/home/bitnix/Desktop/DirtBounties/src/main/java/com/dirtbagmc/dirtbounties/webhook/WebhookService.java