diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/economy/EconomyConfig.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/economy/EconomyConfig.java index 512eff3..cec3a3f 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/economy/EconomyConfig.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/economy/EconomyConfig.java @@ -18,6 +18,7 @@ public record EconomyConfig( boolean enabled, @NotNull String provider, + @NotNull ItemEconomyConfig item, double anyHeadPrice, @NotNull Map categoryPrices, @NotNull Map customCategoryPrices, @@ -27,6 +28,7 @@ public record EconomyConfig( public EconomyConfig { Objects.requireNonNull(provider, "provider"); + Objects.requireNonNull(item, "item"); Objects.requireNonNull(categoryPrices, "categoryPrices"); Objects.requireNonNull(customCategoryPrices, "customCategoryPrices"); Objects.requireNonNull(headPrices, "headPrices"); @@ -55,6 +57,12 @@ public record EconomyConfig( return new EconomyConfig( yaml.getBoolean("enabled", false), yaml.getString("provider", "vault"), + new ItemEconomyConfig( + yaml.getString("item.match", ItemEconomyProvider.MATCH_MATERIAL), + yaml.getString("item.material", "DIAMOND"), + yaml.getString("item.singular", ""), + yaml.getString("item.plural", "") + ), yaml.getDouble("prices.any-head", 0.0D), prices(yaml.getConfigurationSection("prices.categories")), prices(yaml.getConfigurationSection("prices.custom-categories")), @@ -144,10 +152,19 @@ private static void copyDefault(@NotNull Path file) { private static @NotNull String defaultConfig() { return "enabled: false\n" + + "# Supported providers: vault, item\n" + "provider: vault\n" + "\n" + + "item:\n" + + " # Item economy currently supports material-only matching.\n" + + " match: material\n" + + " material: DIAMOND\n" + + " singular: Diamond\n" + + " plural: Diamonds\n" + + "\n" + "prices:\n" + " # Fallback price for every head. Set to 0 to make heads free.\n" + + " # Item economy prices must be whole numbers.\n" + " any-head: 0.0\n" + "\n" + " # Price for player heads. If omitted, any-head is used.\n" diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/economy/EconomyService.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/economy/EconomyService.java index 043e7fb..b6bdf6d 100644 --- a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/economy/EconomyService.java +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/economy/EconomyService.java @@ -3,6 +3,7 @@ import io.github.silentdevelopment.headdb.model.Head; import io.github.silentdevelopment.headdb.paper.HeadDBPlugin; import net.milkbowl.vault.economy.Economy; +import org.bukkit.Material; import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.entity.Player; import org.bukkit.plugin.RegisteredServiceProvider; @@ -36,25 +37,113 @@ private EconomyService(@NotNull HeadDBPlugin plugin, @NotNull EconomyConfig conf return new EconomyService(plugin, config, new DisabledEconomyProvider()); } - if (!config.provider().equalsIgnoreCase("vault")) { - plugin.getSLF4JLogger().warn("Unsupported economy provider '{}'. Economy integration is disabled.", config.provider()); - return new EconomyService(plugin, config, new DisabledEconomyProvider()); - } + return switch (config.provider()) { + case "vault" -> createVault(plugin, config); + case "item" -> createItem(plugin, config); + default -> { + plugin.getSLF4JLogger().warn("Unsupported economy provider '{}'. Economy integration is disabled.", config.provider()); + yield new EconomyService(plugin, config, new DisabledEconomyProvider()); + } + }; + } + private static @NotNull EconomyService createVault(@NotNull HeadDBPlugin plugin, @NotNull EconomyConfig config) { if (plugin.getServer().getPluginManager().getPlugin("Vault") == null) { - plugin.getSLF4JLogger().warn("Economy is enabled but Vault is not loaded."); + plugin.getSLF4JLogger().warn("Economy is enabled with provider 'vault' but Vault is not loaded."); return new EconomyService(plugin, config, new DisabledEconomyProvider()); } RegisteredServiceProvider registration = plugin.getServer().getServicesManager().getRegistration(Economy.class); if (registration == null || registration.getProvider() == null) { - plugin.getSLF4JLogger().warn("Economy is enabled but Vault did not provide an Economy service."); + plugin.getSLF4JLogger().warn("Economy is enabled with provider 'vault' but Vault did not provide an Economy service."); return new EconomyService(plugin, config, new DisabledEconomyProvider()); } return new EconomyService(plugin, config, new VaultEconomyProvider(registration.getProvider())); } + private static @NotNull EconomyService createItem(@NotNull HeadDBPlugin plugin, @NotNull EconomyConfig config) { + ItemEconomyConfig itemConfig = config.item(); + + if (!itemConfig.match().equals(ItemEconomyProvider.MATCH_MATERIAL)) { + plugin.getSLF4JLogger().warn( + "Unsupported item economy match mode '{}'. Only 'material' is supported. Economy integration is disabled.", + itemConfig.match() + ); + return new EconomyService(plugin, config, new DisabledEconomyProvider()); + } + + Material material = Material.matchMaterial(itemConfig.material()); + if (material == null || material.isAir() || !material.isItem()) { + plugin.getSLF4JLogger().warn( + "Invalid item economy material '{}'. Economy integration is disabled.", + itemConfig.material() + ); + return new EconomyService(plugin, config, new DisabledEconomyProvider()); + } + + if (!wholeItemPrices(config)) { + plugin.getSLF4JLogger().warn( + "Item economy requires whole-number prices. Economy integration is disabled until fractional prices are removed." + ); + return new EconomyService(plugin, config, new DisabledEconomyProvider()); + } + + String fallbackName = displayName(material); + String singular = itemConfig.singularOr(fallbackName); + String plural = itemConfig.pluralOr(singular + "s"); + return new EconomyService(plugin, config, new ItemEconomyProvider(material, singular, plural)); + } + + private static boolean wholeItemPrices(@NotNull EconomyConfig config) { + if (!ItemEconomyProvider.isWholeAmount(config.anyHeadPrice())) { + return false; + } + + if (config.playerHeadPrice() != null && !ItemEconomyProvider.isWholeAmount(config.playerHeadPrice())) { + return false; + } + + for (double price : config.categoryPrices().values()) { + if (!ItemEconomyProvider.isWholeAmount(price)) { + return false; + } + } + + for (double price : config.customCategoryPrices().values()) { + if (!ItemEconomyProvider.isWholeAmount(price)) { + return false; + } + } + + for (double price : config.headPrices().values()) { + if (!ItemEconomyProvider.isWholeAmount(price)) { + return false; + } + } + + return true; + } + + private static @NotNull String displayName(@NotNull Material material) { + String[] parts = material.name().toLowerCase(Locale.ROOT).split("_"); + StringBuilder result = new StringBuilder(); + + for (String part : parts) { + if (part.isBlank()) { + continue; + } + + if (!result.isEmpty()) { + result.append(' '); + } + + result.append(Character.toUpperCase(part.charAt(0))).append(part.substring(1)); + } + + return result.toString(); + } + public boolean enabled() { return config.enabled() && provider.available(); } @@ -137,8 +226,12 @@ public void setCustomCategoryPrice(@NotNull String categoryId, double price) { } private void setPrice(@NotNull String path, double price) { - if (price < 0.0D) { - throw new IllegalArgumentException("Price cannot be negative."); + if (!Double.isFinite(price) || price < 0.0D) { + throw new IllegalArgumentException("Price must be a finite non-negative number."); + } + + if (config.provider().equals("item") && !ItemEconomyProvider.isWholeAmount(price)) { + throw new IllegalArgumentException("Item economy prices must be whole numbers."); } Path file = plugin.getDataFolder().toPath().resolve("economy.yml"); diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/economy/ItemEconomyConfig.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/economy/ItemEconomyConfig.java new file mode 100644 index 0000000..21cb80f --- /dev/null +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/economy/ItemEconomyConfig.java @@ -0,0 +1,36 @@ +package io.github.silentdevelopment.headdb.paper.economy; + +import org.jetbrains.annotations.NotNull; + +import java.util.Locale; +import java.util.Objects; + +public record ItemEconomyConfig( + @NotNull String match, + @NotNull String material, + @NotNull String singular, + @NotNull String plural +) { + + public ItemEconomyConfig { + Objects.requireNonNull(match, "match"); + Objects.requireNonNull(material, "material"); + Objects.requireNonNull(singular, "singular"); + Objects.requireNonNull(plural, "plural"); + + match = match.trim().toLowerCase(Locale.ROOT); + material = material.trim(); + singular = singular.trim(); + plural = plural.trim(); + } + + public @NotNull String singularOr(@NotNull String fallback) { + Objects.requireNonNull(fallback, "fallback"); + return singular.isBlank() ? fallback : singular; + } + + public @NotNull String pluralOr(@NotNull String fallback) { + Objects.requireNonNull(fallback, "fallback"); + return plural.isBlank() ? fallback : plural; + } +} diff --git a/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/economy/ItemEconomyProvider.java b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/economy/ItemEconomyProvider.java new file mode 100644 index 0000000..073225f --- /dev/null +++ b/headdb-platforms/headdb-paper/src/main/java/io/github/silentdevelopment/headdb/paper/economy/ItemEconomyProvider.java @@ -0,0 +1,156 @@ +package io.github.silentdevelopment.headdb.paper.economy; + +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Locale; +import java.util.Objects; + +public final class ItemEconomyProvider implements EconomyProvider { + + public static final String MATCH_MATERIAL = "material"; + + private final Material material; + private final String singular; + private final String plural; + + public ItemEconomyProvider(@NotNull Material material, @NotNull String singular, @NotNull String plural) { + this.material = Objects.requireNonNull(material, "material"); + this.singular = Objects.requireNonNull(singular, "singular"); + this.plural = Objects.requireNonNull(plural, "plural"); + } + + @Override + public boolean available() { + return true; + } + + @Override + public @NotNull String format(double amount) { + Long units = wholeUnits(amount); + if (units == null) { + return String.format(Locale.ROOT, "%.2f %s", amount, plural); + } + + return units + " " + (units == 1L ? singular : plural); + } + + @Override + public @NotNull EconomyWithdrawal withdraw(@NotNull Player player, double amount) { + Objects.requireNonNull(player, "player"); + + Long required = wholeUnits(amount); + if (required == null) { + return new EconomyWithdrawal(false, "Item economy requires whole-number prices."); + } + + if (required <= 0L) { + return new EconomyWithdrawal(true, ""); + } + + PlayerInventory inventory = player.getInventory(); + ItemStack[] updated = withdrawStorage(inventory.getStorageContents(), material, required); + if (updated == null) { + return new EconomyWithdrawal(false, "Insufficient item currency."); + } + + inventory.setStorageContents(updated); + return new EconomyWithdrawal(true, ""); + } + + static boolean isWholeAmount(double amount) { + return wholeUnits(amount) != null; + } + + static @Nullable ItemStack[] withdrawStorage(@NotNull ItemStack[] storage, @NotNull Material material, long required) { + Objects.requireNonNull(storage, "storage"); + Objects.requireNonNull(material, "material"); + + if (required < 0L) { + throw new IllegalArgumentException("Required amount cannot be negative."); + } + + int[] matchingAmounts = new int[storage.length]; + for (int index = 0; index < storage.length; index++) { + ItemStack item = storage[index]; + if (item != null && item.getType() == material) { + matchingAmounts[index] = item.getAmount(); + } + } + + int[] remainingAmounts = withdrawAmounts(matchingAmounts, required); + if (remainingAmounts == null) { + return null; + } + + ItemStack[] updated = new ItemStack[storage.length]; + for (int index = 0; index < storage.length; index++) { + ItemStack item = storage[index]; + updated[index] = item == null ? null : item.clone(); + + if (item == null || item.getType() != material) { + continue; + } + + int remaining = remainingAmounts[index]; + if (remaining <= 0) { + updated[index] = null; + } else { + updated[index].setAmount(remaining); + } + } + + return updated; + } + + static @Nullable int[] withdrawAmounts(@NotNull int[] amounts, long required) { + Objects.requireNonNull(amounts, "amounts"); + + if (required < 0L) { + throw new IllegalArgumentException("Required amount cannot be negative."); + } + + long available = 0L; + for (int amount : amounts) { + if (amount < 0) { + throw new IllegalArgumentException("Stack amount cannot be negative."); + } + + available += amount; + if (available >= required) { + break; + } + } + + if (available < required) { + return null; + } + + int[] updated = amounts.clone(); + long remaining = required; + + for (int index = 0; index < updated.length && remaining > 0L; index++) { + int remove = (int) Math.min(remaining, updated[index]); + updated[index] -= remove; + remaining -= remove; + } + + return updated; + } + + private static @Nullable Long wholeUnits(double amount) { + if (!Double.isFinite(amount) || amount < 0.0D || amount != Math.rint(amount)) { + return null; + } + + if (amount > Long.MAX_VALUE) { + return null; + } + + return (long) amount; + } +} diff --git a/headdb-platforms/headdb-paper/src/main/resources/economy.yml b/headdb-platforms/headdb-paper/src/main/resources/economy.yml index dfb2e94..b1b46dd 100644 --- a/headdb-platforms/headdb-paper/src/main/resources/economy.yml +++ b/headdb-platforms/headdb-paper/src/main/resources/economy.yml @@ -1,8 +1,17 @@ enabled: false +# Supported providers: vault, item provider: vault +item: + # Item economy currently supports material-only matching. + match: material + material: DIAMOND + singular: Diamond + plural: Diamonds + prices: # Fallback price for every head. Set to 0 to make heads free. + # Item economy prices must be whole numbers. any-head: 0.0 # Price for player heads. If omitted, any-head is used. diff --git a/headdb-platforms/headdb-paper/src/test/java/io/github/silentdevelopment/headdb/paper/economy/ItemEconomyProviderTest.java b/headdb-platforms/headdb-paper/src/test/java/io/github/silentdevelopment/headdb/paper/economy/ItemEconomyProviderTest.java new file mode 100644 index 0000000..7490e97 --- /dev/null +++ b/headdb-platforms/headdb-paper/src/test/java/io/github/silentdevelopment/headdb/paper/economy/ItemEconomyProviderTest.java @@ -0,0 +1,49 @@ +package io.github.silentdevelopment.headdb.paper.economy; + +import org.bukkit.Material; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ItemEconomyProviderTest { + + @Test + void formatsSingularAndPluralAmounts() { + ItemEconomyProvider provider = new ItemEconomyProvider(Material.DIAMOND, "Diamond", "Diamonds"); + + assertEquals("1 Diamond", provider.format(1.0D)); + assertEquals("2 Diamonds", provider.format(2.0D)); + } + + @Test + void validatesWholeAmounts() { + assertTrue(ItemEconomyProvider.isWholeAmount(0.0D)); + assertTrue(ItemEconomyProvider.isWholeAmount(12.0D)); + assertFalse(ItemEconomyProvider.isWholeAmount(2.5D)); + assertFalse(ItemEconomyProvider.isWholeAmount(Double.NaN)); + } + + @Test + void withdrawsAcrossMultipleStacks() { + int[] amounts = {1, 0, 4}; + + int[] updated = ItemEconomyProvider.withdrawAmounts(amounts, 3); + + assertArrayEquals(new int[]{0, 0, 2}, updated); + assertArrayEquals(new int[]{1, 0, 4}, amounts); + } + + @Test + void insufficientCurrencyDoesNotProduceModifiedAmounts() { + int[] amounts = {2, 0, 0}; + + int[] updated = ItemEconomyProvider.withdrawAmounts(amounts, 3); + + assertNull(updated); + assertArrayEquals(new int[]{2, 0, 0}, amounts); + } +}