This commit is contained in:
2026-06-24 18:38:15 -04:00
commit c5c83aa216
78 changed files with 5066 additions and 0 deletions
View File
+82
View File
@@ -0,0 +1,82 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.dirtbagmc</groupId>
<artifactId>DirtBounties</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>DirtBounties</name>
<description>Premium Paper bounty system for DirtbagMC.</description>
<url>https://dirtbagmc.com</url>
<properties>
<java.version>21</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<paper.version>1.21.8-R0.1-SNAPSHOT</paper.version>
<placeholderapi.version>2.11.6</placeholderapi.version>
</properties>
<repositories>
<repository>
<id>papermc</id>
<url>https://repo.papermc.io/repository/maven-public/</url>
</repository>
<repository>
<id>placeholderapi</id>
<url>https://repo.extendedclip.com/content/repositories/placeholderapi/</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>io.papermc.paper</groupId>
<artifactId>paper-api</artifactId>
<version>${paper.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>me.clip</groupId>
<artifactId>placeholderapi</artifactId>
<version>${placeholderapi.version}</version>
<scope>provided</scope>
<optional>true</optional>
</dependency>
</dependencies>
<build>
<finalName>DirtBounties-${project.version}</finalName>
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>false</filtering>
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<release>${java.version}</release>
<encoding>${project.build.sourceEncoding}</encoding>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.4.2</version>
<configuration>
<archive>
<manifest>
<addDefaultImplementationEntries>true</addDefaultImplementationEntries>
</manifest>
</archive>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -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);
}
}
@@ -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<BountyHistoryRecord> 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<SuspiciousActivity> 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<String> 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<String> 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<String> filter(String input, List<String> 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();
}
}
@@ -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<Bounty> 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<Bounty> 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<String, String> 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<String, String> 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<String> 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<String> 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<String> 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<String> filter(String input, List<String> 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) {
}
}
@@ -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);
}
}
}
@@ -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);
}
}
}
@@ -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;
}
}
@@ -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<UUID, PendingBountyInput> 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<Bounty> 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<Bounty> 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<String, String> 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<String, String> 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<Integer> slots = contentSlots();
List<BountyHistoryRecord> 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<String, String> 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<Integer> slots = contentSlots();
List<SuspiciousActivity> 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<String, String> 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<Bounty> bounties, int page, boolean top) {
List<Integer> 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<String, String> 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<Integer> slots = contentSlots();
int index = slots.indexOf(slot);
if (index < 0) {
return null;
}
int global = page * slots.size() + index;
List<Bounty> 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<Integer> 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<String, String> 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<String, String> 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<String, String> confirmPlaceholders(PendingBountyInput input) {
BountyService.Fee fee = bountyService.previewFee(input.amount(), input.increase());
Map<String, String> 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<String, String> claimRulePlaceholders() {
Map<String, String> 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<String> lines = new ArrayList<>();
List<BountyContribution> 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<Integer> contentSlots() {
List<Integer> 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);
}
}
@@ -0,0 +1,11 @@
package com.dirtbagmc.dirtbounties.gui;
public enum GuiType {
MAIN,
TOP,
DETAIL,
CONFIRM,
ADMIN_MAIN,
ADMIN_HISTORY,
ADMIN_SUSPICIOUS
}
@@ -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
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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<BountyContribution> contributions;
public Bounty(UUID id, UUID targetUuid, String targetName, double amount, long createdAt,
long updatedAt, long expiresAt, List<BountyContribution> 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<BountyContribution> 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;
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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<UUID, Long> runtimeKillerClaims = new HashMap<>();
private final Map<UUID, Long> runtimeTargetClaims = new HashMap<>();
private final Map<String, Long> 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;
}
}
@@ -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<UUID, Bounty> bounties = new HashMap<>();
private final Map<UUID, Long> 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> bounty(UUID targetUuid) {
return Optional.ofNullable(bounties.get(targetUuid));
}
public Collection<Bounty> activeBounties() {
return List.copyOf(bounties.values());
}
public List<Bounty> activeSorted() {
return bounties.values().stream()
.sorted(Comparator.comparingDouble(Bounty::amount).reversed()
.thenComparing(Bounty::updatedAt, Comparator.reverseOrder()))
.toList();
}
public List<Bounty> 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<String, String> 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<Map.Entry<UUID, Bounty>> 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<String, String> 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<String, String> 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<String, String> bountyPlaceholders(Bounty bounty) {
Map<String, String> 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<String, String> 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) {
}
}
@@ -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<String, CombatRecord> 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<Map.Entry<String, CombatRecord>> 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;
}
}
}
@@ -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<BountyHistoryRecord> records = new ArrayList<>();
private final List<SuspiciousActivity> 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<BountyHistoryRecord> 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<BountyHistoryRecord> recent(int limit) {
return records.stream()
.sorted(Comparator.comparingLong(BountyHistoryRecord::createdAt).reversed())
.limit(limit)
.toList();
}
public List<SuspiciousActivity> 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 <T> void trim(List<T> 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();
}
}
@@ -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<String, String> 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<String, String> placeholders) {
List<String> 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<String, String> 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<String, String> placeholders) {
return ColorUtil.component(apply(raw, placeholders));
}
public String legacy(String raw, Map<String, String> placeholders) {
return ColorUtil.legacySection(apply(raw, placeholders));
}
public List<Component> components(List<String> lines, Map<String, String> 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<String, String> placeholders) {
Map<String, String> merged = new HashMap<>();
merged.put("prefix", configService.messages().getString("prefix", ""));
if (placeholders != null) {
merged.putAll(placeholders);
}
return ColorUtil.replace(raw, merged);
}
}
@@ -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<UUID, CacheEntry> 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<UUID, StorageManager.PlayerCacheData> 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<String> knownIps;
private long lastSeen;
private CacheEntry(String name, String lastIp, Set<String> 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<String> knownIps() {
return knownIps;
}
public long lastSeen() {
return lastSeen;
}
public void lastSeen(long lastSeen) {
this.lastSeen = lastSeen;
}
}
}
@@ -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<UUID, Bounty> loadBounties() {
Map<UUID, Bounty> 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<BountyContribution> 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<Bounty> 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<BountyHistoryRecord> loadHistory() {
List<BountyHistoryRecord> 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<BountyHistoryRecord> 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<SuspiciousActivity> loadSuspicious() {
List<SuspiciousActivity> 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<SuspiciousActivity> 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<UUID, PlayerCacheData> loadPlayerCache() {
Map<UUID, PlayerCacheData> 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<UUID, PlayerCacheData> cache) {
YamlConfiguration yaml = new YamlConfiguration();
for (Map.Entry<UUID, PlayerCacheData> 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<String> knownIps, long lastSeen) {
public PlayerCacheData {
knownIps = knownIps == null ? Set.of() : Set.copyOf(knownIps);
}
}
}
@@ -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<String, String> placeholders) {
String value = input == null ? "" : input;
if (placeholders == null || placeholders.isEmpty()) {
return value;
}
for (Map.Entry<String, String> 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();
}
}
@@ -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<String, String> 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<String> 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<String> lore, MessageService messages,
Map<String, String> 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;
}
}
@@ -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));
}
}
@@ -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));
}
}
@@ -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<String> 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", "");
}
}
+154
View File
@@ -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: "&#3A2416&lᴅ&#4A2D1B&lɪ&#5A351F&lʀ&#6A3F24&lᴛ&#7A4A2A&lʙ&#8B6B35&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
+177
View File
@@ -0,0 +1,177 @@
titles:
main: "&#3A2416&lDirtBounties &8| &6Active"
top: "&#3A2416&lDirtBounties &8| &6Top"
detail: "&#3A2416&lDirtBounties &8| &c{target}"
confirm: "&#3A2416&lDirtBounties &8| &aConfirm"
admin-main: "&#3A2416&lDirtBounties &8| &4Admin"
admin-history: "&#3A2416&lDirtBounties &8| &4History"
admin-suspicious: "&#3A2416&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."
+105
View File
@@ -0,0 +1,105 @@
prefix: "&#3A2416&lᴅ&#4A2D1B&lɪ&#5A351F&lʀ&#6A3F24&lᴛ&#7A4A2A&lʙ&#8B6B35&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 <player> <amount> [reason] &7Place a bounty."
- "&8- &e/bounty add <player> <amount> [reason] &7Increase a bounty."
- "&8- &e/bounty list &7List active bounties."
- "&8- &e/bounty top &7View top bounties."
- "&8- &e/bounty view <player> &7View bounty details."
- "&8- &e/bounty claiminfo <player> &7View claim rules."
admin-help:
- "{prefix}&6&lDirtBounties Admin"
- "&8- &e/bountyadmin reload &7Reload configs and storage."
- "&8- &e/bountyadmin remove <player> &7Remove a bounty and refund by config."
- "&8- &e/bountyadmin clearall confirm &7Remove every active bounty."
- "&8- &e/bountyadmin set <player> <amount> &7Set a bounty value."
- "&8- &e/bountyadmin expire <player> &7Expire a bounty."
- "&8- &e/bountyadmin history <player> &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"
+67
View File
@@ -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
Binary file not shown.
+154
View File
@@ -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: "&#3A2416&lᴅ&#4A2D1B&lɪ&#5A351F&lʀ&#6A3F24&lᴛ&#7A4A2A&lʙ&#8B6B35&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
+177
View File
@@ -0,0 +1,177 @@
titles:
main: "&#3A2416&lDirtBounties &8| &6Active"
top: "&#3A2416&lDirtBounties &8| &6Top"
detail: "&#3A2416&lDirtBounties &8| &c{target}"
confirm: "&#3A2416&lDirtBounties &8| &aConfirm"
admin-main: "&#3A2416&lDirtBounties &8| &4Admin"
admin-history: "&#3A2416&lDirtBounties &8| &4History"
admin-suspicious: "&#3A2416&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."
+105
View File
@@ -0,0 +1,105 @@
prefix: "&#3A2416&lᴅ&#4A2D1B&lɪ&#5A351F&lʀ&#6A3F24&lᴛ&#7A4A2A&lʙ&#8B6B35&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 <player> <amount> [reason] &7Place a bounty."
- "&8- &e/bounty add <player> <amount> [reason] &7Increase a bounty."
- "&8- &e/bounty list &7List active bounties."
- "&8- &e/bounty top &7View top bounties."
- "&8- &e/bounty view <player> &7View bounty details."
- "&8- &e/bounty claiminfo <player> &7View claim rules."
admin-help:
- "{prefix}&6&lDirtBounties Admin"
- "&8- &e/bountyadmin reload &7Reload configs and storage."
- "&8- &e/bountyadmin remove <player> &7Remove a bounty and refund by config."
- "&8- &e/bountyadmin clearall confirm &7Remove every active bounty."
- "&8- &e/bountyadmin set <player> <amount> &7Set a bounty value."
- "&8- &e/bountyadmin expire <player> &7Expire a bounty."
- "&8- &e/bountyadmin history <player> &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"
+67
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
artifactId=DirtBounties
groupId=com.dirtbagmc
version=1.0.0
@@ -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
@@ -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