Initial Realease

This commit is contained in:
2026-01-14 00:04:03 +00:00
commit a3fcb844a6
130 changed files with 11649 additions and 0 deletions
@@ -0,0 +1,178 @@
package pt.henrique.communityMarket;
import org.bukkit.Bukkit;
import org.bukkit.plugin.java.JavaPlugin;
import pt.henrique.communityMarket.command.MarketCommand;
import pt.henrique.communityMarket.config.ConfigManager;
import pt.henrique.communityMarket.config.MessageManager;
import pt.henrique.communityMarket.db.DatabaseManager;
import pt.henrique.communityMarket.economy.EconomyManager;
import pt.henrique.communityMarket.gui.GuiManager;
import pt.henrique.communityMarket.listener.GuiListener;
import pt.henrique.communityMarket.listener.PlayerListener;
import pt.henrique.communityMarket.service.*;
import pt.henrique.communityMarket.task.AuctionTask;
import pt.henrique.communityMarket.task.ExpiredListingTask;
import java.util.logging.Level;
public final class CommunityMarket extends JavaPlugin {
private static CommunityMarket instance;
private ConfigManager configManager;
private MessageManager messageManager;
private DatabaseManager databaseManager;
private EconomyManager economyManager;
private GuiManager guiManager;
private ListingService listingService;
private AuctionService auctionService;
private ClaimService claimService;
private EarningsService earningsService;
private TransactionService transactionService;
private AuctionTask auctionTask;
private ExpiredListingTask expiredListingTask;
@Override
public void onEnable() {
instance = this;
// Load configurations
configManager = new ConfigManager(this);
messageManager = new MessageManager(this);
// Initialize economy
economyManager = new EconomyManager(this);
if (!economyManager.setupEconomy()) {
getLogger().severe("No economy plugin found! Please install Vault or EssentialsX.");
getLogger().severe("Disabling CommunityMarket...");
getServer().getPluginManager().disablePlugin(this);
return;
}
getLogger().info("Economy provider: " + economyManager.getProviderName());
// Initialize database
databaseManager = new DatabaseManager(this);
if (!databaseManager.initialize()) {
getLogger().severe("Failed to initialize database!");
getLogger().severe("Disabling CommunityMarket...");
getServer().getPluginManager().disablePlugin(this);
return;
}
getLogger().info("Database initialized successfully.");
// Initialize services
claimService = new ClaimService(this);
earningsService = new EarningsService(this);
listingService = new ListingService(this);
auctionService = new AuctionService(this);
transactionService = new TransactionService(this);
// Initialize GUI manager
guiManager = new GuiManager(this);
// Register command
MarketCommand marketCommand = new MarketCommand(this);
var command = getCommand("market");
if (command != null) {
command.setExecutor(marketCommand);
command.setTabCompleter(marketCommand);
}
// Register listeners
getServer().getPluginManager().registerEvents(new GuiListener(this), this);
getServer().getPluginManager().registerEvents(new PlayerListener(this), this);
// Start tasks
startTasks();
getLogger().info("CommunityMarket has been enabled!");
}
@Override
public void onDisable() {
// Stop tasks
if (auctionTask != null) {
auctionTask.cancel();
}
if (expiredListingTask != null) {
expiredListingTask.cancel();
}
// Close GUI manager
if (guiManager != null) {
guiManager.closeAllGuis();
}
// Close database
if (databaseManager != null) {
databaseManager.shutdown();
}
getLogger().info("CommunityMarket has been disabled!");
}
private void startTasks() {
// Auction check task
int auctionInterval = configManager.getAuctionCheckInterval() * 20; // Convert to ticks
auctionTask = new AuctionTask(this);
auctionTask.runTaskTimerAsynchronously(this, auctionInterval, auctionInterval);
// Expired listing check task
int expiredInterval = configManager.getExpiredCheckInterval() * 60 * 20; // Convert minutes to ticks
expiredListingTask = new ExpiredListingTask(this);
expiredListingTask.runTaskTimerAsynchronously(this, expiredInterval, expiredInterval);
}
public void reload() {
configManager.reload();
messageManager.reload();
getLogger().info("Configuration reloaded.");
}
public static CommunityMarket getInstance() {
return instance;
}
public ConfigManager getConfigManager() {
return configManager;
}
public MessageManager getMessageManager() {
return messageManager;
}
public DatabaseManager getDatabaseManager() {
return databaseManager;
}
public EconomyManager getEconomyManager() {
return economyManager;
}
public GuiManager getGuiManager() {
return guiManager;
}
public ListingService getListingService() {
return listingService;
}
public AuctionService getAuctionService() {
return auctionService;
}
public ClaimService getClaimService() {
return claimService;
}
public EarningsService getEarningsService() {
return earningsService;
}
public TransactionService getTransactionService() {
return transactionService;
}
}
@@ -0,0 +1,54 @@
package pt.henrique.communityMarket.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 org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import pt.henrique.communityMarket.CommunityMarket;
import java.util.Collections;
import java.util.List;
/**
* The only command in the plugin: /market (alias: /cmarket)
* Opens the main market GUI. All other actions are done through GUIs.
*/
public class MarketCommand implements CommandExecutor, TabCompleter {
private final CommunityMarket plugin;
public MarketCommand(CommunityMarket plugin) {
this.plugin = plugin;
}
@Override
public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command,
@NotNull String label, @NotNull String[] args) {
// Only players can use this command
if (!(sender instanceof Player player)) {
sender.sendMessage(plugin.getMessageManager().get("messages.player-only"));
return true;
}
// Check basic permission
if (!player.hasPermission("communitymarket.use")) {
player.sendMessage(plugin.getMessageManager().getPrefixed("messages.no-permission"));
return true;
}
// Open the main market GUI
plugin.getGuiManager().openMainMenu(player);
return true;
}
@Override
public @Nullable List<String> onTabComplete(@NotNull CommandSender sender, @NotNull Command command,
@NotNull String label, @NotNull String[] args) {
// No tab completions - everything is GUI-based
return Collections.emptyList();
}
}
@@ -0,0 +1,289 @@
package pt.henrique.communityMarket.config;
import org.bukkit.Material;
import org.bukkit.configuration.file.FileConfiguration;
import pt.henrique.communityMarket.CommunityMarket;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* Manages the main plugin configuration
*/
public class ConfigManager {
private final CommunityMarket plugin;
private FileConfiguration config;
// Database settings
private String databaseType;
private String sqliteFile;
private String mysqlHost;
private int mysqlPort;
private String mysqlDatabase;
private String mysqlUsername;
private String mysqlPassword;
private int poolMaxSize;
private int poolMinIdle;
private long poolConnectionTimeout;
private long poolIdleTimeout;
private long poolMaxLifetime;
// Economy settings
private String currencyFormat;
private String currencySymbol;
private double marketTax;
private double auctionTax;
// Market settings
private int maxListingsPerPlayer;
private int listingCooldown;
private int defaultDurationHours;
private List<Integer> availableDurations;
private double minPrice;
private double maxPrice;
// Auction settings
private int maxAuctionsPerPlayer;
private int minDurationHours;
private int maxDurationHours;
private int defaultAuctionDurationHours;
private List<Integer> availableAuctionDurations;
private double minStartPrice;
private double maxStartPrice;
private double minBidIncrementPercent;
private double minBidIncrementAbsolute;
private boolean antiSnipeEnabled;
private int antiSnipeTriggerSeconds;
private int antiSnipeExtensionSeconds;
private int antiSnipeMaxExtensions;
// Blacklist
private Set<Material> blacklistedMaterials;
private List<String> blacklistedKeywords;
// GUI settings
private String mainMenuTitle;
private String browseMarketTitle;
private String browseAuctionsTitle;
private String createListingTitle;
private String createAuctionTitle;
private String myListingsTitle;
private String myAuctionsTitle;
private String claimTitle;
private String earningsTitle;
private String confirmTitle;
private String numberInputTitle;
private String adminTitle;
private int itemsPerPage;
private boolean helpButtonEnabled;
private String clickSound;
private String successSound;
private String errorSound;
private String purchaseSound;
// Notifications
private boolean notifyOnSale;
private boolean notifyOnOutbid;
private boolean notifyOnWin;
private boolean notifyOnExpire;
// Performance
private int cacheDuration;
private int auctionCheckInterval;
private int expiredCheckInterval;
public ConfigManager(CommunityMarket plugin) {
this.plugin = plugin;
reload();
}
public void reload() {
plugin.saveDefaultConfig();
plugin.reloadConfig();
config = plugin.getConfig();
loadSettings();
}
private void loadSettings() {
// Database settings
databaseType = config.getString("database.type", "sqlite");
sqliteFile = config.getString("database.sqlite.file", "database.db");
mysqlHost = config.getString("database.mysql.host", "localhost");
mysqlPort = config.getInt("database.mysql.port", 3306);
mysqlDatabase = config.getString("database.mysql.database", "communitymarket");
mysqlUsername = config.getString("database.mysql.username", "root");
mysqlPassword = config.getString("database.mysql.password", "");
poolMaxSize = config.getInt("database.mysql.pool.maximum-pool-size", 10);
poolMinIdle = config.getInt("database.mysql.pool.minimum-idle", 2);
poolConnectionTimeout = config.getLong("database.mysql.pool.connection-timeout", 30000);
poolIdleTimeout = config.getLong("database.mysql.pool.idle-timeout", 600000);
poolMaxLifetime = config.getLong("database.mysql.pool.max-lifetime", 1800000);
// Economy settings
currencyFormat = config.getString("economy.currency-format", "$#,##0.00");
currencySymbol = config.getString("economy.currency-symbol", "$");
marketTax = config.getDouble("economy.taxes.market-tax", 5.0);
auctionTax = config.getDouble("economy.taxes.auction-tax", 7.5);
// Market settings
maxListingsPerPlayer = config.getInt("market.max-listings-per-player", 20);
listingCooldown = config.getInt("market.listing-cooldown", 0);
defaultDurationHours = config.getInt("market.default-duration-hours", 168);
availableDurations = config.getIntegerList("market.available-durations");
minPrice = config.getDouble("market.min-price", 1.0);
maxPrice = config.getDouble("market.max-price", 1000000000.0);
// Auction settings
maxAuctionsPerPlayer = config.getInt("auction.max-auctions-per-player", 10);
minDurationHours = config.getInt("auction.min-duration-hours", 1);
maxDurationHours = config.getInt("auction.max-duration-hours", 168);
defaultAuctionDurationHours = config.getInt("auction.default-duration-hours", 24);
availableAuctionDurations = config.getIntegerList("auction.available-durations");
minStartPrice = config.getDouble("auction.min-start-price", 1.0);
maxStartPrice = config.getDouble("auction.max-start-price", 1000000000.0);
minBidIncrementPercent = config.getDouble("auction.min-bid-increment-percent", 5.0);
minBidIncrementAbsolute = config.getDouble("auction.min-bid-increment-absolute", 1.0);
antiSnipeEnabled = config.getBoolean("auction.anti-snipe.enabled", true);
antiSnipeTriggerSeconds = config.getInt("auction.anti-snipe.trigger-seconds", 30);
antiSnipeExtensionSeconds = config.getInt("auction.anti-snipe.extension-seconds", 30);
antiSnipeMaxExtensions = config.getInt("auction.anti-snipe.max-extensions", 10);
// Blacklist
blacklistedMaterials = new HashSet<>();
for (String materialName : config.getStringList("blacklist.materials")) {
try {
Material material = Material.valueOf(materialName.toUpperCase());
blacklistedMaterials.add(material);
} catch (IllegalArgumentException e) {
plugin.getLogger().warning("Invalid material in blacklist: " + materialName);
}
}
blacklistedKeywords = config.getStringList("blacklist.keywords");
// GUI settings
mainMenuTitle = config.getString("gui.main-menu-title", "&8&lCommunity Market");
browseMarketTitle = config.getString("gui.browse-market-title", "&8&lBrowse Market");
browseAuctionsTitle = config.getString("gui.browse-auctions-title", "&8&lBrowse Auctions");
createListingTitle = config.getString("gui.create-listing-title", "&8&lCreate Listing");
createAuctionTitle = config.getString("gui.create-auction-title", "&8&lCreate Auction");
myListingsTitle = config.getString("gui.my-listings-title", "&8&lMy Listings");
myAuctionsTitle = config.getString("gui.my-auctions-title", "&8&lMy Auctions");
claimTitle = config.getString("gui.claim-title", "&8&lClaim Items");
earningsTitle = config.getString("gui.earnings-title", "&8&lEarnings");
confirmTitle = config.getString("gui.confirm-title", "&8&lConfirm Action");
numberInputTitle = config.getString("gui.number-input-title", "&8&lEnter Amount");
adminTitle = config.getString("gui.admin-title", "&8&lAdmin Panel");
itemsPerPage = config.getInt("gui.items-per-page", 45);
helpButtonEnabled = config.getBoolean("gui.show-help-button", true);
clickSound = config.getString("gui.sounds.click", "UI_BUTTON_CLICK");
successSound = config.getString("gui.sounds.success", "ENTITY_PLAYER_LEVELUP");
errorSound = config.getString("gui.sounds.error", "ENTITY_VILLAGER_NO");
purchaseSound = config.getString("gui.sounds.purchase", "ENTITY_EXPERIENCE_ORB_PICKUP");
// Notifications
notifyOnSale = config.getBoolean("notifications.notify-on-sale", true);
notifyOnOutbid = config.getBoolean("notifications.notify-on-outbid", true);
notifyOnWin = config.getBoolean("notifications.notify-on-win", true);
notifyOnExpire = config.getBoolean("notifications.notify-on-expire", true);
// Performance
cacheDuration = config.getInt("performance.cache-duration", 30);
auctionCheckInterval = config.getInt("performance.auction-check-interval", 5);
expiredCheckInterval = config.getInt("performance.expired-check-interval", 5);
}
// Getters
public String getDatabaseType() { return databaseType; }
public String getSqliteFile() { return sqliteFile; }
public String getMysqlHost() { return mysqlHost; }
public int getMysqlPort() { return mysqlPort; }
public String getMysqlDatabase() { return mysqlDatabase; }
public String getMysqlUsername() { return mysqlUsername; }
public String getMysqlPassword() { return mysqlPassword; }
public int getPoolMaxSize() { return poolMaxSize; }
public int getPoolMinIdle() { return poolMinIdle; }
public long getPoolConnectionTimeout() { return poolConnectionTimeout; }
public long getPoolIdleTimeout() { return poolIdleTimeout; }
public long getPoolMaxLifetime() { return poolMaxLifetime; }
public String getCurrencyFormat() { return currencyFormat; }
public String getCurrencySymbol() { return currencySymbol; }
public double getMarketTax() { return marketTax; }
public double getAuctionTax() { return auctionTax; }
public int getMaxListingsPerPlayer() { return maxListingsPerPlayer; }
public int getListingCooldown() { return listingCooldown; }
public int getDefaultDurationHours() { return defaultDurationHours; }
public List<Integer> getAvailableDurations() { return availableDurations; }
public double getMinPrice() { return minPrice; }
public double getMaxPrice() { return maxPrice; }
public int getMaxAuctionsPerPlayer() { return maxAuctionsPerPlayer; }
public int getMinDurationHours() { return minDurationHours; }
public int getMaxDurationHours() { return maxDurationHours; }
public int getDefaultAuctionDurationHours() { return defaultAuctionDurationHours; }
public List<Integer> getAvailableAuctionDurations() { return availableAuctionDurations; }
public double getMinStartPrice() { return minStartPrice; }
public double getMaxStartPrice() { return maxStartPrice; }
public double getMinBidIncrementPercent() { return minBidIncrementPercent; }
public double getMinBidIncrementAbsolute() { return minBidIncrementAbsolute; }
public boolean isAntiSnipeEnabled() { return antiSnipeEnabled; }
public int getAntiSnipeTriggerSeconds() { return antiSnipeTriggerSeconds; }
public int getAntiSnipeExtensionSeconds() { return antiSnipeExtensionSeconds; }
public int getAntiSnipeMaxExtensions() { return antiSnipeMaxExtensions; }
public Set<Material> getBlacklistedMaterials() { return blacklistedMaterials; }
public List<String> getBlacklistedKeywords() { return blacklistedKeywords; }
public String getMainMenuTitle() { return mainMenuTitle; }
public String getBrowseMarketTitle() { return browseMarketTitle; }
public String getBrowseAuctionsTitle() { return browseAuctionsTitle; }
public String getCreateListingTitle() { return createListingTitle; }
public String getCreateAuctionTitle() { return createAuctionTitle; }
public String getMyListingsTitle() { return myListingsTitle; }
public String getMyAuctionsTitle() { return myAuctionsTitle; }
public String getClaimTitle() { return claimTitle; }
public String getEarningsTitle() { return earningsTitle; }
public String getConfirmTitle() { return confirmTitle; }
public String getNumberInputTitle() { return numberInputTitle; }
public String getAdminTitle() { return adminTitle; }
public int getItemsPerPage() { return itemsPerPage; }
public boolean isHelpButtonEnabled() { return helpButtonEnabled; }
public String getClickSound() { return clickSound; }
public String getSuccessSound() { return successSound; }
public String getErrorSound() { return errorSound; }
public String getPurchaseSound() { return purchaseSound; }
public boolean isNotifyOnSale() { return notifyOnSale; }
public boolean isNotifyOnOutbid() { return notifyOnOutbid; }
public boolean isNotifyOnWin() { return notifyOnWin; }
public boolean isNotifyOnExpire() { return notifyOnExpire; }
public int getCacheDuration() { return cacheDuration; }
public int getAuctionCheckInterval() { return auctionCheckInterval; }
public int getExpiredCheckInterval() { return expiredCheckInterval; }
/**
* Checks if a material is blacklisted
*/
public boolean isMaterialBlacklisted(Material material) {
return blacklistedMaterials.contains(material);
}
/**
* Checks if text contains blacklisted keywords
*/
public boolean containsBlacklistedKeyword(String text) {
if (text == null) return false;
String lowerText = text.toLowerCase();
for (String keyword : blacklistedKeywords) {
if (lowerText.contains(keyword.toLowerCase())) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,230 @@
package pt.henrique.communityMarket.config;
import net.kyori.adventure.text.Component;
import org.bukkit.configuration.file.FileConfiguration;
import org.bukkit.configuration.file.YamlConfiguration;
import pt.henrique.communityMarket.CommunityMarket;
import pt.henrique.communityMarket.util.TextUtil;
import java.io.File;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.text.DecimalFormat;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Manages plugin messages and localization.
* Supports multiple languages loaded from lang/ folder.
*/
public class MessageManager {
private final CommunityMarket plugin;
private FileConfiguration messagesConfig;
private final Map<String, String> messageCache;
private DecimalFormat currencyFormatter;
private String currentLanguage;
public MessageManager(CommunityMarket plugin) {
this.plugin = plugin;
this.messageCache = new HashMap<>();
reload();
}
public void reload() {
messageCache.clear();
// Get language from config
currentLanguage = plugin.getConfig().getString("language", "en_US");
// Save default language files
saveDefaultLanguageFiles();
// Load the selected language file
File langFolder = new File(plugin.getDataFolder(), "lang");
File langFile = new File(langFolder, currentLanguage + ".yml");
if (!langFile.exists()) {
plugin.getLogger().warning("Language file not found: " + currentLanguage + ".yml, falling back to en_US");
langFile = new File(langFolder, "en_US.yml");
}
messagesConfig = YamlConfiguration.loadConfiguration(langFile);
// Load defaults from jar as fallback
InputStream defaultStream = plugin.getResource("lang/en_US.yml");
if (defaultStream != null) {
YamlConfiguration defaultConfig = YamlConfiguration.loadConfiguration(
new InputStreamReader(defaultStream, StandardCharsets.UTF_8));
messagesConfig.setDefaults(defaultConfig);
}
// Setup currency formatter
String format = plugin.getConfigManager().getCurrencyFormat();
try {
currencyFormatter = new DecimalFormat(format.replace("$", ""));
} catch (Exception e) {
currencyFormatter = new DecimalFormat("#,##0.00");
}
plugin.getLogger().info("Loaded language: " + currentLanguage);
}
private void saveDefaultLanguageFiles() {
File langFolder = new File(plugin.getDataFolder(), "lang");
if (!langFolder.exists()) {
langFolder.mkdirs();
}
// Save default language files if they don't exist
String[] languages = {"en_US.yml", "pt_PT.yml"};
for (String lang : languages) {
File langFile = new File(langFolder, lang);
if (!langFile.exists()) {
plugin.saveResource("lang/" + lang, false);
}
}
}
/**
* Gets the current language code
*/
public String getCurrentLanguage() {
return currentLanguage;
}
/**
* Gets a raw message string from the config
*/
public String getRaw(String path) {
if (messageCache.containsKey(path)) {
return messageCache.get(path);
}
String message = messagesConfig.getString(path, "&cMissing message: " + path);
messageCache.put(path, message);
return message;
}
/**
* Gets a message as a Component
*/
public Component get(String path) {
return TextUtil.colorize(getRaw(path));
}
/**
* Gets a message with placeholders replaced
*/
public Component get(String path, Map<String, String> placeholders) {
String message = getRaw(path);
for (Map.Entry<String, String> entry : placeholders.entrySet()) {
message = message.replace("{" + entry.getKey() + "}", entry.getValue());
}
return TextUtil.colorize(message);
}
/**
* Gets a message with a single placeholder replaced
*/
public Component get(String path, String placeholder, String value) {
String message = getRaw(path).replace("{" + placeholder + "}", value);
return TextUtil.colorize(message);
}
/**
* Gets a prefixed message
*/
public Component getPrefixed(String path) {
return TextUtil.colorize(getRaw("prefix") + getRaw(path));
}
/**
* Gets a prefixed message with placeholders
*/
public Component getPrefixed(String path, Map<String, String> placeholders) {
String message = getRaw(path);
for (Map.Entry<String, String> entry : placeholders.entrySet()) {
message = message.replace("{" + entry.getKey() + "}", entry.getValue());
}
return TextUtil.colorize(getRaw("prefix") + message);
}
/**
* Gets a prefixed message with a single placeholder
*/
public Component getPrefixed(String path, String placeholder, String value) {
String message = getRaw(path).replace("{" + placeholder + "}", value);
return TextUtil.colorize(getRaw("prefix") + message);
}
/**
* Gets a list of messages from the config
*/
public List<String> getList(String path) {
return messagesConfig.getStringList(path);
}
/**
* Gets a list of messages as Components
*/
public List<Component> getComponentList(String path) {
return getList(path).stream()
.map(TextUtil::colorize)
.toList();
}
/**
* Formats a currency amount
*/
public String formatCurrency(double amount) {
String symbol = plugin.getConfigManager().getCurrencySymbol();
return symbol + currencyFormatter.format(amount);
}
/**
* Gets a button name from config
*/
public String getButton(String buttonKey) {
return getRaw("buttons." + buttonKey);
}
/**
* Gets button lore list from config
*/
public List<String> getLore(String loreKey) {
return getList("lore." + loreKey);
}
/**
* Gets lore with placeholders replaced
*/
public List<String> getLore(String loreKey, Map<String, String> placeholders) {
List<String> lore = getList("lore." + loreKey);
return lore.stream()
.map(line -> {
String result = line;
for (Map.Entry<String, String> entry : placeholders.entrySet()) {
result = result.replace("{" + entry.getKey() + "}", entry.getValue());
}
return result;
})
.toList();
}
/**
* Gets filter display name
*/
public String getFilter(String filterKey) {
return getRaw("filters." + filterKey);
}
/**
* Gets sort display name
*/
public String getSort(String sortKey) {
return getRaw("sort." + sortKey);
}
}
@@ -0,0 +1,987 @@
package pt.henrique.communityMarket.db;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import org.bukkit.inventory.ItemStack;
import pt.henrique.communityMarket.CommunityMarket;
import pt.henrique.communityMarket.model.*;
import pt.henrique.communityMarket.util.ItemSerializer;
import java.io.File;
import java.io.IOException;
import java.sql.*;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.logging.Level;
/**
* Manages database connections and operations.
* Supports SQLite (default) and MySQL.
* All operations are asynchronous to prevent blocking the main thread.
*/
public class DatabaseManager {
private final CommunityMarket plugin;
private HikariDataSource dataSource;
private boolean isMySQL;
// Schema version for migrations
private static final int SCHEMA_VERSION = 1;
public DatabaseManager(CommunityMarket plugin) {
this.plugin = plugin;
}
/**
* Initializes the database connection pool and creates tables.
*
* @return true if successful
*/
public boolean initialize() {
try {
var config = plugin.getConfigManager();
isMySQL = "mysql".equalsIgnoreCase(config.getDatabaseType());
HikariConfig hikariConfig = new HikariConfig();
if (isMySQL) {
// MySQL configuration
hikariConfig.setJdbcUrl("jdbc:mysql://" + config.getMysqlHost() + ":" +
config.getMysqlPort() + "/" + config.getMysqlDatabase() +
"?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=utf8");
hikariConfig.setUsername(config.getMysqlUsername());
hikariConfig.setPassword(config.getMysqlPassword());
hikariConfig.setDriverClassName("com.mysql.cj.jdbc.Driver");
} else {
// SQLite configuration
File dataFolder = plugin.getDataFolder();
if (!dataFolder.exists()) {
dataFolder.mkdirs();
}
File dbFile = new File(dataFolder, config.getSqliteFile());
hikariConfig.setJdbcUrl("jdbc:sqlite:" + dbFile.getAbsolutePath());
hikariConfig.setDriverClassName("org.sqlite.JDBC");
}
// Connection pool settings
hikariConfig.setMaximumPoolSize(config.getPoolMaxSize());
hikariConfig.setMinimumIdle(config.getPoolMinIdle());
hikariConfig.setConnectionTimeout(config.getPoolConnectionTimeout());
hikariConfig.setIdleTimeout(config.getPoolIdleTimeout());
hikariConfig.setMaxLifetime(config.getPoolMaxLifetime());
hikariConfig.setPoolName("CommunityMarket-Pool");
dataSource = new HikariDataSource(hikariConfig);
// Create tables
createTables();
plugin.getLogger().info("Database connection established (" +
(isMySQL ? "MySQL" : "SQLite") + ")");
return true;
} catch (Exception e) {
plugin.getLogger().log(Level.SEVERE, "Failed to initialize database", e);
return false;
}
}
/**
* Shuts down the database connection pool.
*/
public void shutdown() {
if (dataSource != null && !dataSource.isClosed()) {
dataSource.close();
plugin.getLogger().info("Database connection closed.");
}
}
/**
* Gets a connection from the pool.
*/
private Connection getConnection() throws SQLException {
return dataSource.getConnection();
}
/**
* Creates all database tables.
*/
private void createTables() throws SQLException {
try (Connection conn = getConnection(); Statement stmt = conn.createStatement()) {
// Listings table
stmt.execute("""
CREATE TABLE IF NOT EXISTS listings (
id INTEGER PRIMARY KEY %s,
seller_uuid VARCHAR(36) NOT NULL,
seller_name VARCHAR(16) NOT NULL,
item_data TEXT NOT NULL,
amount INTEGER NOT NULL,
price DOUBLE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
created_at BIGINT NOT NULL,
expires_at BIGINT,
buyer_uuid VARCHAR(36),
buyer_name VARCHAR(16),
sold_at BIGINT
)
""".formatted(isMySQL ? "AUTO_INCREMENT" : "AUTOINCREMENT"));
// Auctions table
stmt.execute("""
CREATE TABLE IF NOT EXISTS auctions (
id INTEGER PRIMARY KEY %s,
seller_uuid VARCHAR(36) NOT NULL,
seller_name VARCHAR(16) NOT NULL,
item_data TEXT NOT NULL,
start_price DOUBLE NOT NULL,
current_bid DOUBLE NOT NULL DEFAULT 0,
highest_bidder_uuid VARCHAR(36),
highest_bidder_name VARCHAR(16),
bid_count INTEGER NOT NULL DEFAULT 0,
buyout_price DOUBLE,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
created_at BIGINT NOT NULL,
ends_at BIGINT NOT NULL,
extension_count INTEGER NOT NULL DEFAULT 0
)
""".formatted(isMySQL ? "AUTO_INCREMENT" : "AUTOINCREMENT"));
// Bids table (bid history)
stmt.execute("""
CREATE TABLE IF NOT EXISTS bids (
id INTEGER PRIMARY KEY %s,
auction_id INTEGER NOT NULL,
bidder_uuid VARCHAR(36) NOT NULL,
bidder_name VARCHAR(16) NOT NULL,
amount DOUBLE NOT NULL,
created_at BIGINT NOT NULL,
FOREIGN KEY (auction_id) REFERENCES auctions(id)
)
""".formatted(isMySQL ? "AUTO_INCREMENT" : "AUTOINCREMENT"));
// Claim storage table
stmt.execute("""
CREATE TABLE IF NOT EXISTS claim_storage (
id INTEGER PRIMARY KEY %s,
player_uuid VARCHAR(36) NOT NULL,
item_data TEXT NOT NULL,
reason VARCHAR(50) NOT NULL,
source_info VARCHAR(100),
created_at BIGINT NOT NULL
)
""".formatted(isMySQL ? "AUTO_INCREMENT" : "AUTOINCREMENT"));
// Pending earnings table
stmt.execute("""
CREATE TABLE IF NOT EXISTS pending_earnings (
id INTEGER PRIMARY KEY %s,
player_uuid VARCHAR(36) NOT NULL,
amount DOUBLE NOT NULL,
source VARCHAR(100),
created_at BIGINT NOT NULL,
withdrawn BOOLEAN NOT NULL DEFAULT FALSE
)
""".formatted(isMySQL ? "AUTO_INCREMENT" : "AUTOINCREMENT"));
// Player data table (for cooldowns, etc.)
stmt.execute("""
CREATE TABLE IF NOT EXISTS player_data (
player_uuid VARCHAR(36) PRIMARY KEY,
last_listing_time BIGINT,
preferred_language VARCHAR(10)
)
""");
// Create indexes for performance
try {
stmt.execute("CREATE INDEX IF NOT EXISTS idx_listings_seller ON listings(seller_uuid)");
stmt.execute("CREATE INDEX IF NOT EXISTS idx_listings_status ON listings(status)");
stmt.execute("CREATE INDEX IF NOT EXISTS idx_auctions_seller ON auctions(seller_uuid)");
stmt.execute("CREATE INDEX IF NOT EXISTS idx_auctions_status ON auctions(status)");
stmt.execute("CREATE INDEX IF NOT EXISTS idx_claim_player ON claim_storage(player_uuid)");
stmt.execute("CREATE INDEX IF NOT EXISTS idx_earnings_player ON pending_earnings(player_uuid)");
} catch (SQLException e) {
// Indexes might already exist, ignore
}
}
}
// ==================== LISTING OPERATIONS ====================
/**
* Creates a new listing and returns its ID.
*/
public CompletableFuture<Integer> createListing(Listing listing) {
return CompletableFuture.supplyAsync(() -> {
String sql = """
INSERT INTO listings (seller_uuid, seller_name, item_data, amount, price, status, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""";
try (Connection conn = getConnection();
PreparedStatement stmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
stmt.setString(1, listing.getSellerUuid().toString());
stmt.setString(2, listing.getSellerName());
stmt.setString(3, ItemSerializer.serialize(listing.getItem()));
stmt.setInt(4, listing.getAmount());
stmt.setDouble(5, listing.getPrice());
stmt.setString(6, listing.getStatus().name());
stmt.setLong(7, listing.getCreatedAt().toEpochMilli());
stmt.setLong(8, listing.getExpiresAt() != null ? listing.getExpiresAt().toEpochMilli() : 0);
stmt.executeUpdate();
try (ResultSet rs = stmt.getGeneratedKeys()) {
if (rs.next()) {
return rs.getInt(1);
}
}
} catch (Exception e) {
plugin.getLogger().log(Level.WARNING, "Failed to create listing", e);
}
return -1;
});
}
/**
* Gets all active listings.
*/
public CompletableFuture<List<Listing>> getActiveListings() {
return CompletableFuture.supplyAsync(() -> {
List<Listing> listings = new ArrayList<>();
String sql = "SELECT * FROM listings WHERE status = 'ACTIVE' ORDER BY created_at DESC";
try (Connection conn = getConnection();
PreparedStatement stmt = conn.prepareStatement(sql);
ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
Listing listing = mapListing(rs);
if (listing != null) {
listings.add(listing);
}
}
} catch (Exception e) {
plugin.getLogger().log(Level.WARNING, "Failed to get active listings", e);
}
return listings;
});
}
/**
* Gets a listing by ID.
*/
public CompletableFuture<Optional<Listing>> getListing(int id) {
return CompletableFuture.supplyAsync(() -> {
String sql = "SELECT * FROM listings WHERE id = ?";
try (Connection conn = getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setInt(1, id);
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
return Optional.ofNullable(mapListing(rs));
}
}
} catch (Exception e) {
plugin.getLogger().log(Level.WARNING, "Failed to get listing", e);
}
return Optional.empty();
});
}
/**
* Gets all listings for a player.
*/
public CompletableFuture<List<Listing>> getPlayerListings(UUID playerUuid) {
return CompletableFuture.supplyAsync(() -> {
List<Listing> listings = new ArrayList<>();
String sql = "SELECT * FROM listings WHERE seller_uuid = ? AND status = 'ACTIVE' ORDER BY created_at DESC";
try (Connection conn = getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setString(1, playerUuid.toString());
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
Listing listing = mapListing(rs);
if (listing != null) {
listings.add(listing);
}
}
}
} catch (Exception e) {
plugin.getLogger().log(Level.WARNING, "Failed to get player listings", e);
}
return listings;
});
}
/**
* Counts active listings for a player.
*/
public CompletableFuture<Integer> countPlayerListings(UUID playerUuid) {
return CompletableFuture.supplyAsync(() -> {
String sql = "SELECT COUNT(*) FROM listings WHERE seller_uuid = ? AND status = 'ACTIVE'";
try (Connection conn = getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setString(1, playerUuid.toString());
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
return rs.getInt(1);
}
}
} catch (Exception e) {
plugin.getLogger().log(Level.WARNING, "Failed to count player listings", e);
}
return 0;
});
}
/**
* Atomically purchases a listing.
* Returns true if successful (listing was still available).
*/
public CompletableFuture<Boolean> purchaseListing(int listingId, UUID buyerUuid, String buyerName) {
return CompletableFuture.supplyAsync(() -> {
String sql = "UPDATE listings SET status = 'SOLD', buyer_uuid = ?, buyer_name = ?, sold_at = ? " +
"WHERE id = ? AND status = 'ACTIVE'";
try (Connection conn = getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setString(1, buyerUuid.toString());
stmt.setString(2, buyerName);
stmt.setLong(3, Instant.now().toEpochMilli());
stmt.setInt(4, listingId);
int updated = stmt.executeUpdate();
return updated > 0;
} catch (Exception e) {
plugin.getLogger().log(Level.WARNING, "Failed to purchase listing", e);
}
return false;
});
}
/**
* Updates a listing's status.
*/
public CompletableFuture<Boolean> updateListingStatus(int listingId, Listing.ListingStatus status) {
return CompletableFuture.supplyAsync(() -> {
String sql = "UPDATE listings SET status = ? WHERE id = ?";
try (Connection conn = getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setString(1, status.name());
stmt.setInt(2, listingId);
return stmt.executeUpdate() > 0;
} catch (Exception e) {
plugin.getLogger().log(Level.WARNING, "Failed to update listing status", e);
}
return false;
});
}
/**
* Gets expired active listings.
*/
public CompletableFuture<List<Listing>> getExpiredListings() {
return CompletableFuture.supplyAsync(() -> {
List<Listing> listings = new ArrayList<>();
String sql = "SELECT * FROM listings WHERE status = 'ACTIVE' AND expires_at > 0 AND expires_at < ?";
try (Connection conn = getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setLong(1, Instant.now().toEpochMilli());
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
Listing listing = mapListing(rs);
if (listing != null) {
listings.add(listing);
}
}
}
} catch (Exception e) {
plugin.getLogger().log(Level.WARNING, "Failed to get expired listings", e);
}
return listings;
});
}
private Listing mapListing(ResultSet rs) throws SQLException {
try {
Listing listing = new Listing();
listing.setId(rs.getInt("id"));
listing.setSellerUuid(UUID.fromString(rs.getString("seller_uuid")));
listing.setSellerName(rs.getString("seller_name"));
listing.setItem(ItemSerializer.deserialize(rs.getString("item_data")));
listing.setAmount(rs.getInt("amount"));
listing.setPrice(rs.getDouble("price"));
listing.setStatus(Listing.ListingStatus.valueOf(rs.getString("status")));
listing.setCreatedAt(Instant.ofEpochMilli(rs.getLong("created_at")));
long expiresAt = rs.getLong("expires_at");
if (expiresAt > 0) {
listing.setExpiresAt(Instant.ofEpochMilli(expiresAt));
}
return listing;
} catch (Exception e) {
plugin.getLogger().log(Level.WARNING, "Failed to map listing", e);
return null;
}
}
// ==================== AUCTION OPERATIONS ====================
/**
* Creates a new auction and returns its ID.
*/
public CompletableFuture<Integer> createAuction(Auction auction) {
return CompletableFuture.supplyAsync(() -> {
String sql = """
INSERT INTO auctions (seller_uuid, seller_name, item_data, start_price, current_bid,
buyout_price, status, created_at, ends_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""";
try (Connection conn = getConnection();
PreparedStatement stmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
stmt.setString(1, auction.getSellerUuid().toString());
stmt.setString(2, auction.getSellerName());
stmt.setString(3, ItemSerializer.serialize(auction.getItem()));
stmt.setDouble(4, auction.getStartPrice());
stmt.setDouble(5, 0);
if (auction.getBuyoutPrice() != null) {
stmt.setDouble(6, auction.getBuyoutPrice());
} else {
stmt.setNull(6, Types.DOUBLE);
}
stmt.setString(7, auction.getStatus().name());
stmt.setLong(8, auction.getCreatedAt().toEpochMilli());
stmt.setLong(9, auction.getEndsAt().toEpochMilli());
stmt.executeUpdate();
try (ResultSet rs = stmt.getGeneratedKeys()) {
if (rs.next()) {
return rs.getInt(1);
}
}
} catch (Exception e) {
plugin.getLogger().log(Level.WARNING, "Failed to create auction", e);
}
return -1;
});
}
/**
* Gets all active auctions.
*/
public CompletableFuture<List<Auction>> getActiveAuctions() {
return CompletableFuture.supplyAsync(() -> {
List<Auction> auctions = new ArrayList<>();
String sql = "SELECT * FROM auctions WHERE status = 'ACTIVE' ORDER BY ends_at ASC";
try (Connection conn = getConnection();
PreparedStatement stmt = conn.prepareStatement(sql);
ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
Auction auction = mapAuction(rs);
if (auction != null) {
auctions.add(auction);
}
}
} catch (Exception e) {
plugin.getLogger().log(Level.WARNING, "Failed to get active auctions", e);
}
return auctions;
});
}
/**
* Gets an auction by ID.
*/
public CompletableFuture<Optional<Auction>> getAuction(int id) {
return CompletableFuture.supplyAsync(() -> {
String sql = "SELECT * FROM auctions WHERE id = ?";
try (Connection conn = getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setInt(1, id);
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
return Optional.ofNullable(mapAuction(rs));
}
}
} catch (Exception e) {
plugin.getLogger().log(Level.WARNING, "Failed to get auction", e);
}
return Optional.empty();
});
}
/**
* Gets all auctions for a player.
*/
public CompletableFuture<List<Auction>> getPlayerAuctions(UUID playerUuid) {
return CompletableFuture.supplyAsync(() -> {
List<Auction> auctions = new ArrayList<>();
String sql = "SELECT * FROM auctions WHERE seller_uuid = ? AND status = 'ACTIVE' ORDER BY ends_at ASC";
try (Connection conn = getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setString(1, playerUuid.toString());
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
Auction auction = mapAuction(rs);
if (auction != null) {
auctions.add(auction);
}
}
}
} catch (Exception e) {
plugin.getLogger().log(Level.WARNING, "Failed to get player auctions", e);
}
return auctions;
});
}
/**
* Counts active auctions for a player.
*/
public CompletableFuture<Integer> countPlayerAuctions(UUID playerUuid) {
return CompletableFuture.supplyAsync(() -> {
String sql = "SELECT COUNT(*) FROM auctions WHERE seller_uuid = ? AND status = 'ACTIVE'";
try (Connection conn = getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setString(1, playerUuid.toString());
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
return rs.getInt(1);
}
}
} catch (Exception e) {
plugin.getLogger().log(Level.WARNING, "Failed to count player auctions", e);
}
return 0;
});
}
/**
* Places a bid on an auction.
*/
public CompletableFuture<Boolean> placeBid(int auctionId, UUID bidderUuid, String bidderName, double amount) {
return CompletableFuture.supplyAsync(() -> {
String updateSql = """
UPDATE auctions SET current_bid = ?, highest_bidder_uuid = ?, highest_bidder_name = ?,
bid_count = bid_count + 1
WHERE id = ? AND status = 'ACTIVE' AND current_bid < ?
""";
String insertBidSql = """
INSERT INTO bids (auction_id, bidder_uuid, bidder_name, amount, created_at)
VALUES (?, ?, ?, ?, ?)
""";
try (Connection conn = getConnection()) {
conn.setAutoCommit(false);
try (PreparedStatement updateStmt = conn.prepareStatement(updateSql);
PreparedStatement insertStmt = conn.prepareStatement(insertBidSql)) {
updateStmt.setDouble(1, amount);
updateStmt.setString(2, bidderUuid.toString());
updateStmt.setString(3, bidderName);
updateStmt.setInt(4, auctionId);
updateStmt.setDouble(5, amount);
int updated = updateStmt.executeUpdate();
if (updated > 0) {
// Insert bid history
insertStmt.setInt(1, auctionId);
insertStmt.setString(2, bidderUuid.toString());
insertStmt.setString(3, bidderName);
insertStmt.setDouble(4, amount);
insertStmt.setLong(5, Instant.now().toEpochMilli());
insertStmt.executeUpdate();
conn.commit();
return true;
} else {
conn.rollback();
return false;
}
} catch (Exception e) {
conn.rollback();
throw e;
}
} catch (Exception e) {
plugin.getLogger().log(Level.WARNING, "Failed to place bid", e);
}
return false;
});
}
/**
* Updates an auction's status.
*/
public CompletableFuture<Boolean> updateAuctionStatus(int auctionId, Auction.AuctionStatus status) {
return CompletableFuture.supplyAsync(() -> {
String sql = "UPDATE auctions SET status = ? WHERE id = ?";
try (Connection conn = getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setString(1, status.name());
stmt.setInt(2, auctionId);
return stmt.executeUpdate() > 0;
} catch (Exception e) {
plugin.getLogger().log(Level.WARNING, "Failed to update auction status", e);
}
return false;
});
}
/**
* Gets auctions that have ended but are still active.
*/
public CompletableFuture<List<Auction>> getEndedAuctions() {
return CompletableFuture.supplyAsync(() -> {
List<Auction> auctions = new ArrayList<>();
String sql = "SELECT * FROM auctions WHERE status = 'ACTIVE' AND ends_at < ?";
try (Connection conn = getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setLong(1, Instant.now().toEpochMilli());
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
Auction auction = mapAuction(rs);
if (auction != null) {
auctions.add(auction);
}
}
}
} catch (Exception e) {
plugin.getLogger().log(Level.WARNING, "Failed to get ended auctions", e);
}
return auctions;
});
}
private Auction mapAuction(ResultSet rs) throws SQLException {
try {
Auction auction = new Auction();
auction.setId(rs.getInt("id"));
auction.setSellerUuid(UUID.fromString(rs.getString("seller_uuid")));
auction.setSellerName(rs.getString("seller_name"));
auction.setItem(ItemSerializer.deserialize(rs.getString("item_data")));
auction.setStartPrice(rs.getDouble("start_price"));
auction.setCurrentBid(rs.getDouble("current_bid"));
String highestBidder = rs.getString("highest_bidder_uuid");
if (highestBidder != null) {
auction.setHighestBidderUuid(UUID.fromString(highestBidder));
auction.setHighestBidderName(rs.getString("highest_bidder_name"));
}
auction.setBidCount(rs.getInt("bid_count"));
double buyout = rs.getDouble("buyout_price");
if (!rs.wasNull()) {
auction.setBuyoutPrice(buyout);
}
auction.setStatus(Auction.AuctionStatus.valueOf(rs.getString("status")));
auction.setCreatedAt(Instant.ofEpochMilli(rs.getLong("created_at")));
auction.setEndsAt(Instant.ofEpochMilli(rs.getLong("ends_at")));
auction.setExtensionCount(rs.getInt("extension_count"));
return auction;
} catch (Exception e) {
plugin.getLogger().log(Level.WARNING, "Failed to map auction", e);
return null;
}
}
// ==================== CLAIM STORAGE OPERATIONS ====================
/**
* Adds an item to claim storage.
*/
public CompletableFuture<Integer> addClaimItem(ClaimItem claimItem) {
return CompletableFuture.supplyAsync(() -> {
String sql = """
INSERT INTO claim_storage (player_uuid, item_data, reason, source_info, created_at)
VALUES (?, ?, ?, ?, ?)
""";
try (Connection conn = getConnection();
PreparedStatement stmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
stmt.setString(1, claimItem.getPlayerUuid().toString());
stmt.setString(2, ItemSerializer.serialize(claimItem.getItem()));
stmt.setString(3, claimItem.getReason().name());
stmt.setString(4, claimItem.getSourceInfo());
stmt.setLong(5, claimItem.getCreatedAt().toEpochMilli());
stmt.executeUpdate();
try (ResultSet rs = stmt.getGeneratedKeys()) {
if (rs.next()) {
return rs.getInt(1);
}
}
} catch (Exception e) {
plugin.getLogger().log(Level.WARNING, "Failed to add claim item", e);
}
return -1;
});
}
/**
* Gets all claim items for a player.
*/
public CompletableFuture<List<ClaimItem>> getPlayerClaimItems(UUID playerUuid) {
return CompletableFuture.supplyAsync(() -> {
List<ClaimItem> items = new ArrayList<>();
String sql = "SELECT * FROM claim_storage WHERE player_uuid = ? ORDER BY created_at DESC";
try (Connection conn = getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setString(1, playerUuid.toString());
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
ClaimItem item = mapClaimItem(rs);
if (item != null) {
items.add(item);
}
}
}
} catch (Exception e) {
plugin.getLogger().log(Level.WARNING, "Failed to get claim items", e);
}
return items;
});
}
/**
* Counts claim items for a player.
*/
public CompletableFuture<Integer> countPlayerClaimItems(UUID playerUuid) {
return CompletableFuture.supplyAsync(() -> {
String sql = "SELECT COUNT(*) FROM claim_storage WHERE player_uuid = ?";
try (Connection conn = getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setString(1, playerUuid.toString());
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
return rs.getInt(1);
}
}
} catch (Exception e) {
plugin.getLogger().log(Level.WARNING, "Failed to count claim items", e);
}
return 0;
});
}
/**
* Removes a claim item.
*/
public CompletableFuture<Boolean> removeClaimItem(int id) {
return CompletableFuture.supplyAsync(() -> {
String sql = "DELETE FROM claim_storage WHERE id = ?";
try (Connection conn = getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setInt(1, id);
return stmt.executeUpdate() > 0;
} catch (Exception e) {
plugin.getLogger().log(Level.WARNING, "Failed to remove claim item", e);
}
return false;
});
}
private ClaimItem mapClaimItem(ResultSet rs) throws SQLException {
try {
ClaimItem item = new ClaimItem();
item.setId(rs.getInt("id"));
item.setPlayerUuid(UUID.fromString(rs.getString("player_uuid")));
item.setItem(ItemSerializer.deserialize(rs.getString("item_data")));
item.setReason(ClaimItem.ClaimReason.valueOf(rs.getString("reason")));
item.setSourceInfo(rs.getString("source_info"));
item.setCreatedAt(Instant.ofEpochMilli(rs.getLong("created_at")));
return item;
} catch (Exception e) {
plugin.getLogger().log(Level.WARNING, "Failed to map claim item", e);
return null;
}
}
// ==================== EARNINGS OPERATIONS ====================
/**
* Adds pending earnings.
*/
public CompletableFuture<Integer> addPendingEarnings(PendingEarnings earnings) {
return CompletableFuture.supplyAsync(() -> {
String sql = """
INSERT INTO pending_earnings (player_uuid, amount, source, created_at, withdrawn)
VALUES (?, ?, ?, ?, ?)
""";
try (Connection conn = getConnection();
PreparedStatement stmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
stmt.setString(1, earnings.getPlayerUuid().toString());
stmt.setDouble(2, earnings.getAmount());
stmt.setString(3, earnings.getSource());
stmt.setLong(4, earnings.getCreatedAt().toEpochMilli());
stmt.setBoolean(5, false);
stmt.executeUpdate();
try (ResultSet rs = stmt.getGeneratedKeys()) {
if (rs.next()) {
return rs.getInt(1);
}
}
} catch (Exception e) {
plugin.getLogger().log(Level.WARNING, "Failed to add pending earnings", e);
}
return -1;
});
}
/**
* Gets total pending earnings for a player.
*/
public CompletableFuture<Double> getPlayerPendingEarnings(UUID playerUuid) {
return CompletableFuture.supplyAsync(() -> {
String sql = "SELECT SUM(amount) FROM pending_earnings WHERE player_uuid = ? AND withdrawn = FALSE";
try (Connection conn = getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setString(1, playerUuid.toString());
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
return rs.getDouble(1);
}
}
} catch (Exception e) {
plugin.getLogger().log(Level.WARNING, "Failed to get pending earnings", e);
}
return 0.0;
});
}
/**
* Marks all earnings as withdrawn for a player.
*/
public CompletableFuture<Boolean> withdrawAllEarnings(UUID playerUuid) {
return CompletableFuture.supplyAsync(() -> {
String sql = "UPDATE pending_earnings SET withdrawn = TRUE WHERE player_uuid = ? AND withdrawn = FALSE";
try (Connection conn = getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setString(1, playerUuid.toString());
return stmt.executeUpdate() > 0;
} catch (Exception e) {
plugin.getLogger().log(Level.WARNING, "Failed to withdraw earnings", e);
}
return false;
});
}
// ==================== PLAYER DATA OPERATIONS ====================
/**
* Gets the last listing time for a player.
*/
public CompletableFuture<Optional<Instant>> getLastListingTime(UUID playerUuid) {
return CompletableFuture.supplyAsync(() -> {
String sql = "SELECT last_listing_time FROM player_data WHERE player_uuid = ?";
try (Connection conn = getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setString(1, playerUuid.toString());
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
long time = rs.getLong("last_listing_time");
if (!rs.wasNull() && time > 0) {
return Optional.of(Instant.ofEpochMilli(time));
}
}
}
} catch (Exception e) {
plugin.getLogger().log(Level.WARNING, "Failed to get last listing time", e);
}
return Optional.empty();
});
}
/**
* Updates the last listing time for a player.
*/
public void updateLastListingTime(UUID playerUuid) {
CompletableFuture.runAsync(() -> {
String sql = isMySQL
? "INSERT INTO player_data (player_uuid, last_listing_time) VALUES (?, ?) ON DUPLICATE KEY UPDATE last_listing_time = ?"
: "INSERT OR REPLACE INTO player_data (player_uuid, last_listing_time) VALUES (?, ?)";
try (Connection conn = getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
long now = Instant.now().toEpochMilli();
stmt.setString(1, playerUuid.toString());
stmt.setLong(2, now);
if (isMySQL) {
stmt.setLong(3, now);
}
stmt.executeUpdate();
} catch (Exception e) {
plugin.getLogger().log(Level.WARNING, "Failed to update last listing time", e);
}
});
}
}
@@ -0,0 +1,254 @@
package pt.henrique.communityMarket.economy;
import net.milkbowl.vault.economy.Economy;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
import org.bukkit.plugin.RegisteredServiceProvider;
import pt.henrique.communityMarket.CommunityMarket;
import java.util.UUID;
import java.util.logging.Level;
/**
* Manages economy operations with support for Vault and EssentialsX fallback.
* <p>
* Priority: Vault > EssentialsX
* If neither is available, the plugin will disable itself.
*/
public class EconomyManager {
private final CommunityMarket plugin;
private Economy vaultEconomy;
private com.earth2me.essentials.Essentials essentials;
private EconomyProvider provider = EconomyProvider.NONE;
public enum EconomyProvider {
VAULT,
ESSENTIALS,
NONE
}
public EconomyManager(CommunityMarket plugin) {
this.plugin = plugin;
}
/**
* Attempts to set up an economy provider.
* Tries Vault first, then EssentialsX.
*
* @return true if an economy provider was found
*/
public boolean setupEconomy() {
// Try Vault first
if (setupVault()) {
provider = EconomyProvider.VAULT;
plugin.getLogger().info("Using Vault as economy provider.");
return true;
}
// Fallback to EssentialsX
if (setupEssentials()) {
provider = EconomyProvider.ESSENTIALS;
plugin.getLogger().info("Using EssentialsX as economy provider.");
return true;
}
plugin.getLogger().severe("No economy provider found!");
return false;
}
/**
* Attempts to hook into Vault economy
*/
private boolean setupVault() {
if (Bukkit.getPluginManager().getPlugin("Vault") == null) {
return false;
}
try {
RegisteredServiceProvider<Economy> rsp = Bukkit.getServicesManager().getRegistration(Economy.class);
if (rsp == null) {
plugin.getLogger().warning("Vault found but no economy provider registered.");
return false;
}
vaultEconomy = rsp.getProvider();
return vaultEconomy != null;
} catch (Exception e) {
plugin.getLogger().log(Level.WARNING, "Failed to hook into Vault", e);
return false;
}
}
/**
* Attempts to hook into EssentialsX economy
*/
private boolean setupEssentials() {
if (Bukkit.getPluginManager().getPlugin("Essentials") == null) {
return false;
}
try {
essentials = (com.earth2me.essentials.Essentials) Bukkit.getPluginManager().getPlugin("Essentials");
return essentials != null && essentials.isEnabled();
} catch (Exception e) {
plugin.getLogger().log(Level.WARNING, "Failed to hook into EssentialsX", e);
return false;
}
}
/**
* Gets the name of the active economy provider
*/
public String getProviderName() {
return switch (provider) {
case VAULT -> "Vault (" + (vaultEconomy != null ? vaultEconomy.getName() : "Unknown") + ")";
case ESSENTIALS -> "EssentialsX";
case NONE -> "None";
};
}
/**
* Gets a player's current balance
*
* @param playerUuid The player's UUID
* @return The player's balance
*/
public double getBalance(UUID playerUuid) {
OfflinePlayer player = Bukkit.getOfflinePlayer(playerUuid);
return switch (provider) {
case VAULT -> vaultEconomy.getBalance(player);
case ESSENTIALS -> {
try {
yield essentials.getUser(playerUuid).getMoney().doubleValue();
} catch (Exception e) {
plugin.getLogger().log(Level.WARNING, "Failed to get balance from EssentialsX", e);
yield 0.0;
}
}
case NONE -> 0.0;
};
}
/**
* Checks if a player has at least the specified amount
*
* @param playerUuid The player's UUID
* @param amount The amount to check
* @return true if the player has enough money
*/
public boolean has(UUID playerUuid, double amount) {
return getBalance(playerUuid) >= amount;
}
/**
* Withdraws money from a player's account
*
* @param playerUuid The player's UUID
* @param amount The amount to withdraw
* @return true if successful
*/
public boolean withdraw(UUID playerUuid, double amount) {
if (amount <= 0) return true;
OfflinePlayer player = Bukkit.getOfflinePlayer(playerUuid);
return switch (provider) {
case VAULT -> {
if (!vaultEconomy.has(player, amount)) {
yield false;
}
yield vaultEconomy.withdrawPlayer(player, amount).transactionSuccess();
}
case ESSENTIALS -> {
try {
var user = essentials.getUser(playerUuid);
if (user.getMoney().doubleValue() < amount) {
yield false;
}
user.setMoney(user.getMoney().subtract(java.math.BigDecimal.valueOf(amount)));
yield true;
} catch (Exception e) {
plugin.getLogger().log(Level.WARNING, "Failed to withdraw from EssentialsX", e);
yield false;
}
}
case NONE -> false;
};
}
/**
* Deposits money into a player's account
*
* @param playerUuid The player's UUID
* @param amount The amount to deposit
* @return true if successful
*/
public boolean deposit(UUID playerUuid, double amount) {
if (amount <= 0) return true;
OfflinePlayer player = Bukkit.getOfflinePlayer(playerUuid);
return switch (provider) {
case VAULT -> vaultEconomy.depositPlayer(player, amount).transactionSuccess();
case ESSENTIALS -> {
try {
var user = essentials.getUser(playerUuid);
user.setMoney(user.getMoney().add(java.math.BigDecimal.valueOf(amount)));
yield true;
} catch (Exception e) {
plugin.getLogger().log(Level.WARNING, "Failed to deposit to EssentialsX", e);
yield false;
}
}
case NONE -> false;
};
}
/**
* Transfers money between two players
*
* @param fromUuid The UUID of the payer
* @param toUuid The UUID of the receiver
* @param amount The amount to transfer
* @return true if successful
*/
public boolean transfer(UUID fromUuid, UUID toUuid, double amount) {
if (amount <= 0) return true;
// Withdraw first
if (!withdraw(fromUuid, amount)) {
return false;
}
// Then deposit - if this fails, refund the withdrawal
if (!deposit(toUuid, amount)) {
deposit(fromUuid, amount); // Attempt refund
return false;
}
return true;
}
/**
* Formats an amount according to the economy's formatting
*
* @param amount The amount to format
* @return Formatted currency string
*/
public String format(double amount) {
if (provider == EconomyProvider.VAULT && vaultEconomy != null) {
return vaultEconomy.format(amount);
}
return plugin.getMessageManager().formatCurrency(amount);
}
/**
* Gets the economy provider type
*/
public EconomyProvider getProvider() {
return provider;
}
}
@@ -0,0 +1,347 @@
package pt.henrique.communityMarket.gui;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import pt.henrique.communityMarket.CommunityMarket;
import pt.henrique.communityMarket.util.ItemBuilder;
import pt.henrique.communityMarket.util.TextUtil;
/**
* Admin panel GUI for moderating the marketplace.
* Provides access to view all listings/auctions, remove items, and reload config.
*/
public class AdminGui implements MarketGui {
private final CommunityMarket plugin;
private final GuiManager guiManager;
private Inventory inventory;
private static final int VIEW_LISTINGS_SLOT = 20;
private static final int VIEW_AUCTIONS_SLOT = 24;
private static final int RELOAD_SLOT = 40;
private static final int BACK_SLOT = 49;
public AdminGui(CommunityMarket plugin, GuiManager guiManager) {
this.plugin = plugin;
this.guiManager = guiManager;
}
public void open(Player player) {
if (!player.hasPermission("communitymarket.admin")) {
player.sendMessage(plugin.getMessageManager().getPrefixed("messages.no-permission"));
return;
}
var msgManager = plugin.getMessageManager();
String title = TextUtil.colorizeToString(msgManager.getRaw("gui-titles.admin-panel"));
inventory = Bukkit.createInventory(this, 54, title);
buildGui();
player.openInventory(inventory);
guiManager.registerGui(player.getUniqueId(), this);
}
private void buildGui() {
var msgManager = plugin.getMessageManager();
// Fill with glass
ItemStack filler = new ItemBuilder(Material.RED_STAINED_GLASS_PANE).name(" ").build();
for (int i = 0; i < 54; i++) {
inventory.setItem(i, filler);
}
// Admin panel header
inventory.setItem(4, new ItemBuilder(Material.COMMAND_BLOCK)
.name("&c&lAdmin Panel")
.lore(
"&7Manage the marketplace.",
"&7Remove listings, cancel auctions,",
"&7and reload configuration."
)
.build());
// View all listings
inventory.setItem(VIEW_LISTINGS_SLOT, new ItemBuilder(Material.CHEST)
.name(msgManager.getButton("admin-view-listings"))
.lore(
"&7View all active listings",
"&7from all players.",
"",
"&cClick on items to remove them."
)
.build());
// View all auctions
inventory.setItem(VIEW_AUCTIONS_SLOT, new ItemBuilder(Material.GOLD_BLOCK)
.name(msgManager.getButton("admin-view-auctions"))
.lore(
"&7View all active auctions",
"&7from all players.",
"",
"&cClick on items to force-end them."
)
.build());
// Reload config
inventory.setItem(RELOAD_SLOT, new ItemBuilder(Material.REPEATING_COMMAND_BLOCK)
.name(msgManager.getButton("admin-reload"))
.lore(
"&7Reload plugin configuration",
"&7and language files."
)
.build());
// Back button
inventory.setItem(BACK_SLOT, new ItemBuilder(Material.BARRIER)
.name(msgManager.getButton("back"))
.build());
}
@Override
public void handleClick(InventoryClickEvent event) {
event.setCancelled(true);
Player player = (Player) event.getWhoClicked();
int slot = event.getRawSlot();
switch (slot) {
case VIEW_LISTINGS_SLOT -> openAdminListings(player);
case VIEW_AUCTIONS_SLOT -> openAdminAuctions(player);
case RELOAD_SLOT -> {
plugin.reload();
player.sendMessage(plugin.getMessageManager().getPrefixed("messages.admin-reload"));
playSound(player, plugin.getConfigManager().getSuccessSound());
}
case BACK_SLOT -> guiManager.openMainMenu(player);
}
}
private void openAdminListings(Player player) {
// Opens browse market but with admin remove capability
new AdminListingsGui(plugin, guiManager).open(player, 0);
}
private void openAdminAuctions(Player player) {
new AdminAuctionsGui(plugin, guiManager).open(player, 0);
}
private void playSound(Player player, String soundName) {
pt.henrique.communityMarket.util.SoundUtil.playSound(player, soundName);
}
@Override
public GuiType getType() {
return GuiType.ADMIN;
}
@Override
public Inventory getInventory() {
return inventory;
}
// ==================== Inner Admin GUIs ====================
/**
* Admin view of all listings with remove capability
*/
private static class AdminListingsGui implements MarketGui {
private final CommunityMarket plugin;
private final GuiManager guiManager;
private Inventory inventory;
private Player player;
private int page;
private java.util.List<pt.henrique.communityMarket.model.Listing> listings;
public AdminListingsGui(CommunityMarket plugin, GuiManager guiManager) {
this.plugin = plugin;
this.guiManager = guiManager;
}
public void open(Player player, int page) {
this.player = player;
this.page = page;
plugin.getListingService().getActiveListings().thenAccept(loaded -> {
this.listings = loaded;
Bukkit.getScheduler().runTask(plugin, () -> {
buildGui();
player.openInventory(inventory);
guiManager.registerGui(player.getUniqueId(), this);
});
});
}
private void buildGui() {
var msgManager = plugin.getMessageManager();
String title = TextUtil.colorizeToString(msgManager.getRaw("gui-titles.admin-listings"));
inventory = Bukkit.createInventory(this, 54, title);
ItemStack filler = new ItemBuilder(Material.RED_STAINED_GLASS_PANE).name(" ").build();
for (int i = 45; i < 54; i++) {
inventory.setItem(i, filler);
}
int start = page * 45;
int end = Math.min(start + 45, listings.size());
for (int i = start; i < end; i++) {
var listing = listings.get(i);
ItemStack display = listing.getItem().clone();
inventory.setItem(i - start, new ItemBuilder(display)
.addLore(java.util.List.of(
"",
"&7Seller: &f" + listing.getSellerName(),
"&7Price: &a" + msgManager.formatCurrency(listing.getPrice()),
"&7ID: &f#" + listing.getId(),
"",
"&cClick to remove"
))
.build());
}
inventory.setItem(49, new ItemBuilder(Material.BARRIER)
.name(msgManager.getButton("back"))
.build());
}
@Override
public void handleClick(InventoryClickEvent event) {
event.setCancelled(true);
Player player = (Player) event.getWhoClicked();
int slot = event.getRawSlot();
if (slot == 49) {
new AdminGui(plugin, guiManager).open(player);
return;
}
if (slot >= 0 && slot < 45 && slot + page * 45 < listings.size()) {
var listing = listings.get(slot + page * 45);
plugin.getListingService().cancelListing(listing.getId(), player.getUniqueId(), true)
.thenAccept(success -> {
Bukkit.getScheduler().runTask(plugin, () -> {
if (success) {
player.sendMessage(plugin.getMessageManager().getPrefixed(
"messages.admin-listing-removed", "id", String.valueOf(listing.getId())));
}
open(player, page);
});
});
}
}
@Override
public GuiType getType() {
return GuiType.ADMIN_LISTINGS;
}
@Override
public Inventory getInventory() {
return inventory;
}
}
/**
* Admin view of all auctions with cancel capability
*/
private static class AdminAuctionsGui implements MarketGui {
private final CommunityMarket plugin;
private final GuiManager guiManager;
private Inventory inventory;
private Player player;
private int page;
private java.util.List<pt.henrique.communityMarket.model.Auction> auctions;
public AdminAuctionsGui(CommunityMarket plugin, GuiManager guiManager) {
this.plugin = plugin;
this.guiManager = guiManager;
}
public void open(Player player, int page) {
this.player = player;
this.page = page;
plugin.getAuctionService().getActiveAuctions().thenAccept(loaded -> {
this.auctions = loaded;
Bukkit.getScheduler().runTask(plugin, () -> {
buildGui();
player.openInventory(inventory);
guiManager.registerGui(player.getUniqueId(), this);
});
});
}
private void buildGui() {
var msgManager = plugin.getMessageManager();
String title = TextUtil.colorizeToString(msgManager.getRaw("gui-titles.admin-auctions"));
inventory = Bukkit.createInventory(this, 54, title);
ItemStack filler = new ItemBuilder(Material.RED_STAINED_GLASS_PANE).name(" ").build();
for (int i = 45; i < 54; i++) {
inventory.setItem(i, filler);
}
int start = page * 45;
int end = Math.min(start + 45, auctions.size());
for (int i = start; i < end; i++) {
var auction = auctions.get(i);
ItemStack display = auction.getItem().clone();
String bidder = auction.getHighestBidderName() != null ? auction.getHighestBidderName() : "None";
inventory.setItem(i - start, new ItemBuilder(display)
.addLore(java.util.List.of(
"",
"&7Seller: &f" + auction.getSellerName(),
"&7Current Bid: &a" + msgManager.formatCurrency(auction.getCurrentBid()),
"&7Bidder: &f" + bidder,
"&7Bids: &f" + auction.getBidCount(),
"&7ID: &f#" + auction.getId(),
"",
"&cClick to force-end"
))
.build());
}
inventory.setItem(49, new ItemBuilder(Material.BARRIER)
.name(msgManager.getButton("back"))
.build());
}
@Override
public void handleClick(InventoryClickEvent event) {
event.setCancelled(true);
Player player = (Player) event.getWhoClicked();
int slot = event.getRawSlot();
if (slot == 49) {
new AdminGui(plugin, guiManager).open(player);
return;
}
if (slot >= 0 && slot < 45 && slot + page * 45 < auctions.size()) {
var auction = auctions.get(slot + page * 45);
plugin.getAuctionService().cancelAuction(auction.getId(), player.getUniqueId(), true)
.thenAccept(result -> {
Bukkit.getScheduler().runTask(plugin, () -> {
player.sendMessage(plugin.getMessageManager().getPrefixed(
"messages.admin-auction-cancelled", "id", String.valueOf(auction.getId())));
open(player, page);
});
});
}
}
@Override
public GuiType getType() {
return GuiType.ADMIN_AUCTIONS;
}
@Override
public Inventory getInventory() {
return inventory;
}
}
}
@@ -0,0 +1,299 @@
package pt.henrique.communityMarket.gui;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
import org.bukkit.event.inventory.ClickType;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import pt.henrique.communityMarket.CommunityMarket;
import pt.henrique.communityMarket.model.Auction;
import pt.henrique.communityMarket.service.AuctionService;
import pt.henrique.communityMarket.util.ItemBuilder;
import pt.henrique.communityMarket.util.TextUtil;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* GUI for browsing active auctions.
* Left-click to bid, right-click to buyout (if available).
*/
public class BrowseAuctionsGui implements MarketGui {
private final CommunityMarket plugin;
private final GuiManager guiManager;
private Inventory inventory;
private Player player;
private int currentPage;
private List<Auction> auctions;
// Layout constants
private static final int ITEMS_PER_PAGE = 45;
private static final int PREV_PAGE_SLOT = 45;
private static final int INFO_SLOT = 49;
private static final int NEXT_PAGE_SLOT = 53;
private static final int BACK_SLOT = 48;
public BrowseAuctionsGui(CommunityMarket plugin, GuiManager guiManager) {
this.plugin = plugin;
this.guiManager = guiManager;
}
public void open(Player player, int page) {
this.player = player;
this.currentPage = page;
// Load auctions asynchronously
plugin.getAuctionService().getActiveAuctions().thenAccept(loadedAuctions -> {
this.auctions = loadedAuctions;
Bukkit.getScheduler().runTask(plugin, () -> {
buildGui();
player.openInventory(inventory);
guiManager.registerGui(player.getUniqueId(), this);
playSound(player, plugin.getConfigManager().getClickSound());
});
});
}
private void buildGui() {
var msgManager = plugin.getMessageManager();
String title = msgManager.getRaw("gui-titles.browse-auctions")
.replace("{page}", String.valueOf(currentPage + 1));
inventory = Bukkit.createInventory(this, 54, TextUtil.colorizeToString(title));
// Fill bottom row
ItemStack filler = new ItemBuilder(Material.GRAY_STAINED_GLASS_PANE).name(" ").build();
for (int i = 45; i < 54; i++) {
inventory.setItem(i, filler);
}
// Add auctions
int startIndex = currentPage * ITEMS_PER_PAGE;
int endIndex = Math.min(startIndex + ITEMS_PER_PAGE, auctions.size());
for (int i = startIndex; i < endIndex; i++) {
Auction auction = auctions.get(i);
int slot = i - startIndex;
inventory.setItem(slot, createAuctionItem(auction));
}
// Navigation
if (currentPage > 0) {
inventory.setItem(PREV_PAGE_SLOT, new ItemBuilder(Material.ARROW)
.name(msgManager.getButton("previous-page"))
.build());
}
int totalPages = (int) Math.ceil((double) auctions.size() / ITEMS_PER_PAGE);
if (currentPage < totalPages - 1) {
inventory.setItem(NEXT_PAGE_SLOT, new ItemBuilder(Material.ARROW)
.name(msgManager.getButton("next-page"))
.build());
}
inventory.setItem(INFO_SLOT, new ItemBuilder(Material.PAPER)
.name("&ePage " + (currentPage + 1) + "/" + Math.max(1, totalPages))
.lore("&7Total auctions: &f" + auctions.size())
.build());
inventory.setItem(BACK_SLOT, new ItemBuilder(Material.BARRIER)
.name(msgManager.getButton("back"))
.build());
}
private ItemStack createAuctionItem(Auction auction) {
var msgManager = plugin.getMessageManager();
ItemStack display = auction.getItem().clone();
// Time remaining
Duration remaining = Duration.between(Instant.now(), auction.getEndsAt());
String ends = TextUtil.formatDuration(remaining);
// Current bidder
String bidder = auction.getHighestBidderName() != null ? auction.getHighestBidderName() : "&7None";
String currentBid = auction.getBidCount() > 0
? msgManager.formatCurrency(auction.getCurrentBid())
: msgManager.formatCurrency(auction.getStartPrice());
List<String> lore = new ArrayList<>();
lore.add("");
for (String line : msgManager.getLore("auction-info", Map.of(
"seller", auction.getSellerName(),
"start_price", msgManager.formatCurrency(auction.getStartPrice()),
"current_bid", currentBid,
"bidder", bidder,
"bid_count", String.valueOf(auction.getBidCount()),
"ends", ends
))) {
lore.add(line);
}
// Add buyout info if available
if (auction.hasBuyout()) {
lore.add("&7Buyout: &a" + msgManager.formatCurrency(auction.getBuyoutPrice()));
}
return new ItemBuilder(display)
.addLore(lore)
.build();
}
@Override
public void handleClick(InventoryClickEvent event) {
event.setCancelled(true);
Player player = (Player) event.getWhoClicked();
int slot = event.getRawSlot();
if (slot == BACK_SLOT) {
guiManager.openMainMenu(player);
return;
}
if (slot == PREV_PAGE_SLOT && currentPage > 0) {
open(player, currentPage - 1);
return;
}
int totalPages = (int) Math.ceil((double) auctions.size() / ITEMS_PER_PAGE);
if (slot == NEXT_PAGE_SLOT && currentPage < totalPages - 1) {
open(player, currentPage + 1);
return;
}
// Click on auction
if (slot >= 0 && slot < ITEMS_PER_PAGE) {
int auctionIndex = currentPage * ITEMS_PER_PAGE + slot;
if (auctionIndex < auctions.size()) {
Auction auction = auctions.get(auctionIndex);
// Right-click for buyout
if (event.getClick() == ClickType.RIGHT && auction.hasBuyout()) {
handleBuyout(player, auction);
} else {
// Left-click for bid
handleBid(player, auction);
}
}
}
}
private void handleBid(Player player, Auction auction) {
var msgManager = plugin.getMessageManager();
// Can't bid on own auction
if (auction.getSellerUuid().equals(player.getUniqueId())) {
player.sendMessage(msgManager.getPrefixed("messages.auction-own-item"));
playSound(player, plugin.getConfigManager().getErrorSound());
return;
}
// Calculate minimum bid
double minBid = plugin.getAuctionService().calculateMinBid(auction);
// Open number input for bid amount
guiManager.openNumberInput(player, bidAmount -> {
if (bidAmount <= 0) {
guiManager.openBrowseAuctions(player, currentPage);
return;
}
// Place bid
plugin.getAuctionService().placeBid(auction.getId(), player, bidAmount)
.thenAccept(result -> {
Bukkit.getScheduler().runTask(plugin, () -> {
switch (result) {
case SUCCESS -> {
player.sendMessage(msgManager.getPrefixed("messages.auction-bid-placed", Map.of(
"amount", msgManager.formatCurrency(bidAmount),
"item", auction.getItem().getType().name()
)));
playSound(player, plugin.getConfigManager().getSuccessSound());
guiManager.openBrowseAuctions(player, currentPage);
}
case BID_TOO_LOW -> {
player.sendMessage(msgManager.getPrefixed("messages.auction-bid-too-low",
"min", msgManager.formatCurrency(minBid)));
playSound(player, plugin.getConfigManager().getErrorSound());
}
case INSUFFICIENT_FUNDS -> {
player.sendMessage(msgManager.getPrefixed("messages.auction-insufficient-funds",
"price", msgManager.formatCurrency(bidAmount)));
playSound(player, plugin.getConfigManager().getErrorSound());
}
default -> {
player.sendMessage(msgManager.getPrefixed("messages.auction-not-found"));
playSound(player, plugin.getConfigManager().getErrorSound());
guiManager.openBrowseAuctions(player, currentPage);
}
}
});
});
}, minBid, minBid, plugin.getConfigManager().getMaxPrice(),
msgManager.getRaw("gui-titles.number-input"));
}
private void handleBuyout(Player player, Auction auction) {
var msgManager = plugin.getMessageManager();
if (auction.getSellerUuid().equals(player.getUniqueId())) {
player.sendMessage(msgManager.getPrefixed("messages.auction-own-item"));
playSound(player, plugin.getConfigManager().getErrorSound());
return;
}
String[] info = {
"&7Item: &f" + auction.getItem().getType().name(),
"&7Buyout Price: &a" + msgManager.formatCurrency(auction.getBuyoutPrice()),
"",
"&eClick to confirm buyout!"
};
guiManager.openConfirmation(player, confirmed -> {
if (confirmed) {
plugin.getAuctionService().buyout(auction.getId(), player)
.thenAccept(result -> {
Bukkit.getScheduler().runTask(plugin, () -> {
if (result == AuctionService.BidResult.BUYOUT_SUCCESS) {
player.sendMessage(msgManager.getPrefixed("messages.auction-buyout", Map.of(
"item", auction.getItem().getType().name(),
"price", msgManager.formatCurrency(auction.getBuyoutPrice())
)));
playSound(player, plugin.getConfigManager().getPurchaseSound());
} else {
player.sendMessage(msgManager.getPrefixed("messages.auction-not-found"));
playSound(player, plugin.getConfigManager().getErrorSound());
}
guiManager.openBrowseAuctions(player, currentPage);
});
});
} else {
guiManager.openBrowseAuctions(player, currentPage);
}
}, msgManager.getRaw("gui-titles.confirm-purchase"), info);
}
private void playSound(Player player, String soundName) {
pt.henrique.communityMarket.util.SoundUtil.playSound(player, soundName);
}
@Override
public GuiType getType() {
return GuiType.BROWSE_AUCTIONS;
}
@Override
public Inventory getInventory() {
return inventory;
}
}
@@ -0,0 +1,257 @@
package pt.henrique.communityMarket.gui;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import pt.henrique.communityMarket.CommunityMarket;
import pt.henrique.communityMarket.model.Listing;
import pt.henrique.communityMarket.service.ListingService;
import pt.henrique.communityMarket.util.ItemBuilder;
import pt.henrique.communityMarket.util.TextUtil;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* GUI for browsing the market's fixed-price listings.
* Features pagination, and click-to-buy functionality.
*/
public class BrowseMarketGui implements MarketGui {
private final CommunityMarket plugin;
private final GuiManager guiManager;
private Inventory inventory;
private Player player;
private int currentPage;
private List<Listing> listings;
// Layout constants
private static final int ITEMS_PER_PAGE = 45;
private static final int PREV_PAGE_SLOT = 45;
private static final int INFO_SLOT = 49;
private static final int NEXT_PAGE_SLOT = 53;
private static final int BACK_SLOT = 48;
private static final int FILTER_SLOT = 47;
private static final int SORT_SLOT = 51;
public BrowseMarketGui(CommunityMarket plugin, GuiManager guiManager) {
this.plugin = plugin;
this.guiManager = guiManager;
}
/**
* Opens the browse market GUI for a player
*/
public void open(Player player, int page) {
this.player = player;
this.currentPage = page;
// Load listings asynchronously
plugin.getListingService().getActiveListings().thenAccept(loadedListings -> {
this.listings = loadedListings;
// Build GUI on main thread
Bukkit.getScheduler().runTask(plugin, () -> {
buildGui();
player.openInventory(inventory);
guiManager.registerGui(player.getUniqueId(), this);
playSound(player, plugin.getConfigManager().getClickSound());
});
});
}
private void buildGui() {
var msgManager = plugin.getMessageManager();
String title = msgManager.getRaw("gui-titles.browse-market")
.replace("{page}", String.valueOf(currentPage + 1));
inventory = Bukkit.createInventory(this, 54, TextUtil.colorizeToString(title));
// Fill bottom row with glass
ItemStack filler = new ItemBuilder(Material.GRAY_STAINED_GLASS_PANE).name(" ").build();
for (int i = 45; i < 54; i++) {
inventory.setItem(i, filler);
}
// Add listings to slots 0-44
int startIndex = currentPage * ITEMS_PER_PAGE;
int endIndex = Math.min(startIndex + ITEMS_PER_PAGE, listings.size());
for (int i = startIndex; i < endIndex; i++) {
Listing listing = listings.get(i);
int slot = i - startIndex;
inventory.setItem(slot, createListingItem(listing));
}
// Navigation buttons
if (currentPage > 0) {
inventory.setItem(PREV_PAGE_SLOT, new ItemBuilder(Material.ARROW)
.name(msgManager.getButton("previous-page"))
.build());
}
int totalPages = (int) Math.ceil((double) listings.size() / ITEMS_PER_PAGE);
if (currentPage < totalPages - 1) {
inventory.setItem(NEXT_PAGE_SLOT, new ItemBuilder(Material.ARROW)
.name(msgManager.getButton("next-page"))
.build());
}
// Info/page indicator
inventory.setItem(INFO_SLOT, new ItemBuilder(Material.PAPER)
.name("&ePage " + (currentPage + 1) + "/" + Math.max(1, totalPages))
.lore("&7Total listings: &f" + listings.size())
.build());
// Back button
inventory.setItem(BACK_SLOT, new ItemBuilder(Material.BARRIER)
.name(msgManager.getButton("back"))
.build());
}
private ItemStack createListingItem(Listing listing) {
var msgManager = plugin.getMessageManager();
ItemStack display = listing.getItem().clone();
display.setAmount(listing.getAmount());
// Calculate time remaining
String expires;
if (listing.getExpiresAt() != null) {
Duration remaining = Duration.between(Instant.now(), listing.getExpiresAt());
expires = TextUtil.formatDuration(remaining);
} else {
expires = "Never";
}
List<String> lore = new ArrayList<>();
lore.add(""); // Empty line separator
for (String line : msgManager.getLore("listing-info", Map.of(
"seller", listing.getSellerName(),
"price", msgManager.formatCurrency(listing.getPrice()),
"amount", String.valueOf(listing.getAmount()),
"expires", expires
))) {
lore.add(line);
}
return new ItemBuilder(display)
.addLore(lore)
.build();
}
@Override
public void handleClick(InventoryClickEvent event) {
event.setCancelled(true);
Player player = (Player) event.getWhoClicked();
int slot = event.getRawSlot();
// Bottom row navigation
if (slot == BACK_SLOT) {
guiManager.openMainMenu(player);
return;
}
if (slot == PREV_PAGE_SLOT && currentPage > 0) {
open(player, currentPage - 1);
return;
}
int totalPages = (int) Math.ceil((double) listings.size() / ITEMS_PER_PAGE);
if (slot == NEXT_PAGE_SLOT && currentPage < totalPages - 1) {
open(player, currentPage + 1);
return;
}
// Click on a listing (slots 0-44)
if (slot >= 0 && slot < ITEMS_PER_PAGE) {
int listingIndex = currentPage * ITEMS_PER_PAGE + slot;
if (listingIndex < listings.size()) {
Listing listing = listings.get(listingIndex);
handleListingClick(player, listing);
}
}
}
private void handleListingClick(Player player, Listing listing) {
var msgManager = plugin.getMessageManager();
// Can't buy own listing
if (listing.getSellerUuid().equals(player.getUniqueId())) {
player.sendMessage(msgManager.getPrefixed("messages.listing-own-item"));
playSound(player, plugin.getConfigManager().getErrorSound());
return;
}
// Show confirmation
double tax = plugin.getTransactionService().calculateListingTax(listing.getPrice());
String[] info = {
"&7Item: &f" + listing.getItem().getType().name() + " x" + listing.getAmount(),
"&7Seller: &f" + listing.getSellerName(),
"&7Price: &a" + msgManager.formatCurrency(listing.getPrice()),
"",
"&eClick to confirm purchase!"
};
guiManager.openConfirmation(player, confirmed -> {
if (confirmed) {
// Attempt purchase
plugin.getListingService().purchaseListing(listing.getId(), player)
.thenAccept(result -> {
Bukkit.getScheduler().runTask(plugin, () -> {
switch (result) {
case SUCCESS -> {
player.sendMessage(msgManager.getPrefixed("messages.listing-purchased", Map.of(
"item", listing.getItem().getType().name(),
"amount", String.valueOf(listing.getAmount()),
"price", msgManager.formatCurrency(listing.getPrice())
)));
playSound(player, plugin.getConfigManager().getPurchaseSound());
guiManager.openBrowseMarket(player, currentPage);
}
case INSUFFICIENT_FUNDS -> {
player.sendMessage(msgManager.getPrefixed("messages.listing-insufficient-funds",
"price", msgManager.formatCurrency(listing.getPrice())));
playSound(player, plugin.getConfigManager().getErrorSound());
}
case ALREADY_SOLD, NOT_FOUND -> {
player.sendMessage(msgManager.getPrefixed("messages.listing-not-found"));
playSound(player, plugin.getConfigManager().getErrorSound());
guiManager.openBrowseMarket(player, currentPage);
}
default -> {
player.sendMessage(msgManager.getPrefixed("messages.listing-not-found"));
playSound(player, plugin.getConfigManager().getErrorSound());
}
}
});
});
} else {
guiManager.openBrowseMarket(player, currentPage);
}
}, msgManager.getRaw("gui-titles.confirm-purchase"), info);
}
private void playSound(Player player, String soundName) {
pt.henrique.communityMarket.util.SoundUtil.playSound(player, soundName);
}
@Override
public GuiType getType() {
return GuiType.BROWSE_MARKET;
}
@Override
public Inventory getInventory() {
return inventory;
}
}
@@ -0,0 +1,205 @@
package pt.henrique.communityMarket.gui;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import pt.henrique.communityMarket.CommunityMarket;
import pt.henrique.communityMarket.model.ClaimItem;
import pt.henrique.communityMarket.service.ClaimService;
import pt.henrique.communityMarket.util.ItemBuilder;
import pt.henrique.communityMarket.util.TextUtil;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* GUI for claiming items from expired listings, won auctions, etc.
* Click on an item to claim it, or use "Claim All" button.
*/
public class ClaimGui implements MarketGui {
private final CommunityMarket plugin;
private final GuiManager guiManager;
private Inventory inventory;
private Player player;
private List<ClaimItem> claimItems;
private static final int BACK_SLOT = 49;
private static final int CLAIM_ALL_SLOT = 45;
public ClaimGui(CommunityMarket plugin, GuiManager guiManager) {
this.plugin = plugin;
this.guiManager = guiManager;
}
public void open(Player player) {
this.player = player;
plugin.getClaimService().getPlayerClaimItems(player.getUniqueId())
.thenAccept(items -> {
this.claimItems = items;
Bukkit.getScheduler().runTask(plugin, () -> {
buildGui();
player.openInventory(inventory);
guiManager.registerGui(player.getUniqueId(), this);
});
});
}
private void buildGui() {
var msgManager = plugin.getMessageManager();
String title = TextUtil.colorizeToString(msgManager.getRaw("gui-titles.claim-items"));
inventory = Bukkit.createInventory(this, 54, title);
// Fill bottom row
ItemStack filler = new ItemBuilder(Material.GRAY_STAINED_GLASS_PANE).name(" ").build();
for (int i = 45; i < 54; i++) {
inventory.setItem(i, filler);
}
// Add claim items
for (int i = 0; i < Math.min(claimItems.size(), 45); i++) {
ClaimItem item = claimItems.get(i);
inventory.setItem(i, createClaimItemDisplay(item));
}
// Claim All button
if (!claimItems.isEmpty()) {
inventory.setItem(CLAIM_ALL_SLOT, new ItemBuilder(Material.HOPPER)
.name(msgManager.getButton("claim-all"))
.lore("&7Claim all items at once")
.glow()
.build());
}
// Back button
inventory.setItem(BACK_SLOT, new ItemBuilder(Material.BARRIER)
.name(msgManager.getButton("back"))
.build());
// Show empty message if no items
if (claimItems.isEmpty()) {
inventory.setItem(22, new ItemBuilder(Material.BARRIER)
.name("&cNo items to claim")
.lore("&7Items from expired listings,",
"&7won auctions, etc. appear here.")
.build());
}
}
private ItemStack createClaimItemDisplay(ClaimItem claimItem) {
var msgManager = plugin.getMessageManager();
ItemStack display = claimItem.getItem().clone();
String age = TextUtil.formatDuration(
Duration.between(claimItem.getCreatedAt(), Instant.now())) + " ago";
List<String> lore = new ArrayList<>();
lore.add("");
lore.addAll(msgManager.getLore("claim-item-info", Map.of(
"reason", claimItem.getReason().getDisplayName(),
"source", claimItem.getSourceInfo() != null ? claimItem.getSourceInfo() : "Unknown",
"date", age
)));
return new ItemBuilder(display)
.addLore(lore)
.build();
}
@Override
public void handleClick(InventoryClickEvent event) {
event.setCancelled(true);
Player player = (Player) event.getWhoClicked();
int slot = event.getRawSlot();
if (slot == BACK_SLOT) {
guiManager.openMainMenu(player);
return;
}
if (slot == CLAIM_ALL_SLOT && !claimItems.isEmpty()) {
claimAll(player);
return;
}
// Click on item to claim
if (slot >= 0 && slot < 45 && slot < claimItems.size()) {
ClaimItem item = claimItems.get(slot);
claimSingle(player, item);
}
}
private void claimSingle(Player player, ClaimItem item) {
var msgManager = plugin.getMessageManager();
plugin.getClaimService().claimItem(item.getId(), player)
.thenAccept(result -> {
Bukkit.getScheduler().runTask(plugin, () -> {
switch (result) {
case SUCCESS -> {
player.sendMessage(msgManager.getPrefixed("messages.claim-success"));
playSound(player, plugin.getConfigManager().getSuccessSound());
open(player); // Refresh
}
case INVENTORY_FULL -> {
player.sendMessage(msgManager.getPrefixed("messages.claim-inventory-full"));
playSound(player, plugin.getConfigManager().getErrorSound());
}
default -> {
player.sendMessage(msgManager.getPrefixed("messages.claim-empty"));
playSound(player, plugin.getConfigManager().getErrorSound());
open(player);
}
}
});
});
}
private void claimAll(Player player) {
var msgManager = plugin.getMessageManager();
plugin.getClaimService().claimAll(player)
.thenAccept(count -> {
Bukkit.getScheduler().runTask(plugin, () -> {
if (count > 0) {
player.sendMessage(msgManager.getPrefixed("messages.claim-all-success",
"count", String.valueOf(count)));
playSound(player, plugin.getConfigManager().getSuccessSound());
} else {
player.sendMessage(msgManager.getPrefixed("messages.claim-empty"));
}
open(player); // Refresh
});
});
}
private void playSound(Player player, String soundName) {
try {
Sound sound = Sound.valueOf(soundName);
player.playSound(player.getLocation(), sound, 0.5f, 1.0f);
} catch (IllegalArgumentException ignored) {}
}
@Override
public GuiType getType() {
return GuiType.CLAIM;
}
@Override
public Inventory getInventory() {
return inventory;
}
}
@@ -0,0 +1,120 @@
package pt.henrique.communityMarket.gui;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import pt.henrique.communityMarket.CommunityMarket;
import pt.henrique.communityMarket.util.ItemBuilder;
import pt.henrique.communityMarket.util.TextUtil;
import java.util.ArrayList;
import java.util.List;
/**
* Confirmation dialog GUI for important actions like purchases and cancellations.
*/
public class ConfirmationGui implements MarketGui {
private final CommunityMarket plugin;
private final GuiManager guiManager;
private final ConfirmCallback callback;
private final String title;
private final String[] infoLines;
private Inventory inventory;
private static final int INFO_SLOT = 13;
private static final int CONFIRM_SLOT = 29;
private static final int CANCEL_SLOT = 33;
@FunctionalInterface
public interface ConfirmCallback {
void onComplete(boolean confirmed);
}
public ConfirmationGui(CommunityMarket plugin, GuiManager guiManager,
ConfirmCallback callback, String title, String... infoLines) {
this.plugin = plugin;
this.guiManager = guiManager;
this.callback = callback;
this.title = title;
this.infoLines = infoLines;
}
public void open(Player player) {
inventory = Bukkit.createInventory(this, 45, TextUtil.colorizeToString(title));
buildGui();
player.openInventory(inventory);
guiManager.registerGui(player.getUniqueId(), this);
}
private void buildGui() {
var msgManager = plugin.getMessageManager();
// Fill with glass
ItemStack filler = new ItemBuilder(Material.GRAY_STAINED_GLASS_PANE).name(" ").build();
for (int i = 0; i < 45; i++) {
inventory.setItem(i, filler);
}
// Info display
List<String> lore = new ArrayList<>();
for (String line : infoLines) {
lore.add(line);
}
inventory.setItem(INFO_SLOT, new ItemBuilder(Material.PAPER)
.name("&e&lConfirm Action")
.lore(lore)
.build());
// Confirm button
inventory.setItem(CONFIRM_SLOT, new ItemBuilder(Material.LIME_WOOL)
.name(msgManager.getButton("confirm"))
.lore("&aClick to confirm")
.glow()
.build());
// Cancel button
inventory.setItem(CANCEL_SLOT, new ItemBuilder(Material.RED_WOOL)
.name(msgManager.getButton("cancel"))
.lore("&cClick to cancel")
.build());
}
@Override
public void handleClick(InventoryClickEvent event) {
event.setCancelled(true);
Player player = (Player) event.getWhoClicked();
int slot = event.getRawSlot();
if (slot == CONFIRM_SLOT) {
playSound(player, plugin.getConfigManager().getSuccessSound());
player.closeInventory();
callback.onComplete(true);
} else if (slot == CANCEL_SLOT) {
player.closeInventory();
callback.onComplete(false);
}
}
private void playSound(Player player, String soundName) {
pt.henrique.communityMarket.util.SoundUtil.playSound(player, soundName);
}
@Override
public GuiType getType() {
return GuiType.CONFIRMATION;
}
@Override
public Inventory getInventory() {
return inventory;
}
}
@@ -0,0 +1,378 @@
package pt.henrique.communityMarket.gui;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import pt.henrique.communityMarket.CommunityMarket;
import pt.henrique.communityMarket.util.ItemBuilder;
import pt.henrique.communityMarket.util.InventoryUtil;
import pt.henrique.communityMarket.util.SoundUtil;
import pt.henrique.communityMarket.util.TextUtil;
import java.util.List;
/**
* GUI for setting up a new auction.
* This GUI is opened AFTER the player has selected an item and quantity.
* All elements are merged: start price, buyout, and duration are single clickable items.
*
* Layout (54-slot chest):
* ┌─────────────────────────────────────────────────────┐
* │ . . . . INFO . . . . │ Row 0 │
* │ . . . . ITEM . . . . │ Row 1: Item │
* │ . . . . . . . . . │ Row 2 │
* │ . START . BUYOUT . DURATION . │ Row 3: Setup │
* │ . . . . . . . . . │ Row 4 │
* │ BACK . . . CONFIRM . . . .│ Row 5: Action│
* └─────────────────────────────────────────────────────┘
*/
public class CreateAuctionGui implements MarketGui {
private final CommunityMarket plugin;
private final GuiManager guiManager;
private Inventory inventory;
private Player player;
// Selected item info (from ItemSelectionGui -> QuantitySelectGui)
private int sourceInventorySlot = -1;
private ItemStack selectedItem = null;
// Auction settings
private double startPrice;
private Double buyoutPrice = null;
private int durationHours;
// ==================== LAYOUT CONSTANTS ====================
private static final int INFO_SLOT = 4; // Top center
private static final int ITEM_DISPLAY_SLOT = 13; // Center
// Row 3: Merged elements (single slot each)
private static final int START_PRICE_SLOT = 28; // Start price (display + click)
private static final int BUYOUT_SLOT = 31; // Buyout (display + click)
private static final int DURATION_SLOT = 34; // Duration (display + click)
private static final int BACK_SLOT = 45; // Bottom-left
private static final int CONFIRM_SLOT = 49; // Bottom-center
// ===========================================================
public CreateAuctionGui(CommunityMarket plugin, GuiManager guiManager) {
this.plugin = plugin;
this.guiManager = guiManager;
}
/**
* Opens the auction creation GUI with a pre-selected item and quantity.
* Called from QuantitySelectGui or ItemSelectionGui (for unstackable items).
*
* @param player The player
* @param inventorySlot The slot in the player's inventory where the item is
* @param item A clone of the selected item with the desired quantity
*/
public void openWithItem(Player player, int inventorySlot, ItemStack item) {
this.player = player;
this.sourceInventorySlot = inventorySlot;
this.selectedItem = item;
this.durationHours = plugin.getConfigManager().getDefaultAuctionDurationHours();
this.startPrice = plugin.getConfigManager().getMinStartPrice();
this.buyoutPrice = null;
var msgManager = plugin.getMessageManager();
String title = TextUtil.colorizeToString(msgManager.getRaw("gui-titles.create-auction"));
inventory = Bukkit.createInventory(this, 54, title);
buildGui();
player.openInventory(inventory);
guiManager.registerGui(player.getUniqueId(), this);
}
/**
* @deprecated Use openWithItem instead. This opens item selection first.
*/
public void open(Player player) {
new ItemSelectionGui(plugin, guiManager, ItemSelectionGui.SelectionMode.AUCTION).open(player);
}
private void buildGui() {
var msgManager = plugin.getMessageManager();
// Fill with glass
ItemStack filler = new ItemBuilder(Material.GRAY_STAINED_GLASS_PANE).name(" ").build();
for (int i = 0; i < 54; i++) {
inventory.setItem(i, filler);
}
// Info panel
inventory.setItem(INFO_SLOT, new ItemBuilder(Material.OAK_SIGN)
.name("&6&lCreate Auction")
.lore(
"&7Set starting price, optional buyout,",
"&7and duration for your auction.",
"",
"&7Tax on sale: &f" + plugin.getConfigManager().getAuctionTax() + "%"
)
.build());
// Selected item display
if (selectedItem != null) {
inventory.setItem(ITEM_DISPLAY_SLOT, new ItemBuilder(selectedItem.clone())
.addLore(List.of(
"",
"&7Quantity: &f" + selectedItem.getAmount(),
"&eThis item will be auctioned"
))
.build());
}
// Merged start price element
updateStartPriceElement();
// Merged buyout element
updateBuyoutElement();
// Merged duration element
updateDurationElement();
// Back button
inventory.setItem(BACK_SLOT, new ItemBuilder(Material.RED_WOOL)
.name(msgManager.getButton("back"))
.lore("&7Return to item selection")
.build());
// Confirm button
updateConfirmButton();
}
/**
* Updates the merged start price element (displays + clickable)
*/
private void updateStartPriceElement() {
var msgManager = plugin.getMessageManager();
inventory.setItem(START_PRICE_SLOT, new ItemBuilder(Material.GOLD_INGOT)
.name("&6Starting Price: " + msgManager.formatCurrency(startPrice))
.lore(
"",
"&7Minimum bid to start",
"&7the auction.",
"",
"&eClick to change"
)
.glow()
.build());
}
/**
* Updates the merged buyout element (displays + clickable)
*/
private void updateBuyoutElement() {
var msgManager = plugin.getMessageManager();
if (buyoutPrice != null) {
inventory.setItem(BUYOUT_SLOT, new ItemBuilder(Material.DIAMOND)
.name("&bBuyout: " + msgManager.formatCurrency(buyoutPrice))
.lore(
"",
"&7Instant purchase price.",
"",
"&eLeft-click to change",
"&cRight-click to remove"
)
.glow()
.build());
} else {
inventory.setItem(BUYOUT_SLOT, new ItemBuilder(Material.DIAMOND)
.name("&bBuyout: &7Not set")
.lore(
"",
"&7Optional instant purchase",
"&7price for your auction.",
"",
"&eClick to set buyout price"
)
.build());
}
}
/**
* Updates the merged duration element (displays + clickable)
*/
private void updateDurationElement() {
String durationText = formatDuration(durationHours);
inventory.setItem(DURATION_SLOT, new ItemBuilder(Material.CLOCK)
.name("&eDuration: " + durationText)
.lore(
"",
"&7Auction ends after this time.",
"",
"&eClick to change duration"
)
.glow()
.build());
}
private String formatDuration(int hours) {
if (hours >= 24) {
int days = hours / 24;
return days + " day" + (days > 1 ? "s" : "");
} else {
return hours + " hour" + (hours > 1 ? "s" : "");
}
}
private void updateConfirmButton() {
var msgManager = plugin.getMessageManager();
inventory.setItem(CONFIRM_SLOT, new ItemBuilder(Material.LIME_WOOL)
.name(msgManager.getButton("confirm"))
.lore(
"&7Item: &f" + selectedItem.getType().name() + " x" + selectedItem.getAmount(),
"&7Start: &a" + msgManager.formatCurrency(startPrice),
buyoutPrice != null ? "&7Buyout: &b" + msgManager.formatCurrency(buyoutPrice) : "&7Buyout: &7None",
"&7Duration: &e" + formatDuration(durationHours),
"",
"&aClick to create auction!"
)
.glow()
.build());
}
@Override
public void handleClick(InventoryClickEvent event) {
event.setCancelled(true);
Player player = (Player) event.getWhoClicked();
int slot = event.getRawSlot();
var msgManager = plugin.getMessageManager();
switch (slot) {
case START_PRICE_SLOT -> {
guiManager.openNumberInput(player, newPrice -> {
if (newPrice > 0) {
this.startPrice = newPrice;
// Ensure buyout is higher than start
if (buyoutPrice != null && buyoutPrice <= startPrice) {
buyoutPrice = null;
}
}
reopenGui();
}, startPrice, plugin.getConfigManager().getMinStartPrice(),
plugin.getConfigManager().getMaxStartPrice(),
msgManager.getRaw("gui-titles.number-input"));
}
case BUYOUT_SLOT -> {
if (event.isRightClick() && buyoutPrice != null) {
// Remove buyout
buyoutPrice = null;
updateBuyoutElement();
updateConfirmButton();
SoundUtil.playSound(player, plugin.getConfigManager().getClickSound());
} else {
// Set buyout
double defaultBuyout = buyoutPrice != null ? buyoutPrice : startPrice * 2;
guiManager.openNumberInput(player, newPrice -> {
if (newPrice > startPrice) {
this.buyoutPrice = newPrice;
}
reopenGui();
}, defaultBuyout, startPrice + 1, plugin.getConfigManager().getMaxPrice(),
msgManager.getRaw("gui-titles.number-input"));
}
}
case DURATION_SLOT -> {
List<Integer> durations = plugin.getConfigManager().getAvailableAuctionDurations();
if (!durations.isEmpty()) {
int currentIndex = durations.indexOf(durationHours);
int nextIndex = (currentIndex + 1) % durations.size();
durationHours = durations.get(nextIndex);
updateDurationElement();
updateConfirmButton();
SoundUtil.playSound(player, plugin.getConfigManager().getClickSound());
}
}
case CONFIRM_SLOT -> {
confirmAuction(player);
}
case BACK_SLOT -> {
// Go back to item selection
new ItemSelectionGui(plugin, guiManager, ItemSelectionGui.SelectionMode.AUCTION).open(player);
}
}
}
private void confirmAuction(Player player) {
var msgManager = plugin.getMessageManager();
// Verify item still exists in player's inventory with sufficient quantity
int available = InventoryUtil.countSimilarItems(player.getInventory(), selectedItem);
if (available < selectedItem.getAmount()) {
if (available < 1) {
player.sendMessage(msgManager.getPrefixed("messages.item-no-longer-available"));
} else {
player.sendMessage(msgManager.getPrefixed("messages.quantity-changed"));
}
SoundUtil.playSound(player, plugin.getConfigManager().getErrorSound());
new ItemSelectionGui(plugin, guiManager, ItemSelectionGui.SelectionMode.AUCTION).open(player);
return;
}
// Validate item again
var validation = plugin.getTransactionService().validateItem(selectedItem);
if (!validation.isValid()) {
player.sendMessage(msgManager.getPrefixed("messages." + validation.getErrorKey()));
SoundUtil.playSound(player, plugin.getConfigManager().getErrorSound());
return;
}
// Create the auction
ItemStack auctionItem = selectedItem.clone();
plugin.getTransactionService().createAuctionTransaction(
player, auctionItem, startPrice, buyoutPrice, durationHours
).thenAccept(result -> {
Bukkit.getScheduler().runTask(plugin, () -> {
if (result.isSuccess()) {
// Remove items from inventory AFTER successful creation
InventoryUtil.removeItems(player, auctionItem, auctionItem.getAmount());
player.sendMessage(msgManager.getPrefixed("messages.auction-created",
"id", String.valueOf(result.getId())));
SoundUtil.playSound(player, plugin.getConfigManager().getSuccessSound());
player.closeInventory();
guiManager.openMainMenu(player);
} else {
player.sendMessage(msgManager.getPrefixed("messages." + result.getErrorKey()));
SoundUtil.playSound(player, plugin.getConfigManager().getErrorSound());
}
});
});
}
private void reopenGui() {
var msgManager = plugin.getMessageManager();
String title = TextUtil.colorizeToString(msgManager.getRaw("gui-titles.create-auction"));
inventory = Bukkit.createInventory(this, 54, title);
buildGui();
player.openInventory(inventory);
}
@Override
public boolean allowsItemMovement() {
return false;
}
@Override
public GuiType getType() {
return GuiType.CREATE_AUCTION;
}
@Override
public Inventory getInventory() {
return inventory;
}
}
@@ -0,0 +1,325 @@
package pt.henrique.communityMarket.gui;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import pt.henrique.communityMarket.CommunityMarket;
import pt.henrique.communityMarket.util.ItemBuilder;
import pt.henrique.communityMarket.util.InventoryUtil;
import pt.henrique.communityMarket.util.SoundUtil;
import pt.henrique.communityMarket.util.TextUtil;
import java.util.List;
/**
* GUI for setting up a new fixed-price listing.
* This GUI is opened AFTER the player has selected an item and quantity.
* All elements are merged: price and duration are single clickable items.
*
* Layout (54-slot chest):
* ┌─────────────────────────────────────────────────────┐
* │ . . . . INFO . . . . │ Row 0 │
* │ . . . . ITEM . . . . │ Row 1: Item │
* │ . . . . . . . . . │ Row 2 │
* │ . . PRICE . . . DURATION │ Row 3: Setup │
* │ . . . . . . . . . │ Row 4 │
* │ BACK . . . CONFIRM . . . .│ Row 5: Action│
* └─────────────────────────────────────────────────────┘
*/
public class CreateListingGui implements MarketGui {
private final CommunityMarket plugin;
private final GuiManager guiManager;
private Inventory inventory;
private Player player;
// Selected item info (from ItemSelectionGui -> QuantitySelectGui)
private int sourceInventorySlot = -1;
private ItemStack selectedItem = null;
// Listing settings
private double price;
private int durationHours;
// ==================== LAYOUT CONSTANTS ====================
private static final int INFO_SLOT = 4; // Top center
private static final int ITEM_DISPLAY_SLOT = 13; // Center row 1
// Row 3: Merged elements (single slot each)
private static final int PRICE_SLOT = 29; // Price (display + click to adjust)
private static final int DURATION_SLOT = 33; // Duration (display + click to cycle)
private static final int BACK_SLOT = 45; // Bottom-left
private static final int CONFIRM_SLOT = 49; // Bottom-center
// ===========================================================
public CreateListingGui(CommunityMarket plugin, GuiManager guiManager) {
this.plugin = plugin;
this.guiManager = guiManager;
}
/**
* Opens the listing creation GUI with a pre-selected item and quantity.
* Called from QuantitySelectGui or ItemSelectionGui (for unstackable items).
*
* @param player The player
* @param inventorySlot The slot in the player's inventory where the item is
* @param item A clone of the selected item with the desired quantity
*/
public void openWithItem(Player player, int inventorySlot, ItemStack item) {
this.player = player;
this.sourceInventorySlot = inventorySlot;
this.selectedItem = item;
this.durationHours = plugin.getConfigManager().getDefaultDurationHours();
this.price = plugin.getConfigManager().getMinPrice();
var msgManager = plugin.getMessageManager();
String title = TextUtil.colorizeToString(msgManager.getRaw("gui-titles.create-listing"));
inventory = Bukkit.createInventory(this, 54, title);
buildGui();
player.openInventory(inventory);
guiManager.registerGui(player.getUniqueId(), this);
}
/**
* @deprecated Use openWithItem instead. This opens item selection first.
*/
public void open(Player player) {
new ItemSelectionGui(plugin, guiManager, ItemSelectionGui.SelectionMode.LISTING).open(player);
}
private void buildGui() {
var msgManager = plugin.getMessageManager();
// Fill with glass
ItemStack filler = new ItemBuilder(Material.GRAY_STAINED_GLASS_PANE).name(" ").build();
for (int i = 0; i < 54; i++) {
inventory.setItem(i, filler);
}
// Info panel
inventory.setItem(INFO_SLOT, new ItemBuilder(Material.OAK_SIGN)
.name("&6&lCreate Listing")
.lore(
"&7Set a price and duration",
"&7for your listing.",
"",
"&7Tax: &f" + plugin.getConfigManager().getMarketTax() + "%"
)
.build());
// Selected item display
if (selectedItem != null) {
inventory.setItem(ITEM_DISPLAY_SLOT, new ItemBuilder(selectedItem.clone())
.addLore(List.of(
"",
"&7Quantity: &f" + selectedItem.getAmount(),
"&eThis item will be listed"
))
.build());
}
// Merged price element
updatePriceElement();
// Merged duration element
updateDurationElement();
// Back button
inventory.setItem(BACK_SLOT, new ItemBuilder(Material.RED_WOOL)
.name(msgManager.getButton("back"))
.lore("&7Return to item selection")
.build());
// Confirm button
updateConfirmButton();
}
/**
* Updates the merged price element (displays current price + clickable to change)
*/
private void updatePriceElement() {
var msgManager = plugin.getMessageManager();
double tax = plugin.getTransactionService().calculateListingTax(price);
double earnings = price - tax;
inventory.setItem(PRICE_SLOT, new ItemBuilder(Material.GOLD_INGOT)
.name("&6Price: " + msgManager.formatCurrency(price))
.lore(
"",
"&7Tax (" + plugin.getConfigManager().getMarketTax() + "%): &c" + msgManager.formatCurrency(tax),
"&7You receive: &a" + msgManager.formatCurrency(earnings),
"",
"&eClick to change price"
)
.glow()
.build());
}
/**
* Updates the merged duration element (displays current duration + clickable to cycle)
*/
private void updateDurationElement() {
String durationText = formatDuration(durationHours);
inventory.setItem(DURATION_SLOT, new ItemBuilder(Material.CLOCK)
.name("&eDuration: " + durationText)
.lore(
"",
"&7Listing expires after this time",
"",
"&eClick to change duration"
)
.glow()
.build());
}
private String formatDuration(int hours) {
if (hours >= 24) {
int days = hours / 24;
return days + " day" + (days > 1 ? "s" : "");
} else {
return hours + " hour" + (hours > 1 ? "s" : "");
}
}
private void updateConfirmButton() {
var msgManager = plugin.getMessageManager();
double tax = plugin.getTransactionService().calculateListingTax(price);
double earnings = price - tax;
inventory.setItem(CONFIRM_SLOT, new ItemBuilder(Material.LIME_WOOL)
.name(msgManager.getButton("confirm"))
.lore(
"&7Item: &f" + selectedItem.getType().name() + " x" + selectedItem.getAmount(),
"&7Price: &a" + msgManager.formatCurrency(price),
"&7You receive: &a" + msgManager.formatCurrency(earnings),
"&7Duration: &e" + formatDuration(durationHours),
"",
"&aClick to create listing!"
)
.glow()
.build());
}
@Override
public void handleClick(InventoryClickEvent event) {
event.setCancelled(true);
Player player = (Player) event.getWhoClicked();
int slot = event.getRawSlot();
var msgManager = plugin.getMessageManager();
switch (slot) {
case PRICE_SLOT -> {
// Open number input for price
guiManager.openNumberInput(player, newPrice -> {
if (newPrice > 0) {
this.price = newPrice;
}
reopenGui();
}, price, plugin.getConfigManager().getMinPrice(),
plugin.getConfigManager().getMaxPrice(),
msgManager.getRaw("gui-titles.number-input"));
}
case DURATION_SLOT -> {
// Cycle through available durations
List<Integer> durations = plugin.getConfigManager().getAvailableDurations();
if (!durations.isEmpty()) {
int currentIndex = durations.indexOf(durationHours);
int nextIndex = (currentIndex + 1) % durations.size();
durationHours = durations.get(nextIndex);
updateDurationElement();
updateConfirmButton();
SoundUtil.playSound(player, plugin.getConfigManager().getClickSound());
}
}
case CONFIRM_SLOT -> {
confirmListing(player);
}
case BACK_SLOT -> {
// Go back to item selection
new ItemSelectionGui(plugin, guiManager, ItemSelectionGui.SelectionMode.LISTING).open(player);
}
}
}
private void confirmListing(Player player) {
var msgManager = plugin.getMessageManager();
// Verify item still exists in player's inventory with sufficient quantity
int available = InventoryUtil.countSimilarItems(player.getInventory(), selectedItem);
if (available < selectedItem.getAmount()) {
if (available < 1) {
player.sendMessage(msgManager.getPrefixed("messages.item-no-longer-available"));
} else {
player.sendMessage(msgManager.getPrefixed("messages.quantity-changed"));
}
SoundUtil.playSound(player, plugin.getConfigManager().getErrorSound());
new ItemSelectionGui(plugin, guiManager, ItemSelectionGui.SelectionMode.LISTING).open(player);
return;
}
// Validate item again
var validation = plugin.getTransactionService().validateItem(selectedItem);
if (!validation.isValid()) {
player.sendMessage(msgManager.getPrefixed("messages." + validation.getErrorKey()));
SoundUtil.playSound(player, plugin.getConfigManager().getErrorSound());
return;
}
// Create the listing
int amount = selectedItem.getAmount();
ItemStack listItem = selectedItem.clone();
plugin.getTransactionService().createListingTransaction(
player, listItem, amount, price, durationHours
).thenAccept(result -> {
Bukkit.getScheduler().runTask(plugin, () -> {
if (result.isSuccess()) {
// Remove items from inventory AFTER successful creation
InventoryUtil.removeItems(player, listItem, amount);
player.sendMessage(msgManager.getPrefixed("messages.listing-created",
"id", String.valueOf(result.getId())));
SoundUtil.playSound(player, plugin.getConfigManager().getSuccessSound());
player.closeInventory();
guiManager.openMainMenu(player);
} else {
player.sendMessage(msgManager.getPrefixed("messages." + result.getErrorKey()));
SoundUtil.playSound(player, plugin.getConfigManager().getErrorSound());
}
});
});
}
private void reopenGui() {
var msgManager = plugin.getMessageManager();
String title = TextUtil.colorizeToString(msgManager.getRaw("gui-titles.create-listing"));
inventory = Bukkit.createInventory(this, 54, title);
buildGui();
player.openInventory(inventory);
}
@Override
public boolean allowsItemMovement() {
return false;
}
@Override
public GuiType getType() {
return GuiType.CREATE_LISTING;
}
@Override
public Inventory getInventory() {
return inventory;
}
}
@@ -0,0 +1,155 @@
package pt.henrique.communityMarket.gui;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import pt.henrique.communityMarket.CommunityMarket;
import pt.henrique.communityMarket.util.ItemBuilder;
import pt.henrique.communityMarket.util.TextUtil;
/**
* GUI for viewing and withdrawing pending earnings from sales.
*/
public class EarningsGui implements MarketGui {
private final CommunityMarket plugin;
private final GuiManager guiManager;
private Inventory inventory;
private Player player;
private double pendingAmount;
private static final int EARNINGS_DISPLAY_SLOT = 13;
private static final int WITHDRAW_SLOT = 31;
private static final int BACK_SLOT = 49;
public EarningsGui(CommunityMarket plugin, GuiManager guiManager) {
this.plugin = plugin;
this.guiManager = guiManager;
}
public void open(Player player) {
this.player = player;
plugin.getEarningsService().getPendingEarnings(player.getUniqueId())
.thenAccept(amount -> {
this.pendingAmount = amount;
Bukkit.getScheduler().runTask(plugin, () -> {
buildGui();
player.openInventory(inventory);
guiManager.registerGui(player.getUniqueId(), this);
});
});
}
private void buildGui() {
var msgManager = plugin.getMessageManager();
String title = TextUtil.colorizeToString(msgManager.getRaw("gui-titles.earnings"));
inventory = Bukkit.createInventory(this, 54, title);
// Fill with glass
ItemStack filler = new ItemBuilder(Material.GRAY_STAINED_GLASS_PANE).name(" ").build();
for (int i = 0; i < 54; i++) {
inventory.setItem(i, filler);
}
// Earnings display
inventory.setItem(EARNINGS_DISPLAY_SLOT, new ItemBuilder(Material.EMERALD_BLOCK)
.name("&a&lPending Earnings")
.lore(
"",
"&7Total: &a" + msgManager.formatCurrency(pendingAmount),
"",
"&7This is money from your sales",
"&7waiting to be withdrawn."
)
.glow(pendingAmount > 0)
.build());
// Withdraw button
if (pendingAmount > 0) {
inventory.setItem(WITHDRAW_SLOT, new ItemBuilder(Material.GOLD_BLOCK)
.name(msgManager.getButton("withdraw"))
.lore(
"&7Click to withdraw all earnings",
"&7Amount: &a" + msgManager.formatCurrency(pendingAmount)
)
.glow()
.build());
} else {
inventory.setItem(WITHDRAW_SLOT, new ItemBuilder(Material.BARRIER)
.name("&cNo Earnings")
.lore("&7You have no pending earnings to withdraw.")
.build());
}
// Back button
inventory.setItem(BACK_SLOT, new ItemBuilder(Material.BARRIER)
.name(msgManager.getButton("back"))
.build());
}
@Override
public void handleClick(InventoryClickEvent event) {
event.setCancelled(true);
Player player = (Player) event.getWhoClicked();
int slot = event.getRawSlot();
if (slot == BACK_SLOT) {
guiManager.openMainMenu(player);
return;
}
if (slot == WITHDRAW_SLOT && pendingAmount > 0) {
withdrawEarnings(player);
}
}
private void withdrawEarnings(Player player) {
var msgManager = plugin.getMessageManager();
plugin.getEarningsService().withdrawAll(player)
.thenAccept(result -> {
Bukkit.getScheduler().runTask(plugin, () -> {
if (result.isSuccess()) {
double newBalance = plugin.getEconomyManager().getBalance(player.getUniqueId());
player.sendMessage(msgManager.getPrefixed("messages.earnings-withdrawn",
java.util.Map.of(
"amount", msgManager.formatCurrency(result.getAmount()),
"balance", msgManager.formatCurrency(newBalance)
)));
playSound(player, plugin.getConfigManager().getSuccessSound());
open(player); // Refresh
} else {
if ("no_earnings".equals(result.getError())) {
player.sendMessage(msgManager.getPrefixed("messages.earnings-empty"));
} else {
player.sendMessage(msgManager.getPrefixed("messages.earnings-empty"));
}
playSound(player, plugin.getConfigManager().getErrorSound());
}
});
});
}
private void playSound(Player player, String soundName) {
pt.henrique.communityMarket.util.SoundUtil.playSound(player, soundName);
}
@Override
public GuiType getType() {
return GuiType.EARNINGS;
}
@Override
public Inventory getInventory() {
return inventory;
}
}
@@ -0,0 +1,211 @@
package pt.henrique.communityMarket.gui;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.InventoryHolder;
import org.jetbrains.annotations.NotNull;
import pt.henrique.communityMarket.CommunityMarket;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* Manages all GUI instances and player GUI states.
* Central hub for opening, closing, and tracking GUI interactions.
*/
public class GuiManager {
private final CommunityMarket plugin;
// Track open GUIs per player
private final Map<UUID, MarketGui> openGuis = new HashMap<>();
// GUI builders/templates
private final MainMenuGui mainMenuGui;
private final BrowseMarketGui browseMarketGui;
private final BrowseAuctionsGui browseAuctionsGui;
private final CreateListingGui createListingGui;
private final CreateAuctionGui createAuctionGui;
private final MyListingsGui myListingsGui;
private final MyAuctionsGui myAuctionsGui;
private final ClaimGui claimGui;
private final EarningsGui earningsGui;
private final HelpGui helpGui;
private final AdminGui adminGui;
public GuiManager(CommunityMarket plugin) {
this.plugin = plugin;
// Initialize GUI handlers
this.mainMenuGui = new MainMenuGui(plugin, this);
this.browseMarketGui = new BrowseMarketGui(plugin, this);
this.browseAuctionsGui = new BrowseAuctionsGui(plugin, this);
this.createListingGui = new CreateListingGui(plugin, this);
this.createAuctionGui = new CreateAuctionGui(plugin, this);
this.myListingsGui = new MyListingsGui(plugin, this);
this.myAuctionsGui = new MyAuctionsGui(plugin, this);
this.claimGui = new ClaimGui(plugin, this);
this.earningsGui = new EarningsGui(plugin, this);
this.helpGui = new HelpGui(plugin, this);
this.adminGui = new AdminGui(plugin, this);
}
/**
* Opens the main menu for a player
*/
public void openMainMenu(Player player) {
mainMenuGui.open(player);
}
/**
* Opens the browse market GUI
*/
public void openBrowseMarket(Player player, int page) {
browseMarketGui.open(player, page);
}
/**
* Opens the browse auctions GUI
*/
public void openBrowseAuctions(Player player, int page) {
browseAuctionsGui.open(player, page);
}
/**
* Opens the create listing flow (starts with item selection)
*/
public void openCreateListing(Player player) {
// Open item selection first, which will then open CreateListingGui
new ItemSelectionGui(plugin, this, ItemSelectionGui.SelectionMode.LISTING).open(player);
}
/**
* Opens the create auction flow (starts with item selection)
*/
public void openCreateAuction(Player player) {
// Open item selection first, which will then open CreateAuctionGui
new ItemSelectionGui(plugin, this, ItemSelectionGui.SelectionMode.AUCTION).open(player);
}
/**
* Opens the player's listings GUI
*/
public void openMyListings(Player player) {
myListingsGui.open(player);
}
/**
* Opens the player's auctions GUI
*/
public void openMyAuctions(Player player) {
myAuctionsGui.open(player);
}
/**
* Opens the claim GUI
*/
public void openClaim(Player player) {
claimGui.open(player);
}
/**
* Opens the earnings GUI
*/
public void openEarnings(Player player) {
earningsGui.open(player);
}
/**
* Opens the help GUI
*/
public void openHelp(Player player) {
helpGui.open(player);
}
/**
* Opens the admin panel
*/
public void openAdmin(Player player) {
adminGui.open(player);
}
/**
* Opens a number input GUI
*/
public void openNumberInput(Player player, NumberInputGui.NumberInputCallback callback,
double currentValue, double minValue, double maxValue, String title) {
new NumberInputGui(plugin, this, callback, currentValue, minValue, maxValue, title).open(player);
}
/**
* Opens a confirmation GUI
*/
public void openConfirmation(Player player, ConfirmationGui.ConfirmCallback callback,
String title, String... infoLines) {
new ConfirmationGui(plugin, this, callback, title, infoLines).open(player);
}
/**
* Registers an open GUI for a player
*/
public void registerGui(UUID playerUuid, MarketGui gui) {
openGuis.put(playerUuid, gui);
}
/**
* Unregisters a GUI when closed
*/
public void unregisterGui(UUID playerUuid) {
openGuis.remove(playerUuid);
}
/**
* Gets the currently open GUI for a player
*/
public MarketGui getOpenGui(UUID playerUuid) {
return openGuis.get(playerUuid);
}
/**
* Checks if a player has a market GUI open
*/
public boolean hasGuiOpen(UUID playerUuid) {
return openGuis.containsKey(playerUuid);
}
/**
* Closes all open market GUIs (used on plugin disable)
*/
public void closeAllGuis() {
for (UUID uuid : openGuis.keySet()) {
Player player = Bukkit.getPlayer(uuid);
if (player != null && player.isOnline()) {
player.closeInventory();
}
}
openGuis.clear();
}
/**
* Gets the plugin instance
*/
public CommunityMarket getPlugin() {
return plugin;
}
// Getters for GUI handlers
public MainMenuGui getMainMenuGui() { return mainMenuGui; }
public BrowseMarketGui getBrowseMarketGui() { return browseMarketGui; }
public BrowseAuctionsGui getBrowseAuctionsGui() { return browseAuctionsGui; }
public CreateListingGui getCreateListingGui() { return createListingGui; }
public CreateAuctionGui getCreateAuctionGui() { return createAuctionGui; }
public MyListingsGui getMyListingsGui() { return myListingsGui; }
public MyAuctionsGui getMyAuctionsGui() { return myAuctionsGui; }
public ClaimGui getClaimGui() { return claimGui; }
public EarningsGui getEarningsGui() { return earningsGui; }
public HelpGui getHelpGui() { return helpGui; }
public AdminGui getAdminGui() { return adminGui; }
}
@@ -0,0 +1,187 @@
package pt.henrique.communityMarket.gui;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import pt.henrique.communityMarket.CommunityMarket;
import pt.henrique.communityMarket.util.ItemBuilder;
import pt.henrique.communityMarket.util.TextUtil;
import java.util.List;
/**
* Help GUI showing how to use the marketplace.
*/
public class HelpGui implements MarketGui {
private final CommunityMarket plugin;
private final GuiManager guiManager;
private Inventory inventory;
private static final int BACK_SLOT = 49;
public HelpGui(CommunityMarket plugin, GuiManager guiManager) {
this.plugin = plugin;
this.guiManager = guiManager;
}
public void open(Player player) {
var msgManager = plugin.getMessageManager();
String title = TextUtil.colorizeToString(msgManager.getRaw("gui-titles.help"));
inventory = Bukkit.createInventory(this, 54, title);
buildGui();
player.openInventory(inventory);
guiManager.registerGui(player.getUniqueId(), this);
}
private void buildGui() {
var msgManager = plugin.getMessageManager();
// Fill with glass
ItemStack filler = new ItemBuilder(Material.GRAY_STAINED_GLASS_PANE).name(" ").build();
for (int i = 0; i < 54; i++) {
inventory.setItem(i, filler);
}
// Help content
List<String> helpContent = msgManager.getList("help.content");
// Main help book
inventory.setItem(4, new ItemBuilder(Material.WRITTEN_BOOK)
.name(msgManager.getRaw("help.title"))
.build());
// Feature explanations
inventory.setItem(19, new ItemBuilder(Material.CHEST)
.name("&aBrowse Market")
.lore(
"&7View all fixed-price listings",
"&7from other players.",
"",
"&7Click on items to purchase them."
)
.build());
inventory.setItem(20, new ItemBuilder(Material.GOLD_INGOT)
.name("&6Browse Auctions")
.lore(
"&7View all active auctions.",
"",
"&eLeft-click &7to place a bid",
"&eRight-click &7to buyout (if available)"
)
.build());
inventory.setItem(21, new ItemBuilder(Material.WRITABLE_BOOK)
.name("&eCreate Listing")
.lore(
"&7Sell items at a fixed price.",
"",
"&71. Place your item in the slot",
"&72. Set the price",
"&73. Choose duration",
"&74. Click confirm"
)
.build());
inventory.setItem(22, new ItemBuilder(Material.GOLDEN_HELMET)
.name("&eCreate Auction")
.lore(
"&7Auction items to the highest bidder.",
"",
"&71. Place your item in the slot",
"&72. Set starting price",
"&73. Optionally set buyout",
"&74. Choose duration",
"&75. Click confirm"
)
.build());
inventory.setItem(23, new ItemBuilder(Material.BOOK)
.name("&bMy Listings")
.lore(
"&7View your active listings.",
"",
"&7Click on a listing to cancel it.",
"&7Cancelled items go to claim storage."
)
.build());
inventory.setItem(24, new ItemBuilder(Material.CLOCK)
.name("&bMy Auctions")
.lore(
"&7View your active auctions.",
"",
"&7You can only cancel auctions",
"&7that have no bids yet."
)
.build());
inventory.setItem(25, new ItemBuilder(Material.ENDER_CHEST)
.name("&dClaim Items")
.lore(
"&7Collect items waiting for you:",
"",
"&7- Expired listings",
"&7- Cancelled listings",
"&7- Won auctions",
"&7- Auction refunds"
)
.build());
inventory.setItem(31, new ItemBuilder(Material.EMERALD)
.name("&aEarnings")
.lore(
"&7Withdraw money from sales.",
"",
"&7When you sell something, the money",
"&7goes to pending earnings first.",
"&7Withdraw it here."
)
.build());
// Tax info
inventory.setItem(40, new ItemBuilder(Material.GOLD_NUGGET)
.name("&6Tax Information")
.lore(
"&7Market Tax: &f" + plugin.getConfigManager().getMarketTax() + "%",
"&7Auction Tax: &f" + plugin.getConfigManager().getAuctionTax() + "%",
"",
"&7Taxes are deducted from seller earnings."
)
.build());
// Back button
inventory.setItem(BACK_SLOT, new ItemBuilder(Material.BARRIER)
.name(msgManager.getButton("back"))
.build());
}
@Override
public void handleClick(InventoryClickEvent event) {
event.setCancelled(true);
Player player = (Player) event.getWhoClicked();
int slot = event.getRawSlot();
if (slot == BACK_SLOT) {
guiManager.openMainMenu(player);
}
}
@Override
public GuiType getType() {
return GuiType.HELP;
}
@Override
public Inventory getInventory() {
return inventory;
}
}
@@ -0,0 +1,260 @@
package pt.henrique.communityMarket.gui;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
import pt.henrique.communityMarket.CommunityMarket;
import pt.henrique.communityMarket.util.ItemBuilder;
import pt.henrique.communityMarket.util.InventoryUtil;
import pt.henrique.communityMarket.util.SoundUtil;
import pt.henrique.communityMarket.util.TextUtil;
import java.util.HashMap;
import java.util.Map;
/**
* GUI for selecting an item from the player's inventory to list or auction.
* Displays a mirror view of the player's inventory as clickable icons.
* This replaces the "drag item into slot" workflow for better UX.
*/
public class ItemSelectionGui implements MarketGui {
private final CommunityMarket plugin;
private final GuiManager guiManager;
private final SelectionMode mode;
private Inventory inventory;
private Player player;
// Maps GUI slot -> player inventory slot for tracking selections
private final Map<Integer, Integer> slotMapping = new HashMap<>();
// Layout: 54-slot chest
// Rows 0-3 (slots 0-35): Player inventory mirror
// Row 4 (slots 36-44): Hotbar mirror
// Row 5 (slots 45-53): Navigation/info bar
private static final int INFO_SLOT = 49;
private static final int BACK_SLOT = 45;
public enum SelectionMode {
LISTING,
AUCTION
}
public ItemSelectionGui(CommunityMarket plugin, GuiManager guiManager, SelectionMode mode) {
this.plugin = plugin;
this.guiManager = guiManager;
this.mode = mode;
}
/**
* Opens the item selection GUI for a player
*/
public void open(Player player) {
this.player = player;
var msgManager = plugin.getMessageManager();
String titleKey = mode == SelectionMode.LISTING
? "gui-titles.select-item-listing"
: "gui-titles.select-item-auction";
String title = TextUtil.colorizeToString(msgManager.getRaw(titleKey));
inventory = Bukkit.createInventory(this, 54, title);
buildGui();
player.openInventory(inventory);
guiManager.registerGui(player.getUniqueId(), this);
}
private void buildGui() {
var msgManager = plugin.getMessageManager();
slotMapping.clear();
// Fill bottom row with glass
ItemStack filler = new ItemBuilder(Material.GRAY_STAINED_GLASS_PANE).name(" ").build();
for (int i = 45; i < 54; i++) {
inventory.setItem(i, filler);
}
PlayerInventory playerInv = player.getInventory();
// Mirror player's main inventory (slots 9-35 in player inv -> slots 0-26 in GUI)
for (int i = 9; i < 36; i++) {
ItemStack item = playerInv.getItem(i);
int guiSlot = i - 9; // Maps 9-35 to 0-26
if (item != null && !item.getType().isAir()) {
// Check if item can be listed
if (canBeSelected(item)) {
inventory.setItem(guiSlot, createSelectableItem(item));
slotMapping.put(guiSlot, i);
} else {
// Show as blocked/unavailable
inventory.setItem(guiSlot, createBlockedItem(item));
}
}
}
// Mirror hotbar (slots 0-8 in player inv -> slots 27-35 in GUI)
for (int i = 0; i < 9; i++) {
ItemStack item = playerInv.getItem(i);
int guiSlot = 27 + i; // Maps 0-8 to 27-35
if (item != null && !item.getType().isAir()) {
if (canBeSelected(item)) {
inventory.setItem(guiSlot, createSelectableItem(item));
slotMapping.put(guiSlot, i);
} else {
inventory.setItem(guiSlot, createBlockedItem(item));
}
}
}
// Info display
String modeText = mode == SelectionMode.LISTING ? "&eListing" : "&6Auction";
inventory.setItem(INFO_SLOT, new ItemBuilder(Material.PAPER)
.name("&fSelect an Item")
.lore(
"&7Click on an item from your",
"&7inventory to create a " + modeText + "&7.",
"",
"&7Blacklisted items are shown in red."
)
.build());
// Back button
inventory.setItem(BACK_SLOT, new ItemBuilder(Material.BARRIER)
.name(msgManager.getButton("back"))
.lore("&7Return to main menu")
.build());
}
/**
* Checks if an item can be selected for listing/auction
*/
private boolean canBeSelected(ItemStack item) {
if (item == null || item.getType().isAir()) {
return false;
}
// Check material blacklist
if (plugin.getConfigManager().isMaterialBlacklisted(item.getType())) {
return false;
}
// Check keyword blacklist in name/lore
var validation = plugin.getTransactionService().validateItem(item);
return validation.isValid();
}
/**
* Creates a display item with selection lore
*/
private ItemStack createSelectableItem(ItemStack original) {
return new ItemBuilder(original.clone())
.addLore(java.util.List.of(
"",
"&a► Click to select"
))
.build();
}
/**
* Creates a blocked/unavailable item display
*/
private ItemStack createBlockedItem(ItemStack original) {
return new ItemBuilder(Material.BARRIER)
.name("&c" + original.getType().name())
.lore(
"&7This item cannot be listed.",
"&cBlacklisted or invalid."
)
.build();
}
@Override
public void handleClick(InventoryClickEvent event) {
event.setCancelled(true);
Player player = (Player) event.getWhoClicked();
int slot = event.getRawSlot();
// Back button
if (slot == BACK_SLOT) {
guiManager.openMainMenu(player);
return;
}
// Check if clicked slot contains a selectable item
if (slotMapping.containsKey(slot)) {
int playerInvSlot = slotMapping.get(slot);
ItemStack selectedItem = player.getInventory().getItem(playerInvSlot);
// Verify item still exists
if (selectedItem == null || selectedItem.getType().isAir()) {
player.sendMessage(plugin.getMessageManager().getPrefixed("messages.invalid-item"));
SoundUtil.playSound(player, plugin.getConfigManager().getErrorSound());
open(player); // Refresh
return;
}
// Verify item is still valid
if (!canBeSelected(selectedItem)) {
player.sendMessage(plugin.getMessageManager().getPrefixed("messages.blacklisted-item"));
SoundUtil.playSound(player, plugin.getConfigManager().getErrorSound());
return;
}
SoundUtil.playSound(player, plugin.getConfigManager().getClickSound());
// Check if item is stackable (max stack size > 1)
int maxStackSize = selectedItem.getMaxStackSize();
int availableQuantity = InventoryUtil.countSimilarItems(player.getInventory(), selectedItem);
if (maxStackSize > 1 && availableQuantity > 1) {
// Stackable item with more than 1 available - show quantity selector
new QuantitySelectGui(plugin, guiManager, quantity -> {
if (quantity > 0) {
// Proceed with the selected quantity
ItemStack itemWithQuantity = selectedItem.clone();
itemWithQuantity.setAmount(quantity);
if (mode == SelectionMode.LISTING) {
guiManager.getCreateListingGui().openWithItem(player, playerInvSlot, itemWithQuantity);
} else {
guiManager.getCreateAuctionGui().openWithItem(player, playerInvSlot, itemWithQuantity);
}
} else {
// User cancelled - go back to item selection
open(player);
}
}, selectedItem, availableQuantity).open(player);
} else {
// Unstackable item or only 1 available - skip quantity selection
ItemStack singleItem = selectedItem.clone();
singleItem.setAmount(1);
if (mode == SelectionMode.LISTING) {
guiManager.getCreateListingGui().openWithItem(player, playerInvSlot, singleItem);
} else {
guiManager.getCreateAuctionGui().openWithItem(player, playerInvSlot, singleItem);
}
}
}
}
@Override
public GuiType getType() {
return GuiType.ITEM_SELECTION;
}
@Override
public Inventory getInventory() {
return inventory;
}
}
@@ -0,0 +1,261 @@
package pt.henrique.communityMarket.gui;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import pt.henrique.communityMarket.CommunityMarket;
import pt.henrique.communityMarket.util.ItemBuilder;
import pt.henrique.communityMarket.util.SoundUtil;
import pt.henrique.communityMarket.util.TextUtil;
import java.util.Map;
/**
* The main menu GUI - the hub of the marketplace.
* Contains buttons for all major features.
*/
public class MainMenuGui implements MarketGui {
private final CommunityMarket plugin;
private final GuiManager guiManager;
private Inventory inventory;
private Player player;
// Slot positions for buttons
private static final int SLOT_BROWSE_MARKET = 10;
private static final int SLOT_BROWSE_AUCTIONS = 12;
private static final int SLOT_CREATE_LISTING = 14;
private static final int SLOT_CREATE_AUCTION = 16;
private static final int SLOT_MY_LISTINGS = 28;
private static final int SLOT_MY_AUCTIONS = 30;
private static final int SLOT_CLAIM = 32;
private static final int SLOT_EARNINGS = 34;
private static final int SLOT_HELP = 40;
private static final int SLOT_ADMIN = 44;
public MainMenuGui(CommunityMarket plugin, GuiManager guiManager) {
this.plugin = plugin;
this.guiManager = guiManager;
}
/**
* Opens the main menu for a player
*/
public void open(Player player) {
this.player = player;
String title = TextUtil.colorizeToString(
plugin.getMessageManager().getRaw("gui-titles.main-menu"));
inventory = Bukkit.createInventory(this, 54, title);
// Fill background with glass panes
ItemStack filler = new ItemBuilder(Material.GRAY_STAINED_GLASS_PANE)
.name(" ")
.build();
for (int i = 0; i < 54; i++) {
inventory.setItem(i, filler);
}
// Build menu items asynchronously (to get counts from DB)
buildMenuAsync();
}
private void buildMenuAsync() {
// Get player stats for lore
var listingCountFuture = plugin.getListingService().countPlayerListings(player.getUniqueId());
var auctionCountFuture = plugin.getAuctionService().countPlayerAuctions(player.getUniqueId());
var claimCountFuture = plugin.getClaimService().countPlayerClaimItems(player.getUniqueId());
var earningsFuture = plugin.getEarningsService().getPendingEarnings(player.getUniqueId());
// When all futures complete, build the GUI
listingCountFuture.thenCombine(auctionCountFuture, (listingCount, auctionCount) ->
claimCountFuture.thenCombine(earningsFuture, (claimCount, earnings) -> {
// Run on main thread
Bukkit.getScheduler().runTask(plugin, () -> {
buildMenu(listingCount, auctionCount, claimCount, earnings);
player.openInventory(inventory);
guiManager.registerGui(player.getUniqueId(), this);
playSound(player, plugin.getConfigManager().getClickSound());
});
return null;
})
);
}
private void buildMenu(int listingCount, int auctionCount, int claimCount, double earnings) {
var msgManager = plugin.getMessageManager();
var configManager = plugin.getConfigManager();
// Browse Market
inventory.setItem(SLOT_BROWSE_MARKET, new ItemBuilder(Material.CHEST)
.name(msgManager.getButton("browse-market"))
.lore(msgManager.getLore("browse-market"))
.build());
// Browse Auctions
inventory.setItem(SLOT_BROWSE_AUCTIONS, new ItemBuilder(Material.GOLD_INGOT)
.name(msgManager.getButton("browse-auctions"))
.lore(msgManager.getLore("browse-auctions"))
.build());
// Create Listing
inventory.setItem(SLOT_CREATE_LISTING, new ItemBuilder(Material.WRITABLE_BOOK)
.name(msgManager.getButton("create-listing"))
.lore(msgManager.getLore("create-listing", Map.of(
"tax", String.valueOf(configManager.getMarketTax())
)))
.build());
// Create Auction
inventory.setItem(SLOT_CREATE_AUCTION, new ItemBuilder(Material.GOLDEN_HELMET)
.name(msgManager.getButton("create-auction"))
.lore(msgManager.getLore("create-auction", Map.of(
"tax", String.valueOf(configManager.getAuctionTax())
)))
.build());
// My Listings
inventory.setItem(SLOT_MY_LISTINGS, new ItemBuilder(Material.BOOK)
.name(msgManager.getButton("my-listings"))
.lore(msgManager.getLore("my-listings", Map.of(
"count", String.valueOf(listingCount),
"max", String.valueOf(configManager.getMaxListingsPerPlayer())
)))
.build());
// My Auctions
inventory.setItem(SLOT_MY_AUCTIONS, new ItemBuilder(Material.CLOCK)
.name(msgManager.getButton("my-auctions"))
.lore(msgManager.getLore("my-auctions", Map.of(
"count", String.valueOf(auctionCount),
"max", String.valueOf(configManager.getMaxAuctionsPerPlayer())
)))
.build());
// Claim Items
inventory.setItem(SLOT_CLAIM, new ItemBuilder(Material.ENDER_CHEST)
.name(msgManager.getButton("claim-items"))
.lore(msgManager.getLore("claim-items", Map.of(
"count", String.valueOf(claimCount)
)))
.glow(claimCount > 0) // Glow if there are items to claim
.build());
// Earnings
inventory.setItem(SLOT_EARNINGS, new ItemBuilder(Material.EMERALD)
.name(msgManager.getButton("earnings"))
.lore(msgManager.getLore("earnings", Map.of(
"amount", msgManager.formatCurrency(earnings)
)))
.glow(earnings > 0) // Glow if there are earnings
.build());
// Help (only if enabled in config)
if (configManager.isHelpButtonEnabled()) {
inventory.setItem(SLOT_HELP, new ItemBuilder(Material.OAK_SIGN)
.name(msgManager.getButton("help"))
.lore(msgManager.getLore("help"))
.build());
}
// If help is disabled, the slot stays as glass pane (already filled)
// Admin button (only if player has permission)
if (player.hasPermission("communitymarket.admin")) {
inventory.setItem(SLOT_ADMIN, new ItemBuilder(Material.COMMAND_BLOCK)
.name(msgManager.getButton("admin"))
.lore(msgManager.getLore("admin"))
.build());
}
}
@Override
public void handleClick(InventoryClickEvent event) {
event.setCancelled(true);
if (event.getCurrentItem() == null) return;
Player player = (Player) event.getWhoClicked();
int slot = event.getRawSlot();
switch (slot) {
case SLOT_BROWSE_MARKET -> {
if (player.hasPermission("communitymarket.buy")) {
guiManager.openBrowseMarket(player, 0);
} else {
player.sendMessage(plugin.getMessageManager().getPrefixed("messages.no-permission"));
playSound(player, plugin.getConfigManager().getErrorSound());
}
}
case SLOT_BROWSE_AUCTIONS -> {
if (player.hasPermission("communitymarket.bid")) {
guiManager.openBrowseAuctions(player, 0);
} else {
player.sendMessage(plugin.getMessageManager().getPrefixed("messages.no-permission"));
playSound(player, plugin.getConfigManager().getErrorSound());
}
}
case SLOT_CREATE_LISTING -> {
if (player.hasPermission("communitymarket.sell")) {
guiManager.openCreateListing(player);
} else {
player.sendMessage(plugin.getMessageManager().getPrefixed("messages.no-permission"));
playSound(player, plugin.getConfigManager().getErrorSound());
}
}
case SLOT_CREATE_AUCTION -> {
if (player.hasPermission("communitymarket.auction")) {
guiManager.openCreateAuction(player);
} else {
player.sendMessage(plugin.getMessageManager().getPrefixed("messages.no-permission"));
playSound(player, plugin.getConfigManager().getErrorSound());
}
}
case SLOT_MY_LISTINGS -> guiManager.openMyListings(player);
case SLOT_MY_AUCTIONS -> guiManager.openMyAuctions(player);
case SLOT_CLAIM -> {
if (player.hasPermission("communitymarket.claim")) {
guiManager.openClaim(player);
} else {
player.sendMessage(plugin.getMessageManager().getPrefixed("messages.no-permission"));
playSound(player, plugin.getConfigManager().getErrorSound());
}
}
case SLOT_EARNINGS -> {
if (player.hasPermission("communitymarket.withdraw")) {
guiManager.openEarnings(player);
} else {
player.sendMessage(plugin.getMessageManager().getPrefixed("messages.no-permission"));
playSound(player, plugin.getConfigManager().getErrorSound());
}
}
case SLOT_HELP -> {
if (plugin.getConfigManager().isHelpButtonEnabled()) {
guiManager.openHelp(player);
}
}
case SLOT_ADMIN -> {
if (player.hasPermission("communitymarket.admin")) {
guiManager.openAdmin(player);
}
}
}
}
private void playSound(Player player, String soundName) {
SoundUtil.playSound(player, soundName);
}
@Override
public GuiType getType() {
return GuiType.MAIN_MENU;
}
@Override
public @org.jetbrains.annotations.NotNull Inventory getInventory() {
return inventory;
}
}
@@ -0,0 +1,85 @@
package pt.henrique.communityMarket.gui;
import org.bukkit.entity.Player;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryCloseEvent;
import org.bukkit.event.inventory.InventoryDragEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.InventoryHolder;
import org.jetbrains.annotations.NotNull;
import pt.henrique.communityMarket.CommunityMarket;
/**
* Base interface for all market GUI screens.
* Implements InventoryHolder to allow identification of market inventories.
*/
public interface MarketGui extends InventoryHolder {
/**
* Gets the GUI type identifier
*/
GuiType getType();
/**
* Handles a click event in this GUI
*
* @param event The click event
*/
void handleClick(InventoryClickEvent event);
/**
* Handles a drag event in this GUI
*
* @param event The drag event
*/
default void handleDrag(InventoryDragEvent event) {
// By default, cancel all drags to prevent item manipulation
event.setCancelled(true);
}
/**
* Handles the inventory being closed
*
* @param event The close event
*/
default void handleClose(InventoryCloseEvent event) {
// Default: do nothing special
}
/**
* Checks if items can be moved in this GUI
* Most GUIs should return false, but create listing/auction GUIs need true
*/
default boolean allowsItemMovement() {
return false;
}
/**
* Enum of all GUI types for identification
*/
enum GuiType {
MAIN_MENU,
BROWSE_MARKET,
BROWSE_AUCTIONS,
CREATE_LISTING,
CREATE_AUCTION,
ITEM_SELECTION,
QUANTITY_SELECT,
MY_LISTINGS,
MY_AUCTIONS,
CLAIM,
EARNINGS,
HELP,
ADMIN,
ADMIN_LISTINGS,
ADMIN_AUCTIONS,
NUMBER_INPUT,
CONFIRMATION,
LISTING_DETAILS,
AUCTION_DETAILS,
DURATION_SELECT,
FILTER_MENU,
SORT_MENU
}
}
@@ -0,0 +1,192 @@
package pt.henrique.communityMarket.gui;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import pt.henrique.communityMarket.CommunityMarket;
import pt.henrique.communityMarket.model.Auction;
import pt.henrique.communityMarket.service.AuctionService;
import pt.henrique.communityMarket.util.ItemBuilder;
import pt.henrique.communityMarket.util.TextUtil;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* GUI for viewing and managing the player's own auctions.
* Click to cancel (only if no bids).
*/
public class MyAuctionsGui implements MarketGui {
private final CommunityMarket plugin;
private final GuiManager guiManager;
private Inventory inventory;
private Player player;
private List<Auction> auctions;
private static final int BACK_SLOT = 49;
public MyAuctionsGui(CommunityMarket plugin, GuiManager guiManager) {
this.plugin = plugin;
this.guiManager = guiManager;
}
public void open(Player player) {
this.player = player;
plugin.getAuctionService().getPlayerAuctions(player.getUniqueId())
.thenAccept(loadedAuctions -> {
this.auctions = loadedAuctions;
Bukkit.getScheduler().runTask(plugin, () -> {
buildGui();
player.openInventory(inventory);
guiManager.registerGui(player.getUniqueId(), this);
});
});
}
private void buildGui() {
var msgManager = plugin.getMessageManager();
String title = TextUtil.colorizeToString(msgManager.getRaw("gui-titles.my-auctions"));
inventory = Bukkit.createInventory(this, 54, title);
// Fill bottom
ItemStack filler = new ItemBuilder(Material.GRAY_STAINED_GLASS_PANE).name(" ").build();
for (int i = 45; i < 54; i++) {
inventory.setItem(i, filler);
}
// Add auctions
for (int i = 0; i < Math.min(auctions.size(), 45); i++) {
Auction auction = auctions.get(i);
inventory.setItem(i, createAuctionItem(auction));
}
// Back button
inventory.setItem(BACK_SLOT, new ItemBuilder(Material.BARRIER)
.name(msgManager.getButton("back"))
.build());
}
private ItemStack createAuctionItem(Auction auction) {
var msgManager = plugin.getMessageManager();
ItemStack display = auction.getItem().clone();
Duration remaining = Duration.between(Instant.now(), auction.getEndsAt());
String ends = TextUtil.formatDuration(remaining);
String bidder = auction.getHighestBidderName() != null ? auction.getHighestBidderName() : "&7None";
String currentBid = auction.getBidCount() > 0
? msgManager.formatCurrency(auction.getCurrentBid())
: "&7No bids";
List<String> lore = new ArrayList<>();
lore.add("");
lore.addAll(msgManager.getLore("my-auction-info", Map.of(
"start_price", msgManager.formatCurrency(auction.getStartPrice()),
"current_bid", currentBid,
"bidder", bidder,
"bid_count", String.valueOf(auction.getBidCount()),
"ends", ends
)));
// Show if cancellable
if (auction.getBidCount() == 0) {
lore.add("");
lore.add("&aClick to cancel");
} else {
lore.add("");
lore.add("&cCannot cancel - has bids");
}
return new ItemBuilder(display)
.addLore(lore)
.build();
}
@Override
public void handleClick(InventoryClickEvent event) {
event.setCancelled(true);
Player player = (Player) event.getWhoClicked();
int slot = event.getRawSlot();
if (slot == BACK_SLOT) {
guiManager.openMainMenu(player);
return;
}
// Click on auction to cancel
if (slot >= 0 && slot < 45 && slot < auctions.size()) {
Auction auction = auctions.get(slot);
if (auction.getBidCount() > 0) {
player.sendMessage(plugin.getMessageManager().getPrefixed("messages.auction-not-found"));
playSound(player, plugin.getConfigManager().getErrorSound());
return;
}
confirmCancel(player, auction);
}
}
private void confirmCancel(Player player, Auction auction) {
var msgManager = plugin.getMessageManager();
String[] info = {
"&7Item: &f" + auction.getItem().getType().name(),
"&7Starting Price: &a" + msgManager.formatCurrency(auction.getStartPrice()),
"",
"&cThis will cancel your auction.",
"&cThe item will be moved to claim storage."
};
guiManager.openConfirmation(player, confirmed -> {
if (confirmed) {
plugin.getAuctionService().cancelAuction(auction.getId(), player.getUniqueId(), false)
.thenAccept(result -> {
Bukkit.getScheduler().runTask(plugin, () -> {
if (result == AuctionService.CancelResult.SUCCESS) {
player.sendMessage(msgManager.getPrefixed("messages.auction-cancelled"));
playSound(player, plugin.getConfigManager().getSuccessSound());
} else if (result == AuctionService.CancelResult.HAS_BIDS) {
player.sendMessage(msgManager.getPrefixed("messages.auction-not-found"));
playSound(player, plugin.getConfigManager().getErrorSound());
} else {
player.sendMessage(msgManager.getPrefixed("messages.auction-not-found"));
playSound(player, plugin.getConfigManager().getErrorSound());
}
open(player); // Refresh
});
});
} else {
open(player);
}
}, msgManager.getRaw("gui-titles.confirm-cancel"), info);
}
private void playSound(Player player, String soundName) {
pt.henrique.communityMarket.util.SoundUtil.playSound(player, soundName);
}
@Override
public GuiType getType() {
return GuiType.MY_AUCTIONS;
}
@Override
public Inventory getInventory() {
return inventory;
}
}
@@ -0,0 +1,175 @@
package pt.henrique.communityMarket.gui;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import pt.henrique.communityMarket.CommunityMarket;
import pt.henrique.communityMarket.model.Listing;
import pt.henrique.communityMarket.util.ItemBuilder;
import pt.henrique.communityMarket.util.TextUtil;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* GUI for viewing and managing the player's own listings.
* Click on a listing to cancel it.
*/
public class MyListingsGui implements MarketGui {
private final CommunityMarket plugin;
private final GuiManager guiManager;
private Inventory inventory;
private Player player;
private List<Listing> listings;
private static final int BACK_SLOT = 49;
public MyListingsGui(CommunityMarket plugin, GuiManager guiManager) {
this.plugin = plugin;
this.guiManager = guiManager;
}
public void open(Player player) {
this.player = player;
plugin.getListingService().getPlayerListings(player.getUniqueId())
.thenAccept(loadedListings -> {
this.listings = loadedListings;
Bukkit.getScheduler().runTask(plugin, () -> {
buildGui();
player.openInventory(inventory);
guiManager.registerGui(player.getUniqueId(), this);
});
});
}
private void buildGui() {
var msgManager = plugin.getMessageManager();
String title = TextUtil.colorizeToString(msgManager.getRaw("gui-titles.my-listings"));
inventory = Bukkit.createInventory(this, 54, title);
// Fill bottom
ItemStack filler = new ItemBuilder(Material.GRAY_STAINED_GLASS_PANE).name(" ").build();
for (int i = 45; i < 54; i++) {
inventory.setItem(i, filler);
}
// Add listings
for (int i = 0; i < Math.min(listings.size(), 45); i++) {
Listing listing = listings.get(i);
inventory.setItem(i, createListingItem(listing));
}
// Back button
inventory.setItem(BACK_SLOT, new ItemBuilder(Material.BARRIER)
.name(msgManager.getButton("back"))
.build());
}
private ItemStack createListingItem(Listing listing) {
var msgManager = plugin.getMessageManager();
ItemStack display = listing.getItem().clone();
display.setAmount(listing.getAmount());
String expires;
if (listing.getExpiresAt() != null) {
Duration remaining = Duration.between(Instant.now(), listing.getExpiresAt());
expires = TextUtil.formatDuration(remaining);
} else {
expires = "Never";
}
String created = TextUtil.formatDuration(
Duration.between(listing.getCreatedAt(), Instant.now())) + " ago";
List<String> lore = new ArrayList<>();
lore.add("");
lore.addAll(msgManager.getLore("my-listing-info", Map.of(
"price", msgManager.formatCurrency(listing.getPrice()),
"amount", String.valueOf(listing.getAmount()),
"created", created,
"expires", expires
)));
return new ItemBuilder(display)
.addLore(lore)
.build();
}
@Override
public void handleClick(InventoryClickEvent event) {
event.setCancelled(true);
Player player = (Player) event.getWhoClicked();
int slot = event.getRawSlot();
if (slot == BACK_SLOT) {
guiManager.openMainMenu(player);
return;
}
// Click on listing to cancel
if (slot >= 0 && slot < 45 && slot < listings.size()) {
Listing listing = listings.get(slot);
confirmCancel(player, listing);
}
}
private void confirmCancel(Player player, Listing listing) {
var msgManager = plugin.getMessageManager();
String[] info = {
"&7Item: &f" + listing.getItem().getType().name() + " x" + listing.getAmount(),
"&7Price: &a" + msgManager.formatCurrency(listing.getPrice()),
"",
"&cThis will cancel your listing.",
"&cThe item will be moved to claim storage."
};
guiManager.openConfirmation(player, confirmed -> {
if (confirmed) {
plugin.getListingService().cancelListing(listing.getId(), player.getUniqueId(), false)
.thenAccept(success -> {
Bukkit.getScheduler().runTask(plugin, () -> {
if (success) {
player.sendMessage(msgManager.getPrefixed("messages.listing-cancelled"));
playSound(player, plugin.getConfigManager().getSuccessSound());
} else {
player.sendMessage(msgManager.getPrefixed("messages.listing-not-found"));
playSound(player, plugin.getConfigManager().getErrorSound());
}
open(player); // Refresh
});
});
} else {
open(player);
}
}, msgManager.getRaw("gui-titles.confirm-cancel"), info);
}
private void playSound(Player player, String soundName) {
pt.henrique.communityMarket.util.SoundUtil.playSound(player, soundName);
}
@Override
public GuiType getType() {
return GuiType.MY_LISTINGS;
}
@Override
public Inventory getInventory() {
return inventory;
}
}
@@ -0,0 +1,261 @@
package pt.henrique.communityMarket.gui;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import pt.henrique.communityMarket.CommunityMarket;
import pt.henrique.communityMarket.util.ItemBuilder;
import pt.henrique.communityMarket.util.SoundUtil;
import pt.henrique.communityMarket.util.TextUtil;
/**
* GUI-based numeric input to replace chat input.
* Players can increment/decrement values using buttons.
*
* Layout (54-slot chest):
* ┌─────────────────────────────────────────────────────┐
* │ . . . . . . . . . │ Row 0: Empty │
* │ . . . . [DISPLAY] . . . │ Row 1: Value │
* │ . . . . . . . . . │ Row 2: Empty │
* │ -1K -100 -10 -1 . +1 +10 +100 +1K│ Row 3: Adjust │
* │ . MIN . . . . . MAX . │ Row 4: Presets│
* │ BACK . . . CONFIRM . . . │ Row 5: Actions│
* └─────────────────────────────────────────────────────┘
*/
public class NumberInputGui implements MarketGui {
private final CommunityMarket plugin;
private final GuiManager guiManager;
private final NumberInputCallback callback;
private final double minValue;
private final double maxValue;
private final String title;
private Inventory inventory;
private Player player;
private double currentValue;
// ==================== LAYOUT CONSTANTS ====================
// Row 1: Current value display (center)
private static final int DISPLAY_SLOT = 13;
// Row 3: Decrease buttons (LEFT side) - slots 27-30
private static final int SUB_1000_SLOT = 27; // -1,000
private static final int SUB_100_SLOT = 28; // -100
private static final int SUB_10_SLOT = 29; // -10
private static final int SUB_1_SLOT = 30; // -1
// Row 3: Increase buttons (RIGHT side) - slots 32-35
private static final int ADD_1_SLOT = 32; // +1
private static final int ADD_10_SLOT = 33; // +10
private static final int ADD_100_SLOT = 34; // +100
private static final int ADD_1000_SLOT = 35; // +1,000
// Row 4: Preset buttons
private static final int SET_MIN_SLOT = 37; // Set to minimum
private static final int SET_MAX_SLOT = 43; // Set to maximum
// Row 5: Action buttons
private static final int BACK_SLOT = 45; // Cancel/Back (bottom-left)
private static final int CONFIRM_SLOT = 49; // Confirm (bottom-center)
// ===========================================================
@FunctionalInterface
public interface NumberInputCallback {
void onComplete(double value);
}
public NumberInputGui(CommunityMarket plugin, GuiManager guiManager,
NumberInputCallback callback, double currentValue,
double minValue, double maxValue, String title) {
this.plugin = plugin;
this.guiManager = guiManager;
this.callback = callback;
this.currentValue = currentValue;
this.minValue = minValue;
this.maxValue = maxValue;
this.title = title;
}
public void open(Player player) {
this.player = player;
inventory = Bukkit.createInventory(this, 54, TextUtil.colorizeToString(title));
buildGui();
player.openInventory(inventory);
guiManager.registerGui(player.getUniqueId(), this);
}
private void buildGui() {
var msgManager = plugin.getMessageManager();
// Fill all slots with glass panes
ItemStack filler = new ItemBuilder(Material.GRAY_STAINED_GLASS_PANE).name(" ").build();
for (int i = 0; i < 54; i++) {
inventory.setItem(i, filler);
}
// Current value display (center, row 1)
updateDisplay();
// === DECREASE BUTTONS (LEFT SIDE) ===
inventory.setItem(SUB_1000_SLOT, new ItemBuilder(Material.RED_STAINED_GLASS_PANE)
.name("&c-1,000")
.lore("&7Click: &c-1,000", "&7Shift-click: &c-10,000")
.build());
inventory.setItem(SUB_100_SLOT, new ItemBuilder(Material.RED_STAINED_GLASS_PANE)
.name("&c-100")
.lore("&7Click: &c-100", "&7Shift-click: &c-1,000")
.build());
inventory.setItem(SUB_10_SLOT, new ItemBuilder(Material.RED_STAINED_GLASS_PANE)
.name("&c-10")
.lore("&7Click: &c-10", "&7Shift-click: &c-100")
.build());
inventory.setItem(SUB_1_SLOT, new ItemBuilder(Material.RED_STAINED_GLASS_PANE)
.name("&c-1")
.lore("&7Click: &c-1", "&7Shift-click: &c-10")
.build());
// === INCREASE BUTTONS (RIGHT SIDE) ===
inventory.setItem(ADD_1_SLOT, new ItemBuilder(Material.LIME_STAINED_GLASS_PANE)
.name("&a+1")
.lore("&7Click: &a+1", "&7Shift-click: &a+10")
.build());
inventory.setItem(ADD_10_SLOT, new ItemBuilder(Material.LIME_STAINED_GLASS_PANE)
.name("&a+10")
.lore("&7Click: &a+10", "&7Shift-click: &a+100")
.build());
inventory.setItem(ADD_100_SLOT, new ItemBuilder(Material.LIME_STAINED_GLASS_PANE)
.name("&a+100")
.lore("&7Click: &a+100", "&7Shift-click: &a+1,000")
.build());
inventory.setItem(ADD_1000_SLOT, new ItemBuilder(Material.LIME_STAINED_GLASS_PANE)
.name("&a+1,000")
.lore("&7Click: &a+1,000", "&7Shift-click: &a+10,000")
.build());
// === PRESET BUTTONS ===
inventory.setItem(SET_MIN_SLOT, new ItemBuilder(Material.ORANGE_STAINED_GLASS_PANE)
.name("&6Set Minimum")
.lore("&7Set to: &f" + msgManager.formatCurrency(minValue))
.build());
inventory.setItem(SET_MAX_SLOT, new ItemBuilder(Material.ORANGE_STAINED_GLASS_PANE)
.name("&6Set Maximum")
.lore("&7Set to: &f" + msgManager.formatCurrency(maxValue))
.build());
// === ACTION BUTTONS ===
inventory.setItem(BACK_SLOT, new ItemBuilder(Material.RED_WOOL)
.name(msgManager.getButton("cancel"))
.lore("&7Cancel and go back")
.build());
inventory.setItem(CONFIRM_SLOT, new ItemBuilder(Material.LIME_WOOL)
.name(msgManager.getButton("confirm"))
.lore("&7Confirm: &a" + msgManager.formatCurrency(currentValue))
.build());
}
private void updateDisplay() {
var msgManager = plugin.getMessageManager();
inventory.setItem(DISPLAY_SLOT, new ItemBuilder(Material.GOLD_INGOT)
.name("&6&l" + msgManager.formatCurrency(currentValue))
.lore(
"",
"&7Minimum: &f" + msgManager.formatCurrency(minValue),
"&7Maximum: &f" + msgManager.formatCurrency(maxValue),
"",
"&eUse buttons to adjust"
)
.glow()
.build());
// Also update confirm button lore
inventory.setItem(CONFIRM_SLOT, new ItemBuilder(Material.LIME_WOOL)
.name(plugin.getMessageManager().getButton("confirm"))
.lore("&7Confirm: &a" + msgManager.formatCurrency(currentValue))
.build());
}
@Override
public void handleClick(InventoryClickEvent event) {
event.setCancelled(true);
Player player = (Player) event.getWhoClicked();
int slot = event.getRawSlot();
boolean shift = event.isShiftClick();
// Multiplier for shift-click (10x)
double multiplier = shift ? 10 : 1;
switch (slot) {
// Decrease buttons
case SUB_1000_SLOT -> adjustValue(-1000 * multiplier);
case SUB_100_SLOT -> adjustValue(-100 * multiplier);
case SUB_10_SLOT -> adjustValue(-10 * multiplier);
case SUB_1_SLOT -> adjustValue(-1 * multiplier);
// Increase buttons
case ADD_1_SLOT -> adjustValue(1 * multiplier);
case ADD_10_SLOT -> adjustValue(10 * multiplier);
case ADD_100_SLOT -> adjustValue(100 * multiplier);
case ADD_1000_SLOT -> adjustValue(1000 * multiplier);
// Preset buttons
case SET_MIN_SLOT -> {
currentValue = minValue;
updateDisplay();
SoundUtil.playSound(player, plugin.getConfigManager().getClickSound());
}
case SET_MAX_SLOT -> {
currentValue = maxValue;
updateDisplay();
SoundUtil.playSound(player, plugin.getConfigManager().getClickSound());
}
// Action buttons
case CONFIRM_SLOT -> {
SoundUtil.playSound(player, plugin.getConfigManager().getSuccessSound());
player.closeInventory();
callback.onComplete(currentValue);
}
case BACK_SLOT -> {
player.closeInventory();
callback.onComplete(-1); // Signal cancellation
}
}
}
private void adjustValue(double delta) {
double newValue = currentValue + delta;
// Clamp to min/max
newValue = Math.max(minValue, Math.min(maxValue, newValue));
// Round to 2 decimal places
currentValue = Math.round(newValue * 100.0) / 100.0;
updateDisplay();
SoundUtil.playSound(player, plugin.getConfigManager().getClickSound(), 0.3f, 1.2f);
}
@Override
public GuiType getType() {
return GuiType.NUMBER_INPUT;
}
@Override
public Inventory getInventory() {
return inventory;
}
}
@@ -0,0 +1,323 @@
package pt.henrique.communityMarket.gui;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import pt.henrique.communityMarket.CommunityMarket;
import pt.henrique.communityMarket.util.ItemBuilder;
import pt.henrique.communityMarket.util.InventoryUtil;
import pt.henrique.communityMarket.util.SoundUtil;
import pt.henrique.communityMarket.util.TextUtil;
/**
* GUI for selecting quantity of an item to list or auction.
* Only shown for stackable items; unstackable items skip this step.
*
* Layout (54-slot chest):
* ┌─────────────────────────────────────────────────────┐
* │ . . . . INFO . . . . │ Row 0 │
* │ . . . . ITEM . . . . │ Row 1: Item │
* │ . . . . DISPLAY . . . . │ Row 2: Qty │
* │ -64 -32 -16 -1 . +1 +16 +32 +64 │ Row 3: Adjust│
* │ . MIN . . . . . MAX . │ Row 4: Preset│
* │ BACK . . . CONFIRM . . . .│ Row 5: Action│
* └─────────────────────────────────────────────────────┘
*/
public class QuantitySelectGui implements MarketGui {
private final CommunityMarket plugin;
private final GuiManager guiManager;
private final QuantityCallback callback;
private final ItemStack selectedItem;
private final int maxQuantity;
private Inventory inventory;
private Player player;
private int currentQuantity;
// ==================== LAYOUT CONSTANTS ====================
private static final int INFO_SLOT = 4; // Top center info
private static final int ITEM_DISPLAY_SLOT = 13; // Item preview
private static final int QUANTITY_DISPLAY_SLOT = 22; // Current quantity display
// Row 3: Decrease buttons (LEFT side) - slots 27-30
private static final int SUB_64_SLOT = 27; // -64
private static final int SUB_32_SLOT = 28; // -32
private static final int SUB_16_SLOT = 29; // -16
private static final int SUB_1_SLOT = 30; // -1
// Row 3: Increase buttons (RIGHT side) - slots 32-35
private static final int ADD_1_SLOT = 32; // +1
private static final int ADD_16_SLOT = 33; // +16
private static final int ADD_32_SLOT = 34; // +32
private static final int ADD_64_SLOT = 35; // +64
// Row 4: Preset buttons
private static final int SET_MIN_SLOT = 37; // Set to 1
private static final int SET_MAX_SLOT = 43; // Set to max
// Row 5: Action buttons
private static final int BACK_SLOT = 45; // Cancel/Back
private static final int CONFIRM_SLOT = 49; // Confirm
// ===========================================================
@FunctionalInterface
public interface QuantityCallback {
void onComplete(int quantity);
}
public QuantitySelectGui(CommunityMarket plugin, GuiManager guiManager,
QuantityCallback callback, ItemStack selectedItem,
int maxQuantity) {
this.plugin = plugin;
this.guiManager = guiManager;
this.callback = callback;
this.selectedItem = selectedItem;
this.maxQuantity = maxQuantity;
this.currentQuantity = Math.min(selectedItem.getAmount(), maxQuantity);
}
public void open(Player player) {
this.player = player;
var msgManager = plugin.getMessageManager();
String title = TextUtil.colorizeToString(msgManager.getRaw("gui-titles.quantity-select"));
inventory = Bukkit.createInventory(this, 54, title);
buildGui();
player.openInventory(inventory);
guiManager.registerGui(player.getUniqueId(), this);
}
private void buildGui() {
var msgManager = plugin.getMessageManager();
// Fill with glass
ItemStack filler = new ItemBuilder(Material.GRAY_STAINED_GLASS_PANE).name(" ").build();
for (int i = 0; i < 54; i++) {
inventory.setItem(i, filler);
}
// Info panel
inventory.setItem(INFO_SLOT, new ItemBuilder(Material.OAK_SIGN)
.name("&6&lSelect Quantity")
.lore(
"&7Choose how many items",
"&7you want to sell.",
"",
"&7Available: &f" + maxQuantity
)
.build());
// Item preview
ItemStack displayItem = selectedItem.clone();
displayItem.setAmount(currentQuantity);
inventory.setItem(ITEM_DISPLAY_SLOT, new ItemBuilder(displayItem)
.addLore(java.util.List.of(
"",
"&7Selected: &f" + currentQuantity
))
.build());
// Quantity display
updateQuantityDisplay();
// === DECREASE BUTTONS (LEFT SIDE) ===
inventory.setItem(SUB_64_SLOT, new ItemBuilder(Material.RED_STAINED_GLASS_PANE)
.name("&c-64")
.lore("&7Click: &c-64")
.amount(64)
.build());
inventory.setItem(SUB_32_SLOT, new ItemBuilder(Material.RED_STAINED_GLASS_PANE)
.name("&c-32")
.lore("&7Click: &c-32")
.amount(32)
.build());
inventory.setItem(SUB_16_SLOT, new ItemBuilder(Material.RED_STAINED_GLASS_PANE)
.name("&c-16")
.lore("&7Click: &c-16")
.amount(16)
.build());
inventory.setItem(SUB_1_SLOT, new ItemBuilder(Material.RED_STAINED_GLASS_PANE)
.name("&c-1")
.lore("&7Click: &c-1")
.build());
// === INCREASE BUTTONS (RIGHT SIDE) ===
inventory.setItem(ADD_1_SLOT, new ItemBuilder(Material.LIME_STAINED_GLASS_PANE)
.name("&a+1")
.lore("&7Click: &a+1")
.build());
inventory.setItem(ADD_16_SLOT, new ItemBuilder(Material.LIME_STAINED_GLASS_PANE)
.name("&a+16")
.lore("&7Click: &a+16")
.amount(16)
.build());
inventory.setItem(ADD_32_SLOT, new ItemBuilder(Material.LIME_STAINED_GLASS_PANE)
.name("&a+32")
.lore("&7Click: &a+32")
.amount(32)
.build());
inventory.setItem(ADD_64_SLOT, new ItemBuilder(Material.LIME_STAINED_GLASS_PANE)
.name("&a+64")
.lore("&7Click: &a+64")
.amount(64)
.build());
// === PRESET BUTTONS ===
inventory.setItem(SET_MIN_SLOT, new ItemBuilder(Material.ORANGE_STAINED_GLASS_PANE)
.name("&6Set Minimum")
.lore("&7Set to: &f1")
.build());
inventory.setItem(SET_MAX_SLOT, new ItemBuilder(Material.ORANGE_STAINED_GLASS_PANE)
.name("&6Set Maximum")
.lore("&7Set to: &f" + maxQuantity)
.build());
// === ACTION BUTTONS ===
inventory.setItem(BACK_SLOT, new ItemBuilder(Material.RED_WOOL)
.name(msgManager.getButton("back"))
.lore("&7Return to item selection")
.build());
updateConfirmButton();
}
private void updateQuantityDisplay() {
inventory.setItem(QUANTITY_DISPLAY_SLOT, new ItemBuilder(Material.PAPER)
.name("&6&lQuantity: " + currentQuantity)
.lore(
"",
"&7Minimum: &f1",
"&7Maximum: &f" + maxQuantity,
"",
"&eUse buttons to adjust"
)
.amount(Math.min(currentQuantity, 64))
.glow()
.build());
// Update item display amount
ItemStack displayItem = selectedItem.clone();
displayItem.setAmount(Math.min(currentQuantity, 64));
inventory.setItem(ITEM_DISPLAY_SLOT, new ItemBuilder(displayItem)
.addLore(java.util.List.of(
"",
"&7Selected: &f" + currentQuantity
))
.build());
}
private void updateConfirmButton() {
var msgManager = plugin.getMessageManager();
inventory.setItem(CONFIRM_SLOT, new ItemBuilder(Material.LIME_WOOL)
.name(msgManager.getButton("confirm"))
.lore("&7Quantity: &a" + currentQuantity)
.build());
}
@Override
public void handleClick(InventoryClickEvent event) {
event.setCancelled(true);
Player player = (Player) event.getWhoClicked();
int slot = event.getRawSlot();
switch (slot) {
// Decrease buttons
case SUB_64_SLOT -> adjustQuantity(-64);
case SUB_32_SLOT -> adjustQuantity(-32);
case SUB_16_SLOT -> adjustQuantity(-16);
case SUB_1_SLOT -> adjustQuantity(-1);
// Increase buttons
case ADD_1_SLOT -> adjustQuantity(1);
case ADD_16_SLOT -> adjustQuantity(16);
case ADD_32_SLOT -> adjustQuantity(32);
case ADD_64_SLOT -> adjustQuantity(64);
// Preset buttons
case SET_MIN_SLOT -> {
currentQuantity = 1;
updateQuantityDisplay();
updateConfirmButton();
SoundUtil.playSound(player, plugin.getConfigManager().getClickSound());
}
case SET_MAX_SLOT -> {
currentQuantity = maxQuantity;
updateQuantityDisplay();
updateConfirmButton();
SoundUtil.playSound(player, plugin.getConfigManager().getClickSound());
}
// Action buttons
case CONFIRM_SLOT -> {
// Validate quantity is still valid
int currentAvailable = countAvailableItems(player);
if (currentQuantity > currentAvailable) {
player.sendMessage(plugin.getMessageManager().getPrefixed("messages.quantity-changed"));
currentQuantity = Math.min(currentQuantity, currentAvailable);
if (currentQuantity < 1) {
player.sendMessage(plugin.getMessageManager().getPrefixed("messages.item-no-longer-available"));
SoundUtil.playSound(player, plugin.getConfigManager().getErrorSound());
player.closeInventory();
return;
}
updateQuantityDisplay();
updateConfirmButton();
SoundUtil.playSound(player, plugin.getConfigManager().getErrorSound());
return;
}
SoundUtil.playSound(player, plugin.getConfigManager().getSuccessSound());
player.closeInventory();
callback.onComplete(currentQuantity);
}
case BACK_SLOT -> {
player.closeInventory();
callback.onComplete(-1); // Signal cancellation
}
}
}
private void adjustQuantity(int delta) {
int newQuantity = currentQuantity + delta;
// Clamp to 1..maxQuantity
newQuantity = Math.max(1, Math.min(maxQuantity, newQuantity));
currentQuantity = newQuantity;
updateQuantityDisplay();
updateConfirmButton();
SoundUtil.playSound(player, plugin.getConfigManager().getClickSound(), 0.3f, 1.2f);
}
/**
* Counts how many of the selected item the player currently has.
* Uses strict item comparison including all metadata.
*/
private int countAvailableItems(Player player) {
return InventoryUtil.countSimilarItems(player.getInventory(), selectedItem);
}
@Override
public GuiType getType() {
return GuiType.QUANTITY_SELECT;
}
@Override
public Inventory getInventory() {
return inventory;
}
}
@@ -0,0 +1,141 @@
package pt.henrique.communityMarket.listener;
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.*;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.InventoryHolder;
import pt.henrique.communityMarket.CommunityMarket;
import pt.henrique.communityMarket.gui.MarketGui;
/**
* Listener for all GUI-related inventory events.
* Handles clicks, drags, and closes for market GUIs.
* Implements security measures to prevent item duplication exploits.
*/
public class GuiListener implements Listener {
private final CommunityMarket plugin;
public GuiListener(CommunityMarket plugin) {
this.plugin = plugin;
}
/**
* Handles all click events in market GUIs.
* Security: Validates all click actions and prevents item manipulation.
*/
@EventHandler(priority = EventPriority.HIGH)
public void onInventoryClick(InventoryClickEvent event) {
if (!(event.getWhoClicked() instanceof Player player)) return;
Inventory topInventory = event.getView().getTopInventory();
InventoryHolder holder = topInventory.getHolder();
// Check if this is a market GUI
if (!(holder instanceof MarketGui marketGui)) return;
// Get the clicked inventory
Inventory clickedInventory = event.getClickedInventory();
// Handle different GUI types
if (marketGui.allowsItemMovement()) {
// GUIs like CreateListing allow item placement in specific slots
handleItemMovementGui(event, marketGui);
} else {
// Standard GUIs don't allow any item movement
event.setCancelled(true);
}
// Always delegate to the GUI's click handler
marketGui.handleClick(event);
}
/**
* Handles GUIs that allow item movement (like create listing/auction).
* Only allows items in designated slots.
*/
private void handleItemMovementGui(InventoryClickEvent event, MarketGui marketGui) {
// Cancel by default - the GUI handler will un-cancel for specific slots
ClickType clickType = event.getClick();
// Block potentially exploitative click types
switch (clickType) {
case DOUBLE_CLICK -> {
// Prevent collecting items from market GUI via double-click
event.setCancelled(true);
}
case NUMBER_KEY -> {
// Block number key swaps to prevent inventory tricks
if (event.getRawSlot() < event.getView().getTopInventory().getSize()) {
// Allow in player inventory, cancel in market GUI
// The specific GUI handler will manage this
}
}
case SWAP_OFFHAND -> {
event.setCancelled(true);
}
default -> {
// Let the GUI handler decide
}
}
}
/**
* Handles drag events - prevents dragging items across market GUIs.
*/
@EventHandler(priority = EventPriority.HIGH)
public void onInventoryDrag(InventoryDragEvent event) {
if (!(event.getWhoClicked() instanceof Player)) return;
Inventory topInventory = event.getView().getTopInventory();
InventoryHolder holder = topInventory.getHolder();
if (!(holder instanceof MarketGui marketGui)) return;
// Check if any dragged slots are in the top inventory
int topSize = topInventory.getSize();
boolean affectsTop = event.getRawSlots().stream()
.anyMatch(slot -> slot < topSize);
if (affectsTop) {
// Delegate to GUI handler (most will cancel)
marketGui.handleDrag(event);
}
}
/**
* Handles inventory close events.
* Ensures items are returned and GUI state is cleaned up.
*/
@EventHandler(priority = EventPriority.MONITOR)
public void onInventoryClose(InventoryCloseEvent event) {
if (!(event.getPlayer() instanceof Player player)) return;
Inventory inventory = event.getInventory();
InventoryHolder holder = inventory.getHolder();
if (!(holder instanceof MarketGui marketGui)) return;
// Notify the GUI of the close
marketGui.handleClose(event);
// Unregister from GUI manager
plugin.getGuiManager().unregisterGui(player.getUniqueId());
}
/**
* Prevents moving items out of market GUIs via shift-click from player inventory.
*/
@EventHandler(priority = EventPriority.HIGH)
public void onInventoryMoveItem(InventoryMoveItemEvent event) {
// This handles hopper/dropper interactions - cancel if involves market GUI
if (event.getSource().getHolder() instanceof MarketGui ||
event.getDestination().getHolder() instanceof MarketGui) {
event.setCancelled(true);
}
}
}
@@ -0,0 +1,84 @@
package pt.henrique.communityMarket.listener;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import pt.henrique.communityMarket.CommunityMarket;
/**
* Listener for player join/quit events.
* Notifies players of pending items and earnings on join.
*/
public class PlayerListener implements Listener {
private final CommunityMarket plugin;
public PlayerListener(CommunityMarket plugin) {
this.plugin = plugin;
}
/**
* Notifies players of pending claims and earnings when they join.
*/
@EventHandler(priority = EventPriority.MONITOR)
public void onPlayerJoin(PlayerJoinEvent event) {
Player player = event.getPlayer();
// Delay notification slightly to ensure player is fully loaded
plugin.getServer().getScheduler().runTaskLater(plugin, () -> {
if (!player.isOnline()) return;
notifyPendingItems(player);
}, 40L); // 2 second delay
}
/**
* Cleans up when a player quits.
*/
@EventHandler(priority = EventPriority.MONITOR)
public void onPlayerQuit(PlayerQuitEvent event) {
Player player = event.getPlayer();
// Close any open market GUI to prevent issues
if (plugin.getGuiManager().hasGuiOpen(player.getUniqueId())) {
player.closeInventory();
}
}
/**
* Notifies a player about pending claims and earnings.
*/
private void notifyPendingItems(Player player) {
var msgManager = plugin.getMessageManager();
// Check for pending claim items
plugin.getClaimService().countPlayerClaimItems(player.getUniqueId())
.thenAccept(claimCount -> {
if (claimCount > 0) {
plugin.getServer().getScheduler().runTask(plugin, () -> {
if (player.isOnline()) {
player.sendMessage(msgManager.getPrefixed("messages.claim-items",
java.util.Map.of("count", String.valueOf(claimCount))));
}
});
}
});
// Check for pending earnings
plugin.getEarningsService().getPendingEarnings(player.getUniqueId())
.thenAccept(earnings -> {
if (earnings > 0) {
plugin.getServer().getScheduler().runTask(plugin, () -> {
if (player.isOnline()) {
player.sendMessage(msgManager.getPrefixed("messages.earnings-balance",
"amount", msgManager.formatCurrency(earnings)));
}
});
}
});
}
}
@@ -0,0 +1,200 @@
package pt.henrique.communityMarket.model;
import org.bukkit.inventory.ItemStack;
import java.time.Instant;
import java.util.UUID;
/**
* Represents an auction listing
*/
public class Auction {
private int id;
private UUID sellerUuid;
private String sellerName;
private ItemStack item;
private double startPrice;
private double currentBid;
private UUID highestBidderUuid;
private String highestBidderName;
private int bidCount;
private Double buyoutPrice; // nullable
private Instant createdAt;
private Instant endsAt;
private int extensionCount;
private AuctionStatus status;
public Auction() {
this.status = AuctionStatus.ACTIVE;
this.createdAt = Instant.now();
this.bidCount = 0;
this.extensionCount = 0;
this.currentBid = 0;
}
public Auction(UUID sellerUuid, String sellerName, ItemStack item, double startPrice, Double buyoutPrice, Instant endsAt) {
this();
this.sellerUuid = sellerUuid;
this.sellerName = sellerName;
this.item = item;
this.startPrice = startPrice;
this.buyoutPrice = buyoutPrice;
this.endsAt = endsAt;
}
// Getters and Setters
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public UUID getSellerUuid() {
return sellerUuid;
}
public void setSellerUuid(UUID sellerUuid) {
this.sellerUuid = sellerUuid;
}
public String getSellerName() {
return sellerName;
}
public void setSellerName(String sellerName) {
this.sellerName = sellerName;
}
public ItemStack getItem() {
return item;
}
public void setItem(ItemStack item) {
this.item = item;
}
public double getStartPrice() {
return startPrice;
}
public void setStartPrice(double startPrice) {
this.startPrice = startPrice;
}
public double getCurrentBid() {
return currentBid;
}
public void setCurrentBid(double currentBid) {
this.currentBid = currentBid;
}
public UUID getHighestBidderUuid() {
return highestBidderUuid;
}
public void setHighestBidderUuid(UUID highestBidderUuid) {
this.highestBidderUuid = highestBidderUuid;
}
public String getHighestBidderName() {
return highestBidderName;
}
public void setHighestBidderName(String highestBidderName) {
this.highestBidderName = highestBidderName;
}
public int getBidCount() {
return bidCount;
}
public void setBidCount(int bidCount) {
this.bidCount = bidCount;
}
public Double getBuyoutPrice() {
return buyoutPrice;
}
public void setBuyoutPrice(Double buyoutPrice) {
this.buyoutPrice = buyoutPrice;
}
public boolean hasBuyout() {
return buyoutPrice != null && buyoutPrice > 0;
}
public Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
public Instant getEndsAt() {
return endsAt;
}
public void setEndsAt(Instant endsAt) {
this.endsAt = endsAt;
}
public int getExtensionCount() {
return extensionCount;
}
public void setExtensionCount(int extensionCount) {
this.extensionCount = extensionCount;
}
public void incrementExtensionCount() {
this.extensionCount++;
}
public AuctionStatus getStatus() {
return status;
}
public void setStatus(AuctionStatus status) {
this.status = status;
}
public boolean isEnded() {
return Instant.now().isAfter(endsAt);
}
public boolean isActive() {
return status == AuctionStatus.ACTIVE && !isEnded();
}
public boolean hasBids() {
return bidCount > 0;
}
public double getEffectivePrice() {
return hasBids() ? currentBid : startPrice;
}
public double getMinimumBid(double minIncrementPercent, double minIncrementAbsolute) {
if (!hasBids()) {
return startPrice;
}
double percentIncrement = currentBid * (minIncrementPercent / 100.0);
return currentBid + Math.max(percentIncrement, minIncrementAbsolute);
}
public enum AuctionStatus {
ACTIVE,
ENDED,
SOLD,
CANCELLED,
EXPIRED,
NO_BIDS
}
}
@@ -0,0 +1,79 @@
package pt.henrique.communityMarket.model;
import java.time.Instant;
import java.util.UUID;
/**
* Represents a bid on an auction
*/
public class Bid {
private int id;
private int auctionId;
private UUID bidderUuid;
private String bidderName;
private double amount;
private Instant createdAt;
public Bid() {
this.createdAt = Instant.now();
}
public Bid(int auctionId, UUID bidderUuid, String bidderName, double amount) {
this();
this.auctionId = auctionId;
this.bidderUuid = bidderUuid;
this.bidderName = bidderName;
this.amount = amount;
}
// Getters and Setters
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public int getAuctionId() {
return auctionId;
}
public void setAuctionId(int auctionId) {
this.auctionId = auctionId;
}
public UUID getBidderUuid() {
return bidderUuid;
}
public void setBidderUuid(UUID bidderUuid) {
this.bidderUuid = bidderUuid;
}
public String getBidderName() {
return bidderName;
}
public void setBidderName(String bidderName) {
this.bidderName = bidderName;
}
public double getAmount() {
return amount;
}
public void setAmount(double amount) {
this.amount = amount;
}
public Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
}
@@ -0,0 +1,102 @@
package pt.henrique.communityMarket.model;
import org.bukkit.inventory.ItemStack;
import java.time.Instant;
import java.util.UUID;
/**
* Represents an item in claim storage waiting to be claimed by a player
*/
public class ClaimItem {
private int id;
private UUID playerUuid;
private ItemStack item;
private ClaimReason reason;
private String sourceInfo; // Additional info like listing ID, auction ID, etc.
private Instant createdAt;
public ClaimItem() {
this.createdAt = Instant.now();
}
public ClaimItem(UUID playerUuid, ItemStack item, ClaimReason reason, String sourceInfo) {
this();
this.playerUuid = playerUuid;
this.item = item;
this.reason = reason;
this.sourceInfo = sourceInfo;
}
// Getters and Setters
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public UUID getPlayerUuid() {
return playerUuid;
}
public void setPlayerUuid(UUID playerUuid) {
this.playerUuid = playerUuid;
}
public ItemStack getItem() {
return item;
}
public void setItem(ItemStack item) {
this.item = item;
}
public ClaimReason getReason() {
return reason;
}
public void setReason(ClaimReason reason) {
this.reason = reason;
}
public String getSourceInfo() {
return sourceInfo;
}
public void setSourceInfo(String sourceInfo) {
this.sourceInfo = sourceInfo;
}
public Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
public enum ClaimReason {
EXPIRED_LISTING("Expired Listing"),
CANCELLED_LISTING("Cancelled Listing"),
WON_AUCTION("Won Auction"),
AUCTION_NO_BIDS("Auction Ended (No Bids)"),
CANCELLED_AUCTION("Cancelled Auction"),
PURCHASE_FULL_INVENTORY("Purchase (Inventory Full)"),
OUTBID_REFUND("Outbid Refund"),
ADMIN_RETURN("Admin Return");
private final String displayName;
ClaimReason(String displayName) {
this.displayName = displayName;
}
public String getDisplayName() {
return displayName;
}
}
}
@@ -0,0 +1,126 @@
package pt.henrique.communityMarket.model;
import org.bukkit.inventory.ItemStack;
import java.time.Instant;
import java.util.UUID;
/**
* Represents a fixed-price market listing
*/
public class Listing {
private int id;
private UUID sellerUuid;
private String sellerName;
private ItemStack item;
private int amount;
private double price;
private Instant createdAt;
private Instant expiresAt;
private ListingStatus status;
public Listing() {
this.status = ListingStatus.ACTIVE;
this.createdAt = Instant.now();
}
public Listing(UUID sellerUuid, String sellerName, ItemStack item, int amount, double price, Instant expiresAt) {
this();
this.sellerUuid = sellerUuid;
this.sellerName = sellerName;
this.item = item;
this.amount = amount;
this.price = price;
this.expiresAt = expiresAt;
}
// Getters and Setters
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public UUID getSellerUuid() {
return sellerUuid;
}
public void setSellerUuid(UUID sellerUuid) {
this.sellerUuid = sellerUuid;
}
public String getSellerName() {
return sellerName;
}
public void setSellerName(String sellerName) {
this.sellerName = sellerName;
}
public ItemStack getItem() {
return item;
}
public void setItem(ItemStack item) {
this.item = item;
}
public int getAmount() {
return amount;
}
public void setAmount(int amount) {
this.amount = amount;
}
public double getPrice() {
return price;
}
public void setPrice(double price) {
this.price = price;
}
public Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
public Instant getExpiresAt() {
return expiresAt;
}
public void setExpiresAt(Instant expiresAt) {
this.expiresAt = expiresAt;
}
public ListingStatus getStatus() {
return status;
}
public void setStatus(ListingStatus status) {
this.status = status;
}
public boolean isExpired() {
return expiresAt != null && Instant.now().isAfter(expiresAt);
}
public boolean isActive() {
return status == ListingStatus.ACTIVE && !isExpired();
}
public enum ListingStatus {
ACTIVE,
SOLD,
EXPIRED,
CANCELLED
}
}
@@ -0,0 +1,79 @@
package pt.henrique.communityMarket.model;
import java.time.Instant;
import java.util.UUID;
/**
* Represents pending earnings that a player can withdraw
*/
public class PendingEarnings {
private int id;
private UUID playerUuid;
private double amount;
private String source; // e.g., "Listing #123", "Auction #456"
private Instant createdAt;
private boolean withdrawn;
public PendingEarnings() {
this.createdAt = Instant.now();
this.withdrawn = false;
}
public PendingEarnings(UUID playerUuid, double amount, String source) {
this();
this.playerUuid = playerUuid;
this.amount = amount;
this.source = source;
}
// Getters and Setters
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public UUID getPlayerUuid() {
return playerUuid;
}
public void setPlayerUuid(UUID playerUuid) {
this.playerUuid = playerUuid;
}
public double getAmount() {
return amount;
}
public void setAmount(double amount) {
this.amount = amount;
}
public String getSource() {
return source;
}
public void setSource(String source) {
this.source = source;
}
public Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(Instant createdAt) {
this.createdAt = createdAt;
}
public boolean isWithdrawn() {
return withdrawn;
}
public void setWithdrawn(boolean withdrawn) {
this.withdrawn = withdrawn;
}
}
@@ -0,0 +1,473 @@
package pt.henrique.communityMarket.service;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import pt.henrique.communityMarket.CommunityMarket;
import pt.henrique.communityMarket.model.Auction;
import pt.henrique.communityMarket.model.Bid;
import pt.henrique.communityMarket.model.ClaimItem;
import pt.henrique.communityMarket.model.PendingEarnings;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
/**
* Service layer for managing auctions.
* Handles creation, bidding, buyout, and auction end processing.
*/
public class AuctionService {
private final CommunityMarket plugin;
// Simple cache for active auctions
private List<Auction> cachedAuctions;
private long cacheExpiry = 0;
// Track pending operations to prevent race conditions
private final Map<Integer, Boolean> pendingBids = new ConcurrentHashMap<>();
public AuctionService(CommunityMarket plugin) {
this.plugin = plugin;
}
/**
* Creates a new auction
*
* @param player The seller
* @param item The item to auction
* @param startPrice Starting bid price
* @param buyoutPrice Optional buyout price (null for no buyout)
* @param durationHours Duration in hours
* @return CompletableFuture with the auction ID or -1 if failed
*/
public CompletableFuture<Integer> createAuction(Player player, ItemStack item, double startPrice,
Double buyoutPrice, int durationHours) {
// Validate
if (item == null || item.getType().isAir()) {
return CompletableFuture.completedFuture(-1);
}
if (plugin.getConfigManager().isMaterialBlacklisted(item.getType())) {
return CompletableFuture.completedFuture(-1);
}
// Create auction object
Auction auction = new Auction(
player.getUniqueId(),
player.getName(),
item.clone(),
startPrice,
buyoutPrice,
Instant.now().plusSeconds(durationHours * 3600L)
);
// Save to database
return plugin.getDatabaseManager().createAuction(auction)
.thenApply(id -> {
if (id > 0) {
invalidateCache();
}
return id;
});
}
/**
* Gets all active auctions with caching
*/
public CompletableFuture<List<Auction>> getActiveAuctions() {
long now = System.currentTimeMillis();
if (cachedAuctions != null && now < cacheExpiry) {
return CompletableFuture.completedFuture(cachedAuctions);
}
return plugin.getDatabaseManager().getActiveAuctions()
.thenApply(auctions -> {
cachedAuctions = auctions;
cacheExpiry = System.currentTimeMillis() +
(plugin.getConfigManager().getCacheDuration() * 1000L);
return auctions;
});
}
/**
* Gets a specific auction by ID
*/
public CompletableFuture<Optional<Auction>> getAuction(int id) {
return plugin.getDatabaseManager().getAuction(id);
}
/**
* Gets all auctions for a specific player
*/
public CompletableFuture<List<Auction>> getPlayerAuctions(UUID playerUuid) {
return plugin.getDatabaseManager().getPlayerAuctions(playerUuid);
}
/**
* Counts active auctions for a player
*/
public CompletableFuture<Integer> countPlayerAuctions(UUID playerUuid) {
return plugin.getDatabaseManager().countPlayerAuctions(playerUuid);
}
/**
* Checks if a player can create a new auction (not at limit)
*/
public CompletableFuture<Boolean> canCreateAuction(UUID playerUuid) {
int maxAuctions = plugin.getConfigManager().getMaxAuctionsPerPlayer();
return countPlayerAuctions(playerUuid)
.thenApply(count -> count < maxAuctions);
}
/**
* Calculates the minimum bid for an auction
*
* @param auction The auction
* @return Minimum bid amount
*/
public double calculateMinBid(Auction auction) {
return auction.getMinimumBid(
plugin.getConfigManager().getMinBidIncrementPercent(),
plugin.getConfigManager().getMinBidIncrementAbsolute()
);
}
/**
* Places a bid on an auction
*
* @param auctionId The auction to bid on
* @param bidder The bidder
* @param amount The bid amount
* @return CompletableFuture with bid result
*/
public CompletableFuture<BidResult> placeBid(int auctionId, Player bidder, double amount) {
// Check if already processing this auction
if (pendingBids.putIfAbsent(auctionId, true) != null) {
return CompletableFuture.completedFuture(BidResult.ALREADY_PROCESSING);
}
try {
return getAuction(auctionId)
.thenCompose(optAuction -> {
if (optAuction.isEmpty()) {
pendingBids.remove(auctionId);
return CompletableFuture.completedFuture(BidResult.NOT_FOUND);
}
Auction auction = optAuction.get();
// Can't bid on own auction
if (auction.getSellerUuid().equals(bidder.getUniqueId())) {
pendingBids.remove(auctionId);
return CompletableFuture.completedFuture(BidResult.OWN_AUCTION);
}
// Check bid amount
double minBid = calculateMinBid(auction);
if (amount < minBid) {
pendingBids.remove(auctionId);
return CompletableFuture.completedFuture(BidResult.BID_TOO_LOW);
}
// Check bidder funds
if (!plugin.getEconomyManager().has(bidder.getUniqueId(), amount)) {
pendingBids.remove(auctionId);
return CompletableFuture.completedFuture(BidResult.INSUFFICIENT_FUNDS);
}
// Store previous bidder info for refund
UUID previousBidder = auction.getHighestBidderUuid();
double previousBid = auction.getCurrentBid();
// Withdraw from bidder first
if (!plugin.getEconomyManager().withdraw(bidder.getUniqueId(), amount)) {
pendingBids.remove(auctionId);
return CompletableFuture.completedFuture(BidResult.ECONOMY_ERROR);
}
// Place bid in database
return plugin.getDatabaseManager().placeBid(auctionId, bidder.getUniqueId(), bidder.getName(), amount)
.thenApply(success -> {
if (!success) {
// Refund bidder
plugin.getEconomyManager().deposit(bidder.getUniqueId(), amount);
pendingBids.remove(auctionId);
return BidResult.AUCTION_ENDED;
}
// Refund previous bidder
if (previousBidder != null && previousBid > 0) {
plugin.getEconomyManager().deposit(previousBidder, previousBid);
// Notify previous bidder if online
if (plugin.getConfigManager().isNotifyOnOutbid()) {
Player prevBidderPlayer = Bukkit.getPlayer(previousBidder);
if (prevBidderPlayer != null && prevBidderPlayer.isOnline()) {
Map<String, String> placeholders = Map.of(
"item", auction.getItem().getType().name(),
"amount", plugin.getMessageManager().formatCurrency(amount),
"bidder", bidder.getName()
);
prevBidderPlayer.sendMessage(plugin.getMessageManager()
.getPrefixed("messages.auction-outbid", placeholders));
}
}
}
invalidateCache();
pendingBids.remove(auctionId);
return BidResult.SUCCESS;
});
});
} catch (Exception e) {
pendingBids.remove(auctionId);
throw e;
}
}
/**
* Buys out an auction immediately
*
* @param auctionId The auction to buyout
* @param buyer The buyer
* @return CompletableFuture with buyout result
*/
public CompletableFuture<BidResult> buyout(int auctionId, Player buyer) {
return getAuction(auctionId)
.thenCompose(optAuction -> {
if (optAuction.isEmpty()) {
return CompletableFuture.completedFuture(BidResult.NOT_FOUND);
}
Auction auction = optAuction.get();
// Check if buyout is available
if (!auction.hasBuyout()) {
return CompletableFuture.completedFuture(BidResult.NO_BUYOUT);
}
// Can't buyout own auction
if (auction.getSellerUuid().equals(buyer.getUniqueId())) {
return CompletableFuture.completedFuture(BidResult.OWN_AUCTION);
}
// Check buyer funds
double buyoutPrice = auction.getBuyoutPrice();
if (!plugin.getEconomyManager().has(buyer.getUniqueId(), buyoutPrice)) {
return CompletableFuture.completedFuture(BidResult.INSUFFICIENT_FUNDS);
}
// Place bid at buyout price (this will end the auction)
return placeBid(auctionId, buyer, buyoutPrice)
.thenCompose(result -> {
if (result == BidResult.SUCCESS) {
// Immediately end the auction
return processAuctionEnd(auctionId)
.thenApply(v -> BidResult.BUYOUT_SUCCESS);
}
return CompletableFuture.completedFuture(result);
});
});
}
/**
* Cancels an auction (only if no bids)
*
* @param auctionId The auction to cancel
* @param playerUuid The player attempting to cancel
* @param isAdmin Whether this is an admin action
* @return CompletableFuture with success status
*/
public CompletableFuture<CancelResult> cancelAuction(int auctionId, UUID playerUuid, boolean isAdmin) {
return getAuction(auctionId)
.thenCompose(optAuction -> {
if (optAuction.isEmpty()) {
return CompletableFuture.completedFuture(CancelResult.NOT_FOUND);
}
Auction auction = optAuction.get();
// Check permission
if (!isAdmin && !auction.getSellerUuid().equals(playerUuid)) {
return CompletableFuture.completedFuture(CancelResult.NOT_OWNER);
}
// Can only cancel if no bids (unless admin)
if (!isAdmin && auction.getBidCount() > 0) {
return CompletableFuture.completedFuture(CancelResult.HAS_BIDS);
}
// If admin cancelling with bids, refund highest bidder
if (isAdmin && auction.getBidCount() > 0 && auction.getHighestBidderUuid() != null) {
plugin.getEconomyManager().deposit(auction.getHighestBidderUuid(), auction.getCurrentBid());
}
// Update status
return plugin.getDatabaseManager().updateAuctionStatus(auctionId, Auction.AuctionStatus.CANCELLED)
.thenApply(success -> {
if (success) {
// Return item to claim storage
ClaimItem claimItem = new ClaimItem(
auction.getSellerUuid(),
auction.getItem().clone(),
ClaimItem.ClaimReason.CANCELLED_AUCTION,
"Auction #" + auctionId
);
plugin.getDatabaseManager().addClaimItem(claimItem);
invalidateCache();
return CancelResult.SUCCESS;
}
return CancelResult.FAILED;
});
});
}
/**
* Processes ended auctions - delivers items and handles payments
*/
public CompletableFuture<Void> processEndedAuctions() {
return plugin.getDatabaseManager().getEndedAuctions()
.thenAccept(auctions -> {
for (Auction auction : auctions) {
processAuctionEnd(auction.getId());
}
if (!auctions.isEmpty()) {
invalidateCache();
}
});
}
/**
* Processes a single auction end
*/
private CompletableFuture<Void> processAuctionEnd(int auctionId) {
return getAuction(auctionId)
.thenAccept(optAuction -> {
if (optAuction.isEmpty()) return;
Auction auction = optAuction.get();
// Update status first
Auction.AuctionStatus newStatus = auction.getBidCount() > 0
? Auction.AuctionStatus.SOLD
: Auction.AuctionStatus.EXPIRED;
plugin.getDatabaseManager().updateAuctionStatus(auctionId, newStatus);
if (auction.getBidCount() > 0 && auction.getHighestBidderUuid() != null) {
// Auction has a winner
double winningBid = auction.getCurrentBid();
double tax = plugin.getConfigManager().getAuctionTax();
double sellerEarnings = winningBid * (1 - tax / 100);
// Add pending earnings for seller
PendingEarnings earnings = new PendingEarnings(
auction.getSellerUuid(),
sellerEarnings,
"Auction #" + auctionId
);
plugin.getDatabaseManager().addPendingEarnings(earnings);
// Add item to winner's claim storage
ClaimItem claimItem = new ClaimItem(
auction.getHighestBidderUuid(),
auction.getItem().clone(),
ClaimItem.ClaimReason.WON_AUCTION,
"Auction #" + auctionId
);
plugin.getDatabaseManager().addClaimItem(claimItem);
// Notify winner if online
if (plugin.getConfigManager().isNotifyOnWin()) {
Player winner = Bukkit.getPlayer(auction.getHighestBidderUuid());
if (winner != null && winner.isOnline()) {
Map<String, String> placeholders = Map.of(
"item", auction.getItem().getType().name(),
"price", plugin.getMessageManager().formatCurrency(winningBid)
);
winner.sendMessage(plugin.getMessageManager()
.getPrefixed("messages.auction-ended-winner", placeholders));
}
}
// Notify seller if online
if (plugin.getConfigManager().isNotifyOnSale()) {
Player seller = Bukkit.getPlayer(auction.getSellerUuid());
if (seller != null && seller.isOnline()) {
Map<String, String> placeholders = Map.of(
"item", auction.getItem().getType().name(),
"winner", auction.getHighestBidderName(),
"price", plugin.getMessageManager().formatCurrency(sellerEarnings)
);
seller.sendMessage(plugin.getMessageManager()
.getPrefixed("messages.auction-ended-seller", placeholders));
}
}
} else {
// No bids - return item to seller
ClaimItem claimItem = new ClaimItem(
auction.getSellerUuid(),
auction.getItem().clone(),
ClaimItem.ClaimReason.AUCTION_NO_BIDS,
"Auction #" + auctionId
);
plugin.getDatabaseManager().addClaimItem(claimItem);
// Notify seller if online
if (plugin.getConfigManager().isNotifyOnExpire()) {
Player seller = Bukkit.getPlayer(auction.getSellerUuid());
if (seller != null && seller.isOnline()) {
seller.sendMessage(plugin.getMessageManager().getPrefixed(
"messages.auction-ended-no-bids",
"item", auction.getItem().getType().name()
));
}
}
}
});
}
/**
* Invalidates the auction cache
*/
public void invalidateCache() {
cachedAuctions = null;
cacheExpiry = 0;
}
/**
* Result of a bid attempt
*/
public enum BidResult {
SUCCESS,
BUYOUT_SUCCESS,
NOT_FOUND,
AUCTION_ENDED,
OWN_AUCTION,
BID_TOO_LOW,
INSUFFICIENT_FUNDS,
ECONOMY_ERROR,
ALREADY_PROCESSING,
NO_BUYOUT
}
/**
* Result of a cancel attempt
*/
public enum CancelResult {
SUCCESS,
NOT_FOUND,
NOT_OWNER,
HAS_BIDS,
FAILED
}
}
@@ -0,0 +1,154 @@
package pt.henrique.communityMarket.service;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import pt.henrique.communityMarket.CommunityMarket;
import pt.henrique.communityMarket.model.ClaimItem;
import pt.henrique.communityMarket.util.InventoryUtil;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
/**
* Service layer for managing claim storage.
* Players claim items from expired listings, won auctions, etc.
*/
public class ClaimService {
private final CommunityMarket plugin;
public ClaimService(CommunityMarket plugin) {
this.plugin = plugin;
}
/**
* Gets all pending claim items for a player
*
* @param playerUuid The player's UUID
* @return CompletableFuture with list of claim items
*/
public CompletableFuture<List<ClaimItem>> getPlayerClaimItems(UUID playerUuid) {
return plugin.getDatabaseManager().getPlayerClaimItems(playerUuid);
}
/**
* Counts pending claim items for a player
*
* @param playerUuid The player's UUID
* @return CompletableFuture with count
*/
public CompletableFuture<Integer> countPlayerClaimItems(UUID playerUuid) {
return plugin.getDatabaseManager().countPlayerClaimItems(playerUuid);
}
/**
* Claims a single item
*
* @param claimId The claim item ID
* @param player The player claiming
* @return CompletableFuture with claim result
*/
public CompletableFuture<ClaimResult> claimItem(int claimId, Player player) {
return plugin.getDatabaseManager().getPlayerClaimItems(player.getUniqueId())
.thenCompose(items -> {
// Find the specific item
ClaimItem claimItem = items.stream()
.filter(i -> i.getId() == claimId)
.findFirst()
.orElse(null);
if (claimItem == null) {
return CompletableFuture.completedFuture(ClaimResult.NOT_FOUND);
}
// Check inventory space
if (!InventoryUtil.hasSpace(player, claimItem.getItem())) {
return CompletableFuture.completedFuture(ClaimResult.INVENTORY_FULL);
}
// Remove from database first
return plugin.getDatabaseManager().removeClaimItem(claimId)
.thenApply(success -> {
if (!success) {
return ClaimResult.FAILED;
}
// Give item to player on main thread
Bukkit.getScheduler().runTask(plugin, () -> {
ItemStack leftover = InventoryUtil.giveItem(player, claimItem.getItem());
if (leftover != null) {
// This shouldn't happen since we checked space, but just in case
plugin.getDatabaseManager().addClaimItem(new ClaimItem(
player.getUniqueId(),
leftover,
claimItem.getReason(),
claimItem.getSourceInfo()
));
}
});
return ClaimResult.SUCCESS;
});
});
}
/**
* Claims all items for a player
*
* @param player The player claiming
* @return CompletableFuture with number of items claimed
*/
public CompletableFuture<Integer> claimAll(Player player) {
return plugin.getDatabaseManager().getPlayerClaimItems(player.getUniqueId())
.thenApply(items -> {
int claimed = 0;
for (ClaimItem item : items) {
// Check if player has space
if (!InventoryUtil.hasSpace(player, item.getItem())) {
break; // Stop claiming if inventory is full
}
// Remove from database
boolean removed = plugin.getDatabaseManager().removeClaimItem(item.getId()).join();
if (removed) {
// Give item on main thread
ItemStack itemToGive = item.getItem();
Bukkit.getScheduler().runTask(plugin, () -> {
InventoryUtil.giveItem(player, itemToGive);
});
claimed++;
}
}
return claimed;
});
}
/**
* Adds an item to a player's claim storage
*
* @param playerUuid The player's UUID
* @param item The item to add
* @param reason The reason for the claim
* @param sourceInfo Additional info about the source
* @return CompletableFuture with the claim ID
*/
public CompletableFuture<Integer> addClaimItem(UUID playerUuid, ItemStack item, ClaimItem.ClaimReason reason, String sourceInfo) {
ClaimItem claimItem = new ClaimItem(playerUuid, item, reason, sourceInfo);
return plugin.getDatabaseManager().addClaimItem(claimItem);
}
/**
* Result of a claim attempt
*/
public enum ClaimResult {
SUCCESS,
NOT_FOUND,
INVENTORY_FULL,
FAILED
}
}
@@ -0,0 +1,112 @@
package pt.henrique.communityMarket.service;
import org.bukkit.entity.Player;
import pt.henrique.communityMarket.CommunityMarket;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
/**
* Service layer for managing player earnings.
* Handles pending earnings from sales and withdrawals.
*/
public class EarningsService {
private final CommunityMarket plugin;
public EarningsService(CommunityMarket plugin) {
this.plugin = plugin;
}
/**
* Gets the total pending earnings for a player
*
* @param playerUuid The player's UUID
* @return CompletableFuture with the total pending amount
*/
public CompletableFuture<Double> getPendingEarnings(UUID playerUuid) {
return plugin.getDatabaseManager().getPlayerPendingEarnings(playerUuid);
}
/**
* Withdraws all pending earnings for a player
*
* @param player The player withdrawing
* @return CompletableFuture with the withdrawal result
*/
public CompletableFuture<WithdrawResult> withdrawAll(Player player) {
return getPendingEarnings(player.getUniqueId())
.thenCompose(amount -> {
if (amount <= 0) {
return CompletableFuture.completedFuture(WithdrawResult.NO_EARNINGS);
}
// Mark earnings as withdrawn in database first
return plugin.getDatabaseManager().withdrawAllEarnings(player.getUniqueId())
.thenApply(success -> {
if (!success) {
return WithdrawResult.FAILED;
}
// Deposit to player's economy account
if (!plugin.getEconomyManager().deposit(player.getUniqueId(), amount)) {
// Economy failed - this is a problem
// The earnings are marked as withdrawn but money wasn't given
plugin.getLogger().severe("Failed to deposit " + amount + " to " + player.getName() + " after marking earnings as withdrawn!");
return WithdrawResult.ECONOMY_ERROR;
}
return WithdrawResult.success(amount);
});
});
}
/**
* Adds pending earnings for a player
*
* @param playerUuid The player's UUID
* @param amount The amount to add
* @param source Description of the source (e.g., "Listing #123")
* @return CompletableFuture with the earnings ID
*/
public CompletableFuture<Integer> addEarnings(UUID playerUuid, double amount, String source) {
var earnings = new pt.henrique.communityMarket.model.PendingEarnings(playerUuid, amount, source);
return plugin.getDatabaseManager().addPendingEarnings(earnings);
}
/**
* Result of a withdrawal attempt
*/
public static class WithdrawResult {
private final boolean success;
private final double amount;
private final String error;
private WithdrawResult(boolean success, double amount, String error) {
this.success = success;
this.amount = amount;
this.error = error;
}
public static WithdrawResult success(double amount) {
return new WithdrawResult(true, amount, null);
}
public static final WithdrawResult NO_EARNINGS = new WithdrawResult(false, 0, "no_earnings");
public static final WithdrawResult FAILED = new WithdrawResult(false, 0, "failed");
public static final WithdrawResult ECONOMY_ERROR = new WithdrawResult(false, 0, "economy_error");
public boolean isSuccess() {
return success;
}
public double getAmount() {
return amount;
}
public String getError() {
return error;
}
}
}
@@ -0,0 +1,377 @@
package pt.henrique.communityMarket.service;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import pt.henrique.communityMarket.CommunityMarket;
import pt.henrique.communityMarket.model.ClaimItem;
import pt.henrique.communityMarket.model.Listing;
import pt.henrique.communityMarket.model.PendingEarnings;
import pt.henrique.communityMarket.util.InventoryUtil;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
/**
* Service layer for managing fixed-price market listings.
* Handles creation, purchase, cancellation, and expiration of listings.
*/
public class ListingService {
private final CommunityMarket plugin;
// Simple cache for active listings
private List<Listing> cachedListings;
private long cacheExpiry = 0;
// Track pending operations to prevent double-purchases
private final Map<Integer, Boolean> pendingPurchases = new ConcurrentHashMap<>();
public ListingService(CommunityMarket plugin) {
this.plugin = plugin;
}
/**
* Creates a new listing for a player
*
* @param player The seller
* @param item The item to sell
* @param amount Amount of items
* @param price Total price for all items
* @param durationHours Duration in hours
* @return CompletableFuture with the listing ID or -1 if failed
*/
public CompletableFuture<Integer> createListing(Player player, ItemStack item, int amount, double price, int durationHours) {
// Validate
if (item == null || item.getType().isAir()) {
return CompletableFuture.completedFuture(-1);
}
if (plugin.getConfigManager().isMaterialBlacklisted(item.getType())) {
return CompletableFuture.completedFuture(-1);
}
// Create listing object
Listing listing = new Listing(
player.getUniqueId(),
player.getName(),
item.clone(),
amount,
price,
Instant.now().plusSeconds(durationHours * 3600L)
);
// Save to database
return plugin.getDatabaseManager().createListing(listing)
.thenApply(id -> {
if (id > 0) {
invalidateCache();
// Update cooldown
plugin.getDatabaseManager().updateLastListingTime(player.getUniqueId());
}
return id;
});
}
/**
* Gets all active listings with caching
*/
public CompletableFuture<List<Listing>> getActiveListings() {
long now = System.currentTimeMillis();
// Return cached if still valid
if (cachedListings != null && now < cacheExpiry) {
return CompletableFuture.completedFuture(cachedListings);
}
return plugin.getDatabaseManager().getActiveListings()
.thenApply(listings -> {
cachedListings = listings;
cacheExpiry = System.currentTimeMillis() +
(plugin.getConfigManager().getCacheDuration() * 1000L);
return listings;
});
}
/**
* Gets a specific listing by ID
*/
public CompletableFuture<Optional<Listing>> getListing(int id) {
return plugin.getDatabaseManager().getListing(id);
}
/**
* Gets all listings for a specific player
*/
public CompletableFuture<List<Listing>> getPlayerListings(UUID playerUuid) {
return plugin.getDatabaseManager().getPlayerListings(playerUuid);
}
/**
* Counts active listings for a player
*/
public CompletableFuture<Integer> countPlayerListings(UUID playerUuid) {
return plugin.getDatabaseManager().countPlayerListings(playerUuid);
}
/**
* Checks if a player can create a new listing (not at limit and no cooldown)
*/
public CompletableFuture<Boolean> canCreateListing(UUID playerUuid) {
int maxListings = plugin.getConfigManager().getMaxListingsPerPlayer();
return countPlayerListings(playerUuid)
.thenCompose(count -> {
if (count >= maxListings) {
return CompletableFuture.completedFuture(false);
}
int cooldown = plugin.getConfigManager().getListingCooldown();
if (cooldown <= 0) {
return CompletableFuture.completedFuture(true);
}
return plugin.getDatabaseManager().getLastListingTime(playerUuid)
.thenApply(lastTime -> {
if (lastTime.isEmpty()) return true;
return Instant.now().isAfter(lastTime.get().plusSeconds(cooldown));
});
});
}
/**
* Calculates the remaining cooldown time in seconds
*/
public CompletableFuture<Long> getRemainingCooldown(UUID playerUuid) {
int cooldown = plugin.getConfigManager().getListingCooldown();
if (cooldown <= 0) {
return CompletableFuture.completedFuture(0L);
}
return plugin.getDatabaseManager().getLastListingTime(playerUuid)
.thenApply(lastTime -> {
if (lastTime.isEmpty()) return 0L;
long remaining = lastTime.get().plusSeconds(cooldown).getEpochSecond() - Instant.now().getEpochSecond();
return Math.max(0, remaining);
});
}
/**
* Attempts to purchase a listing.
* This is an atomic operation that prevents double-purchases.
*
* @param listingId The listing to purchase
* @param buyer The buyer
* @return CompletableFuture with success status
*/
public CompletableFuture<PurchaseResult> purchaseListing(int listingId, Player buyer) {
// Check if already processing this listing
if (pendingPurchases.putIfAbsent(listingId, true) != null) {
return CompletableFuture.completedFuture(PurchaseResult.ALREADY_PROCESSING);
}
try {
return getListing(listingId)
.thenCompose(optListing -> {
if (optListing.isEmpty()) {
pendingPurchases.remove(listingId);
return CompletableFuture.completedFuture(PurchaseResult.NOT_FOUND);
}
Listing listing = optListing.get();
// Can't buy own listing
if (listing.getSellerUuid().equals(buyer.getUniqueId())) {
pendingPurchases.remove(listingId);
return CompletableFuture.completedFuture(PurchaseResult.OWN_LISTING);
}
// Check buyer funds
if (!plugin.getEconomyManager().has(buyer.getUniqueId(), listing.getPrice())) {
pendingPurchases.remove(listingId);
return CompletableFuture.completedFuture(PurchaseResult.INSUFFICIENT_FUNDS);
}
// Attempt atomic purchase in DB
return plugin.getDatabaseManager().purchaseListing(listingId, buyer.getUniqueId(), buyer.getName())
.thenApply(success -> {
if (!success) {
pendingPurchases.remove(listingId);
return PurchaseResult.ALREADY_SOLD;
}
// Withdraw from buyer
if (!plugin.getEconomyManager().withdraw(buyer.getUniqueId(), listing.getPrice())) {
// Rollback DB change
plugin.getDatabaseManager().updateListingStatus(listingId, Listing.ListingStatus.ACTIVE);
pendingPurchases.remove(listingId);
return PurchaseResult.ECONOMY_ERROR;
}
// Calculate seller earnings after tax
double tax = plugin.getConfigManager().getMarketTax();
double sellerEarnings = listing.getPrice() * (1 - tax / 100);
// Add pending earnings for seller
PendingEarnings earnings = new PendingEarnings(
listing.getSellerUuid(),
sellerEarnings,
"Listing #" + listingId
);
plugin.getDatabaseManager().addPendingEarnings(earnings);
// Give item to buyer or add to claim storage
Bukkit.getScheduler().runTask(plugin, () -> {
ItemStack item = listing.getItem().clone();
item.setAmount(listing.getAmount());
ItemStack leftover = InventoryUtil.giveItem(buyer, item);
if (leftover != null) {
// Couldn't fit in inventory, add to claim storage
ClaimItem claimItem = new ClaimItem(
buyer.getUniqueId(),
leftover,
ClaimItem.ClaimReason.PURCHASE_FULL_INVENTORY,
"Listing #" + listingId
);
plugin.getDatabaseManager().addClaimItem(claimItem);
buyer.sendMessage(plugin.getMessageManager().getPrefixed("messages.claim-inventory-full"));
}
});
// Notify seller if online
if (plugin.getConfigManager().isNotifyOnSale()) {
Player seller = Bukkit.getPlayer(listing.getSellerUuid());
if (seller != null && seller.isOnline()) {
Map<String, String> placeholders = Map.of(
"item", listing.getItem().getType().name(),
"amount", String.valueOf(listing.getAmount()),
"buyer", buyer.getName(),
"price", plugin.getMessageManager().formatCurrency(listing.getPrice())
);
seller.sendMessage(plugin.getMessageManager().getPrefixed("messages.listing-sold", placeholders));
}
}
invalidateCache();
pendingPurchases.remove(listingId);
return PurchaseResult.SUCCESS;
});
});
} catch (Exception e) {
pendingPurchases.remove(listingId);
throw e;
}
}
/**
* Cancels a listing and returns the item to the seller's claim storage
*
* @param listingId The listing to cancel
* @param playerUuid The player attempting to cancel (must be seller or admin)
* @param isAdmin Whether this is an admin action
* @return CompletableFuture with success status
*/
public CompletableFuture<Boolean> cancelListing(int listingId, UUID playerUuid, boolean isAdmin) {
return getListing(listingId)
.thenCompose(optListing -> {
if (optListing.isEmpty()) {
return CompletableFuture.completedFuture(false);
}
Listing listing = optListing.get();
// Check permission
if (!isAdmin && !listing.getSellerUuid().equals(playerUuid)) {
return CompletableFuture.completedFuture(false);
}
// Update status
return plugin.getDatabaseManager().updateListingStatus(listingId, Listing.ListingStatus.CANCELLED)
.thenApply(success -> {
if (success) {
// Return item to claim storage
ItemStack item = listing.getItem().clone();
item.setAmount(listing.getAmount());
ClaimItem claimItem = new ClaimItem(
listing.getSellerUuid(),
item,
ClaimItem.ClaimReason.CANCELLED_LISTING,
"Listing #" + listingId
);
plugin.getDatabaseManager().addClaimItem(claimItem);
invalidateCache();
}
return success;
});
});
}
/**
* Processes expired listings - moves items to claim storage
*/
public CompletableFuture<Void> processExpiredListings() {
return plugin.getDatabaseManager().getExpiredListings()
.thenAccept(listings -> {
for (Listing listing : listings) {
// Update status to expired
plugin.getDatabaseManager().updateListingStatus(listing.getId(), Listing.ListingStatus.EXPIRED)
.thenAccept(success -> {
if (success) {
// Return item to claim storage
ItemStack item = listing.getItem().clone();
item.setAmount(listing.getAmount());
ClaimItem claimItem = new ClaimItem(
listing.getSellerUuid(),
item,
ClaimItem.ClaimReason.EXPIRED_LISTING,
"Listing #" + listing.getId()
);
plugin.getDatabaseManager().addClaimItem(claimItem);
// Notify seller if online
if (plugin.getConfigManager().isNotifyOnExpire()) {
Player seller = Bukkit.getPlayer(listing.getSellerUuid());
if (seller != null && seller.isOnline()) {
seller.sendMessage(plugin.getMessageManager().getPrefixed(
"messages.listing-expired",
"id", String.valueOf(listing.getId())
));
}
}
}
});
}
if (!listings.isEmpty()) {
invalidateCache();
}
});
}
/**
* Invalidates the listing cache
*/
public void invalidateCache() {
cachedListings = null;
cacheExpiry = 0;
}
/**
* Result of a purchase attempt
*/
public enum PurchaseResult {
SUCCESS,
NOT_FOUND,
ALREADY_SOLD,
OWN_LISTING,
INSUFFICIENT_FUNDS,
ECONOMY_ERROR,
ALREADY_PROCESSING
}
}
@@ -0,0 +1,297 @@
package pt.henrique.communityMarket.service;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import pt.henrique.communityMarket.CommunityMarket;
import java.util.concurrent.CompletableFuture;
/**
* Service layer for handling marketplace transactions.
* Provides a unified interface for complex operations that
* involve multiple services (purchases, bids, etc.)
*/
public class TransactionService {
private final CommunityMarket plugin;
public TransactionService(CommunityMarket plugin) {
this.plugin = plugin;
}
/**
* Validates an item before it can be listed or auctioned.
* Checks material blacklist, keywords in name/lore, and other restrictions.
*
* @param item The item to validate
* @return ValidationResult with success status and error message if applicable
*/
public ValidationResult validateItem(ItemStack item) {
if (item == null || item.getType().isAir()) {
return new ValidationResult(false, "invalid-item");
}
// Check material blacklist
if (plugin.getConfigManager().isMaterialBlacklisted(item.getType())) {
return new ValidationResult(false, "blacklisted-item");
}
// Check for blacklisted keywords in item name/lore
if (item.hasItemMeta()) {
var meta = item.getItemMeta();
// Check display name
if (meta.hasDisplayName()) {
String displayName = meta.getDisplayName();
if (plugin.getConfigManager().containsBlacklistedKeyword(displayName)) {
return new ValidationResult(false, "blacklisted-content");
}
}
// Check lore
if (meta.hasLore()) {
for (String loreLine : meta.getLore()) {
if (plugin.getConfigManager().containsBlacklistedKeyword(loreLine)) {
return new ValidationResult(false, "blacklisted-content");
}
}
}
}
return new ValidationResult(true, null);
}
/**
* Validates a price for a listing
*
* @param price The price to validate
* @return true if the price is within allowed range
*/
public boolean validateListingPrice(double price) {
double min = plugin.getConfigManager().getMinPrice();
double max = plugin.getConfigManager().getMaxPrice();
return price >= min && price <= max;
}
/**
* Validates a starting price for an auction
*
* @param price The price to validate
* @return true if the price is within allowed range
*/
public boolean validateAuctionStartPrice(double price) {
double min = plugin.getConfigManager().getMinStartPrice();
double max = plugin.getConfigManager().getMaxStartPrice();
return price >= min && price <= max;
}
/**
* Validates an auction duration
*
* @param hours The duration in hours
* @return true if the duration is within allowed range
*/
public boolean validateAuctionDuration(int hours) {
int min = plugin.getConfigManager().getMinDurationHours();
int max = plugin.getConfigManager().getMaxDurationHours();
return hours >= min && hours <= max;
}
/**
* Calculates the tax amount for a listing sale
*
* @param salePrice The sale price
* @return The tax amount
*/
public double calculateListingTax(double salePrice) {
double taxPercent = plugin.getConfigManager().getMarketTax();
return salePrice * (taxPercent / 100);
}
/**
* Calculates the seller's earnings after tax for a listing
*
* @param salePrice The sale price
* @return The amount the seller receives
*/
public double calculateListingEarnings(double salePrice) {
return salePrice - calculateListingTax(salePrice);
}
/**
* Calculates the tax amount for an auction sale
*
* @param salePrice The final sale price
* @return The tax amount
*/
public double calculateAuctionTax(double salePrice) {
double taxPercent = plugin.getConfigManager().getAuctionTax();
return salePrice * (taxPercent / 100);
}
/**
* Calculates the seller's earnings after tax for an auction
*
* @param salePrice The final sale price
* @return The amount the seller receives
*/
public double calculateAuctionEarnings(double salePrice) {
return salePrice - calculateAuctionTax(salePrice);
}
/**
* Creates a listing with full validation and item removal
*
* @param player The seller
* @param item The item to list
* @param amount Amount to list
* @param price Total price
* @param durationHours Duration in hours
* @return CompletableFuture with transaction result
*/
public CompletableFuture<TransactionResult> createListingTransaction(
Player player, ItemStack item, int amount, double price, int durationHours) {
// Validate item
ValidationResult validation = validateItem(item);
if (!validation.isValid()) {
return CompletableFuture.completedFuture(
TransactionResult.failed(validation.getErrorKey()));
}
// Validate price
if (!validateListingPrice(price)) {
return CompletableFuture.completedFuture(
TransactionResult.failed("invalid-price"));
}
// Check listing limit
return plugin.getListingService().canCreateListing(player.getUniqueId())
.thenCompose(canCreate -> {
if (!canCreate) {
// Check if it's cooldown or limit
return plugin.getListingService().getRemainingCooldown(player.getUniqueId())
.thenApply(cooldown -> {
if (cooldown > 0) {
return TransactionResult.failed("listing-cooldown");
}
return TransactionResult.failed("listing-limit-reached");
});
}
// Create the listing
ItemStack listItem = item.clone();
listItem.setAmount(amount);
return plugin.getListingService().createListing(player, listItem, amount, price, durationHours)
.thenApply(id -> {
if (id > 0) {
return TransactionResult.success(id);
}
return TransactionResult.failed("failed");
});
});
}
/**
* Creates an auction with full validation and item removal
*
* @param player The seller
* @param item The item to auction
* @param startPrice Starting bid
* @param buyoutPrice Optional buyout price
* @param durationHours Duration in hours
* @return CompletableFuture with transaction result
*/
public CompletableFuture<TransactionResult> createAuctionTransaction(
Player player, ItemStack item, double startPrice, Double buyoutPrice, int durationHours) {
// Validate item
ValidationResult validation = validateItem(item);
if (!validation.isValid()) {
return CompletableFuture.completedFuture(
TransactionResult.failed(validation.getErrorKey()));
}
// Validate prices
if (!validateAuctionStartPrice(startPrice)) {
return CompletableFuture.completedFuture(
TransactionResult.failed("invalid-price"));
}
if (buyoutPrice != null && buyoutPrice <= startPrice) {
return CompletableFuture.completedFuture(
TransactionResult.failed("invalid-price"));
}
// Validate duration
if (!validateAuctionDuration(durationHours)) {
return CompletableFuture.completedFuture(
TransactionResult.failed("invalid-duration"));
}
// Check auction limit
return plugin.getAuctionService().canCreateAuction(player.getUniqueId())
.thenCompose(canCreate -> {
if (!canCreate) {
return CompletableFuture.completedFuture(
TransactionResult.failed("auction-limit-reached"));
}
// Create the auction
return plugin.getAuctionService().createAuction(
player, item.clone(), startPrice, buyoutPrice, durationHours)
.thenApply(id -> {
if (id > 0) {
return TransactionResult.success(id);
}
return TransactionResult.failed("failed");
});
});
}
/**
* Result of item validation
*/
public record ValidationResult(boolean isValid, String errorKey) {
public String getErrorKey() {
return errorKey;
}
}
/**
* Result of a transaction
*/
public static class TransactionResult {
private final boolean success;
private final int id;
private final String errorKey;
private TransactionResult(boolean success, int id, String errorKey) {
this.success = success;
this.id = id;
this.errorKey = errorKey;
}
public static TransactionResult success(int id) {
return new TransactionResult(true, id, null);
}
public static TransactionResult failed(String errorKey) {
return new TransactionResult(false, -1, errorKey);
}
public boolean isSuccess() {
return success;
}
public int getId() {
return id;
}
public String getErrorKey() {
return errorKey;
}
}
}
@@ -0,0 +1,28 @@
package pt.henrique.communityMarket.task;
import org.bukkit.scheduler.BukkitRunnable;
import pt.henrique.communityMarket.CommunityMarket;
/**
* Periodic task that checks for ended auctions and processes them.
* Runs asynchronously to avoid blocking the main thread.
*/
public class AuctionTask extends BukkitRunnable {
private final CommunityMarket plugin;
public AuctionTask(CommunityMarket plugin) {
this.plugin = plugin;
}
@Override
public void run() {
// Process ended auctions
plugin.getAuctionService().processEndedAuctions()
.exceptionally(ex -> {
plugin.getLogger().warning("Error processing ended auctions: " + ex.getMessage());
return null;
});
}
}
@@ -0,0 +1,28 @@
package pt.henrique.communityMarket.task;
import org.bukkit.scheduler.BukkitRunnable;
import pt.henrique.communityMarket.CommunityMarket;
/**
* Periodic task that checks for expired listings and moves items to claim storage.
* Runs asynchronously to avoid blocking the main thread.
*/
public class ExpiredListingTask extends BukkitRunnable {
private final CommunityMarket plugin;
public ExpiredListingTask(CommunityMarket plugin) {
this.plugin = plugin;
}
@Override
public void run() {
// Process expired listings
plugin.getListingService().processExpiredListings()
.exceptionally(ex -> {
plugin.getLogger().warning("Error processing expired listings: " + ex.getMessage());
return null;
});
}
}
@@ -0,0 +1,269 @@
package pt.henrique.communityMarket.util;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import pt.henrique.communityMarket.CommunityMarket;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Utility class for inventory operations
*/
public class InventoryUtil {
/**
* Checks if a player has space for an item in their inventory
*
* @param player The player
* @param item The item to check
* @return True if the player has space
*/
public static boolean hasSpace(Player player, ItemStack item) {
if (item == null || item.getType().isAir()) {
return true;
}
// Try to add a clone and see if anything remains
ItemStack clone = item.clone();
Map<Integer, ItemStack> leftover = new HashMap<>();
// Check each slot
for (int i = 0; i < 36; i++) {
ItemStack slot = player.getInventory().getItem(i);
if (slot == null || slot.getType().isAir()) {
return true;
}
if (slot.isSimilar(clone) && slot.getAmount() < slot.getMaxStackSize()) {
int canAdd = slot.getMaxStackSize() - slot.getAmount();
if (canAdd >= clone.getAmount()) {
return true;
}
}
}
return false;
}
/**
* Gives an item to a player, returning any items that couldn't fit
*
* @param player The player
* @param item The item to give
* @return Items that couldn't fit, or null if all items were added
*/
public static ItemStack giveItem(Player player, ItemStack item) {
if (item == null || item.getType().isAir()) {
return null;
}
HashMap<Integer, ItemStack> leftover = player.getInventory().addItem(item.clone());
if (leftover.isEmpty()) {
return null;
}
return leftover.values().iterator().next();
}
/**
* Removes a specific amount of an item from a player's inventory
*
* @param player The player
* @param item The item type to remove
* @param amount The amount to remove
* @return True if the full amount was removed
*/
public static boolean removeItem(Player player, ItemStack item, int amount) {
if (item == null || amount <= 0) {
return true;
}
ItemStack toRemove = item.clone();
toRemove.setAmount(amount);
HashMap<Integer, ItemStack> notRemoved = player.getInventory().removeItem(toRemove);
return notRemoved.isEmpty();
}
/**
* Counts how many of a specific item a player has
*
* @param player The player
* @param item The item type to count
* @return Total count
*/
public static int countItem(Player player, ItemStack item) {
if (item == null) {
return 0;
}
int count = 0;
for (ItemStack slot : player.getInventory().getContents()) {
if (slot != null && slot.isSimilar(item)) {
count += slot.getAmount();
}
}
return count;
}
/**
* Counts how many similar items exist in an inventory.
* Uses strict comparison including all metadata (material, name, lore,
* enchantments, custom model data, etc.).
*
* @param inventory The inventory to search
* @param item The item to match against
* @return Total count of matching items
*/
public static int countSimilarItems(org.bukkit.inventory.Inventory inventory, ItemStack item) {
if (item == null || inventory == null) {
return 0;
}
int count = 0;
for (ItemStack slot : inventory.getContents()) {
if (slot != null && slot.isSimilar(item)) {
count += slot.getAmount();
}
}
return count;
}
/**
* Gets a display name for an item, using material name if custom name is not set
*
* @param item The item
* @return Display name
*/
public static String getDisplayName(ItemStack item) {
if (item == null) {
return "Unknown";
}
ItemMeta meta = item.getItemMeta();
if (meta != null && meta.hasDisplayName()) {
return TextUtil.stripColor(meta.getDisplayName());
}
return formatMaterialName(item.getType());
}
/**
* Formats a material name into a readable string
*
* @param material The material
* @return Formatted name (e.g., "Diamond Sword")
*/
public static String formatMaterialName(Material material) {
String name = material.name().replace("_", " ").toLowerCase();
StringBuilder result = new StringBuilder();
boolean capitalizeNext = true;
for (char c : name.toCharArray()) {
if (c == ' ') {
result.append(c);
capitalizeNext = true;
} else if (capitalizeNext) {
result.append(Character.toUpperCase(c));
capitalizeNext = false;
} else {
result.append(c);
}
}
return result.toString();
}
/**
* Determines the category of an item for filtering purposes
*
* @param item The item
* @return Category name
*/
public static ItemCategory getCategory(ItemStack item) {
if (item == null) {
return ItemCategory.MISC;
}
Material material = item.getType();
String name = material.name();
// Check for enchantments first
if (item.hasItemMeta() && item.getItemMeta().hasEnchants()) {
return ItemCategory.ENCHANTED;
}
// Weapons
if (name.endsWith("_SWORD") || name.endsWith("_AXE") || name.equals("BOW") ||
name.equals("CROSSBOW") || name.equals("TRIDENT") || name.equals("MACE")) {
return ItemCategory.WEAPONS;
}
// Armor
if (name.endsWith("_HELMET") || name.endsWith("_CHESTPLATE") ||
name.endsWith("_LEGGINGS") || name.endsWith("_BOOTS") ||
name.equals("SHIELD") || name.equals("ELYTRA")) {
return ItemCategory.ARMOR;
}
// Tools
if (name.endsWith("_PICKAXE") || name.endsWith("_SHOVEL") ||
name.endsWith("_HOE") || name.equals("FISHING_ROD") ||
name.equals("FLINT_AND_STEEL") || name.equals("SHEARS")) {
return ItemCategory.TOOLS;
}
// Blocks
if (material.isBlock()) {
return ItemCategory.BLOCKS;
}
// Food
if (material.isEdible()) {
return ItemCategory.FOOD;
}
// Potions
if (name.contains("POTION") || name.equals("DRAGON_BREATH")) {
return ItemCategory.POTIONS;
}
return ItemCategory.MISC;
}
public enum ItemCategory {
ALL("All Items", Material.CHEST),
WEAPONS("Weapons", Material.DIAMOND_SWORD),
ARMOR("Armor", Material.DIAMOND_CHESTPLATE),
TOOLS("Tools", Material.DIAMOND_PICKAXE),
BLOCKS("Blocks", Material.GRASS_BLOCK),
FOOD("Food", Material.GOLDEN_APPLE),
POTIONS("Potions", Material.POTION),
ENCHANTED("Enchanted", Material.ENCHANTED_BOOK),
MISC("Miscellaneous", Material.ENDER_PEARL);
private final String displayName;
private final Material icon;
ItemCategory(String displayName, Material icon) {
this.displayName = displayName;
this.icon = icon;
}
public String getDisplayName() {
return displayName;
}
public Material getIcon() {
return icon;
}
}
}
@@ -0,0 +1,215 @@
package pt.henrique.communityMarket.util;
import net.kyori.adventure.text.Component;
import org.bukkit.Material;
import org.bukkit.enchantments.Enchantment;
import org.bukkit.inventory.ItemFlag;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
/**
* Fluent builder for creating ItemStacks with custom properties.
* Used throughout the GUI system to create menu items.
*/
public class ItemBuilder {
private final ItemStack item;
private final ItemMeta meta;
/**
* Creates a new ItemBuilder with the specified material
*/
public ItemBuilder(Material material) {
this(material, 1);
}
/**
* Creates a new ItemBuilder with the specified material and amount
*/
public ItemBuilder(Material material, int amount) {
this.item = new ItemStack(material, amount);
this.meta = item.getItemMeta();
}
/**
* Creates a new ItemBuilder from an existing ItemStack
*/
public ItemBuilder(ItemStack item) {
this.item = item.clone();
this.meta = this.item.getItemMeta();
}
/**
* Sets the display name using legacy color codes
*/
public ItemBuilder name(String name) {
if (meta != null) {
meta.displayName(TextUtil.colorize(name));
}
return this;
}
/**
* Sets the display name using a Component
*/
public ItemBuilder name(Component name) {
if (meta != null) {
meta.displayName(name);
}
return this;
}
/**
* Sets the lore using a list of strings with legacy color codes
*/
public ItemBuilder lore(List<String> lore) {
if (meta != null && lore != null) {
List<Component> components = lore.stream()
.map(TextUtil::colorize)
.collect(Collectors.toList());
meta.lore(components);
}
return this;
}
/**
* Sets the lore using varargs strings
*/
public ItemBuilder lore(String... lore) {
return lore(List.of(lore));
}
/**
* Adds a line to the existing lore
*/
public ItemBuilder addLore(String line) {
if (meta != null) {
List<Component> existingLore = meta.lore();
List<Component> newLore = existingLore != null ? new ArrayList<>(existingLore) : new ArrayList<>();
newLore.add(TextUtil.colorize(line));
meta.lore(newLore);
}
return this;
}
/**
* Adds multiple lines to the existing lore
*/
public ItemBuilder addLore(List<String> lines) {
if (meta != null && lines != null) {
List<Component> existingLore = meta.lore();
List<Component> newLore = existingLore != null ? new ArrayList<>(existingLore) : new ArrayList<>();
for (String line : lines) {
newLore.add(TextUtil.colorize(line));
}
meta.lore(newLore);
}
return this;
}
/**
* Sets the item amount
*/
public ItemBuilder amount(int amount) {
item.setAmount(Math.min(64, Math.max(1, amount)));
return this;
}
/**
* Adds an enchantment glow effect (uses LUCK with HIDE_ENCHANTS flag)
*/
public ItemBuilder glow(boolean glow) {
if (glow && meta != null) {
meta.addEnchant(Enchantment.LUCK_OF_THE_SEA, 1, true);
meta.addItemFlags(ItemFlag.HIDE_ENCHANTS);
}
return this;
}
/**
* Always adds glow effect
*/
public ItemBuilder glow() {
return glow(true);
}
/**
* Adds an enchantment
*/
public ItemBuilder enchant(Enchantment enchantment, int level) {
if (meta != null) {
meta.addEnchant(enchantment, level, true);
}
return this;
}
/**
* Hides all item flags (enchants, attributes, etc.)
*/
public ItemBuilder hideFlags() {
if (meta != null) {
meta.addItemFlags(ItemFlag.values());
}
return this;
}
/**
* Adds specific item flags
*/
public ItemBuilder addFlags(ItemFlag... flags) {
if (meta != null) {
meta.addItemFlags(flags);
}
return this;
}
/**
* Sets the item as unbreakable
*/
public ItemBuilder unbreakable(boolean unbreakable) {
if (meta != null) {
meta.setUnbreakable(unbreakable);
if (unbreakable) {
meta.addItemFlags(ItemFlag.HIDE_UNBREAKABLE);
}
}
return this;
}
/**
* Sets custom model data
*/
public ItemBuilder customModelData(int data) {
if (meta != null) {
meta.setCustomModelData(data);
}
return this;
}
/**
* Builds and returns the final ItemStack
*/
public ItemStack build() {
item.setItemMeta(meta);
return item;
}
/**
* Creates a copy of an ItemStack with modified lore
*/
public static ItemStack withLore(ItemStack original, List<String> lore) {
return new ItemBuilder(original).lore(lore).build();
}
/**
* Creates a copy of an ItemStack with additional lore appended
*/
public static ItemStack appendLore(ItemStack original, List<String> additionalLore) {
return new ItemBuilder(original).addLore(additionalLore).build();
}
}
@@ -0,0 +1,73 @@
package pt.henrique.communityMarket.util;
import org.bukkit.inventory.ItemStack;
import org.bukkit.util.io.BukkitObjectInputStream;
import org.bukkit.util.io.BukkitObjectOutputStream;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Base64;
/**
* Utility class for serializing and deserializing ItemStacks to/from Base64 strings
*/
public class ItemSerializer {
/**
* Serializes an ItemStack to a Base64 encoded string
*
* @param item The ItemStack to serialize
* @return Base64 encoded string representation
* @throws IOException If serialization fails
*/
public static String serialize(ItemStack item) throws IOException {
if (item == null) {
return null;
}
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
BukkitObjectOutputStream dataOutput = new BukkitObjectOutputStream(outputStream)) {
dataOutput.writeObject(item);
return Base64.getEncoder().encodeToString(outputStream.toByteArray());
}
}
/**
* Deserializes a Base64 encoded string to an ItemStack
*
* @param data The Base64 encoded string
* @return The deserialized ItemStack
* @throws IOException If deserialization fails
* @throws ClassNotFoundException If the class is not found
*/
public static ItemStack deserialize(String data) throws IOException, ClassNotFoundException {
if (data == null || data.isEmpty()) {
return null;
}
byte[] bytes = Base64.getDecoder().decode(data);
try (ByteArrayInputStream inputStream = new ByteArrayInputStream(bytes);
BukkitObjectInputStream dataInput = new BukkitObjectInputStream(inputStream)) {
return (ItemStack) dataInput.readObject();
}
}
/**
* Safely deserializes an ItemStack, returning null if any error occurs
*
* @param data The Base64 encoded string
* @return The deserialized ItemStack or null if failed
*/
public static ItemStack deserializeSafe(String data) {
try {
return deserialize(data);
} catch (Exception e) {
return null;
}
}
}
@@ -0,0 +1,114 @@
package pt.henrique.communityMarket.util;
import org.bukkit.NamespacedKey;
import org.bukkit.Registry;
import org.bukkit.Sound;
import org.bukkit.entity.Player;
/**
* Utility class for playing sounds.
* Handles Sound API changes gracefully for Paper 1.21+.
*/
public class SoundUtil {
/**
* Plays a sound to a player by name.
*
* @param player The player
* @param soundName The sound name (e.g., "UI_BUTTON_CLICK" or "ui.button.click")
* @param volume Volume (0.0 - 1.0)
* @param pitch Pitch (0.5 - 2.0)
*/
public static void playSound(Player player, String soundName, float volume, float pitch) {
if (player == null || soundName == null || soundName.isEmpty()) {
return;
}
try {
Sound sound = findSound(soundName);
if (sound != null) {
player.playSound(player.getLocation(), sound, volume, pitch);
}
} catch (Exception ignored) {
// Sound not found or error playing, ignore silently
}
}
/**
* Plays a sound with default volume and pitch.
*/
public static void playSound(Player player, String soundName) {
playSound(player, soundName, 0.5f, 1.0f);
}
/**
* Finds a Sound by name using the Registry API (Paper 1.21+).
* Supports both formats: "UI_BUTTON_CLICK" and "ui.button.click"
*/
private static Sound findSound(String name) {
if (name == null) return null;
// Convert legacy format (UI_BUTTON_CLICK) to namespaced format (ui.button.click)
String key = name.toLowerCase().replace("_", ".");
// Try direct lookup with the converted key
Sound sound = Registry.SOUNDS.get(NamespacedKey.minecraft(key));
if (sound != null) {
return sound;
}
// Try some common mappings for legacy sound names
String mappedKey = mapLegacySoundName(name);
if (mappedKey != null) {
sound = Registry.SOUNDS.get(NamespacedKey.minecraft(mappedKey));
if (sound != null) {
return sound;
}
}
// Fallback: try the original name as-is (lowercase)
return Registry.SOUNDS.get(NamespacedKey.minecraft(name.toLowerCase()));
}
/**
* Maps legacy enum-style sound names to their registry keys.
*/
private static String mapLegacySoundName(String legacyName) {
if (legacyName == null) return null;
String upper = legacyName.toUpperCase();
return switch (upper) {
case "UI_BUTTON_CLICK" -> "ui.button.click";
case "ENTITY_PLAYER_LEVELUP" -> "entity.player.levelup";
case "ENTITY_VILLAGER_NO" -> "entity.villager.no";
case "ENTITY_EXPERIENCE_ORB_PICKUP" -> "entity.experience_orb.pickup";
case "BLOCK_NOTE_BLOCK_PLING" -> "block.note_block.pling";
case "ENTITY_ITEM_PICKUP" -> "entity.item.pickup";
case "BLOCK_CHEST_OPEN" -> "block.chest.open";
case "BLOCK_CHEST_CLOSE" -> "block.chest.close";
case "ENTITY_ARROW_HIT_PLAYER" -> "entity.arrow.hit_player";
case "BLOCK_ANVIL_USE" -> "block.anvil.use";
default -> null;
};
}
/**
* Common sounds for easy access
*/
public static void playClickSound(Player player) {
playSound(player, "ui.button.click", 0.5f, 1.0f);
}
public static void playSuccessSound(Player player) {
playSound(player, "entity.player.levelup", 0.5f, 1.5f);
}
public static void playErrorSound(Player player) {
playSound(player, "entity.villager.no", 0.5f, 1.0f);
}
public static void playPurchaseSound(Player player) {
playSound(player, "entity.experience_orb.pickup", 0.5f, 1.0f);
}
}
@@ -0,0 +1,145 @@
package pt.henrique.communityMarket.util;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.TextDecoration;
import net.kyori.adventure.text.minimessage.MiniMessage;
import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer;
import java.time.Duration;
import java.time.Instant;
import java.util.regex.Pattern;
/**
* Utility class for text formatting and color code handling
*/
public class TextUtil {
private static final Pattern HEX_PATTERN = Pattern.compile("&#([A-Fa-f0-9]{6})");
private static final LegacyComponentSerializer LEGACY_SERIALIZER = LegacyComponentSerializer.legacyAmpersand();
private static final MiniMessage MINI_MESSAGE = MiniMessage.miniMessage();
/**
* Colorizes a string using legacy & color codes
*
* @param text The text to colorize
* @return Colorized Component
*/
public static Component colorize(String text) {
if (text == null || text.isEmpty()) {
return Component.empty();
}
// Convert hex codes like &#FFFFFF to adventure format
text = HEX_PATTERN.matcher(text).replaceAll("<#$1>");
// First try legacy format, then MiniMessage
try {
Component component = LEGACY_SERIALIZER.deserialize(text);
// Remove italic decoration that gets added by default
return component.decoration(TextDecoration.ITALIC, false);
} catch (Exception e) {
return MINI_MESSAGE.deserialize(text);
}
}
/**
* Colorizes a string and returns the legacy string representation
*
* @param text The text to colorize
* @return Colorized string with § codes
*/
public static String colorizeToString(String text) {
return LegacyComponentSerializer.legacySection().serialize(colorize(text));
}
/**
* Strips color codes from a string
*
* @param text The text to strip
* @return Text without color codes
*/
public static String stripColor(String text) {
if (text == null) {
return null;
}
return text.replaceAll("(?i)[&§][0-9a-fk-or]", "");
}
/**
* Formats a duration into a human-readable string
*
* @param duration The duration to format
* @return Formatted string (e.g., "2d 5h 30m")
*/
public static String formatDuration(Duration duration) {
if (duration.isNegative() || duration.isZero()) {
return "Expired";
}
long days = duration.toDays();
long hours = duration.toHoursPart();
long minutes = duration.toMinutesPart();
long seconds = duration.toSecondsPart();
StringBuilder sb = new StringBuilder();
if (days > 0) {
sb.append(days).append("d ");
}
if (hours > 0) {
sb.append(hours).append("h ");
}
if (minutes > 0 && days == 0) {
sb.append(minutes).append("m ");
}
if (seconds > 0 && days == 0 && hours == 0) {
sb.append(seconds).append("s");
}
return sb.toString().trim();
}
/**
* Formats a duration from now until a future instant
*
* @param future The future instant
* @return Formatted duration string
*/
public static String formatTimeUntil(Instant future) {
if (future == null) {
return "Never";
}
Duration duration = Duration.between(Instant.now(), future);
return formatDuration(duration);
}
/**
* Checks if a duration is considered "ending soon" (less than 5 minutes)
*
* @param endsAt The end time
* @return True if ending soon
*/
public static boolean isEndingSoon(Instant endsAt) {
if (endsAt == null) {
return false;
}
Duration remaining = Duration.between(Instant.now(), endsAt);
return !remaining.isNegative() && remaining.toMinutes() < 5;
}
/**
* Truncates a string to a maximum length
*
* @param text The text to truncate
* @param maxLength Maximum length
* @return Truncated string with "..." if needed
*/
public static String truncate(String text, int maxLength) {
if (text == null || text.length() <= maxLength) {
return text;
}
return text.substring(0, maxLength - 3) + "...";
}
}
+152
View File
@@ -0,0 +1,152 @@
# ============================================
# CommunityMarket Configuration
# A GUI-only marketplace plugin
# ============================================
# Language setting (available: en_US, pt_PT)
language: en_US
# Database Configuration
database:
# Type: sqlite or mysql
type: sqlite
sqlite:
file: database.db
mysql:
host: localhost
port: 3306
database: communitymarket
username: root
password: ""
pool:
maximum-pool-size: 10
minimum-idle: 2
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
# Economy Settings
economy:
# Currency display format (uses Java DecimalFormat)
currency-format: "$#,##0.00"
currency-symbol: "$"
taxes:
# Tax percentage for fixed-price market sales (seller pays)
market-tax: 5.0
# Tax percentage for auction sales (seller pays)
auction-tax: 7.5
# Fixed-Price Market Settings
market:
# Maximum active listings per player
max-listings-per-player: 20
# Cooldown between creating listings (seconds, 0 = disabled)
listing-cooldown: 0
# Default listing duration in hours
default-duration-hours: 168
# Available durations for players to choose (in hours)
available-durations:
- 24
- 72
- 168
- 336
# Price limits
min-price: 1.0
max-price: 1000000000.0
# Auction Settings
auction:
# Maximum active auctions per player
max-auctions-per-player: 10
# Duration limits (hours)
min-duration-hours: 1
max-duration-hours: 168
default-duration-hours: 24
# Available durations for players to choose (in hours)
available-durations:
- 1
- 6
- 12
- 24
- 48
- 72
- 168
# Price limits for starting bid
min-start-price: 1.0
max-start-price: 1000000000.0
# Minimum bid increment (percentage of current bid)
min-bid-increment-percent: 5.0
# Minimum absolute bid increment
min-bid-increment-absolute: 1.0
# Anti-snipe protection
anti-snipe:
enabled: true
# If bid placed within this many seconds of end, extend auction
trigger-seconds: 30
# How many seconds to extend
extension-seconds: 30
# Maximum number of extensions (0 = unlimited)
max-extensions: 10
# Item Blacklist
blacklist:
# Materials that cannot be listed/auctioned
materials:
- BARRIER
- COMMAND_BLOCK
- CHAIN_COMMAND_BLOCK
- REPEATING_COMMAND_BLOCK
- COMMAND_BLOCK_MINECART
- STRUCTURE_BLOCK
- STRUCTURE_VOID
- JIGSAW
- DEBUG_STICK
- KNOWLEDGE_BOOK
- SPAWNER
- BEDROCK
# Items with these keywords in their name/lore are blocked
keywords:
- "admin"
- "illegal"
- "exploit"
# GUI Settings
gui:
# Items displayed per page in browse views
items-per-page: 45
# Whether to show the Help button in the main menu
# Set to false to hide it (slot will be filled with glass)
show-help-button: true
# Sound effects (use Bukkit Sound enum names)
sounds:
click: UI_BUTTON_CLICK
success: ENTITY_PLAYER_LEVELUP
error: ENTITY_VILLAGER_NO
purchase: ENTITY_EXPERIENCE_ORB_PICKUP
# Notification Settings
notifications:
# Notify seller when their item sells
notify-on-sale: true
# Notify bidder when they are outbid
notify-on-outbid: true
# Notify winner when they win an auction
notify-on-win: true
# Notify seller when their listing expires
notify-on-expire: true
# Performance Settings
performance:
# How often to cache listings/auctions (seconds)
cache-duration: 30
# How often to check for ended auctions (seconds)
auction-check-interval: 5
# How often to check for expired listings (minutes)
expired-check-interval: 5
+353
View File
@@ -0,0 +1,353 @@
# ============================================
# CommunityMarket Language File - English (US)
# ============================================
# General
prefix: "&8[&6Market&8] &r"
# General Messages
messages:
no-permission: "&cYou don't have permission to do that."
player-only: "&cThis command can only be used by players."
reload-success: "&aConfiguration reloaded successfully!"
economy-not-found: "&cNo economy plugin found! Market disabled."
# Listing Messages
listing-created: "&aListing created successfully! ID: #{id}"
listing-cancelled: "&aListing cancelled. Item returned to claim storage."
listing-expired: "&eYour listing #{id} has expired. Item moved to claim storage."
listing-purchased: "&aYou purchased {item} x{amount} for {price}!"
listing-sold: "&aYour {item} x{amount} was sold to {buyer} for {price}!"
listing-limit-reached: "&cYou've reached the maximum number of listings ({max})."
listing-cooldown: "&cPlease wait {time} before creating another listing."
listing-not-found: "&cListing not found or no longer available."
listing-own-item: "&cYou cannot buy your own listing."
listing-insufficient-funds: "&cYou don't have enough money. Required: {price}"
# Auction Messages
auction-created: "&aAuction created successfully! ID: #{id}"
auction-cancelled: "&aAuction cancelled. Item returned to claim storage."
auction-ended-winner: "&aCongratulations! You won the auction for {item}! Paid: {price}"
auction-ended-seller: "&aYour auction for {item} ended! Winner: {winner}, Earned: {price}"
auction-ended-no-bids: "&eYour auction for {item} ended with no bids. Item moved to claim storage."
auction-limit-reached: "&cYou've reached the maximum number of auctions ({max})."
auction-not-found: "&cAuction not found or no longer available."
auction-own-item: "&cYou cannot bid on your own auction."
auction-bid-placed: "&aYou placed a bid of {amount} on {item}!"
auction-outbid: "&eYou've been outbid on {item}! New bid: {amount} by {bidder}"
auction-bid-too-low: "&cBid too low! Minimum bid: {min}"
auction-insufficient-funds: "&cYou don't have enough money. Required: {price}"
auction-buyout: "&aYou bought out the auction for {item} for {price}!"
auction-extended: "&eAuction extended by {seconds}s due to anti-snipe protection."
# Claim Messages
claim-success: "&aItem claimed successfully!"
claim-empty: "&eYou have no items to claim."
claim-inventory-full: "&cYour inventory is full! Please make space."
claim-all-success: "&aClaimed {count} items!"
# Earnings Messages
earnings-withdrawn: "&aWithdrew {amount}! New balance: {balance}"
earnings-empty: "&eYou have no pending earnings."
earnings-balance: "&aYour pending earnings: {amount}"
# Item Validation
invalid-item: "&cPlease select a valid item."
item-no-longer-available: "&cThe selected item is no longer in your inventory."
item-changed: "&cThe selected item has changed. Please select again."
quantity-changed: "&cThe available quantity has changed. Please verify and try again."
blacklisted-item: "&cThis item type is not allowed on the market."
blacklisted-content: "&cThis item contains blacklisted content."
invalid-price: "&cInvalid price. Range: {min} - {max}"
invalid-amount: "&cInvalid amount."
invalid-duration: "&cInvalid duration."
# Admin Messages
admin-listing-removed: "&aListing #{id} removed by admin."
admin-auction-cancelled: "&aAuction #{id} cancelled by admin."
admin-reload: "&aConfiguration reloaded."
# GUI Titles (support color codes)
gui-titles:
main-menu: "&8&lCommunity Market"
browse-market: "&8&lBrowse Market &7(Page {page})"
browse-auctions: "&8&lBrowse Auctions &7(Page {page})"
create-listing: "&8&lCreate Listing"
create-auction: "&8&lCreate Auction"
select-item-listing: "&8&lSelect Item to Sell"
select-item-auction: "&8&lSelect Item to Auction"
quantity-select: "&8&lSelect Quantity"
my-listings: "&8&lMy Listings"
my-auctions: "&8&lMy Auctions"
claim-items: "&8&lClaim Items"
earnings: "&8&lEarnings"
confirm-purchase: "&8&lConfirm Purchase"
confirm-bid: "&8&lConfirm Bid"
confirm-cancel: "&8&lConfirm Cancellation"
number-input: "&8&lEnter Amount"
admin-panel: "&8&lAdmin Panel"
admin-listings: "&8&lAll Listings"
admin-auctions: "&8&lAll Auctions"
listing-details: "&8&lListing Details"
auction-details: "&8&lAuction Details"
filter-menu: "&8&lFilter Options"
sort-menu: "&8&lSort Options"
duration-select: "&8&lSelect Duration"
help: "&8&lHelp"
# Button Names
buttons:
# Main Menu
browse-market: "&aBrowse Market"
browse-auctions: "&6Browse Auctions"
create-listing: "&eCreate Listing"
create-auction: "&eCreate Auction"
my-listings: "&bMy Listings"
my-auctions: "&bMy Auctions"
claim-items: "&dClaim Items"
earnings: "&aEarnings"
help: "&fHelp"
admin: "&cAdmin Panel"
# Navigation
next-page: "&aNext Page →"
previous-page: "&a← Previous Page"
back: "&cBack"
close: "&cClose"
# Actions
confirm: "&aConfirm"
cancel: "&cCancel"
buy: "&aBuy Now"
bid: "&6Place Bid"
buyout: "&eBuyout"
claim: "&aClaim"
claim-all: "&aClaim All"
withdraw: "&aWithdraw All"
remove: "&cRemove Listing"
cancel-auction: "&cCancel Auction"
# Number Input
add-1: "&a+1"
add-10: "&a+10"
add-100: "&a+100"
add-1000: "&a+1,000"
subtract-1: "&c-1"
subtract-10: "&c-10"
subtract-100: "&c-100"
subtract-1000: "&c-1,000"
set-min: "&eSet Min"
set-max: "&eSet Max"
custom-amount: "&bCustom Amount"
# Filters & Sort
filter: "&eFilter"
sort: "&eSort"
search: "&eSearch"
clear-filter: "&cClear Filters"
# Duration
duration-1h: "&e1 Hour"
duration-6h: "&e6 Hours"
duration-12h: "&e12 Hours"
duration-24h: "&e24 Hours"
duration-48h: "&e48 Hours"
duration-72h: "&e3 Days"
duration-168h: "&e7 Days"
duration-336h: "&e14 Days"
# Admin
admin-view-listings: "&aView All Listings"
admin-view-auctions: "&6View All Auctions"
admin-reload: "&eReload Config"
# Button Lore (descriptions)
lore:
browse-market:
- "&7Browse all fixed-price"
- "&7listings from players."
- ""
- "&eClick to browse!"
browse-auctions:
- "&7Browse all active auctions"
- "&7and place bids."
- ""
- "&eClick to browse!"
create-listing:
- "&7Sell items at a fixed price."
- "&7Tax: &f{tax}%"
- ""
- "&eClick to create!"
create-auction:
- "&7Auction items to the"
- "&7highest bidder."
- "&7Tax: &f{tax}%"
- ""
- "&eClick to create!"
my-listings:
- "&7View and manage your"
- "&7active listings."
- ""
- "&7Active: &f{count}/{max}"
- ""
- "&eClick to view!"
my-auctions:
- "&7View and manage your"
- "&7active auctions."
- ""
- "&7Active: &f{count}/{max}"
- ""
- "&eClick to view!"
claim-items:
- "&7Claim items from expired"
- "&7listings or won auctions."
- ""
- "&7Pending: &f{count}"
- ""
- "&eClick to claim!"
earnings:
- "&7View and withdraw your"
- "&7pending earnings from sales."
- ""
- "&7Pending: &a{amount}"
- ""
- "&eClick to view!"
help:
- "&7Learn how to use the"
- "&7Community Market."
- ""
- "&eClick for help!"
admin:
- "&cAdmin Panel"
- "&7Manage listings and auctions."
- ""
- "&eClick to open!"
# Listing Info
listing-info:
- "&7Seller: &f{seller}"
- "&7Price: &a{price}"
- "&7Amount: &f{amount}"
- "&7Expires: &f{expires}"
- ""
- "&eLeft-click to buy!"
# Auction Info
auction-info:
- "&7Seller: &f{seller}"
- "&7Starting bid: &a{start_price}"
- "&7Current bid: &a{current_bid}"
- "&7Bidder: &f{bidder}"
- "&7Bids: &f{bid_count}"
- "&7Ends: &f{ends}"
- ""
- "&eLeft-click to bid!"
- "&eRight-click to buyout!"
# My Listing Info
my-listing-info:
- "&7Price: &a{price}"
- "&7Amount: &f{amount}"
- "&7Created: &f{created}"
- "&7Expires: &f{expires}"
- ""
- "&cClick to cancel"
# My Auction Info
my-auction-info:
- "&7Starting bid: &a{start_price}"
- "&7Current bid: &a{current_bid}"
- "&7Bidder: &f{bidder}"
- "&7Bids: &f{bid_count}"
- "&7Ends: &f{ends}"
- ""
- "&cClick to cancel (if no bids)"
# Confirm Purchase
confirm-purchase-info:
- "&7You are purchasing:"
- "&f{item} x{amount}"
- ""
- "&7Price: &a{price}"
- "&7Tax: &e{tax}"
- "&7Total: &a{total}"
- ""
- "&aClick to confirm!"
# Confirm Bid
confirm-bid-info:
- "&7You are bidding on:"
- "&f{item}"
- ""
- "&7Your bid: &a{bid}"
- "&7Current high: &e{current}"
- ""
- "&aClick to confirm!"
# Claim Item
claim-item-info:
- "&7Reason: &f{reason}"
- "&7From: &f{source}"
- "&7Date: &f{date}"
- ""
- "&eClick to claim!"
# Earnings Info
earnings-info:
- "&7Your pending earnings"
- "&7from market sales."
- ""
- "&7Total: &a{amount}"
- ""
- "&aClick to withdraw!"
# Number Input Info
current-value:
- "&7Current: &a{value}"
# Filter Options
filters:
all: "&fAll Items"
weapons: "&cWeapons"
armor: "&bArmor"
tools: "&eTools"
blocks: "&7Blocks"
food: "&6Food"
potions: "&dPotions"
materials: "&aMaterials"
enchanted: "&5Enchanted Items"
misc: "&8Miscellaneous"
# Sort Options
sort:
newest: "&aNewest First"
oldest: "&eOldest First"
price-low: "&aPrice: Low to High"
price-high: "&cPrice: High to Low"
ending-soon: "&6Ending Soon"
most-bids: "&bMost Bids"
# Time Formats
time:
expired: "&cExpired"
days: "{d}d"
hours: "{h}h"
minutes: "{m}m"
seconds: "{s}s"
# Help Content
help:
title: "&6&lCommunity Market Help"
content:
- "&eBrowse Market &7- View and buy fixed-price listings"
- "&eBrowse Auctions &7- View and bid on auctions"
- "&eCreate Listing &7- Sell items at a fixed price"
- "&eCreate Auction &7- Auction items to highest bidder"
- "&eMy Listings &7- Manage your active listings"
- "&eMy Auctions &7- Manage your active auctions"
- "&eClaim Items &7- Collect unsold/won items"
- "&eEarnings &7- Withdraw money from sales"
- ""
- "&7&oTip: All actions are done through GUIs!"
- "&7&oJust click on buttons to navigate."
+353
View File
@@ -0,0 +1,353 @@
# ============================================
# CommunityMarket Ficheiro de Idioma - Português (Portugal)
# ============================================
# Geral
prefix: "&8[&6Mercado&8] &r"
# Mensagens Gerais
messages:
no-permission: "&cNão tens permissão para fazer isso."
player-only: "&cEste comando só pode ser usado por jogadores."
reload-success: "&aConfiguração recarregada com sucesso!"
economy-not-found: "&cNenhum plugin de economia encontrado! Mercado desativado."
# Mensagens de Anúncios
listing-created: "&aAnúncio criado com sucesso! ID: #{id}"
listing-cancelled: "&aAnúncio cancelado. Item devolvido ao armazém."
listing-expired: "&eO teu anúncio #{id} expirou. Item movido para o armazém."
listing-purchased: "&aCompraste {item} x{amount} por {price}!"
listing-sold: "&aO teu {item} x{amount} foi vendido a {buyer} por {price}!"
listing-limit-reached: "&cAtingiste o número máximo de anúncios ({max})."
listing-cooldown: "&cPor favor aguarda {time} antes de criar outro anúncio."
listing-not-found: "&cAnúncio não encontrado ou já não está disponível."
listing-own-item: "&cNão podes comprar o teu próprio anúncio."
listing-insufficient-funds: "&cNão tens dinheiro suficiente. Necessário: {price}"
# Mensagens de Leilões
auction-created: "&aLeilão criado com sucesso! ID: #{id}"
auction-cancelled: "&aLeilão cancelado. Item devolvido ao armazém."
auction-ended-winner: "&aParabéns! Ganhaste o leilão por {item}! Pagaste: {price}"
auction-ended-seller: "&aO teu leilão por {item} terminou! Vencedor: {winner}, Ganhaste: {price}"
auction-ended-no-bids: "&eO teu leilão por {item} terminou sem licitações. Item movido para o armazém."
auction-limit-reached: "&cAtingiste o número máximo de leilões ({max})."
auction-not-found: "&cLeilão não encontrado ou já não está disponível."
auction-own-item: "&cNão podes licitar no teu próprio leilão."
auction-bid-placed: "&aFizeste uma licitação de {amount} em {item}!"
auction-outbid: "&eForam feitas licitações superiores à tua em {item}! Nova licitação: {amount} por {bidder}"
auction-bid-too-low: "&cLicitação muito baixa! Mínimo: {min}"
auction-insufficient-funds: "&cNão tens dinheiro suficiente. Necessário: {price}"
auction-buyout: "&aCompraste o leilão de {item} por {price}!"
auction-extended: "&eLeilão prolongado por {seconds}s devido à proteção anti-snipe."
# Mensagens de Reclamação
claim-success: "&aItem reclamado com sucesso!"
claim-empty: "&eNão tens itens para reclamar."
claim-inventory-full: "&cO teu inventário está cheio! Por favor liberta espaço."
claim-all-success: "&aReclamaste {count} itens!"
# Mensagens de Ganhos
earnings-withdrawn: "&aLevantaste {amount}! Novo saldo: {balance}"
earnings-empty: "&eNão tens ganhos pendentes."
earnings-balance: "&aOs teus ganhos pendentes: {amount}"
# Validação de Itens
invalid-item: "&cPor favor seleciona um item válido."
item-no-longer-available: "&cO item selecionado já não está no teu inventário."
item-changed: "&cO item selecionado foi alterado. Por favor seleciona novamente."
quantity-changed: "&cA quantidade disponível foi alterada. Por favor verifica e tenta novamente."
blacklisted-item: "&cEste tipo de item não é permitido no mercado."
blacklisted-content: "&cEste item contém conteúdo bloqueado."
invalid-price: "&cPreço inválido. Intervalo: {min} - {max}"
invalid-amount: "&cQuantidade inválida."
invalid-duration: "&cDuração inválida."
# Mensagens de Admin
admin-listing-removed: "&aAnúncio #{id} removido pelo admin."
admin-auction-cancelled: "&aLeilão #{id} cancelado pelo admin."
admin-reload: "&aConfiguração recarregada."
# Títulos GUI (suportam códigos de cor)
gui-titles:
main-menu: "&8&lMercado Comunitário"
browse-market: "&8&lExplorar Mercado &7(Página {page})"
browse-auctions: "&8&lExplorar Leilões &7(Página {page})"
create-listing: "&8&lCriar Anúncio"
create-auction: "&8&lCriar Leilão"
select-item-listing: "&8&lSelecionar Item para Vender"
select-item-auction: "&8&lSelecionar Item para Leilão"
quantity-select: "&8&lSelecionar Quantidade"
my-listings: "&8&lOs Meus Anúncios"
my-auctions: "&8&lOs Meus Leilões"
claim-items: "&8&lReclamar Itens"
earnings: "&8&lGanhos"
confirm-purchase: "&8&lConfirmar Compra"
confirm-bid: "&8&lConfirmar Licitação"
confirm-cancel: "&8&lConfirmar Cancelamento"
number-input: "&8&lIntroduzir Valor"
admin-panel: "&8&lPainel de Admin"
admin-listings: "&8&lTodos os Anúncios"
admin-auctions: "&8&lTodos os Leilões"
listing-details: "&8&lDetalhes do Anúncio"
auction-details: "&8&lDetalhes do Leilão"
filter-menu: "&8&lOpções de Filtro"
sort-menu: "&8&lOpções de Ordenação"
duration-select: "&8&lSelecionar Duração"
help: "&8&lAjuda"
# Nomes dos Botões
buttons:
# Menu Principal
browse-market: "&aExplorar Mercado"
browse-auctions: "&6Explorar Leilões"
create-listing: "&eCriar Anúncio"
create-auction: "&eCriar Leilão"
my-listings: "&bOs Meus Anúncios"
my-auctions: "&bOs Meus Leilões"
claim-items: "&dReclamar Itens"
earnings: "&aGanhos"
help: "&fAjuda"
admin: "&cPainel de Admin"
# Navegação
next-page: "&aPágina Seguinte →"
previous-page: "&a← Página Anterior"
back: "&cVoltar"
close: "&cFechar"
# Ações
confirm: "&aConfirmar"
cancel: "&cCancelar"
buy: "&aComprar Agora"
bid: "&6Fazer Licitação"
buyout: "&eCompra Imediata"
claim: "&aReclamar"
claim-all: "&aReclamar Tudo"
withdraw: "&aLevantar Tudo"
remove: "&cRemover Anúncio"
cancel-auction: "&cCancelar Leilão"
# Entrada Numérica
add-1: "&a+1"
add-10: "&a+10"
add-100: "&a+100"
add-1000: "&a+1.000"
subtract-1: "&c-1"
subtract-10: "&c-10"
subtract-100: "&c-100"
subtract-1000: "&c-1.000"
set-min: "&eDefinir Mín"
set-max: "&eDefinir Máx"
custom-amount: "&bValor Personalizado"
# Filtros e Ordenação
filter: "&eFiltro"
sort: "&eOrdenar"
search: "&ePesquisar"
clear-filter: "&cLimpar Filtros"
# Duração
duration-1h: "&e1 Hora"
duration-6h: "&e6 Horas"
duration-12h: "&e12 Horas"
duration-24h: "&e24 Horas"
duration-48h: "&e48 Horas"
duration-72h: "&e3 Dias"
duration-168h: "&e7 Dias"
duration-336h: "&e14 Dias"
# Admin
admin-view-listings: "&aVer Todos os Anúncios"
admin-view-auctions: "&6Ver Todos os Leilões"
admin-reload: "&eRecarregar Config"
# Lore dos Botões (descrições)
lore:
browse-market:
- "&7Explora todos os anúncios"
- "&7de preço fixo dos jogadores."
- ""
- "&eClica para explorar!"
browse-auctions:
- "&7Explora todos os leilões ativos"
- "&7e faz licitações."
- ""
- "&eClica para explorar!"
create-listing:
- "&7Vende itens a um preço fixo."
- "&7Taxa: &f{tax}%"
- ""
- "&eClica para criar!"
create-auction:
- "&7Leiloa itens ao"
- "&7maior licitador."
- "&7Taxa: &f{tax}%"
- ""
- "&eClica para criar!"
my-listings:
- "&7Vê e gere os teus"
- "&7anúncios ativos."
- ""
- "&7Ativos: &f{count}/{max}"
- ""
- "&eClica para ver!"
my-auctions:
- "&7Vê e gere os teus"
- "&7leilões ativos."
- ""
- "&7Ativos: &f{count}/{max}"
- ""
- "&eClica para ver!"
claim-items:
- "&7Reclama itens de anúncios"
- "&7expirados ou leilões ganhos."
- ""
- "&7Pendentes: &f{count}"
- ""
- "&eClica para reclamar!"
earnings:
- "&7Vê e levanta os teus"
- "&7ganhos pendentes das vendas."
- ""
- "&7Pendente: &a{amount}"
- ""
- "&eClica para ver!"
help:
- "&7Aprende a usar o"
- "&7Mercado Comunitário."
- ""
- "&eClica para ajuda!"
admin:
- "&cPainel de Admin"
- "&7Gere anúncios e leilões."
- ""
- "&eClica para abrir!"
# Info do Anúncio
listing-info:
- "&7Vendedor: &f{seller}"
- "&7Preço: &a{price}"
- "&7Quantidade: &f{amount}"
- "&7Expira: &f{expires}"
- ""
- "&eClique esquerdo para comprar!"
# Info do Leilão
auction-info:
- "&7Vendedor: &f{seller}"
- "&7Licitação inicial: &a{start_price}"
- "&7Licitação atual: &a{current_bid}"
- "&7Licitador: &f{bidder}"
- "&7Licitações: &f{bid_count}"
- "&7Termina: &f{ends}"
- ""
- "&eClique esquerdo para licitar!"
- "&eClique direito para compra imediata!"
# Info do Meu Anúncio
my-listing-info:
- "&7Preço: &a{price}"
- "&7Quantidade: &f{amount}"
- "&7Criado: &f{created}"
- "&7Expira: &f{expires}"
- ""
- "&cClica para cancelar"
# Info do Meu Leilão
my-auction-info:
- "&7Licitação inicial: &a{start_price}"
- "&7Licitação atual: &a{current_bid}"
- "&7Licitador: &f{bidder}"
- "&7Licitações: &f{bid_count}"
- "&7Termina: &f{ends}"
- ""
- "&cClica para cancelar (sem licitações)"
# Confirmar Compra
confirm-purchase-info:
- "&7Estás a comprar:"
- "&f{item} x{amount}"
- ""
- "&7Preço: &a{price}"
- "&7Taxa: &e{tax}"
- "&7Total: &a{total}"
- ""
- "&aClica para confirmar!"
# Confirmar Licitação
confirm-bid-info:
- "&7Estás a licitar em:"
- "&f{item}"
- ""
- "&7A tua licitação: &a{bid}"
- "&7Atual mais alta: &e{current}"
- ""
- "&aClica para confirmar!"
# Reclamar Item
claim-item-info:
- "&7Razão: &f{reason}"
- "&7De: &f{source}"
- "&7Data: &f{date}"
- ""
- "&eClica para reclamar!"
# Info de Ganhos
earnings-info:
- "&7Os teus ganhos pendentes"
- "&7das vendas no mercado."
- ""
- "&7Total: &a{amount}"
- ""
- "&aClica para levantar!"
# Info de Entrada Numérica
current-value:
- "&7Atual: &a{value}"
# Opções de Filtro
filters:
all: "&fTodos os Itens"
weapons: "&cArmas"
armor: "&bArmadura"
tools: "&eFerramentas"
blocks: "&7Blocos"
food: "&6Comida"
potions: "&dPoções"
materials: "&aMateriais"
enchanted: "&5Itens Encantados"
misc: "&8Diversos"
# Opções de Ordenação
sort:
newest: "&aMais Recente"
oldest: "&eMais Antigo"
price-low: "&aPreço: Menor para Maior"
price-high: "&cPreço: Maior para Menor"
ending-soon: "&6A Terminar Em Breve"
most-bids: "&bMais Licitações"
# Formatos de Tempo
time:
expired: "&cExpirado"
days: "{d}d"
hours: "{h}h"
minutes: "{m}m"
seconds: "{s}s"
# Conteúdo de Ajuda
help:
title: "&6&lAjuda do Mercado Comunitário"
content:
- "&eExplorar Mercado &7- Ver e comprar anúncios de preço fixo"
- "&eExplorar Leilões &7- Ver e licitar em leilões"
- "&eCriar Anúncio &7- Vender itens a um preço fixo"
- "&eCriar Leilão &7- Leiloar itens ao maior licitador"
- "&eOs Meus Anúncios &7- Gerir os teus anúncios ativos"
- "&eOs Meus Leilões &7- Gerir os teus leilões ativos"
- "&eReclamar Itens &7- Recolher itens não vendidos/ganhos"
- "&eGanhos &7- Levantar dinheiro das vendas"
- ""
- "&7&oDica: Todas as ações são feitas através de GUIs!"
- "&7&oBasta clicar nos botões para navegar."
+83
View File
@@ -0,0 +1,83 @@
name: CommunityMarket
version: '${project.version}'
main: pt.henrique.communityMarket.CommunityMarket
api-version: '1.21'
description: A GUI-only marketplace plugin for fixed-price listings and auctions
author: Henrique
website: https://github.com/henrique/CommunityMarket
# Soft dependencies - plugin will detect and use these if available
softdepend:
- Vault
- Essentials
load: POSTWORLD
commands:
market:
description: Opens the Community Market main menu
usage: /<command>
aliases: [cmarket]
permission: communitymarket.use
permissions:
communitymarket.*:
description: Grants all CommunityMarket permissions
default: op
children:
communitymarket.use: true
communitymarket.sell: true
communitymarket.auction: true
communitymarket.buy: true
communitymarket.bid: true
communitymarket.claim: true
communitymarket.withdraw: true
communitymarket.admin: true
communitymarket.use:
description: Allows access to the market GUI
default: true
communitymarket.sell:
description: Allows creating fixed-price listings
default: true
communitymarket.auction:
description: Allows creating auctions
default: true
communitymarket.buy:
description: Allows purchasing from the market
default: true
communitymarket.bid:
description: Allows bidding on auctions
default: true
communitymarket.claim:
description: Allows claiming items from storage
default: true
communitymarket.withdraw:
description: Allows withdrawing earnings
default: true
communitymarket.admin:
description: Allows access to admin functions
default: op
children:
communitymarket.admin.viewall: true
communitymarket.admin.remove: true
communitymarket.admin.reload: true
communitymarket.admin.viewall:
description: Allows viewing all listings/auctions
default: op
communitymarket.admin.remove:
description: Allows removing any listing or auction
default: op
communitymarket.admin.reload:
description: Allows reloading configuration
default: op