From 072e7e294c89df34a9ff2365c4cc50faaeb0b2df Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 22 Feb 2026 11:02:34 +0000 Subject: [PATCH] Add ServerShop plugin - complete implementation Co-authored-by: henriquescrrrr <192057244+henriquescrrrr@users.noreply.github.com> --- .gitignore | 27 + servershop/README.md | 234 ++++++++ servershop/pom.xml | 116 ++++ .../pt/henrique/servershop/ServerShop.java | 188 +++++++ .../servershop/category/Category.java | 49 ++ .../servershop/category/CategoryRegistry.java | 141 +++++ .../servershop/command/ShopCommand.java | 68 +++ .../servershop/economy/EconomyManager.java | 106 ++++ .../henrique/servershop/gui/CategoryGui.java | 168 ++++++ .../servershop/gui/GuiController.java | 67 +++ .../pt/henrique/servershop/gui/GuiType.java | 15 + .../servershop/gui/ItemDetailGui.java | 261 +++++++++ .../servershop/gui/MainCategoryGui.java | 139 +++++ .../servershop/gui/ShopGuiListener.java | 426 +++++++++++++++ .../henrique/servershop/i18n/LangManager.java | 118 +++++ .../servershop/pricing/ItemPrice.java | 46 ++ .../servershop/pricing/PricingService.java | 158 ++++++ .../servershop/storage/TransactionLogger.java | 144 +++++ .../pt/henrique/servershop/util/ItemUtil.java | 139 +++++ .../pt/henrique/servershop/util/TextUtil.java | 51 ++ servershop/src/main/resources/config.yml | 74 +++ servershop/src/main/resources/lang/en_US.yml | 97 ++++ servershop/src/main/resources/lang/pt_PT.yml | 90 ++++ servershop/src/main/resources/plugin.yml | 51 ++ servershop/src/main/resources/prices.yml | 499 ++++++++++++++++++ 25 files changed, 3472 insertions(+) create mode 100644 .gitignore create mode 100644 servershop/README.md create mode 100644 servershop/pom.xml create mode 100644 servershop/src/main/java/pt/henrique/servershop/ServerShop.java create mode 100644 servershop/src/main/java/pt/henrique/servershop/category/Category.java create mode 100644 servershop/src/main/java/pt/henrique/servershop/category/CategoryRegistry.java create mode 100644 servershop/src/main/java/pt/henrique/servershop/command/ShopCommand.java create mode 100644 servershop/src/main/java/pt/henrique/servershop/economy/EconomyManager.java create mode 100644 servershop/src/main/java/pt/henrique/servershop/gui/CategoryGui.java create mode 100644 servershop/src/main/java/pt/henrique/servershop/gui/GuiController.java create mode 100644 servershop/src/main/java/pt/henrique/servershop/gui/GuiType.java create mode 100644 servershop/src/main/java/pt/henrique/servershop/gui/ItemDetailGui.java create mode 100644 servershop/src/main/java/pt/henrique/servershop/gui/MainCategoryGui.java create mode 100644 servershop/src/main/java/pt/henrique/servershop/gui/ShopGuiListener.java create mode 100644 servershop/src/main/java/pt/henrique/servershop/i18n/LangManager.java create mode 100644 servershop/src/main/java/pt/henrique/servershop/pricing/ItemPrice.java create mode 100644 servershop/src/main/java/pt/henrique/servershop/pricing/PricingService.java create mode 100644 servershop/src/main/java/pt/henrique/servershop/storage/TransactionLogger.java create mode 100644 servershop/src/main/java/pt/henrique/servershop/util/ItemUtil.java create mode 100644 servershop/src/main/java/pt/henrique/servershop/util/TextUtil.java create mode 100644 servershop/src/main/resources/config.yml create mode 100644 servershop/src/main/resources/lang/en_US.yml create mode 100644 servershop/src/main/resources/lang/pt_PT.yml create mode 100644 servershop/src/main/resources/plugin.yml create mode 100644 servershop/src/main/resources/prices.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e268fe --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Maven build outputs +target/ +dependency-reduced-pom.xml + +# IntelliJ IDEA +.idea/ +*.iml +*.iws +*.ipr + +# Eclipse +.classpath +.project +.settings/ + +# VS Code +.vscode/ + +# macOS +.DS_Store +**/.DS_Store + +# Windows +Thumbs.db + +# Local dev +*.log diff --git a/servershop/README.md b/servershop/README.md new file mode 100644 index 0000000..2ca4397 --- /dev/null +++ b/servershop/README.md @@ -0,0 +1,234 @@ +# ServerShop + +A professional **server-run global market** plugin for **Paper/Purpur 1.21** using **Java 21** and **Maven**. + +ServerShop is designed to complement the [CommunityMarket](../README.md) player-to-player marketplace plugin. By maintaining a **large spread** between buy and sell prices (default: server buys at only 25% of the buy price), the server shop provides an economic safety net while keeping player-to-player trading attractive. + +--- + +## Features + +- **GUI-only** — all interactions through clean, paginated GUIs +- **Category-based shop** — Blocks, Ores, Farming, Food, Mob Drops, Redstone, Decoration, Tools, Combat, Brewing, Misc +- **All Minecraft items** supported via `Material` enumeration; unknown/new items fall back to Misc +- **Quantity selector** — adjust buy/sell quantity in the item detail GUI +- **Vault economy integration** (required) +- **Configurable pricing** — per-item and per-category overrides in `prices.yml` +- **Large spread** — server buys at 25% of sell price by default (fully configurable) +- **i18n** — English (`en_US`) and Portuguese (`pt_PT`) included; easy to add more +- **Optional SQLite transaction logging** +- **Anti-exploit** — shift-click, drag, number-swap events are all cancelled +- **Modrinth-ready** documentation + +--- + +## Requirements + +| Requirement | Version | +|-------------|---------| +| Paper / Purpur | 1.21+ | +| Java | 21+ | +| Vault | Any compatible version | +| Economy plugin | e.g. EssentialsX, CMI | + +--- + +## Installation + +1. Download `ServerShop-.jar` from Modrinth / GitHub Releases. +2. Place the JAR in your server's `plugins/` folder. +3. Make sure [Vault](https://www.spigotmc.org/resources/vault.34315/) and a compatible economy plugin are installed. +4. Start the server — default `config.yml`, `prices.yml`, and language files will be generated. +5. Edit `plugins/ServerShop/config.yml` and `plugins/ServerShop/prices.yml` as needed. +6. Run `/shop reload` (requires `servershop.admin.reload`) to apply changes without restarting. + +--- + +## Commands + +| Command | Description | Permission | +|---------|-------------|------------| +| `/shop` | Opens the main shop GUI | `servershop.use` | +| `/shop reload` | Reloads all configuration | `servershop.admin.reload` | + +Aliases: `/servershop`, `/sshop` + +--- + +## Permissions + +| Permission | Default | Description | +|------------|---------|-------------| +| `servershop.*` | op | All permissions | +| `servershop.use` | true | Open the shop | +| `servershop.buy` | true | Buy items | +| `servershop.sell` | true | Sell items | +| `servershop.admin` | op | Admin commands | +| `servershop.admin.reload` | op | Reload configuration | + +--- + +## How Pricing Works (Spread Explanation) + +The shop deliberately maintains a **large spread** between buy and sell prices to keep player-to-player trading in CommunityMarket more economically attractive. + +**Example with default settings:** + +| Item | Buy Price (player buys) | Sell Price (player sells) | Spread | +|------|------------------------|--------------------------|--------| +| Diamond | $50.00 | $12.50 | 75% | +| Iron Ingot | $5.00 | $1.25 | 75% | +| Wheat | $1.00 | $0.25 | 75% | + +If a player wants to sell diamonds, they get **$12.50** from the server shop. On the CommunityMarket, another player might pay **$35–$45** — much more attractive. + +### Configuring the spread + +In `config.yml`: +```yaml +pricing: + global-sell-multiplier: 0.25 # Server pays 25% of buy price (75% spread) +``` + +Per-category overrides: +```yaml +pricing: + category-sell-multipliers: + BLOCKS: 0.20 # 80% spread on blocks + ORES: 0.20 # 80% spread on ores + FOOD: 0.30 # 70% spread on food +``` + +Per-item explicit prices in `prices.yml`: +```yaml +categories: + ORES: + items: + DIAMOND: + buy-price: 50.00 + sell-price: 12.50 # explicit override + NETHERITE_INGOT: + buy-price: 500.00 + sell-enabled: false # cannot sell netherite back to the server +``` + +--- + +## How to Edit Categories & Prices + +### `prices.yml` structure + +```yaml +categories: + CATEGORY_NAME: + display-name: "&7Human Readable Name" + icon: MATERIAL_NAME # icon shown in the category GUI + buy-enabled: true # can players buy from this category? + sell-enabled: true # can players sell to this category? + items: + MATERIAL_NAME: + buy-price: 10.00 # price to buy 1x from server + sell-price: 2.50 # price server pays for 1x (optional — uses multiplier if omitted) + buy-enabled: true # per-item override (optional) + sell-enabled: true # per-item override (optional) +``` + +- Set `buy-price: -1` to disable buying a specific item. +- Set `sell-price: -1` (or `sell-enabled: false`) to disable selling a specific item. +- Items not listed in any category automatically appear in **Misc** with a default price of $10.00 buy / $2.50 sell. + +### Special-meta items + +By default, items with special metadata (enchanted books, potions, tipped arrows) are **excluded** because they can't be meaningfully sold without meta matching. You can change this: + +```yaml +features: + include-special-meta-items: false # default +``` + +--- + +## Configuration Reference + +### `config.yml` + +```yaml +language: en_US # Language (en_US, pt_PT) + +economy: + currency-format: "$#,##0.00" # Java DecimalFormat pattern + currency-symbol: "$" + +pricing: + global-sell-multiplier: 0.25 # Global buy→sell multiplier + sell-tax-percent: 0.0 # Optional tax on sell proceeds + category-sell-multipliers: # Per-category overrides + BLOCKS: 0.20 + +full-inventory-behavior: DROP # DROP or CANCEL + +gui: + main-title: "&6&lServer Shop" + filler-material: GRAY_STAINED_GLASS_PANE + +features: + enable-buying: true + enable-selling: true + sell-hand-button: true + sell-inventory-button: true + include-special-meta-items: false + +logging: + enabled: true + file: transactions.db +``` + +--- + +## Transaction Logging + +When `logging.enabled: true` (default), every buy and sell is recorded in an SQLite database at `plugins/ServerShop/transactions.db`. + +Schema: +```sql +CREATE TABLE transactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT, + player TEXT, + type TEXT, -- 'BUY' or 'SELL' + material TEXT, + amount INTEGER, + unit_price REAL, + total REAL, + timestamp INTEGER -- Unix epoch seconds +); +``` + +You can query this with any SQLite client or DB browser to generate sales reports. + +--- + +## Known Limitations + +- No admin GUI for viewing transaction stats (planned for v2). +- Search functionality is planned but not implemented in v1 (click Search shows a placeholder message). +- Items with special meta (potions, enchanted books) are excluded by default; when enabled, only the base type is priced (no meta matching). +- The `sell-inventory` button sells **all** sellable items in the inventory at once — use with caution. +- Quantities are capped to 64 × inventory size; extremely large transactions may be slow. + +--- + +## Building from Source + +```bash +cd servershop +mvn clean package +``` + +The shaded JAR will be in `servershop/target/servershop-1.0.0.jar`. + +--- + +## License + +MIT — see the root [LICENSE](../LICENSE) file. diff --git a/servershop/pom.xml b/servershop/pom.xml new file mode 100644 index 0000000..876eb5f --- /dev/null +++ b/servershop/pom.xml @@ -0,0 +1,116 @@ + + + 4.0.0 + + pt.henrique + servershop + 1.0.0 + jar + + ServerShop + A server-run global market plugin for Paper/Purpur 1.21 with large buy/sell spread to complement CommunityMarket + + + 21 + 21 + UTF-8 + 1.21.1-R0.1-SNAPSHOT + + + + + + papermc + https://repo.papermc.io/repository/maven-public/ + + + + jitpack.io + https://jitpack.io + + + + + + + io.papermc.paper + paper-api + ${paper.version} + provided + + + + + com.github.MilkBowl + VaultAPI + 1.7.1 + provided + + + + + org.xerial + sqlite-jdbc + 3.45.1.0 + + + + + com.zaxxer + HikariCP + 5.1.0 + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.12.1 + + 21 + 21 + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.1 + + + package + + shade + + + false + + + com.zaxxer.hikari + pt.henrique.servershop.libs.hikari + + + org.sqlite + pt.henrique.servershop.libs.sqlite + + + false + + + + + + + + + src/main/resources + true + + + + + diff --git a/servershop/src/main/java/pt/henrique/servershop/ServerShop.java b/servershop/src/main/java/pt/henrique/servershop/ServerShop.java new file mode 100644 index 0000000..6c2b37c --- /dev/null +++ b/servershop/src/main/java/pt/henrique/servershop/ServerShop.java @@ -0,0 +1,188 @@ +package pt.henrique.servershop; + +import org.bukkit.command.PluginCommand; +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.plugin.java.JavaPlugin; +import pt.henrique.servershop.category.CategoryRegistry; +import pt.henrique.servershop.command.ShopCommand; +import pt.henrique.servershop.economy.EconomyManager; +import pt.henrique.servershop.gui.*; +import pt.henrique.servershop.i18n.LangManager; +import pt.henrique.servershop.pricing.PricingService; +import pt.henrique.servershop.storage.TransactionLogger; + +import java.io.File; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; + +/** + * Main entry point for the ServerShop plugin. + * + *

ServerShop is a server-run global market designed to coexist with the + * CommunityMarket player-to-player plugin. A deliberately large spread + * between buy and sell prices keeps player-to-player trading more attractive. + * + *

Startup order: + *

    + *
  1. Load {@code config.yml} and {@code prices.yml}
  2. + *
  3. Initialise localisation (LangManager)
  4. + *
  5. Hook into Vault economy (required)
  6. + *
  7. Load categories (CategoryRegistry)
  8. + *
  9. Load prices (PricingService)
  10. + *
  11. Initialise optional transaction logger (SQLite)
  12. + *
  13. Register commands and GUI event listener
  14. + *
+ */ +public final class ServerShop extends JavaPlugin { + + private static ServerShop instance; + + private FileConfiguration pricesConfig; + + private LangManager langManager; + private EconomyManager economyManager; + private CategoryRegistry categoryRegistry; + private PricingService pricingService; + private TransactionLogger transactionLogger; + + // GUI components + private GuiController guiController; + private MainCategoryGui mainCategoryGui; + private CategoryGui categoryGui; + private ItemDetailGui itemDetailGui; + + @Override + public void onEnable() { + instance = this; + + // Ensure default config files are present on disk + saveDefaultConfig(); + savePricesConfig(); + + // Reload config from disk (picks up any player edits) + reloadConfig(); + + // Language + langManager = new LangManager(this); + langManager.load(); + + // Vault economy (required) + economyManager = new EconomyManager(this); + if (!economyManager.setup()) { + getLogger().severe("Vault economy provider not found!"); + getLogger().severe("ServerShop requires Vault and a compatible economy plugin."); + getLogger().severe("Disabling ServerShop."); + getServer().getPluginManager().disablePlugin(this); + return; + } + + // Prices configuration + pricesConfig = loadPricesConfig(); + + // Category registry and pricing + categoryRegistry = new CategoryRegistry(this); + categoryRegistry.load(); + + pricingService = new PricingService(this); + pricingService.load(); + + // Optional transaction logger + transactionLogger = new TransactionLogger(this); + transactionLogger.initialize(); + + // GUI subsystem + guiController = new GuiController(); + mainCategoryGui = new MainCategoryGui(this); + categoryGui = new CategoryGui(this); + itemDetailGui = new ItemDetailGui(this); + + // Register GUI listener + getServer().getPluginManager().registerEvents(new ShopGuiListener(this), this); + + // Register /shop command + ShopCommand shopCommand = new ShopCommand(this); + PluginCommand cmd = getCommand("shop"); + if (cmd != null) { + cmd.setExecutor(shopCommand); + cmd.setTabCompleter(shopCommand); + } + + getLogger().info("ServerShop v" + getDescription().getVersion() + " enabled successfully."); + } + + @Override + public void onDisable() { + if (transactionLogger != null) { + transactionLogger.shutdown(); + } + getLogger().info("ServerShop disabled."); + } + + /** + * Reloads all configuration, language, categories, and prices. + * Called by {@code /shop reload}. + */ + public void reload() { + reloadConfig(); + pricesConfig = loadPricesConfig(); + langManager.load(); + categoryRegistry.load(); + pricingService.load(); + getLogger().info("ServerShop configuration reloaded."); + } + + // ----------------------------------------------------------------------- + // Accessors + // ----------------------------------------------------------------------- + + public static ServerShop getInstance() { return instance; } + + /** @return the loaded {@code prices.yml} configuration */ + public FileConfiguration getPricesConfig() { return pricesConfig; } + + public LangManager getLangManager() { return langManager; } + public EconomyManager getEconomyManager() { return economyManager; } + public CategoryRegistry getCategoryRegistry() { return categoryRegistry; } + public PricingService getPricingService() { return pricingService; } + public TransactionLogger getTransactionLogger() { return transactionLogger; } + public GuiController getGuiController() { return guiController; } + public MainCategoryGui getMainCategoryGui() { return mainCategoryGui; } + public CategoryGui getCategoryGui() { return categoryGui; } + public ItemDetailGui getItemDetailGui() { return itemDetailGui; } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + /** + * Saves the bundled {@code prices.yml} to the data folder if it does not + * already exist. + */ + private void savePricesConfig() { + File pricesFile = new File(getDataFolder(), "prices.yml"); + if (!pricesFile.exists()) { + saveResource("prices.yml", false); + } + } + + /** + * Loads {@code prices.yml} from the data folder, merging bundled defaults. + * + * @return loaded configuration + */ + private FileConfiguration loadPricesConfig() { + File pricesFile = new File(getDataFolder(), "prices.yml"); + YamlConfiguration cfg = YamlConfiguration.loadConfiguration(pricesFile); + + // Merge bundled defaults + InputStream stream = getResource("prices.yml"); + if (stream != null) { + YamlConfiguration defaults = YamlConfiguration.loadConfiguration( + new InputStreamReader(stream, StandardCharsets.UTF_8)); + cfg.setDefaults(defaults); + } + return cfg; + } +} diff --git a/servershop/src/main/java/pt/henrique/servershop/category/Category.java b/servershop/src/main/java/pt/henrique/servershop/category/Category.java new file mode 100644 index 0000000..78e3e27 --- /dev/null +++ b/servershop/src/main/java/pt/henrique/servershop/category/Category.java @@ -0,0 +1,49 @@ +package pt.henrique.servershop.category; + +import org.bukkit.Material; + +/** + * Represents a shop category with its display configuration + * and a mapping of which {@link Material} values belong to it. + */ +public final class Category { + + private final String id; + private final String displayName; + private final Material icon; + private final boolean buyEnabled; + private final boolean sellEnabled; + + /** + * Constructs a new category. + * + * @param id internal identifier (e.g. "BLOCKS") + * @param displayName colour-formatted display name shown in GUIs + * @param icon material used as the category icon button + * @param buyEnabled whether players can buy items in this category by default + * @param sellEnabled whether players can sell items in this category by default + */ + public Category(String id, String displayName, Material icon, + boolean buyEnabled, boolean sellEnabled) { + this.id = id; + this.displayName = displayName; + this.icon = icon; + this.buyEnabled = buyEnabled; + this.sellEnabled = sellEnabled; + } + + /** @return the unique category identifier */ + public String getId() { return id; } + + /** @return colour-formatted display name */ + public String getDisplayName() { return displayName; } + + /** @return icon material */ + public Material getIcon() { return icon; } + + /** @return whether buying is enabled for this category */ + public boolean isBuyEnabled() { return buyEnabled; } + + /** @return whether selling is enabled for this category */ + public boolean isSellEnabled() { return sellEnabled; } +} diff --git a/servershop/src/main/java/pt/henrique/servershop/category/CategoryRegistry.java b/servershop/src/main/java/pt/henrique/servershop/category/CategoryRegistry.java new file mode 100644 index 0000000..6beaf12 --- /dev/null +++ b/servershop/src/main/java/pt/henrique/servershop/category/CategoryRegistry.java @@ -0,0 +1,141 @@ +package pt.henrique.servershop.category; + +import org.bukkit.Material; +import org.bukkit.configuration.ConfigurationSection; +import pt.henrique.servershop.ServerShop; + +import java.util.*; + +/** + * Loads and caches all categories from {@code prices.yml}. + * Provides a fallback "MISC" category for any item that is not explicitly + * assigned to a category. + */ +public final class CategoryRegistry { + + /** Fallback category used when a material has no explicit assignment. */ + public static final String MISC_ID = "MISC"; + + private final ServerShop plugin; + + /** Ordered list of all categories (insertion order from config). */ + private final List categories = new ArrayList<>(); + + /** Maps a Material to the category it belongs to. */ + private final Map materialCategoryMap = new EnumMap<>(Material.class); + + public CategoryRegistry(ServerShop plugin) { + this.plugin = plugin; + } + + /** + * Loads all categories from {@code prices.yml}. + * Must be called (or re-called on reload) from the main thread before the + * shop is opened by any player. + */ + public void load() { + categories.clear(); + materialCategoryMap.clear(); + + ConfigurationSection root = plugin.getPricesConfig() + .getConfigurationSection("categories"); + + if (root == null) { + plugin.getLogger().warning("prices.yml has no 'categories' section!"); + ensureMiscCategory(); + return; + } + + for (String catId : root.getKeys(false)) { + ConfigurationSection catSection = root.getConfigurationSection(catId); + if (catSection == null) continue; + + String displayName = catSection.getString("display-name", catId); + String iconName = catSection.getString("icon", "CHEST"); + Material icon = parseMaterial(iconName, Material.CHEST); + boolean buyEnabled = catSection.getBoolean("buy-enabled", true); + boolean sellEnabled = catSection.getBoolean("sell-enabled", true); + + Category category = new Category(catId, displayName, icon, buyEnabled, sellEnabled); + categories.add(category); + + // Register all items in this category so we can do reverse lookups + ConfigurationSection itemsSection = catSection.getConfigurationSection("items"); + if (itemsSection != null) { + for (String materialName : itemsSection.getKeys(false)) { + Material mat = parseMaterial(materialName, null); + if (mat != null) { + materialCategoryMap.put(mat, category); + } else { + plugin.getLogger().warning( + "prices.yml: unknown material '" + materialName + + "' in category '" + catId + "' — skipping."); + } + } + } + } + + ensureMiscCategory(); + plugin.getLogger().info("Loaded " + categories.size() + " categories with " + + materialCategoryMap.size() + " item mappings."); + } + + /** + * Ensures a MISC category exists for items without an explicit assignment. + * If MISC is already in the list it is used as-is; otherwise a default one + * is appended. + */ + private void ensureMiscCategory() { + boolean hasMisc = categories.stream() + .anyMatch(c -> c.getId().equals(MISC_ID)); + if (!hasMisc) { + categories.add(new Category(MISC_ID, "&8Miscellaneous", + Material.CHEST, true, true)); + } + } + + /** + * Returns the category for a given material. + * Falls back to the MISC category if not explicitly assigned. + * + * @param material the material to look up + * @return the category; never {@code null} + */ + public Category getCategory(Material material) { + Category cat = materialCategoryMap.get(material); + if (cat != null) return cat; + // Fallback: return the MISC category + return categories.stream() + .filter(c -> c.getId().equals(MISC_ID)) + .findFirst() + .orElse(categories.isEmpty() ? null : categories.get(categories.size() - 1)); + } + + /** @return an unmodifiable ordered list of all loaded categories */ + public List getCategories() { + return Collections.unmodifiableList(categories); + } + + /** @return all materials assigned to a given category */ + public List getMaterialsInCategory(Category category) { + List result = new ArrayList<>(); + for (Map.Entry entry : materialCategoryMap.entrySet()) { + if (entry.getValue().getId().equals(category.getId())) { + result.add(entry.getKey()); + } + } + result.sort(Comparator.comparing(Material::name)); + return result; + } + + // ---- helpers ---- + + private Material parseMaterial(String name, Material fallback) { + if (name == null) return fallback; + try { + return Material.valueOf(name.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + return fallback; + } + } +} diff --git a/servershop/src/main/java/pt/henrique/servershop/command/ShopCommand.java b/servershop/src/main/java/pt/henrique/servershop/command/ShopCommand.java new file mode 100644 index 0000000..4062906 --- /dev/null +++ b/servershop/src/main/java/pt/henrique/servershop/command/ShopCommand.java @@ -0,0 +1,68 @@ +package pt.henrique.servershop.command; + +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; +import org.bukkit.entity.Player; +import pt.henrique.servershop.ServerShop; + +import java.util.Collections; +import java.util.List; + +/** + * Handles the {@code /shop} (alias {@code /servershop}) command. + * + *

Usage: + *

    + *
  • {@code /shop} — opens the main category GUI for the player
  • + *
  • {@code /shop reload} — reloads all configuration (requires {@code servershop.admin.reload})
  • + *
+ */ +public final class ShopCommand implements CommandExecutor, TabCompleter { + + private final ServerShop plugin; + + public ShopCommand(ServerShop plugin) { + this.plugin = plugin; + } + + @Override + public boolean onCommand(CommandSender sender, Command command, + String label, String[] args) { + + // Sub-command: reload + if (args.length == 1 && args[0].equalsIgnoreCase("reload")) { + if (!sender.hasPermission("servershop.admin.reload")) { + sender.sendMessage(plugin.getLangManager().get("no-permission")); + return true; + } + plugin.reload(); + sender.sendMessage(plugin.getLangManager().get("reload-success")); + return true; + } + + // Player-only: open GUI + if (!(sender instanceof Player player)) { + sender.sendMessage(plugin.getLangManager().get("player-only")); + return true; + } + + if (!player.hasPermission("servershop.use")) { + player.sendMessage(plugin.getLangManager().get("no-permission")); + return true; + } + + plugin.getMainCategoryGui().open(player); + return true; + } + + @Override + public List onTabComplete(CommandSender sender, Command command, + String alias, String[] args) { + if (args.length == 1 && sender.hasPermission("servershop.admin.reload")) { + return List.of("reload"); + } + return Collections.emptyList(); + } +} diff --git a/servershop/src/main/java/pt/henrique/servershop/economy/EconomyManager.java b/servershop/src/main/java/pt/henrique/servershop/economy/EconomyManager.java new file mode 100644 index 0000000..dee0d66 --- /dev/null +++ b/servershop/src/main/java/pt/henrique/servershop/economy/EconomyManager.java @@ -0,0 +1,106 @@ +package pt.henrique.servershop.economy; + +import net.milkbowl.vault.economy.Economy; +import net.milkbowl.vault.economy.EconomyResponse; +import org.bukkit.entity.Player; +import org.bukkit.plugin.RegisteredServiceProvider; +import pt.henrique.servershop.ServerShop; + +import java.text.DecimalFormat; +import java.text.DecimalFormatSymbols; +import java.util.Locale; + +/** + * Wraps the Vault {@link Economy} service, providing balance checks, + * deposits, withdrawals, and formatted currency strings. + */ +public final class EconomyManager { + + private final ServerShop plugin; + private Economy economy; + private DecimalFormat format; + + public EconomyManager(ServerShop plugin) { + this.plugin = plugin; + } + + /** + * Hooks into the Vault economy service. + * + * @return {@code true} if an economy provider was found, {@code false} otherwise + */ + public boolean setup() { + if (plugin.getServer().getPluginManager().getPlugin("Vault") == null) { + return false; + } + RegisteredServiceProvider rsp = + plugin.getServer().getServicesManager().getRegistration(Economy.class); + if (rsp == null) return false; + economy = rsp.getProvider(); + + String pattern = plugin.getConfig().getString("economy.currency-format", "$#,##0.00"); + try { + format = new DecimalFormat(pattern, DecimalFormatSymbols.getInstance(Locale.US)); + } catch (IllegalArgumentException e) { + plugin.getLogger().warning("Invalid currency-format in config.yml; using default."); + format = new DecimalFormat("$#,##0.00", DecimalFormatSymbols.getInstance(Locale.US)); + } + return true; + } + + /** + * @return the raw Vault {@link Economy} instance + */ + public Economy getEconomy() { return economy; } + + /** + * @return {@code true} if the economy provider has been successfully hooked + */ + public boolean isAvailable() { return economy != null; } + + /** + * Gets a player's current balance. + * + * @param player the player + * @return balance + */ + public double getBalance(Player player) { + return economy.getBalance(player); + } + + /** + * Withdraws {@code amount} from the player's account. + * + * @param player the player + * @param amount amount to withdraw (must be positive) + * @return {@code true} on success + */ + public boolean withdraw(Player player, double amount) { + if (amount <= 0) return false; + EconomyResponse response = economy.withdrawPlayer(player, amount); + return response.transactionSuccess(); + } + + /** + * Deposits {@code amount} into the player's account. + * + * @param player the player + * @param amount amount to deposit (must be positive) + * @return {@code true} on success + */ + public boolean deposit(Player player, double amount) { + if (amount <= 0) return false; + EconomyResponse response = economy.depositPlayer(player, amount); + return response.transactionSuccess(); + } + + /** + * Formats an amount as a currency string using the configured pattern. + * + * @param amount the monetary amount + * @return formatted string, e.g. "$1,234.56" + */ + public String format(double amount) { + return format.format(amount); + } +} diff --git a/servershop/src/main/java/pt/henrique/servershop/gui/CategoryGui.java b/servershop/src/main/java/pt/henrique/servershop/gui/CategoryGui.java new file mode 100644 index 0000000..26f7839 --- /dev/null +++ b/servershop/src/main/java/pt/henrique/servershop/gui/CategoryGui.java @@ -0,0 +1,168 @@ +package pt.henrique.servershop.gui; + +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import pt.henrique.servershop.ServerShop; +import pt.henrique.servershop.category.Category; +import pt.henrique.servershop.i18n.LangManager; +import pt.henrique.servershop.pricing.ItemPrice; +import pt.henrique.servershop.pricing.PricingService; +import pt.henrique.servershop.util.TextUtil; + +import java.util.ArrayList; +import java.util.List; + +/** + * Paginated grid of all items within a single category. + * + *

Layout (54 slots): + *

+ * Row 0-4 (slots 0-44): item grid (9 per row × 5 rows = 45 slots)
+ * Row 5   (slots 45-53): [BACK][fill][fill][PREV][fill][fill][NEXT][fill][fill]
+ * 
+ */ +public final class CategoryGui { + + /** Number of item display slots per page. */ + static final int ITEMS_PER_PAGE = 45; + + static final int SLOT_BACK = 45; + static final int SLOT_PREV = 48; + static final int SLOT_NEXT = 50; + + private final ServerShop plugin; + + public CategoryGui(ServerShop plugin) { + this.plugin = plugin; + } + + /** + * Opens the category GUI for a player. + * + * @param player the player + * @param category the category to display + * @param page zero-based page index + */ + public void open(Player player, Category category, int page) { + LangManager lang = plugin.getLangManager(); + PricingService pricing = plugin.getPricingService(); + + List allItems = plugin.getCategoryRegistry().getMaterialsInCategory(category); + int totalPages = Math.max(1, (int) Math.ceil((double) allItems.size() / ITEMS_PER_PAGE)); + page = Math.max(0, Math.min(page, totalPages - 1)); + + String title = lang.get("gui.category-title", "category", + TextUtil.stripColour(category.getDisplayName())); + Inventory inv = plugin.getServer().createInventory(null, 54, title); + + // Fill bottom row + ItemStack filler = createFiller(); + for (int i = 45; i < 54; i++) inv.setItem(i, filler); + // Fill remaining item slots with filler initially + for (int i = 0; i < 45; i++) inv.setItem(i, filler); + + // Populate items for this page + int start = page * ITEMS_PER_PAGE; + int end = Math.min(start + ITEMS_PER_PAGE, allItems.size()); + for (int i = start; i < end; i++) { + Material mat = allItems.get(i); + ItemPrice price = pricing.getPrice(mat, category); + ItemStack button = createItemButton(mat, price, lang); + inv.setItem(i - start, button); + } + + // Navigation buttons + inv.setItem(SLOT_BACK, createBackButton(lang)); + + if (page > 0) { + inv.setItem(SLOT_PREV, createPrevButton(lang, page, totalPages)); + } + if (page < totalPages - 1) { + inv.setItem(SLOT_NEXT, createNextButton(lang, page, totalPages)); + } + + player.openInventory(inv); + plugin.getGuiController().setGui(player, GuiType.CATEGORY, category, null, page); + } + + // ---- item builders ---- + + private ItemStack createFiller() { + String matName = plugin.getConfig().getString("gui.filler-material", "GRAY_STAINED_GLASS_PANE"); + Material mat; + try { mat = Material.valueOf(matName); } + catch (IllegalArgumentException e) { mat = Material.GRAY_STAINED_GLASS_PANE; } + ItemStack item = new ItemStack(mat); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { meta.setDisplayName(" "); item.setItemMeta(meta); } + return item; + } + + private ItemStack createItemButton(Material material, ItemPrice price, LangManager lang) { + ItemStack item = new ItemStack(material); + ItemMeta meta = item.getItemMeta(); + if (meta == null) return item; + + meta.setDisplayName(TextUtil.colour("&f" + TextUtil.formatMaterialName(material))); + + List lore = new ArrayList<>(); + if (price.isBuyEnabled()) { + lore.add(lang.get("gui.item-buy-price", "price", + plugin.getEconomyManager().format(price.getBuyPrice()))); + } else { + lore.add(lang.get("gui.item-buy-disabled")); + } + if (price.isSellEnabled()) { + lore.add(lang.get("gui.item-sell-price", "price", + plugin.getEconomyManager().format(price.getSellPrice()))); + } else { + lore.add(lang.get("gui.item-sell-disabled")); + } + if (price.isBuyEnabled() && price.isSellEnabled()) { + lore.add(lang.get("gui.item-spread", "spread", + String.format("%.0f", price.getSpreadPercent()))); + } + meta.setLore(lore); + item.setItemMeta(meta); + return item; + } + + private ItemStack createBackButton(LangManager lang) { + ItemStack item = new ItemStack(Material.BARRIER); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + meta.setDisplayName(lang.get("gui.back-button")); + item.setItemMeta(meta); + } + return item; + } + + private ItemStack createPrevButton(LangManager lang, int page, int totalPages) { + ItemStack item = new ItemStack(Material.ARROW); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + meta.setDisplayName(lang.get("gui.prev-page")); + meta.setLore(List.of(lang.get("gui.page-info", + "page", String.valueOf(page), + "total", String.valueOf(totalPages)))); + item.setItemMeta(meta); + } + return item; + } + + private ItemStack createNextButton(LangManager lang, int page, int totalPages) { + ItemStack item = new ItemStack(Material.ARROW); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + meta.setDisplayName(lang.get("gui.next-page")); + meta.setLore(List.of(lang.get("gui.page-info", + "page", String.valueOf(page + 2), + "total", String.valueOf(totalPages)))); + item.setItemMeta(meta); + } + return item; + } +} diff --git a/servershop/src/main/java/pt/henrique/servershop/gui/GuiController.java b/servershop/src/main/java/pt/henrique/servershop/gui/GuiController.java new file mode 100644 index 0000000..2326233 --- /dev/null +++ b/servershop/src/main/java/pt/henrique/servershop/gui/GuiController.java @@ -0,0 +1,67 @@ +package pt.henrique.servershop.gui; + +import org.bukkit.Material; +import org.bukkit.entity.Player; +import pt.henrique.servershop.category.Category; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** + * Tracks which GUI screen each player currently has open and stores + * contextual state (active category, selected material, page, quantity). + * + *

This is used by {@link ShopGuiListener} to route inventory-click events + * to the correct handler logic without needing to inspect inventory titles. + */ +public final class GuiController { + + /** Immutable state record for a single player's current GUI session. */ + record GuiState(GuiType type, Category category, Material material, int page) {} + + /** Tracks open GUIs keyed by player UUID. */ + private final Map openGuis = new HashMap<>(); + + /** + * Records that {@code player} has opened a specific shop GUI screen. + * + * @param player the player + * @param type which screen is open + * @param category the active category (may be {@code null} for MAIN_CATEGORY) + * @param material the selected material (only meaningful for ITEM_DETAIL) + * @param page current page index (CATEGORY) or current quantity (ITEM_DETAIL) + */ + public void setGui(Player player, GuiType type, Category category, + Material material, int page) { + openGuis.put(player.getUniqueId(), new GuiState(type, category, material, page)); + } + + /** + * Removes the GUI state when the player closes the inventory. + * + * @param player the player + */ + public void closeGui(Player player) { + openGuis.remove(player.getUniqueId()); + } + + /** + * Returns the current GUI state for a player. + * + * @param player the player + * @return state, or a {@link GuiType#NONE} state if none recorded + */ + public GuiState getState(Player player) { + return openGuis.getOrDefault(player.getUniqueId(), + new GuiState(GuiType.NONE, null, null, 0)); + } + + /** + * @param player the player + * @return {@code true} if the player has a shop GUI open + */ + public boolean hasShopGui(Player player) { + return openGuis.containsKey(player.getUniqueId()); + } +} diff --git a/servershop/src/main/java/pt/henrique/servershop/gui/GuiType.java b/servershop/src/main/java/pt/henrique/servershop/gui/GuiType.java new file mode 100644 index 0000000..b59ab07 --- /dev/null +++ b/servershop/src/main/java/pt/henrique/servershop/gui/GuiType.java @@ -0,0 +1,15 @@ +package pt.henrique.servershop.gui; + +/** + * Identifies which GUI screen a player currently has open in the server shop. + */ +public enum GuiType { + /** The main category-selection screen. */ + MAIN_CATEGORY, + /** A paginated category item grid. */ + CATEGORY, + /** The item detail / quantity-selector screen. */ + ITEM_DETAIL, + /** Not a shop GUI (player has a different inventory open). */ + NONE +} diff --git a/servershop/src/main/java/pt/henrique/servershop/gui/ItemDetailGui.java b/servershop/src/main/java/pt/henrique/servershop/gui/ItemDetailGui.java new file mode 100644 index 0000000..4256d05 --- /dev/null +++ b/servershop/src/main/java/pt/henrique/servershop/gui/ItemDetailGui.java @@ -0,0 +1,261 @@ +package pt.henrique.servershop.gui; + +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import pt.henrique.servershop.ServerShop; +import pt.henrique.servershop.category.Category; +import pt.henrique.servershop.i18n.LangManager; +import pt.henrique.servershop.pricing.ItemPrice; +import pt.henrique.servershop.util.ItemUtil; +import pt.henrique.servershop.util.TextUtil; + +import java.util.ArrayList; +import java.util.List; + +/** + * The item detail GUI — shows buy/sell price for a specific material and + * lets the player choose a quantity before confirming the transaction. + * + *

Slot layout (54 slots): + *

+ * Row 0: [fll][fll][fll][fll][ITEM][fll][fll][fll][fll]
+ * Row 1: [fll][fll][fll][fll][fll][fll][fll][fll][fll]
+ * Row 2: [fll][fll][fll][fll][fll][fll][fll][fll][fll]
+ * Row 3: [-32][-16][-8 ][-1 ][QTY][+1 ][+8 ][+16][+32]
+ * Row 4: [MIN][fll][BUY][fll][BAL][fll][SEL][fll][MAX]
+ * Row 5: [BCK][fll][fll][fll][fll][fll][fll][fll][fll]
+ * 
+ */ +public final class ItemDetailGui { + + // Slots (0-indexed) + static final int SLOT_ITEM_DISPLAY = 4; + + static final int SLOT_DEC32 = 27; + static final int SLOT_DEC16 = 28; + static final int SLOT_DEC8 = 29; + static final int SLOT_DEC1 = 30; + static final int SLOT_QTY = 31; + static final int SLOT_INC1 = 32; + static final int SLOT_INC8 = 33; + static final int SLOT_INC16 = 34; + static final int SLOT_INC32 = 35; + + static final int SLOT_MIN = 36; + static final int SLOT_BUY = 38; + static final int SLOT_BALANCE = 40; + static final int SLOT_SELL = 42; + static final int SLOT_MAX = 44; + + static final int SLOT_BACK = 45; + + private final ServerShop plugin; + + public ItemDetailGui(ServerShop plugin) { + this.plugin = plugin; + } + + /** + * Opens the item detail GUI for a player. + * + * @param player the player + * @param material the material being inspected + * @param category the category this item belongs to (for buy/sell flags) + * @param quantity initial quantity displayed + */ + public void open(Player player, Material material, Category category, int quantity) { + LangManager lang = plugin.getLangManager(); + PricingService pricing = plugin.getPricingService(); + ItemPrice price = pricing.getPrice(material, category); + + quantity = Math.max(1, quantity); + + String title = lang.get("gui.detail-title", "item", TextUtil.formatMaterialName(material)); + Inventory inv = plugin.getServer().createInventory(null, 54, title); + + // Fill everything with filler + ItemStack filler = createFiller(); + for (int i = 0; i < 54; i++) inv.setItem(i, filler); + + // Item display + inv.setItem(SLOT_ITEM_DISPLAY, createItemDisplay(material, price, lang)); + + // Quantity adjustment buttons + inv.setItem(SLOT_DEC32, createAdjustButton(Material.RED_STAINED_GLASS_PANE, "-32", -32)); + inv.setItem(SLOT_DEC16, createAdjustButton(Material.RED_STAINED_GLASS_PANE, "-16", -16)); + inv.setItem(SLOT_DEC8, createAdjustButton(Material.RED_STAINED_GLASS_PANE, "-8", -8)); + inv.setItem(SLOT_DEC1, createAdjustButton(Material.RED_STAINED_GLASS_PANE, "-1", -1)); + inv.setItem(SLOT_QTY, createQuantityDisplay(quantity, lang)); + inv.setItem(SLOT_INC1, createAdjustButton(Material.LIME_STAINED_GLASS_PANE, "+1", 1)); + inv.setItem(SLOT_INC8, createAdjustButton(Material.LIME_STAINED_GLASS_PANE, "+8", 8)); + inv.setItem(SLOT_INC16, createAdjustButton(Material.LIME_STAINED_GLASS_PANE, "+16", 16)); + inv.setItem(SLOT_INC32, createAdjustButton(Material.LIME_STAINED_GLASS_PANE, "+32", 32)); + + // MIN / MAX buttons + inv.setItem(SLOT_MIN, createMinButton(lang)); + inv.setItem(SLOT_MAX, createMaxButton(player, price, lang)); + + // Wallet display + inv.setItem(SLOT_BALANCE, createWalletDisplay(player, lang)); + + // Buy / Sell buttons + double buyTotal = price.isBuyEnabled() ? price.getBuyPrice() * quantity : 0; + double sellTotal = price.isSellEnabled() ? price.getSellPrice() * quantity : 0; + int inInventory = ItemUtil.countInInventory(player, material); + + if (price.isBuyEnabled()) { + inv.setItem(SLOT_BUY, createBuyButton(quantity, buyTotal, player, lang)); + } + if (price.isSellEnabled()) { + inv.setItem(SLOT_SELL, createSellButton(quantity, sellTotal, inInventory, player, lang)); + } + + // Back button + inv.setItem(SLOT_BACK, createBackButton(lang)); + + player.openInventory(inv); + plugin.getGuiController().setGui(player, GuiType.ITEM_DETAIL, category, material, quantity); + } + + // ---- item builders ---- + + private ItemStack createFiller() { + String matName = plugin.getConfig().getString("gui.filler-material", "GRAY_STAINED_GLASS_PANE"); + Material mat; + try { mat = Material.valueOf(matName); } + catch (IllegalArgumentException e) { mat = Material.GRAY_STAINED_GLASS_PANE; } + ItemStack item = new ItemStack(mat); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { meta.setDisplayName(" "); item.setItemMeta(meta); } + return item; + } + + private ItemStack createItemDisplay(Material material, ItemPrice price, LangManager lang) { + ItemStack item = new ItemStack(material); + ItemMeta meta = item.getItemMeta(); + if (meta == null) return item; + + meta.setDisplayName(TextUtil.colour("&f&l" + TextUtil.formatMaterialName(material))); + List lore = new ArrayList<>(); + if (price.isBuyEnabled()) { + lore.add(lang.get("gui.item-buy-price", "price", + plugin.getEconomyManager().format(price.getBuyPrice()))); + } else { + lore.add(lang.get("gui.item-buy-disabled")); + } + if (price.isSellEnabled()) { + lore.add(lang.get("gui.item-sell-price", "price", + plugin.getEconomyManager().format(price.getSellPrice()))); + } else { + lore.add(lang.get("gui.item-sell-disabled")); + } + if (price.isBuyEnabled() && price.isSellEnabled()) { + lore.add(lang.get("gui.item-spread", "spread", + String.format("%.0f", price.getSpreadPercent()))); + } + meta.setLore(lore); + item.setItemMeta(meta); + return item; + } + + private ItemStack createAdjustButton(Material mat, String label, int delta) { + ItemStack item = new ItemStack(mat); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + // Use the delta sign for colour cue + String colour = delta < 0 ? "&c" : "&a"; + meta.setDisplayName(TextUtil.colour(colour + label)); + item.setItemMeta(meta); + } + return item; + } + + private ItemStack createQuantityDisplay(int qty, LangManager lang) { + ItemStack item = new ItemStack(Material.PAPER); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + meta.setDisplayName(lang.get("gui.quantity-display", "qty", String.valueOf(qty))); + meta.setLore(lang.getList("gui.quantity-lore")); + item.setItemMeta(meta); + } + return item; + } + + private ItemStack createMinButton(LangManager lang) { + ItemStack item = new ItemStack(Material.WHITE_STAINED_GLASS_PANE); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + meta.setDisplayName(lang.get("gui.min-button")); + item.setItemMeta(meta); + } + return item; + } + + private ItemStack createMaxButton(Player player, ItemPrice price, LangManager lang) { + ItemStack item = new ItemStack(Material.WHITE_STAINED_GLASS_PANE); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + // Show both MAX buy (by balance) and MAX sell (by inventory) + String label = price.isBuyEnabled() + ? lang.get("gui.max-button-buy") + : lang.get("gui.max-button-sell"); + meta.setDisplayName(label); + item.setItemMeta(meta); + } + return item; + } + + private ItemStack createWalletDisplay(Player player, LangManager lang) { + ItemStack item = new ItemStack(Material.EMERALD); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + double bal = plugin.getEconomyManager().getBalance(player); + meta.setDisplayName(lang.get("gui.wallet-display", "balance", + plugin.getEconomyManager().format(bal))); + item.setItemMeta(meta); + } + return item; + } + + private ItemStack createBuyButton(int qty, double total, Player player, LangManager lang) { + ItemStack item = new ItemStack(Material.LIME_WOOL); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + meta.setDisplayName(lang.get("gui.buy-button", "qty", String.valueOf(qty))); + double bal = plugin.getEconomyManager().getBalance(player); + meta.setLore(lang.getList("gui.buy-button-lore", + "total", plugin.getEconomyManager().format(total), + "balance", plugin.getEconomyManager().format(bal))); + item.setItemMeta(meta); + } + return item; + } + + private ItemStack createSellButton(int qty, double total, int have, Player player, LangManager lang) { + ItemStack item = new ItemStack(Material.RED_WOOL); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + meta.setDisplayName(lang.get("gui.sell-button", "qty", String.valueOf(qty))); + double bal = plugin.getEconomyManager().getBalance(player); + meta.setLore(lang.getList("gui.sell-button-lore", + "total", plugin.getEconomyManager().format(total), + "balance", plugin.getEconomyManager().format(bal), + "have", String.valueOf(have))); + item.setItemMeta(meta); + } + return item; + } + + private ItemStack createBackButton(LangManager lang) { + ItemStack item = new ItemStack(Material.BARRIER); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + meta.setDisplayName(lang.get("gui.back-button")); + item.setItemMeta(meta); + } + return item; + } +} diff --git a/servershop/src/main/java/pt/henrique/servershop/gui/MainCategoryGui.java b/servershop/src/main/java/pt/henrique/servershop/gui/MainCategoryGui.java new file mode 100644 index 0000000..7509e23 --- /dev/null +++ b/servershop/src/main/java/pt/henrique/servershop/gui/MainCategoryGui.java @@ -0,0 +1,139 @@ +package pt.henrique.servershop.gui; + +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import pt.henrique.servershop.ServerShop; +import pt.henrique.servershop.category.Category; +import pt.henrique.servershop.category.CategoryRegistry; +import pt.henrique.servershop.i18n.LangManager; +import pt.henrique.servershop.util.TextUtil; + +import java.util.ArrayList; +import java.util.List; + +/** + * The main category-selection GUI opened by {@code /shop}. + * + *

Layout (54 slots): + *

+ * [CAT][CAT][CAT][CAT][CAT][CAT][CAT][fll][fll]
+ * [CAT][CAT][CAT][CAT][CAT][CAT][CAT][fll][fll]
+ * [CAT][CAT][CAT][CAT][CAT][CAT][CAT][fll][fll]
+ * [CAT][CAT][CAT][CAT][CAT][CAT][CAT][fll][fll]
+ * [CAT][CAT][CAT][CAT][CAT][CAT][CAT][fll][fll]
+ * [fll][fll][fll][SELL_HAND][fll][SELL_INV][fll][fll][fll]
+ * 
+ */ +public final class MainCategoryGui { + + /** Slot constants for special buttons on the bottom row. */ + static final int SLOT_SELL_HAND = 48; + static final int SLOT_SELL_INV = 50; + + private final ServerShop plugin; + + public MainCategoryGui(ServerShop plugin) { + this.plugin = plugin; + } + + /** + * Opens the main category GUI for a player. + * + * @param player the player to open the GUI for + */ + public void open(Player player) { + LangManager lang = plugin.getLangManager(); + CategoryRegistry registry = plugin.getCategoryRegistry(); + + String title = lang.get("gui.main-title"); + Inventory inv = plugin.getServer().createInventory(null, 54, title); + + // Fill background + ItemStack filler = createFiller(); + for (int i = 0; i < 54; i++) inv.setItem(i, filler); + + // Place category buttons in the first 45 slots + List categories = registry.getCategories(); + int slot = 0; + for (Category category : categories) { + if (slot >= 45) break; // Max 45 category buttons + + List items = registry.getMaterialsInCategory(category); + int itemCount = items.size(); + + ItemStack button = createCategoryButton(category, itemCount, lang); + inv.setItem(slot, button); + slot++; + } + + // Bottom row special buttons + boolean sellHandEnabled = plugin.getConfig().getBoolean("features.sell-hand-button", true); + boolean sellInvEnabled = plugin.getConfig().getBoolean("features.sell-inventory-button", true); + + if (sellHandEnabled) { + inv.setItem(SLOT_SELL_HAND, createSellHandButton(lang)); + } + if (sellInvEnabled) { + inv.setItem(SLOT_SELL_INV, createSellInvButton(lang)); + } + + player.openInventory(inv); + plugin.getGuiController().setGui(player, GuiType.MAIN_CATEGORY, null, null, 0); + } + + // ---- item builders ---- + + private ItemStack createFiller() { + String matName = plugin.getConfig().getString("gui.filler-material", "GRAY_STAINED_GLASS_PANE"); + Material mat; + try { + mat = Material.valueOf(matName); + } catch (IllegalArgumentException e) { + mat = Material.GRAY_STAINED_GLASS_PANE; + } + ItemStack item = new ItemStack(mat); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + meta.setDisplayName(" "); + item.setItemMeta(meta); + } + return item; + } + + private ItemStack createCategoryButton(Category category, int itemCount, LangManager lang) { + ItemStack item = new ItemStack(category.getIcon()); + ItemMeta meta = item.getItemMeta(); + if (meta == null) return item; + + meta.setDisplayName(TextUtil.colour(category.getDisplayName())); + + List lore = new ArrayList<>( + lang.getList("gui.category-lore", "count", String.valueOf(itemCount))); + meta.setLore(lore); + item.setItemMeta(meta); + return item; + } + + private ItemStack createSellHandButton(LangManager lang) { + ItemStack item = new ItemStack(Material.GOLD_INGOT); + ItemMeta meta = item.getItemMeta(); + if (meta == null) return item; + meta.setDisplayName(lang.get("gui.sell-hand-button")); + meta.setLore(lang.getList("gui.sell-hand-lore")); + item.setItemMeta(meta); + return item; + } + + private ItemStack createSellInvButton(LangManager lang) { + ItemStack item = new ItemStack(Material.CHEST); + ItemMeta meta = item.getItemMeta(); + if (meta == null) return item; + meta.setDisplayName(lang.get("gui.sell-inventory-button")); + meta.setLore(lang.getList("gui.sell-inventory-lore")); + item.setItemMeta(meta); + return item; + } +} diff --git a/servershop/src/main/java/pt/henrique/servershop/gui/ShopGuiListener.java b/servershop/src/main/java/pt/henrique/servershop/gui/ShopGuiListener.java new file mode 100644 index 0000000..6b72910 --- /dev/null +++ b/servershop/src/main/java/pt/henrique/servershop/gui/ShopGuiListener.java @@ -0,0 +1,426 @@ +package pt.henrique.servershop.gui; + +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryCloseEvent; +import org.bukkit.event.inventory.InventoryDragEvent; +import pt.henrique.servershop.ServerShop; +import pt.henrique.servershop.category.Category; +import pt.henrique.servershop.category.CategoryRegistry; +import pt.henrique.servershop.gui.GuiController.GuiState; +import pt.henrique.servershop.i18n.LangManager; +import pt.henrique.servershop.pricing.ItemPrice; +import pt.henrique.servershop.pricing.PricingService; +import pt.henrique.servershop.util.ItemUtil; + +import java.util.List; + +/** + * Handles all inventory events for the ServerShop GUI screens. + * + *

All illegal interactions (shift-click, number-key swap, drag) are cancelled + * when the top inventory belongs to a shop GUI to prevent item duplication. + */ +public final class ShopGuiListener implements Listener { + + private final ServerShop plugin; + + public ShopGuiListener(ServerShop plugin) { + this.plugin = plugin; + } + + // ----------------------------------------------------------------------- + // Click events + // ----------------------------------------------------------------------- + + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onInventoryClick(InventoryClickEvent event) { + if (!(event.getWhoClicked() instanceof Player player)) return; + + GuiController controller = plugin.getGuiController(); + if (!controller.hasShopGui(player)) return; + + // Always cancel the click to prevent item movement inside shop GUIs + event.setCancelled(true); + + // Only process clicks on the top inventory (the shop GUI itself) + if (event.getClickedInventory() == null + || event.getClickedInventory() != event.getView().getTopInventory()) { + return; + } + + GuiState state = controller.getState(player); + int slot = event.getRawSlot(); + + switch (state.type()) { + case MAIN_CATEGORY -> handleMainCategoryClick(player, slot, state); + case CATEGORY -> handleCategoryClick(player, slot, state); + case ITEM_DETAIL -> handleItemDetailClick(player, slot, state); + default -> { /* ignore */ } + } + } + + /** Drag events inside shop GUIs are always cancelled. */ + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onInventoryDrag(InventoryDragEvent event) { + if (!(event.getWhoClicked() instanceof Player player)) return; + if (!plugin.getGuiController().hasShopGui(player)) return; + event.setCancelled(true); + } + + /** Remove GUI state when the player closes the inventory. */ + @EventHandler + public void onInventoryClose(InventoryCloseEvent event) { + if (!(event.getPlayer() instanceof Player player)) return; + plugin.getGuiController().closeGui(player); + } + + // ----------------------------------------------------------------------- + // Main category screen + // ----------------------------------------------------------------------- + + private void handleMainCategoryClick(Player player, int slot, GuiState state) { + // Special bottom-row buttons + if (slot == MainCategoryGui.SLOT_SELL_HAND) { + handleSellHand(player); + return; + } + if (slot == MainCategoryGui.SLOT_SELL_INV) { + handleSellInventory(player); + return; + } + + // Category buttons occupy slots 0-44 + if (slot < 0 || slot >= 45) return; + + List categories = plugin.getCategoryRegistry().getCategories(); + if (slot >= categories.size()) return; + + Category chosen = categories.get(slot); + plugin.getCategoryGui().open(player, chosen, 0); + } + + // ----------------------------------------------------------------------- + // Category item grid + // ----------------------------------------------------------------------- + + private void handleCategoryClick(Player player, int slot, GuiState state) { + Category category = state.category(); + int page = state.page(); + + if (slot == CategoryGui.SLOT_BACK) { + plugin.getMainCategoryGui().open(player); + return; + } + if (slot == CategoryGui.SLOT_PREV) { + plugin.getCategoryGui().open(player, category, page - 1); + return; + } + if (slot == CategoryGui.SLOT_NEXT) { + plugin.getCategoryGui().open(player, category, page + 1); + return; + } + + // Item grid (slots 0-44) + if (slot < 0 || slot >= 45) return; + + List materials = plugin.getCategoryRegistry().getMaterialsInCategory(category); + int index = page * CategoryGui.ITEMS_PER_PAGE + slot; + if (index >= materials.size()) return; + + Material chosen = materials.get(index); + plugin.getItemDetailGui().open(player, chosen, category, 1); + } + + // ----------------------------------------------------------------------- + // Item detail / quantity selector + // ----------------------------------------------------------------------- + + private void handleItemDetailClick(Player player, int slot, GuiState state) { + Category category = state.category(); + Material material = state.material(); + int qty = state.page(); // "page" field re-used for quantity in detail GUI + + if (slot == ItemDetailGui.SLOT_BACK) { + plugin.getCategoryGui().open(player, category, 0); + return; + } + + // Quantity adjustment + int delta = switch (slot) { + case ItemDetailGui.SLOT_DEC32 -> -32; + case ItemDetailGui.SLOT_DEC16 -> -16; + case ItemDetailGui.SLOT_DEC8 -> -8; + case ItemDetailGui.SLOT_DEC1 -> -1; + case ItemDetailGui.SLOT_INC1 -> 1; + case ItemDetailGui.SLOT_INC8 -> 8; + case ItemDetailGui.SLOT_INC16 -> 16; + case ItemDetailGui.SLOT_INC32 -> 32; + default -> 0; + }; + + if (delta != 0) { + int newQty = Math.max(1, qty + delta); + plugin.getItemDetailGui().open(player, material, category, newQty); + return; + } + + // MIN button → quantity 1 + if (slot == ItemDetailGui.SLOT_MIN) { + plugin.getItemDetailGui().open(player, material, category, 1); + return; + } + + // MAX button — different behaviour for buy vs sell + if (slot == ItemDetailGui.SLOT_MAX) { + handleMaxButton(player, material, category); + return; + } + + // BUY confirm + if (slot == ItemDetailGui.SLOT_BUY) { + handleBuy(player, material, category, qty); + return; + } + + // SELL confirm + if (slot == ItemDetailGui.SLOT_SELL) { + handleSell(player, material, category, qty); + } + } + + private void handleMaxButton(Player player, Material material, Category category) { + PricingService pricing = plugin.getPricingService(); + ItemPrice price = pricing.getPrice(material, category); + + int maxQty = 1; + + // Compute max by balance (for buying) or by inventory (for selling) + if (price.isBuyEnabled()) { + double balance = plugin.getEconomyManager().getBalance(player); + int maxBuy = (int) (balance / price.getBuyPrice()); + maxQty = Math.max(1, maxBuy); + } else if (price.isSellEnabled()) { + int inInv = ItemUtil.countInInventory(player, material); + maxQty = Math.max(1, inInv); + } + + plugin.getItemDetailGui().open(player, material, category, maxQty); + } + + // ----------------------------------------------------------------------- + // Buy logic + // ----------------------------------------------------------------------- + + private void handleBuy(Player player, Material material, Category category, int qty) { + if (!plugin.getConfig().getBoolean("features.enable-buying", true)) return; + if (!player.hasPermission("servershop.buy")) return; + + LangManager lang = plugin.getLangManager(); + PricingService pricing = plugin.getPricingService(); + ItemPrice price = pricing.getPrice(material, category); + + if (!price.isBuyEnabled()) { + player.closeInventory(); + player.sendMessage(lang.get("buy-fail-disabled")); + return; + } + if (qty < 1) { + player.closeInventory(); + player.sendMessage(lang.get("buy-fail-invalid-qty")); + return; + } + + double total = price.getBuyPrice() * qty; + double balance = plugin.getEconomyManager().getBalance(player); + + // Re-validate balance at click time (anti-exploit) + if (balance < total) { + player.closeInventory(); + player.sendMessage(lang.get("buy-fail-no-money", + "total", plugin.getEconomyManager().format(total), + "balance", plugin.getEconomyManager().format(balance))); + return; + } + + // Check inventory space and give items + boolean dropOnFull = plugin.getConfig() + .getString("full-inventory-behavior", "DROP").equalsIgnoreCase("DROP"); + if (!dropOnFull) { + // Verify sufficient space BEFORE charging (anti-exploit) + if (!ItemUtil.canFit(player, material, qty)) { + player.closeInventory(); + player.sendMessage(lang.get("buy-fail-inventory-full-cancelled")); + return; + } + // Charge the player, then give items + plugin.getEconomyManager().withdraw(player, total); + ItemUtil.addItems(player, material, qty, false); + } else { + // Charge first, then give items (dropping overflow) + plugin.getEconomyManager().withdraw(player, total); + int leftover = ItemUtil.addItems(player, material, qty, true); + if (leftover > 0) { + player.sendMessage(lang.get("buy-drop-notice")); + } + } + + // Log the transaction + plugin.getTransactionLogger().log(player, "BUY", material.name(), + qty, price.getBuyPrice(), total); + + // Notify and refresh the GUI + player.closeInventory(); + player.sendMessage(lang.get("buy-success", + "qty", String.valueOf(qty), + "item", material.name(), + "total", plugin.getEconomyManager().format(total))); + } + + // ----------------------------------------------------------------------- + // Sell logic + // ----------------------------------------------------------------------- + + private void handleSell(Player player, Material material, Category category, int qty) { + if (!plugin.getConfig().getBoolean("features.enable-selling", true)) return; + if (!player.hasPermission("servershop.sell")) return; + + LangManager lang = plugin.getLangManager(); + PricingService pricing = plugin.getPricingService(); + ItemPrice price = pricing.getPrice(material, category); + + if (!price.isSellEnabled()) { + player.closeInventory(); + player.sendMessage(lang.get("sell-fail-disabled")); + return; + } + if (qty < 1) { + player.closeInventory(); + player.sendMessage(lang.get("sell-fail-invalid-qty")); + return; + } + + // Re-validate inventory at click time (anti-exploit) + int inInventory = ItemUtil.countInInventory(player, material); + if (inInventory < qty) { + player.closeInventory(); + player.sendMessage(lang.get("sell-fail-no-items", "item", material.name())); + return; + } + + double unitSellPrice = price.getSellPrice(); + + // Apply sell tax if configured + double taxPercent = plugin.getConfig().getDouble("pricing.sell-tax-percent", 0.0); + if (taxPercent > 0) { + unitSellPrice = unitSellPrice * (1.0 - taxPercent / 100.0); + } + + double total = unitSellPrice * qty; + + // Remove items from inventory then deposit money + ItemUtil.removeItems(player, material, qty); + plugin.getEconomyManager().deposit(player, total); + + // Log the transaction + plugin.getTransactionLogger().log(player, "SELL", material.name(), + qty, price.getSellPrice(), total); + + player.closeInventory(); + player.sendMessage(lang.get("sell-success", + "qty", String.valueOf(qty), + "item", material.name(), + "total", plugin.getEconomyManager().format(total))); + } + + // ----------------------------------------------------------------------- + // Sell hand / sell inventory (from main menu) + // ----------------------------------------------------------------------- + + private void handleSellHand(Player player) { + if (!plugin.getConfig().getBoolean("features.enable-selling", true)) return; + if (!player.hasPermission("servershop.sell")) return; + + LangManager lang = plugin.getLangManager(); + var held = player.getInventory().getItemInMainHand(); + if (held.getType() == Material.AIR) { + player.sendMessage(plugin.getLangManager().get("sell-all-nothing", + "item", "air")); + return; + } + + Material material = held.getType(); + Category category = plugin.getCategoryRegistry().getCategory(material); + PricingService pricing = plugin.getPricingService(); + ItemPrice price = pricing.getPrice(material, category); + + if (!price.isSellEnabled()) { + player.closeInventory(); + player.sendMessage(lang.get("sell-fail-disabled")); + return; + } + + int qty = ItemUtil.countInInventory(player, material); + if (qty == 0) { + player.closeInventory(); + player.sendMessage(lang.get("sell-all-nothing", "item", material.name())); + return; + } + + double total = price.getSellPrice() * qty; + ItemUtil.removeItems(player, material, qty); + plugin.getEconomyManager().deposit(player, total); + plugin.getTransactionLogger().log(player, "SELL", material.name(), + qty, price.getSellPrice(), total); + + player.closeInventory(); + player.sendMessage(lang.get("sell-all-success", + "qty", String.valueOf(qty), + "item", material.name(), + "total", plugin.getEconomyManager().format(total))); + } + + private void handleSellInventory(Player player) { + if (!plugin.getConfig().getBoolean("features.enable-selling", true)) return; + if (!player.hasPermission("servershop.sell")) return; + + // Show a sub-GUI for the item in hand, or a confirmation screen + // For simplicity: sell ALL shoppable items in the player's inventory + // that have a sell price configured. + LangManager lang = plugin.getLangManager(); + double grandTotal = 0; + int totalSold = 0; + + for (Material mat : Material.values()) { + if (!ItemUtil.isShoppable(mat)) continue; + int count = ItemUtil.countInInventory(player, mat); + if (count == 0) continue; + + Category category = plugin.getCategoryRegistry().getCategory(mat); + ItemPrice price = plugin.getPricingService().getPrice(mat, category); + if (!price.isSellEnabled()) continue; + + double itemTotal = price.getSellPrice() * count; + ItemUtil.removeItems(player, mat, count); + plugin.getEconomyManager().deposit(player, itemTotal); + plugin.getTransactionLogger().log(player, "SELL", mat.name(), + count, price.getSellPrice(), itemTotal); + grandTotal += itemTotal; + totalSold += count; + } + + player.closeInventory(); + if (totalSold == 0) { + player.sendMessage(lang.get("sell-all-nothing", "item", "any items")); + } else { + player.sendMessage(lang.get("sell-all-success", + "qty", String.valueOf(totalSold), + "item", "items", + "total", plugin.getEconomyManager().format(grandTotal))); + } + } +} diff --git a/servershop/src/main/java/pt/henrique/servershop/i18n/LangManager.java b/servershop/src/main/java/pt/henrique/servershop/i18n/LangManager.java new file mode 100644 index 0000000..80d3db2 --- /dev/null +++ b/servershop/src/main/java/pt/henrique/servershop/i18n/LangManager.java @@ -0,0 +1,118 @@ +package pt.henrique.servershop.i18n; + +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.configuration.file.YamlConfiguration; +import pt.henrique.servershop.ServerShop; + +import java.io.File; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.List; + +/** + * Loads and serves localised strings from the active language file + * ({@code lang/.yml}). + * + *

If a key is missing from the loaded file the default bundled language + * file ({@code en_US}) is consulted as fallback. + */ +public final class LangManager { + + private final ServerShop plugin; + private FileConfiguration lang; + private FileConfiguration fallback; + + public LangManager(ServerShop plugin) { + this.plugin = plugin; + } + + /** + * Loads the language file configured in {@code config.yml}. + * Call this method on startup and on reload. + */ + public void load() { + String language = plugin.getConfig().getString("language", "en_US"); + lang = loadLangFile(language); + fallback = loadLangFile("en_US"); + } + + /** + * Gets a translated string, substituting {@code %key% → value} placeholders. + * + * @param key the YAML path within the language file + * @param replacements alternating key/value pairs, e.g. {@code "qty", "5"} + * @return the translated, colour-decoded string + */ + public String get(String key, String... replacements) { + String raw = lang.getString(key); + if (raw == null) raw = fallback.getString(key); + if (raw == null) return key; // last resort + + raw = colour(raw); + for (int i = 0; i + 1 < replacements.length; i += 2) { + raw = raw.replace("%" + replacements[i] + "%", replacements[i + 1]); + } + return raw; + } + + /** + * Gets a translated string list (for lore entries etc.). + * + * @param key the YAML path + * @param replacements alternating key/value pairs + * @return colour-decoded list; empty list if key not found + */ + public List getList(String key, String... replacements) { + List raw = lang.getStringList(key); + if (raw.isEmpty()) raw = fallback.getStringList(key); + return raw.stream() + .map(s -> { + s = colour(s); + for (int i = 0; i + 1 < replacements.length; i += 2) { + s = s.replace("%" + replacements[i] + "%", replacements[i + 1]); + } + return s; + }) + .toList(); + } + + // ---- helpers ---- + + /** Translates '&' colour codes into chat colour codes. */ + private String colour(String s) { + return org.bukkit.ChatColor.translateAlternateColorCodes('&', s); + } + + /** + * Loads a language file, first from the data folder then from the bundled + * resources jar if it does not exist on disk. + */ + private FileConfiguration loadLangFile(String language) { + File file = new File(plugin.getDataFolder(), "lang/" + language + ".yml"); + + // Save default resource if absent + if (!file.exists()) { + InputStream stream = plugin.getResource("lang/" + language + ".yml"); + if (stream != null) { + plugin.saveResource("lang/" + language + ".yml", false); + } + } + + if (file.exists()) { + YamlConfiguration cfg = YamlConfiguration.loadConfiguration(file); + // Merge defaults from bundled resource + InputStream stream = plugin.getResource("lang/" + language + ".yml"); + if (stream != null) { + YamlConfiguration defaults = YamlConfiguration.loadConfiguration( + new InputStreamReader(stream, StandardCharsets.UTF_8)); + cfg.setDefaults(defaults); + } + return cfg; + } + + // Nothing on disk and nothing in jar — return empty config + plugin.getLogger().warning("Language file 'lang/" + language + ".yml' not found."); + return new YamlConfiguration(); + } +} diff --git a/servershop/src/main/java/pt/henrique/servershop/pricing/ItemPrice.java b/servershop/src/main/java/pt/henrique/servershop/pricing/ItemPrice.java new file mode 100644 index 0000000..20ade2c --- /dev/null +++ b/servershop/src/main/java/pt/henrique/servershop/pricing/ItemPrice.java @@ -0,0 +1,46 @@ +package pt.henrique.servershop.pricing; + +/** + * Holds the buy price and sell price for a single item in the server shop. + * A price of {@code -1} means that operation (buy or sell) is disabled. + */ +public final class ItemPrice { + + /** Sentinel value indicating that buying or selling is disabled for this item. */ + public static final double DISABLED = -1.0; + + private final double buyPrice; + private final double sellPrice; + + /** + * @param buyPrice price server charges the player to buy 1 unit; use {@link #DISABLED} to disallow + * @param sellPrice price server pays the player for 1 unit; use {@link #DISABLED} to disallow + */ + public ItemPrice(double buyPrice, double sellPrice) { + this.buyPrice = buyPrice; + this.sellPrice = sellPrice; + } + + /** @return price per unit when the player buys, or {@link #DISABLED} */ + public double getBuyPrice() { return buyPrice; } + + /** @return price per unit when the player sells, or {@link #DISABLED} */ + public double getSellPrice() { return sellPrice; } + + /** @return {@code true} if players can buy this item */ + public boolean isBuyEnabled() { return buyPrice > 0; } + + /** @return {@code true} if players can sell this item */ + public boolean isSellEnabled() { return sellPrice > 0; } + + /** + * Calculates the spread percentage between buy and sell prices. + * Spread = (buyPrice - sellPrice) / buyPrice * 100 + * + * @return spread as a percentage, or 0 if buy is disabled + */ + public double getSpreadPercent() { + if (!isBuyEnabled() || !isSellEnabled()) return 0; + return ((buyPrice - sellPrice) / buyPrice) * 100.0; + } +} diff --git a/servershop/src/main/java/pt/henrique/servershop/pricing/PricingService.java b/servershop/src/main/java/pt/henrique/servershop/pricing/PricingService.java new file mode 100644 index 0000000..e9b7e6c --- /dev/null +++ b/servershop/src/main/java/pt/henrique/servershop/pricing/PricingService.java @@ -0,0 +1,158 @@ +package pt.henrique.servershop.pricing; + +import org.bukkit.Material; +import org.bukkit.configuration.ConfigurationSection; +import pt.henrique.servershop.ServerShop; +import pt.henrique.servershop.category.Category; + +import java.util.EnumMap; +import java.util.Locale; +import java.util.Map; + +/** + * Resolves buy/sell prices for every material. + * + *

Resolution order (first match wins): + *

    + *
  1. Per-item override in {@code prices.yml}
  2. + *
  3. Category-level sell multiplier from {@code config.yml}
  4. + *
  5. Global sell multiplier from {@code config.yml}
  6. + *
+ * + *

A sell price is derived from the buy price using + * {@code sellPrice = buyPrice * sellMultiplier} unless an explicit + * {@code sell-price} is specified in {@code prices.yml}. + */ +public final class PricingService { + + private final ServerShop plugin; + + /** Cache of resolved prices per material. */ + private final Map priceCache = new EnumMap<>(Material.class); + + public PricingService(ServerShop plugin) { + this.plugin = plugin; + } + + /** + * Loads all prices from {@code prices.yml} into the cache. + * Must be called (or re-called on reload) after {@link pt.henrique.servershop.category.CategoryRegistry#load()}. + */ + public void load() { + priceCache.clear(); + + double globalSellMultiplier = plugin.getConfig() + .getDouble("pricing.global-sell-multiplier", 0.25); + + ConfigurationSection catSection = plugin.getPricesConfig() + .getConfigurationSection("categories"); + if (catSection == null) return; + + for (String catId : catSection.getKeys(false)) { + ConfigurationSection cat = catSection.getConfigurationSection(catId); + if (cat == null) continue; + + boolean catBuyEnabled = cat.getBoolean("buy-enabled", true); + boolean catSellEnabled = cat.getBoolean("sell-enabled", true); + + // Per-category sell multiplier (falls back to global) + double catSellMultiplier = plugin.getConfig() + .getDouble("pricing.category-sell-multipliers." + catId, globalSellMultiplier); + + ConfigurationSection items = cat.getConfigurationSection("items"); + if (items == null) continue; + + for (String materialName : items.getKeys(false)) { + Material mat = parseMaterial(materialName); + if (mat == null) continue; + + // Item may be a scalar (just buy-price) or a section + double buyPrice; + double sellPrice; + boolean itemBuyEnabled = catBuyEnabled; + boolean itemSellEnabled = catSellEnabled; + + Object raw = items.get(materialName); + if (raw instanceof ConfigurationSection itemSection) { + buyPrice = itemSection.getDouble("buy-price", ItemPrice.DISABLED); + // sell-price: explicit override or derived from multiplier + if (itemSection.contains("sell-price")) { + sellPrice = itemSection.getDouble("sell-price"); + } else if (buyPrice > 0) { + sellPrice = buyPrice * catSellMultiplier; + } else { + sellPrice = ItemPrice.DISABLED; + } + // Per-item buy/sell enabled flags + if (itemSection.contains("buy-enabled")) { + itemBuyEnabled = itemSection.getBoolean("buy-enabled"); + } + if (itemSection.contains("sell-enabled")) { + itemSellEnabled = itemSection.getBoolean("sell-enabled"); + } + } else { + // scalar value — treat as buy-price + buyPrice = items.getDouble(materialName, ItemPrice.DISABLED); + sellPrice = buyPrice > 0 ? buyPrice * catSellMultiplier : ItemPrice.DISABLED; + } + + // Apply enabled flags: if disabled, force price to DISABLED + if (!itemBuyEnabled) buyPrice = ItemPrice.DISABLED; + if (!itemSellEnabled) sellPrice = ItemPrice.DISABLED; + + priceCache.put(mat, new ItemPrice(buyPrice, sellPrice)); + } + } + + plugin.getLogger().info("Loaded prices for " + priceCache.size() + " materials."); + } + + /** + * Returns the {@link ItemPrice} for a material. + * If the material has no configured price a default is synthesised using the + * global multiplier with a nominal base buy price of {@code 10.0}, so that + * newly-added items never cause a NullPointerException. + * + * @param material the material to price + * @return a non-null {@link ItemPrice} + */ + public ItemPrice getPrice(Material material) { + ItemPrice cached = priceCache.get(material); + if (cached != null) return cached; + + // Unknown / unconfigured material: use nominal default + double base = 10.0; + double multiplier = plugin.getConfig() + .getDouble("pricing.global-sell-multiplier", 0.25); + return new ItemPrice(base, base * multiplier); + } + + /** + * Returns the price for a material within a specific category context, + * respecting the category-level buy/sell enabled flags. + * + * @param material the material + * @param category the category (used for enabled checks) + * @return resolved price + */ + public ItemPrice getPrice(Material material, Category category) { + ItemPrice base = getPrice(material); + + // If the category disables buying or selling, override those fields + double buy = category.isBuyEnabled() ? base.getBuyPrice() : ItemPrice.DISABLED; + double sell = category.isSellEnabled() ? base.getSellPrice() : ItemPrice.DISABLED; + + if (buy == base.getBuyPrice() && sell == base.getSellPrice()) return base; + return new ItemPrice(buy, sell); + } + + // ---- helpers ---- + + private Material parseMaterial(String name) { + try { + return Material.valueOf(name.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException e) { + return null; + } + } +} diff --git a/servershop/src/main/java/pt/henrique/servershop/storage/TransactionLogger.java b/servershop/src/main/java/pt/henrique/servershop/storage/TransactionLogger.java new file mode 100644 index 0000000..17e037d --- /dev/null +++ b/servershop/src/main/java/pt/henrique/servershop/storage/TransactionLogger.java @@ -0,0 +1,144 @@ +package pt.henrique.servershop.storage; + +import org.bukkit.entity.Player; +import pt.henrique.servershop.ServerShop; + +import java.io.File; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.logging.Level; + +/** + * Optional SQLite logger for buy/sell transactions. + * All database writes are performed on the Bukkit async scheduler thread. + * + *

Schema: + *

+ * transactions (
+ *   id        INTEGER PRIMARY KEY AUTOINCREMENT,
+ *   uuid      TEXT    NOT NULL,
+ *   player    TEXT    NOT NULL,
+ *   type      TEXT    NOT NULL,   -- 'BUY' or 'SELL'
+ *   material  TEXT    NOT NULL,
+ *   amount    INTEGER NOT NULL,
+ *   unit_price REAL   NOT NULL,
+ *   total     REAL    NOT NULL,
+ *   timestamp INTEGER NOT NULL    -- Unix epoch seconds
+ * )
+ * 
+ */ +public final class TransactionLogger { + + private final ServerShop plugin; + private Connection connection; + private volatile boolean enabled; + + public TransactionLogger(ServerShop plugin) { + this.plugin = plugin; + } + + /** + * Opens (or creates) the SQLite database and sets up the schema. + * Call this on startup; it is safe to call from the main thread as + * it only runs at plugin enable time. + * + * @return {@code true} if the logger was successfully initialised + */ + public boolean initialize() { + enabled = plugin.getConfig().getBoolean("logging.enabled", true); + if (!enabled) { + plugin.getLogger().info("Transaction logging is disabled."); + return true; + } + + String fileName = plugin.getConfig().getString("logging.file", "transactions.db"); + File dbFile = new File(plugin.getDataFolder(), fileName); + plugin.getDataFolder().mkdirs(); + + try { + // Use the bundled (shaded) sqlite-jdbc driver + Class.forName("org.sqlite.JDBC"); + connection = DriverManager.getConnection("jdbc:sqlite:" + dbFile.getAbsolutePath()); + createTable(); + plugin.getLogger().info("Transaction database initialised."); + return true; + } catch (Exception e) { + plugin.getLogger().log(Level.SEVERE, "Failed to initialise transaction database", e); + enabled = false; + return false; + } + } + + /** + * Logs a transaction asynchronously. + * + * @param player the player involved + * @param type "BUY" or "SELL" + * @param material the material name + * @param amount the quantity + * @param unitPrice the per-unit price + * @param total the total transaction value + */ + public void log(Player player, String type, String material, int amount, + double unitPrice, double total) { + if (!enabled || connection == null) return; + + final String uuid = player.getUniqueId().toString(); + final String name = player.getName(); + final long ts = System.currentTimeMillis() / 1000L; + + // Fire and forget on async thread + plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> { + String sql = "INSERT INTO transactions (uuid, player, type, material, amount, " + + "unit_price, total, timestamp) VALUES (?,?,?,?,?,?,?,?)"; + try (PreparedStatement ps = connection.prepareStatement(sql)) { + ps.setString(1, uuid); + ps.setString(2, name); + ps.setString(3, type); + ps.setString(4, material); + ps.setInt(5, amount); + ps.setDouble(6, unitPrice); + ps.setDouble(7, total); + ps.setLong(8, ts); + ps.executeUpdate(); + } catch (SQLException e) { + plugin.getLogger().log(Level.WARNING, "Failed to log transaction", e); + } + }); + } + + /** Closes the database connection gracefully. */ + public void shutdown() { + if (connection != null) { + try { + connection.close(); + } catch (SQLException e) { + plugin.getLogger().log(Level.WARNING, "Error closing transaction database", e); + } + } + } + + // ---- private helpers ---- + + private void createTable() throws SQLException { + String sql = """ + CREATE TABLE IF NOT EXISTS transactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL, + player TEXT NOT NULL, + type TEXT NOT NULL, + material TEXT NOT NULL, + amount INTEGER NOT NULL, + unit_price REAL NOT NULL, + total REAL NOT NULL, + timestamp INTEGER NOT NULL + ) + """; + try (Statement stmt = connection.createStatement()) { + stmt.execute(sql); + } + } +} diff --git a/servershop/src/main/java/pt/henrique/servershop/util/ItemUtil.java b/servershop/src/main/java/pt/henrique/servershop/util/ItemUtil.java new file mode 100644 index 0000000..ae8e879 --- /dev/null +++ b/servershop/src/main/java/pt/henrique/servershop/util/ItemUtil.java @@ -0,0 +1,139 @@ +package pt.henrique.servershop.util; + +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +import java.util.HashMap; +import java.util.Map; + +/** + * Inventory and item helper utilities. + */ +public final class ItemUtil { + + private ItemUtil() {} + + /** + * Counts how many of a given material the player has in their inventory + * (including off-hand and hotbar). + * + * @param player the player + * @param material the material to count + * @return total count across all inventory slots + */ + public static int countInInventory(Player player, Material material) { + int count = 0; + for (ItemStack item : player.getInventory().getContents()) { + if (item != null && item.getType() == material) { + count += item.getAmount(); + } + } + return count; + } + + /** + * Removes exactly {@code amount} of the given material from the player's inventory. + * Does NOT check whether the player actually has enough — call {@link #countInInventory} + * first to validate. + * + * @param player the player + * @param material the material to remove + * @param amount amount to remove + */ + public static void removeItems(Player player, Material material, int amount) { + ItemStack[] contents = player.getInventory().getContents(); + int remaining = amount; + for (int i = 0; i < contents.length && remaining > 0; i++) { + ItemStack stack = contents[i]; + if (stack == null || stack.getType() != material) continue; + + if (stack.getAmount() <= remaining) { + remaining -= stack.getAmount(); + contents[i] = null; + } else { + stack.setAmount(stack.getAmount() - remaining); + remaining = 0; + } + } + player.getInventory().setContents(contents); + } + + /** + * Adds {@code amount} of a material to the player's inventory, + * dropping overflow at the player's feet. + * + * @param player the player + * @param material the material to give + * @param amount total amount to give + * @param drop if {@code true} overflow items are dropped; if {@code false} the method + * returns the number that could NOT be added (caller decides what to do) + * @return number of items that could not fit in the inventory + * (always 0 when {@code drop} is {@code true}) + */ + public static int addItems(Player player, Material material, int amount, boolean drop) { + int maxStack = new ItemStack(material).getMaxStackSize(); + int remaining = amount; + + while (remaining > 0) { + int batch = Math.min(remaining, maxStack); + ItemStack stack = new ItemStack(material, batch); + Map leftover = player.getInventory().addItem(stack); + + int leftoverTotal = leftover.values().stream() + .mapToInt(ItemStack::getAmount).sum(); + + if (leftoverTotal > 0) { + if (drop) { + // Drop at player's feet + player.getWorld().dropItem(player.getLocation(), new ItemStack(material, leftoverTotal)); + remaining = 0; + } else { + return remaining - (batch - leftoverTotal); + } + } else { + remaining -= batch; + } + } + return 0; + } + + /** + * Checks whether the player's inventory can fit {@code amount} additional + * units of {@code material} without any overflow. + * + * @param player the player + * @param material the material to check + * @param amount amount to fit + * @return {@code true} if all {@code amount} units would fit + */ + public static boolean canFit(Player player, Material material, int amount) { + int maxStack = new ItemStack(material).getMaxStackSize(); + int space = 0; + + for (ItemStack slot : player.getInventory().getStorageContents()) { + if (slot == null || slot.getType() == Material.AIR) { + space += maxStack; + } else if (slot.getType() == material && slot.getAmount() < maxStack) { + space += maxStack - slot.getAmount(); + } + if (space >= amount) return true; + } + return space >= amount; + } + + /** + * Checks whether a material is purchasable by the server shop + * (not air, not legacy, not a technical/non-obtainable block). + * + * @param material the material to test + * @return {@code true} if the material can appear in the shop + */ + public static boolean isShoppable(Material material) { + if (material == Material.AIR) return false; + if (!material.isItem()) return false; + // Exclude cave air / void air variants + if (material.name().contains("AIR")) return false; + return true; + } +} diff --git a/servershop/src/main/java/pt/henrique/servershop/util/TextUtil.java b/servershop/src/main/java/pt/henrique/servershop/util/TextUtil.java new file mode 100644 index 0000000..caefc01 --- /dev/null +++ b/servershop/src/main/java/pt/henrique/servershop/util/TextUtil.java @@ -0,0 +1,51 @@ +package pt.henrique.servershop.util; + +import org.bukkit.ChatColor; +import org.bukkit.Material; + +import java.util.Locale; + +/** + * Text / formatting helpers used throughout the plugin. + */ +public final class TextUtil { + + private TextUtil() {} + + /** + * Converts a {@link Material} name into a human-readable title-case string. + * E.g. {@code DIAMOND_PICKAXE} → {@code "Diamond Pickaxe"}. + * + * @param material the material + * @return formatted display name + */ + public static String formatMaterialName(Material material) { + String[] parts = material.name().split("_"); + StringBuilder sb = new StringBuilder(); + for (String part : parts) { + if (!sb.isEmpty()) sb.append(' '); + sb.append(part.charAt(0)).append(part.substring(1).toLowerCase(Locale.ROOT)); + } + return sb.toString(); + } + + /** + * Translates '&' colour codes and strips trailing whitespace. + * + * @param s the string to colour + * @return colour-translated string + */ + public static String colour(String s) { + return ChatColor.translateAlternateColorCodes('&', s); + } + + /** + * Strips all colour codes from a string. + * + * @param s the string + * @return plain text + */ + public static String stripColour(String s) { + return ChatColor.stripColor(colour(s)); + } +} diff --git a/servershop/src/main/resources/config.yml b/servershop/src/main/resources/config.yml new file mode 100644 index 0000000..6e077db --- /dev/null +++ b/servershop/src/main/resources/config.yml @@ -0,0 +1,74 @@ +# ============================================================ +# ServerShop Configuration +# Server-run global market — designed to work alongside +# CommunityMarket with a large buy/sell spread. +# ============================================================ + +# Language (available: en_US, pt_PT) +language: en_US + +# Economy Settings +economy: + # Java DecimalFormat pattern for currency display + currency-format: "$#,##0.00" + currency-symbol: "$" + +# Pricing Settings +pricing: + # Global multiplier applied to base buy price to derive sell price. + # 0.25 means the server pays players only 25% of the buy price (large spread). + # This keeps CommunityMarket attractive for player-to-player trades. + global-sell-multiplier: 0.25 + + # Optional tax deducted from sell proceeds (0.0 = disabled) + sell-tax-percent: 0.0 + + # Per-category overrides for sell multiplier (overrides global-sell-multiplier) + category-sell-multipliers: + BLOCKS: 0.20 + ORES: 0.20 + FARMING: 0.25 + FOOD: 0.25 + MOB_DROPS: 0.20 + REDSTONE: 0.25 + DECORATION: 0.20 + TOOLS: 0.15 + COMBAT: 0.15 + BREWING: 0.25 + MISC: 0.20 + +# Inventory behavior when player's inventory is full +# Options: DROP (drop items at player feet) | CANCEL (refuse the purchase) +full-inventory-behavior: DROP + +# GUI Settings +gui: + # Title of the main category selection GUI + main-title: "&6&lServer Shop" + # Title pattern for category GUIs (%category% is replaced) + category-title: "&6&lShop &8» &e%category%" + # Title for item detail GUI (%item% is replaced) + detail-title: "&6&lShop &8» &e%item%" + # Material used for filler/border panes + filler-material: GRAY_STAINED_GLASS_PANE + # Material for navigation buttons + nav-material: ARROW + +# Feature toggles +features: + # Allow players to buy items + enable-buying: true + # Allow players to sell items + enable-selling: true + # Show "Sell Hand" button in the main GUI + sell-hand-button: true + # Show "Sell Inventory" button in the main GUI (sells all matching items) + sell-inventory-button: true + # Include special-meta items (potions, enchanted books) at base price + include-special-meta-items: false + +# Transaction Logging (SQLite) +logging: + enabled: true + # Database file name (placed in plugin data folder) + file: transactions.db diff --git a/servershop/src/main/resources/lang/en_US.yml b/servershop/src/main/resources/lang/en_US.yml new file mode 100644 index 0000000..3fb5ccb --- /dev/null +++ b/servershop/src/main/resources/lang/en_US.yml @@ -0,0 +1,97 @@ +# ============================================================ +# ServerShop — English (en_US) Language File +# ============================================================ + +# General +prefix: "&8[&6ServerShop&8] &r" +no-permission: "&cYou don't have permission to do that." +player-only: "&cThis command can only be used by players." +reload-success: "&aServerShop configuration reloaded." + +# Economy errors +no-vault: "&cVault economy is not available. ServerShop is disabled." + +# Shop GUI +gui: + main-title: "&6&lServer Shop" + category-title: "&6&lShop &8» &e%category%" + detail-title: "&6&lShop &8» &e%item%" + + # Category selection screen + search-button: "&e&lSearch" + search-lore: + - "&7Click to search for an item" + sell-hand-button: "&c&lSell Hand" + sell-hand-lore: + - "&7Sell the item you are holding" + - "&7to the server shop" + sell-inventory-button: "&c&lSell Inventory" + sell-inventory-lore: + - "&7Sell ALL matching items in" + - "&7your inventory to the server shop" + + # Category item lore + category-lore: + - "&7Click to browse %count% items" + + # Item display in category grid + item-buy-price: "&7Buy: &a%price%" + item-sell-price: "&7Sell: &c%price%" + item-spread: "&7Spread: &e%spread%%" + item-buy-disabled: "&7Buy: &cNot available" + item-sell-disabled: "&7Sell: &cNot available" + + # Item detail GUI + quantity-display: "&fQuantity: &e%qty%" + quantity-lore: + - "&7Left-click buttons to change quantity" + buy-button: "&a&lBUY x%qty%" + buy-button-lore: + - "&7Total: &a%total%" + - "&7Your balance: &e%balance%" + sell-button: "&c&lSELL x%qty%" + sell-button-lore: + - "&7Total: &c%total%" + - "&7Your balance: &e%balance%" + - "&7In inventory: &f%have%" + min-button: "&f&lMIN &7(x1)" + max-button-buy: "&f&lMAX &7(by balance)" + max-button-sell: "&f&lMAX &7(by inventory)" + back-button: "&7« Back" + close-button: "&c✖ Close" + wallet-display: "&eBalance: &a%balance%" + + # Pagination + prev-page: "&7« Previous Page" + next-page: "&7Next Page »" + page-info: "&7Page %page% of %total%" + no-items: "&cNo items in this category." + + # Search + search-title: "Search items..." + search-prompt: "&7Type the item name in the anvil above" + no-results: "&cNo results for \"%query%\"" + + # Confirmation for sell-inventory + sell-inventory-confirm-title: "&4Confirm — Sell All?" + sell-inventory-confirm-lore: + - "&7Sell ALL %count%x %item%?" + - "&7You will receive: &a%total%" + - "" + - "&aLeft-click &7to confirm" + - "&cRight-click &7to cancel" + +# Transaction messages +buy-success: "&aYou bought &e%qty%x %item% &afor &e%total%&a." +buy-fail-no-money: "&cYou don't have enough money. Need: &e%total%&c, have: &e%balance%&c." +buy-fail-disabled: "&cBuying this item is currently disabled." +buy-fail-invalid-qty: "&cInvalid quantity." +buy-fail-inventory-full-cancelled: "&cYour inventory is full! Purchase cancelled." +buy-drop-notice: "&eYour inventory was full — some items were dropped at your feet." + +sell-success: "&aYou sold &e%qty%x %item% &afor &e%total%&a." +sell-fail-no-items: "&cYou don't have enough &e%item%&c in your inventory." +sell-fail-disabled: "&cSelling this item is currently disabled." +sell-fail-invalid-qty: "&cInvalid quantity." +sell-all-success: "&aYou sold &e%qty%x %item% &afor &e%total%&a." +sell-all-nothing: "&cYou don't have any &e%item% &cto sell." diff --git a/servershop/src/main/resources/lang/pt_PT.yml b/servershop/src/main/resources/lang/pt_PT.yml new file mode 100644 index 0000000..5ab7b1f --- /dev/null +++ b/servershop/src/main/resources/lang/pt_PT.yml @@ -0,0 +1,90 @@ +# ============================================================ +# ServerShop — Português (pt_PT) Language File +# ============================================================ + +# Geral +prefix: "&8[&6ServerShop&8] &r" +no-permission: "&cNão tens permissão para fazer isso." +player-only: "&cEste comando só pode ser usado por jogadores." +reload-success: "&aConfiguração do ServerShop recarregada." + +# Erros de economia +no-vault: "&cVault economy não está disponível. ServerShop está desativado." + +# GUI da loja +gui: + main-title: "&6&lLoja do Servidor" + category-title: "&6&lLoja &8» &e%category%" + detail-title: "&6&lLoja &8» &e%item%" + + search-button: "&e&lPesquisar" + search-lore: + - "&7Clica para procurar um item" + sell-hand-button: "&c&lVender na Mão" + sell-hand-lore: + - "&7Vende o item que tens na mão" + - "&7à loja do servidor" + sell-inventory-button: "&c&lVender Inventário" + sell-inventory-lore: + - "&7Vende TODOS os itens iguais" + - "&7no teu inventário à loja" + + category-lore: + - "&7Clica para ver %count% itens" + + item-buy-price: "&7Comprar: &a%price%" + item-sell-price: "&7Vender: &c%price%" + item-spread: "&7Spread: &e%spread%%" + item-buy-disabled: "&7Comprar: &cNão disponível" + item-sell-disabled: "&7Vender: &cNão disponível" + + quantity-display: "&fQuantidade: &e%qty%" + quantity-lore: + - "&7Usa os botões para alterar a quantidade" + buy-button: "&a&lCOMPRAR x%qty%" + buy-button-lore: + - "&7Total: &a%total%" + - "&7Saldo: &e%balance%" + sell-button: "&c&lVENDER x%qty%" + sell-button-lore: + - "&7Total: &c%total%" + - "&7Saldo: &e%balance%" + - "&7No inventário: &f%have%" + min-button: "&f&lMÍN &7(x1)" + max-button-buy: "&f&lMÁX &7(pelo saldo)" + max-button-sell: "&f&lMÁX &7(pelo inventário)" + back-button: "&7« Voltar" + close-button: "&c✖ Fechar" + wallet-display: "&eSaldo: &a%balance%" + + prev-page: "&7« Página Anterior" + next-page: "&7Próxima Página »" + page-info: "&7Página %page% de %total%" + no-items: "&cNenhum item nesta categoria." + + search-title: "Pesquisar itens..." + search-prompt: "&7Escreve o nome do item na bigorna" + no-results: "&cSem resultados para \"%query%\"" + + sell-inventory-confirm-title: "&4Confirmar — Vender Tudo?" + sell-inventory-confirm-lore: + - "&7Vender TODOS %count%x %item%?" + - "&7Receberás: &a%total%" + - "" + - "&aClique esquerdo &7para confirmar" + - "&cClique direito &7para cancelar" + +# Mensagens de transação +buy-success: "&aCompraste &e%qty%x %item% &apor &e%total%&a." +buy-fail-no-money: "&cNão tens dinheiro suficiente. Precisas: &e%total%&c, tens: &e%balance%&c." +buy-fail-disabled: "&cA compra deste item está desativada." +buy-fail-invalid-qty: "&cQuantidade inválida." +buy-fail-inventory-full-cancelled: "&cO teu inventário está cheio! Compra cancelada." +buy-drop-notice: "&eO teu inventário estava cheio — alguns itens foram largados aos teus pés." + +sell-success: "&aVendeste &e%qty%x %item% &apor &e%total%&a." +sell-fail-no-items: "&cNão tens &e%item% &csuficiente no inventário." +sell-fail-disabled: "&cA venda deste item está desativada." +sell-fail-invalid-qty: "&cQuantidade inválida." +sell-all-success: "&aVendeste &e%qty%x %item% &apor &e%total%&a." +sell-all-nothing: "&cNão tens &e%item% &cpara vender." diff --git a/servershop/src/main/resources/plugin.yml b/servershop/src/main/resources/plugin.yml new file mode 100644 index 0000000..e2ce508 --- /dev/null +++ b/servershop/src/main/resources/plugin.yml @@ -0,0 +1,51 @@ +name: ServerShop +version: '${project.version}' +main: pt.henrique.servershop.ServerShop +api-version: '1.21' +description: A server-run global market with configurable pricing spread — complements CommunityMarket +author: Henrique +website: https://github.com/henrique/CommunityMarket + +depend: + - Vault + +load: POSTWORLD + +commands: + shop: + description: Opens the Server Shop main GUI + usage: / + aliases: [servershop, sshop] + permission: servershop.use + +permissions: + servershop.*: + description: Grants all ServerShop permissions + default: op + children: + servershop.use: true + servershop.buy: true + servershop.sell: true + servershop.admin: true + + servershop.use: + description: Allows opening the Server Shop + default: true + + servershop.buy: + description: Allows buying items from the Server Shop + default: true + + servershop.sell: + description: Allows selling items to the Server Shop + default: true + + servershop.admin: + description: Allows admin commands (reload) + default: op + children: + servershop.admin.reload: true + + servershop.admin.reload: + description: Allows reloading the Server Shop configuration + default: op diff --git a/servershop/src/main/resources/prices.yml b/servershop/src/main/resources/prices.yml new file mode 100644 index 0000000..0c1e006 --- /dev/null +++ b/servershop/src/main/resources/prices.yml @@ -0,0 +1,499 @@ +# ============================================================ +# ServerShop — Prices & Categories +# ============================================================ +# Structure: +# categories: +# : +# display-name: "Human readable name" +# icon: MATERIAL_NAME +# buy-enabled: true/false # can players buy from this category? +# sell-enabled: true/false # can players sell to this category? +# items: +# MATERIAL_NAME: +# buy-price: 10.00 # price server charges player to buy 1x +# sell-price: 2.50 # price server pays player for 1x +# # (if omitted, sell-price = buy-price * category/global multiplier) +# buy-enabled: true # override per item +# sell-enabled: true # override per item +# +# Items not listed under any category fall into MISC automatically. +# Set buy-price to -1 to disable buying for that item. +# Set sell-price to -1 to disable selling for that item. +# ============================================================ + +categories: + + BLOCKS: + display-name: "&7Blocks" + icon: STONE + buy-enabled: true + sell-enabled: true + items: + STONE: { buy-price: 1.00 } + COBBLESTONE: { buy-price: 0.50 } + DIRT: { buy-price: 0.20 } + GRASS_BLOCK: { buy-price: 0.30 } + SAND: { buy-price: 0.50 } + GRAVEL: { buy-price: 0.50 } + CLAY: { buy-price: 1.00 } + NETHERRACK: { buy-price: 0.20 } + SOUL_SAND: { buy-price: 1.50 } + SOUL_SOIL: { buy-price: 1.50 } + BASALT: { buy-price: 0.50 } + BLACKSTONE: { buy-price: 0.50 } + OBSIDIAN: { buy-price: 8.00 } + CRYING_OBSIDIAN: { buy-price: 10.00 } + OAK_LOG: { buy-price: 1.50 } + SPRUCE_LOG: { buy-price: 1.50 } + BIRCH_LOG: { buy-price: 1.50 } + JUNGLE_LOG: { buy-price: 1.50 } + ACACIA_LOG: { buy-price: 1.50 } + DARK_OAK_LOG: { buy-price: 1.50 } + MANGROVE_LOG: { buy-price: 1.50 } + CHERRY_LOG: { buy-price: 1.50 } + BAMBOO_BLOCK: { buy-price: 0.50 } + OAK_PLANKS: { buy-price: 0.75 } + SPRUCE_PLANKS: { buy-price: 0.75 } + BIRCH_PLANKS: { buy-price: 0.75 } + JUNGLE_PLANKS: { buy-price: 0.75 } + ACACIA_PLANKS: { buy-price: 0.75 } + DARK_OAK_PLANKS: { buy-price: 0.75 } + MANGROVE_PLANKS: { buy-price: 0.75 } + CHERRY_PLANKS: { buy-price: 0.75 } + GRANITE: { buy-price: 0.80 } + DIORITE: { buy-price: 0.80 } + ANDESITE: { buy-price: 0.80 } + CALCITE: { buy-price: 0.80 } + TUFF: { buy-price: 0.80 } + DEEPSLATE: { buy-price: 1.00 } + MUD: { buy-price: 0.30 } + PACKED_MUD: { buy-price: 0.60 } + MUDDY_MANGROVE_ROOTS: { buy-price: 0.50 } + ICE: { buy-price: 1.00 } + PACKED_ICE: { buy-price: 2.00 } + BLUE_ICE: { buy-price: 4.00 } + SNOW_BLOCK: { buy-price: 0.50 } + SANDSTONE: { buy-price: 1.00 } + RED_SANDSTONE: { buy-price: 1.00 } + PRISMARINE: { buy-price: 3.00 } + PRISMARINE_BRICKS: { buy-price: 4.00 } + DARK_PRISMARINE: { buy-price: 4.00 } + SEA_LANTERN: { buy-price: 6.00 } + PURPUR_BLOCK: { buy-price: 5.00 } + END_STONE: { buy-price: 3.00 } + END_STONE_BRICKS: { buy-price: 4.00 } + + ORES: + display-name: "&bOres & Minerals" + icon: DIAMOND_ORE + buy-enabled: true + sell-enabled: true + items: + COAL: { buy-price: 2.00 } + CHARCOAL: { buy-price: 1.50 } + IRON_INGOT: { buy-price: 5.00 } + IRON_NUGGET: { buy-price: 0.60 } + GOLD_INGOT: { buy-price: 8.00 } + GOLD_NUGGET: { buy-price: 1.00 } + COPPER_INGOT: { buy-price: 3.00 } + RAW_IRON: { buy-price: 4.00 } + RAW_GOLD: { buy-price: 7.00 } + RAW_COPPER: { buy-price: 2.50 } + DIAMOND: { buy-price: 50.00 } + EMERALD: { buy-price: 40.00 } + LAPIS_LAZULI: { buy-price: 5.00 } + REDSTONE: { buy-price: 2.00 } + QUARTZ: { buy-price: 2.00 } + NETHERITE_INGOT: { buy-price: 500.00 } + NETHERITE_SCRAP: { buy-price: 100.00 } + AMETHYST_SHARD: { buy-price: 4.00 } + ECHO_SHARD: { buy-price: 30.00 } + COAL_ORE: { buy-price: 3.00 } + DEEPSLATE_COAL_ORE: { buy-price: 4.00 } + IRON_ORE: { buy-price: 6.00 } + DEEPSLATE_IRON_ORE: { buy-price: 7.00 } + GOLD_ORE: { buy-price: 10.00 } + DEEPSLATE_GOLD_ORE: { buy-price: 11.00 } + COPPER_ORE: { buy-price: 4.00 } + DEEPSLATE_COPPER_ORE: { buy-price: 5.00 } + DIAMOND_ORE: { buy-price: 60.00 } + DEEPSLATE_DIAMOND_ORE: { buy-price: 70.00 } + EMERALD_ORE: { buy-price: 50.00 } + DEEPSLATE_EMERALD_ORE: { buy-price: 60.00 } + LAPIS_ORE: { buy-price: 8.00 } + DEEPSLATE_LAPIS_ORE: { buy-price: 10.00 } + REDSTONE_ORE: { buy-price: 5.00 } + DEEPSLATE_REDSTONE_ORE: { buy-price: 6.00 } + NETHER_QUARTZ_ORE: { buy-price: 3.00 } + NETHER_GOLD_ORE: { buy-price: 9.00 } + ANCIENT_DEBRIS: { buy-price: 200.00 } + + FARMING: + display-name: "&aFarming" + icon: WHEAT + buy-enabled: true + sell-enabled: true + items: + WHEAT: { buy-price: 1.00 } + WHEAT_SEEDS: { buy-price: 0.30 } + CARROT: { buy-price: 0.80 } + POTATO: { buy-price: 0.80 } + BEETROOT: { buy-price: 0.80 } + BEETROOT_SEEDS: { buy-price: 0.30 } + PUMPKIN: { buy-price: 2.00 } + PUMPKIN_SEEDS: { buy-price: 0.50 } + MELON: { buy-price: 0.50 } + MELON_SEEDS: { buy-price: 0.50 } + SUGAR_CANE: { buy-price: 0.50 } + CACTUS: { buy-price: 0.50 } + BAMBOO: { buy-price: 0.20 } + COCOA_BEANS: { buy-price: 1.00 } + NETHER_WART: { buy-price: 2.00 } + CHORUS_FRUIT: { buy-price: 3.00 } + CHORUS_FLOWER: { buy-price: 5.00 } + KELP: { buy-price: 0.30 } + DRIED_KELP_BLOCK: { buy-price: 1.00 } + SEA_PICKLE: { buy-price: 1.00 } + LILY_PAD: { buy-price: 0.50 } + VINE: { buy-price: 0.30 } + GLOW_BERRIES: { buy-price: 1.00 } + SWEET_BERRIES: { buy-price: 0.80 } + TORCHFLOWER_SEEDS: { buy-price: 5.00 } + PITCHER_POD: { buy-price: 5.00 } + SNIFFER_EGG: { buy-price: 20.00 } + + FOOD: + display-name: "&eFood" + icon: COOKED_BEEF + buy-enabled: true + sell-enabled: true + items: + BREAD: { buy-price: 2.00 } + APPLE: { buy-price: 1.50 } + GOLDEN_APPLE: { buy-price: 20.00 } + ENCHANTED_GOLDEN_APPLE: { buy-price: 200.00, sell-enabled: false } + COOKED_BEEF: { buy-price: 3.00 } + BEEF: { buy-price: 1.50 } + COOKED_PORKCHOP: { buy-price: 3.00 } + PORKCHOP: { buy-price: 1.50 } + COOKED_CHICKEN: { buy-price: 2.50 } + CHICKEN: { buy-price: 1.00 } + COOKED_MUTTON: { buy-price: 3.00 } + MUTTON: { buy-price: 1.50 } + COOKED_RABBIT: { buy-price: 3.00 } + RABBIT: { buy-price: 1.50 } + COOKED_COD: { buy-price: 2.50 } + COD: { buy-price: 1.00 } + COOKED_SALMON: { buy-price: 2.50 } + SALMON: { buy-price: 1.00 } + TROPICAL_FISH: { buy-price: 0.50 } + PUFFERFISH: { buy-price: 1.50 } + CAKE: { buy-price: 5.00 } + COOKIE: { buy-price: 1.00 } + PUMPKIN_PIE: { buy-price: 2.00 } + MUSHROOM_STEW: { buy-price: 3.00 } + RABBIT_STEW: { buy-price: 5.00 } + BEETROOT_SOUP: { buy-price: 3.00 } + SUSPICIOUS_STEW: { buy-price: 4.00 } + HONEY_BOTTLE: { buy-price: 5.00 } + + MOB_DROPS: + display-name: "&cMob Drops" + icon: BONE + buy-enabled: true + sell-enabled: true + items: + BONE: { buy-price: 1.50 } + BONE_MEAL: { buy-price: 0.50 } + STRING: { buy-price: 1.00 } + SPIDER_EYE: { buy-price: 2.00 } + ROTTEN_FLESH: { buy-price: 0.20 } + ENDER_PEARL: { buy-price: 8.00 } + BLAZE_ROD: { buy-price: 6.00 } + BLAZE_POWDER: { buy-price: 3.00 } + GHAST_TEAR: { buy-price: 10.00 } + MAGMA_CREAM: { buy-price: 4.00 } + SLIME_BALL: { buy-price: 4.00 } + GUNPOWDER: { buy-price: 3.00 } + FEATHER: { buy-price: 1.00 } + LEATHER: { buy-price: 3.00 } + RABBIT_HIDE: { buy-price: 1.50 } + RABBIT_FOOT: { buy-price: 6.00 } + INK_SAC: { buy-price: 1.50 } + GLOW_INK_SAC: { buy-price: 5.00 } + PRISMARINE_SHARD: { buy-price: 2.50 } + PRISMARINE_CRYSTALS: { buy-price: 2.50 } + SHULKER_SHELL: { buy-price: 20.00 } + TOTEM_OF_UNDYING: { buy-price: 100.00, sell-price: 30.00 } + NETHER_STAR: { buy-price: 200.00, sell-price: 50.00 } + DRAGON_BREATH: { buy-price: 20.00 } + ELYTRA: { buy-price: 300.00, sell-enabled: false } + TURTLE_SCUTE: { buy-price: 8.00 } + ARMADILLO_SCUTE: { buy-price: 5.00 } + BREEZE_ROD: { buy-price: 15.00 } + WIND_CHARGE: { buy-price: 8.00 } + OMINOUS_TRIAL_KEY: { buy-price: 30.00 } + TRIAL_KEY: { buy-price: 15.00 } + + REDSTONE: + display-name: "&cRedstone" + icon: REDSTONE + buy-enabled: true + sell-enabled: true + items: + REDSTONE: { buy-price: 2.00 } + REDSTONE_BLOCK: { buy-price: 18.00 } + REDSTONE_TORCH: { buy-price: 1.00 } + REPEATER: { buy-price: 3.00 } + COMPARATOR: { buy-price: 5.00 } + OBSERVER: { buy-price: 4.00 } + PISTON: { buy-price: 4.00 } + STICKY_PISTON: { buy-price: 6.00 } + DROPPER: { buy-price: 3.00 } + DISPENSER: { buy-price: 5.00 } + HOPPER: { buy-price: 8.00 } + LEVER: { buy-price: 0.50 } + STONE_BUTTON: { buy-price: 0.50 } + OAK_BUTTON: { buy-price: 0.50 } + STONE_PRESSURE_PLATE: { buy-price: 1.00 } + OAK_PRESSURE_PLATE: { buy-price: 1.00 } + TRIPWIRE_HOOK: { buy-price: 2.00 } + TNT: { buy-price: 5.00 } + DAYLIGHT_DETECTOR: { buy-price: 6.00 } + TRAPPED_CHEST: { buy-price: 4.00 } + TARGET: { buy-price: 3.00 } + SCULK_SENSOR: { buy-price: 10.00 } + CALIBRATED_SCULK_SENSOR: { buy-price: 20.00 } + COPPER_BULB: { buy-price: 5.00 } + + DECORATION: + display-name: "&dDecoration" + icon: FLOWER_POT + buy-enabled: true + sell-enabled: true + items: + TORCH: { buy-price: 0.10 } + LANTERN: { buy-price: 2.00 } + SOUL_LANTERN: { buy-price: 2.50 } + CANDLE: { buy-price: 1.50 } + FLOWER_POT: { buy-price: 0.50 } + ITEM_FRAME: { buy-price: 1.00 } + GLOW_ITEM_FRAME: { buy-price: 5.00 } + PAINTING: { buy-price: 1.50 } + ARMOR_STAND: { buy-price: 3.00 } + BANNER: { buy-price: 3.00 } + OAK_SIGN: { buy-price: 1.00 } + HANGING_SIGN: { buy-price: 2.00 } + CHAIN: { buy-price: 1.50 } + BELL: { buy-price: 15.00 } + CONDUIT: { buy-price: 40.00 } + BEACON: { buy-price: 250.00, sell-enabled: false } + BOOKSHELF: { buy-price: 4.00 } + CHISELED_BOOKSHELF: { buy-price: 5.00 } + LECTERN: { buy-price: 6.00 } + JUKEBOX: { buy-price: 10.00 } + NOTE_BLOCK: { buy-price: 3.00 } + GLASS: { buy-price: 1.50 } + GLASS_PANE: { buy-price: 0.50 } + STAINED_GLASS: { buy-price: 2.00 } + STAINED_GLASS_PANE: { buy-price: 0.80 } + TERRACOTTA: { buy-price: 1.50 } + GLAZED_TERRACOTTA: { buy-price: 2.50 } + CONCRETE: { buy-price: 2.00 } + CONCRETE_POWDER: { buy-price: 1.50 } + WOOL: { buy-price: 2.00 } + CARPET: { buy-price: 1.50 } + DANDELION: { buy-price: 0.30 } + POPPY: { buy-price: 0.30 } + BLUE_ORCHID: { buy-price: 0.50 } + ALLIUM: { buy-price: 0.50 } + SUNFLOWER: { buy-price: 0.50 } + ROSE_BUSH: { buy-price: 0.50 } + PEONY: { buy-price: 0.50 } + WITHER_ROSE: { buy-price: 5.00 } + CHERRY_LEAVES: { buy-price: 0.20 } + PINK_PETALS: { buy-price: 0.50 } + TORCHFLOWER: { buy-price: 4.00 } + PITCHER_PLANT: { buy-price: 4.00 } + + TOOLS: + display-name: "&7Tools" + icon: IRON_PICKAXE + buy-enabled: true + sell-enabled: true + items: + WOODEN_PICKAXE: { buy-price: 5.00, sell-price: 1.00 } + STONE_PICKAXE: { buy-price: 10.00, sell-price: 2.00 } + IRON_PICKAXE: { buy-price: 20.00, sell-price: 4.00 } + GOLDEN_PICKAXE: { buy-price: 12.00, sell-price: 2.00 } + DIAMOND_PICKAXE: { buy-price: 80.00, sell-price: 15.00 } + NETHERITE_PICKAXE: { buy-price: 600.00, sell-price: 100.00 } + WOODEN_AXE: { buy-price: 5.00, sell-price: 1.00 } + STONE_AXE: { buy-price: 10.00, sell-price: 2.00 } + IRON_AXE: { buy-price: 20.00, sell-price: 4.00 } + DIAMOND_AXE: { buy-price: 80.00, sell-price: 15.00 } + NETHERITE_AXE: { buy-price: 600.00, sell-price: 100.00 } + WOODEN_SHOVEL: { buy-price: 4.00, sell-price: 0.80 } + STONE_SHOVEL: { buy-price: 8.00, sell-price: 1.50 } + IRON_SHOVEL: { buy-price: 15.00, sell-price: 3.00 } + DIAMOND_SHOVEL: { buy-price: 60.00, sell-price: 10.00 } + NETHERITE_SHOVEL: { buy-price: 550.00, sell-price: 90.00 } + WOODEN_HOE: { buy-price: 4.00, sell-price: 0.80 } + STONE_HOE: { buy-price: 8.00, sell-price: 1.50 } + IRON_HOE: { buy-price: 15.00, sell-price: 3.00 } + DIAMOND_HOE: { buy-price: 60.00, sell-price: 10.00 } + NETHERITE_HOE: { buy-price: 550.00, sell-price: 90.00 } + SHEARS: { buy-price: 8.00, sell-price: 1.50 } + FLINT_AND_STEEL: { buy-price: 6.00, sell-price: 1.00 } + FISHING_ROD: { buy-price: 5.00, sell-price: 1.00 } + COMPASS: { buy-price: 5.00 } + CLOCK: { buy-price: 8.00 } + SPYGLASS: { buy-price: 12.00 } + BRUSH: { buy-price: 5.00 } + BUCKET: { buy-price: 8.00 } + WATER_BUCKET: { buy-price: 9.00 } + LAVA_BUCKET: { buy-price: 12.00 } + MILK_BUCKET: { buy-price: 5.00 } + POWDER_SNOW_BUCKET: { buy-price: 5.00 } + + COMBAT: + display-name: "&4Combat" + icon: IRON_SWORD + buy-enabled: true + sell-enabled: true + items: + WOODEN_SWORD: { buy-price: 5.00, sell-price: 1.00 } + STONE_SWORD: { buy-price: 10.00, sell-price: 2.00 } + IRON_SWORD: { buy-price: 20.00, sell-price: 4.00 } + GOLDEN_SWORD: { buy-price: 12.00, sell-price: 2.00 } + DIAMOND_SWORD: { buy-price: 80.00, sell-price: 15.00 } + NETHERITE_SWORD: { buy-price: 600.00, sell-price: 100.00 } + BOW: { buy-price: 10.00, sell-price: 2.00 } + CROSSBOW: { buy-price: 15.00, sell-price: 3.00 } + TRIDENT: { buy-price: 80.00, sell-price: 15.00 } + ARROW: { buy-price: 0.50 } + SPECTRAL_ARROW: { buy-price: 1.00 } + TIPPED_ARROW: { buy-price: 2.00, sell-enabled: false } + SHIELD: { buy-price: 15.00, sell-price: 3.00 } + IRON_HELMET: { buy-price: 25.00, sell-price: 5.00 } + IRON_CHESTPLATE: { buy-price: 40.00, sell-price: 8.00 } + IRON_LEGGINGS: { buy-price: 35.00, sell-price: 7.00 } + IRON_BOOTS: { buy-price: 20.00, sell-price: 4.00 } + DIAMOND_HELMET: { buy-price: 100.00, sell-price: 20.00 } + DIAMOND_CHESTPLATE: { buy-price: 160.00, sell-price: 30.00 } + DIAMOND_LEGGINGS: { buy-price: 140.00, sell-price: 25.00 } + DIAMOND_BOOTS: { buy-price: 80.00, sell-price: 15.00 } + NETHERITE_HELMET: { buy-price: 700.00, sell-price: 120.00 } + NETHERITE_CHESTPLATE: { buy-price: 900.00, sell-price: 150.00 } + NETHERITE_LEGGINGS: { buy-price: 850.00, sell-price: 140.00 } + NETHERITE_BOOTS: { buy-price: 750.00, sell-price: 130.00 } + GOLDEN_HELMET: { buy-price: 20.00, sell-price: 3.00 } + GOLDEN_CHESTPLATE: { buy-price: 35.00, sell-price: 5.00 } + GOLDEN_LEGGINGS: { buy-price: 30.00, sell-price: 4.50 } + GOLDEN_BOOTS: { buy-price: 20.00, sell-price: 3.00 } + + BREWING: + display-name: "&5Brewing" + icon: BREWING_STAND + buy-enabled: true + sell-enabled: true + items: + BREWING_STAND: { buy-price: 8.00 } + CAULDRON: { buy-price: 10.00 } + GLASS_BOTTLE: { buy-price: 0.50 } + WATER_BOTTLE: { buy-price: 1.00 } + FERMENTED_SPIDER_EYE: { buy-price: 5.00 } + SUGAR: { buy-price: 0.50 } + GLISTERING_MELON_SLICE: { buy-price: 10.00 } + GOLDEN_CARROT: { buy-price: 8.00 } + PUFFERFISH: { buy-price: 4.00 } + MAGMA_CREAM: { buy-price: 4.00 } + PHANTOM_MEMBRANE: { buy-price: 8.00 } + RABBIT_FOOT: { buy-price: 6.00 } + TURTLE_HELMET: { buy-price: 50.00, sell-price: 8.00 } + DRAGON_BREATH: { buy-price: 20.00 } + + MISC: + display-name: "&8Miscellaneous" + icon: CHEST + buy-enabled: true + sell-enabled: true + items: + CHEST: { buy-price: 3.00 } + TRAPPED_CHEST: { buy-price: 4.00 } + BARREL: { buy-price: 4.00 } + SHULKER_BOX: { buy-price: 40.00 } + ENDER_CHEST: { buy-price: 30.00 } + CRAFTING_TABLE: { buy-price: 2.00 } + FURNACE: { buy-price: 3.00 } + BLAST_FURNACE: { buy-price: 8.00 } + SMOKER: { buy-price: 6.00 } + ANVIL: { buy-price: 15.00 } + GRINDSTONE: { buy-price: 8.00 } + STONECUTTER: { buy-price: 6.00 } + SMITHING_TABLE: { buy-price: 8.00 } + LOOM: { buy-price: 5.00 } + CARTOGRAPHY_TABLE: { buy-price: 5.00 } + FLETCHING_TABLE: { buy-price: 5.00 } + ENCHANTING_TABLE: { buy-price: 50.00 } + EXPERIENCE_BOTTLE: { buy-price: 5.00 } + NAME_TAG: { buy-price: 20.00 } + SADDLE: { buy-price: 15.00, sell-price: 3.00 } + LEAD: { buy-price: 5.00 } + BOOK: { buy-price: 2.00 } + BOOKSHELF: { buy-price: 4.00 } + PAPER: { buy-price: 0.30 } + MAP: { buy-price: 2.00 } + CLOCK: { buy-price: 8.00 } + COMPASS: { buy-price: 5.00 } + TORCH: { buy-price: 0.10 } + FLINT: { buy-price: 0.50 } + STICK: { buy-price: 0.10 } + BOWL: { buy-price: 0.20 } + BRICK: { buy-price: 1.00 } + NETHER_BRICK: { buy-price: 1.00 } + CLAY_BALL: { buy-price: 0.30 } + SNOWBALL: { buy-price: 0.10 } + EGG: { buy-price: 0.20 } + TURTLE_EGG: { buy-price: 3.00 } + DRAGON_EGG: { buy-price: 1000.00, sell-enabled: false } + HEART_OF_THE_SEA: { buy-price: 80.00 } + NAUTILUS_SHELL: { buy-price: 10.00 } + TRIDENT: { buy-price: 80.00, sell-price: 15.00 } + MUSIC_DISC_13: { buy-price: 15.00 } + MUSIC_DISC_CAT: { buy-price: 15.00 } + MUSIC_DISC_BLOCKS: { buy-price: 15.00 } + MUSIC_DISC_CHIRP: { buy-price: 15.00 } + MUSIC_DISC_FAR: { buy-price: 15.00 } + MUSIC_DISC_MALL: { buy-price: 15.00 } + MUSIC_DISC_MELLOHI: { buy-price: 15.00 } + MUSIC_DISC_STAL: { buy-price: 15.00 } + MUSIC_DISC_STRAD: { buy-price: 15.00 } + MUSIC_DISC_WARD: { buy-price: 15.00 } + MUSIC_DISC_11: { buy-price: 15.00 } + MUSIC_DISC_WAIT: { buy-price: 15.00 } + MUSIC_DISC_OTHERSIDE: { buy-price: 20.00 } + MUSIC_DISC_5: { buy-price: 20.00 } + MUSIC_DISC_PIGSTEP: { buy-price: 20.00 } + MUSIC_DISC_RELIC: { buy-price: 20.00 } + DISC_FRAGMENT_5: { buy-price: 3.00 } + POTTERY_SHARD_ARMS_UP: { buy-price: 5.00 } + POTTERY_SHARD_BLADE: { buy-price: 5.00 } + POTTERY_SHARD_BREWER: { buy-price: 5.00 } + POTTERY_SHARD_BURN: { buy-price: 5.00 } + POTTERY_SHARD_DANGER: { buy-price: 5.00 } + POTTERY_SHARD_EXPLORER: { buy-price: 5.00 } + POTTERY_SHARD_FRIEND: { buy-price: 5.00 } + POTTERY_SHARD_HEART: { buy-price: 5.00 } + POTTERY_SHARD_HEARTBREAK: { buy-price: 5.00 } + POTTERY_SHARD_HOWL: { buy-price: 5.00 } + POTTERY_SHARD_MINER: { buy-price: 5.00 } + POTTERY_SHARD_MOURNER: { buy-price: 5.00 } + POTTERY_SHARD_PLENTY: { buy-price: 5.00 } + POTTERY_SHARD_PRIZE: { buy-price: 5.00 } + POTTERY_SHARD_SHEAF: { buy-price: 5.00 } + POTTERY_SHARD_SHELTER: { buy-price: 5.00 } + POTTERY_SHARD_SKULL: { buy-price: 5.00 } + POTTERY_SHARD_SNORT: { buy-price: 5.00 } + DECORATED_POT: { buy-price: 8.00 } + BUNDLE: { buy-price: 5.00 }