From d9e9c5d7be09b24b346e40c9de0af82352755e38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hudson=20Andr=C3=A9s?= <123704241+hudsonandres@users.noreply.github.com> Date: Wed, 4 Mar 2026 18:02:50 -0300 Subject: [PATCH] feat: Add file-based trade logging system and preview command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added a comprehensive file-based logging system for trades and a preview command to test GUI layouts without requiring a second player account. ## General explain: ๐Ÿ†• Feat 1: File-Based Trade Logging ### Implementation A singleton logger that writes trade events to daily text files using an asynchronous queue system. **Key Features:** - **Daily log files:** `logs/yyyy-MM-dd.txt` format - **Async processing:** Non-blocking writes using `BlockingQueue` (100 entry buffer) - **Auto-flush:** 1-second intervals to ensure data persistence - **Graceful shutdown:** Flushes remaining queue entries on server stop - **Configurable filters:** Control exactly what gets logged **Log Entry Format:** - dd/MM/yyyy HH:mm:ss - EVENT_TYPE - Details ### Configuration Added to `Config.yml`: ```yaml TradeLog: log-trade-files: false # Master switch for file logging log-filters: # Granular control log-trade-started: false # When trade begins log-trade-finished: false # Successful completion log-trade-cancelled: false # Cancellation + reason log-items-offered: false # Item placement events log-items-received: false # Final item distribution Integration Points TradeLogService: Calls FileTradeLogger.log() for all trade events TradeSystem: Shutdown hook ensures proper queue flushing Works independently from existing database logging ๐Ÿ†• Feat 2: Preview Command ### Implementation Extends the Trade class to create a single-player preview mode for testing GUI layouts. How it works: Opens trade GUI for one player only Simulates trade environment without actual trading Prevents normal trade completion Isolated from regular trade flow Command Usage: ```/trade preview``` Permission: TradeSystem.Trade.Preview Technical Details ๐Ÿ“Š Performance Async logging: No impact on main server thread Queue buffering: Prevents I/O bottlenecks Scheduled flushing: Batches writes for efficiency Error Handling Silent failure mode (logged to console only) Queue overflow protection Thread-safe implementation File Management Automatic date rollover at midnight Creates logs/ directory if missing UTF-8 encoding for international characters --- .../tradesystem/spigot/TradeSystem.java | 3 + .../tradesystem/spigot/commands/TradeCMD.java | 20 ++ .../extras/tradelog/FileTradeLogger.java | 269 ++++++++++++++++++ .../extras/tradelog/TradeLogService.java | 11 +- .../spigot/trade/PreviewTrade.java | 178 ++++++++++++ .../spigot/trade/TradeHandler.java | 21 ++ .../tradesystem/spigot/utils/Lang.java | 2 +- .../tradesystem/spigot/utils/Permissions.java | 1 + .../src/main/resources/Config.yml | 15 + 9 files changed, 517 insertions(+), 3 deletions(-) create mode 100644 TradeSystem-Spigot/src/main/java/de/codingair/tradesystem/spigot/extras/tradelog/FileTradeLogger.java create mode 100644 TradeSystem-Spigot/src/main/java/de/codingair/tradesystem/spigot/trade/PreviewTrade.java diff --git a/TradeSystem-Spigot/src/main/java/de/codingair/tradesystem/spigot/TradeSystem.java b/TradeSystem-Spigot/src/main/java/de/codingair/tradesystem/spigot/TradeSystem.java index 1fdde7cc..f6f80d2c 100644 --- a/TradeSystem-Spigot/src/main/java/de/codingair/tradesystem/spigot/TradeSystem.java +++ b/TradeSystem-Spigot/src/main/java/de/codingair/tradesystem/spigot/TradeSystem.java @@ -146,6 +146,9 @@ public void onDisable() { API.getInstance().onDisable(this); UniversalScheduler.getScheduler(this).cancelTasks(); + // Shutdown file logger gracefully + de.codingair.tradesystem.spigot.extras.tradelog.FileTradeLogger.getInstance().shutdown(); + printConsoleInfo(() -> { tradeHandler.disable(); diff --git a/TradeSystem-Spigot/src/main/java/de/codingair/tradesystem/spigot/commands/TradeCMD.java b/TradeSystem-Spigot/src/main/java/de/codingair/tradesystem/spigot/commands/TradeCMD.java index b8c8567f..7dab2969 100644 --- a/TradeSystem-Spigot/src/main/java/de/codingair/tradesystem/spigot/commands/TradeCMD.java +++ b/TradeSystem-Spigot/src/main/java/de/codingair/tradesystem/spigot/commands/TradeCMD.java @@ -42,6 +42,26 @@ public boolean runCommand(CommandSender sender, String label, String[] args) { } }.setOnlyPlayers(true), true, Arrays.copyOfRange(aliases, 1, aliases.length)); + //PREVIEW + getBaseComponent().addChild(new CommandComponent("preview") { + @Override + public boolean runCommand(CommandSender sender, String label, String[] args) { + if (!sender.hasPermission(Permissions.PERMISSION_PREVIEW)) { + Lang.send(sender, "ยงc", "No_Permissions"); + return true; + } + + Player player = (Player) sender; + if (TradeSystem.handler().isTrading(player)) { + Lang.send(player, "ยงc", "Not_Able_To_Trade"); + return true; + } + + TradeSystem.handler().startPreviewTrade(player); + return true; + } + }); + //TOGGLE for (String cmd : commandManager.getToggleAliases()) { getBaseComponent().addChild(new CommandComponent(cmd) { diff --git a/TradeSystem-Spigot/src/main/java/de/codingair/tradesystem/spigot/extras/tradelog/FileTradeLogger.java b/TradeSystem-Spigot/src/main/java/de/codingair/tradesystem/spigot/extras/tradelog/FileTradeLogger.java new file mode 100644 index 00000000..d7fe5b3f --- /dev/null +++ b/TradeSystem-Spigot/src/main/java/de/codingair/tradesystem/spigot/extras/tradelog/FileTradeLogger.java @@ -0,0 +1,269 @@ +package de.codingair.tradesystem.spigot.extras.tradelog; + +import de.codingair.tradesystem.spigot.TradeSystem; +import org.bukkit.configuration.file.FileConfiguration; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +public class FileTradeLogger { + private static final DateTimeFormatter FILE_DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + private static final DateTimeFormatter LOG_TIMESTAMP_FORMAT = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm:ss"); + private static final int BUFFER_SIZE = 100; + private static final long FLUSH_INTERVAL_MS = 1000; // Flush every second + + private static FileTradeLogger instance; + private final File logsFolder; + private final boolean enabled; + private final LogFilters filters; + private final BlockingQueue logQueue; + private final Thread writerThread; + private volatile boolean running; + + private FileTradeLogger() { + this.logsFolder = new File(TradeSystem.getInstance().getDataFolder(), "logs"); + FileConfiguration config = TradeSystem.getInstance().getFileManager() + .getFile("Config").getConfig(); + + this.enabled = config.getBoolean("TradeSystem.TradeLog.log-trade-files", false); + this.filters = new LogFilters(config); + this.logQueue = new LinkedBlockingQueue<>(BUFFER_SIZE); + this.running = enabled; + + if (enabled) { + if (!logsFolder.exists()) { + if (!logsFolder.mkdirs()) { + TradeSystem.getInstance().getLogger().warning("Failed to create logs folder at: " + logsFolder.getAbsolutePath()); + } + } + + // Start background writer thread + this.writerThread = new Thread(this::processLogQueue, "TradeLog-Writer"); + this.writerThread.setDaemon(true); + this.writerThread.start(); + } else { + this.writerThread = null; + } + } + + public static FileTradeLogger getInstance() { + if (instance == null) { + instance = new FileTradeLogger(); + } + return instance; + } + + /** + * Logs a trade message to a file asynchronously using a queue. + * + * @param player1 The first player involved in the trade + * @param player2 The second player involved in the trade + * @param message The message to log + */ + public void log(@NotNull String player1, @NotNull String player2, @Nullable String message) { + if (!enabled || message == null) return; + + // Check if this type of message should be logged + if (!filters.shouldLog(message)) return; + + // Add to queue instead of writing immediately + LogEntry entry = new LogEntry(player1, player2, message, LocalDateTime.now()); + + if (!logQueue.offer(entry)) { + // Queue is full, force flush + TradeSystem.getInstance().getLogger().warning("TradeLog queue is full, some logs may be delayed"); + } + } + + /** + * Processes the log queue continuously in a background thread. + */ + private void processLogQueue() { + StringBuilder buffer = new StringBuilder(); + String currentFileName = null; + long lastFlush = System.currentTimeMillis(); + + while (running) { + try { + // Wait for logs with timeout to allow periodic flushing + LogEntry entry = logQueue.poll(FLUSH_INTERVAL_MS, TimeUnit.MILLISECONDS); + + if (entry != null) { + String fileName = entry.timestamp.format(FILE_DATE_FORMAT) + ".txt"; + + // If date changed, flush current buffer and switch file + if (currentFileName != null && !currentFileName.equals(fileName)) { + flushBuffer(currentFileName, buffer); + buffer.setLength(0); + } + + currentFileName = fileName; + buffer.append(formatLogEntry(entry)).append(System.lineSeparator()); + } + + // Flush buffer if interval elapsed or buffer is large + long now = System.currentTimeMillis(); + if (buffer.length() > 0 && (now - lastFlush >= FLUSH_INTERVAL_MS || buffer.length() > 8192)) { + flushBuffer(currentFileName, buffer); + buffer.setLength(0); + lastFlush = now; + } + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + + // Final flush on shutdown + if (buffer.length() > 0 && currentFileName != null) { + flushBuffer(currentFileName, buffer); + } + } + + /** + * Flushes the buffer to the specified file. + */ + private void flushBuffer(String fileName, StringBuilder buffer) { + if (fileName == null || buffer.length() == 0) return; + + File logFile = new File(logsFolder, fileName); + try (BufferedWriter writer = new BufferedWriter(new FileWriter(logFile, true))) { + writer.write(buffer.toString()); + writer.flush(); + } catch (IOException e) { + TradeSystem.getInstance().getLogger().severe("Failed to write to trade log file: " + e.getMessage()); + } + } + + /** + * Shuts down the logger gracefully. + */ + public void shutdown() { + if (!enabled) return; + + running = false; + if (writerThread != null) { + try { + writerThread.join(5000); // Wait up to 5 seconds + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + } + + /** + * Formats a log entry with timestamp and player information. + * + * @param entry The log entry to format + * @return Formatted log entry + */ + @NotNull + private String formatLogEntry(@NotNull LogEntry entry) { + String timestamp = entry.timestamp.format(LOG_TIMESTAMP_FORMAT); + return String.format("[%s] [%s <-> %s] %s", timestamp, entry.player1, entry.player2, entry.message); + } + + /** + * Checks if file logging is enabled. + * + * @return true if file logging is enabled, false otherwise + */ + public boolean isEnabled() { + return enabled; + } + + /** + * Gets the logs folder. + * + * @return The logs folder + */ + @NotNull + public File getLogsFolder() { + return logsFolder; + } + + /** + * Represents a log entry to be written. + */ + private static class LogEntry { + private final String player1; + private final String player2; + private final String message; + private final LocalDateTime timestamp; + + public LogEntry(@NotNull String player1, @NotNull String player2, @NotNull String message, @NotNull LocalDateTime timestamp) { + this.player1 = player1; + this.player2 = player2; + this.message = message; + this.timestamp = timestamp; + } + } + + /** + * Inner class to manage log filtering based on configuration. + */ + private static class LogFilters { + private final boolean logTradeStarted; + private final boolean logTradeFinished; + private final boolean logTradeCancelled; + private final boolean logItemsOffered; + private final boolean logItemsReceived; + + public LogFilters(@NotNull FileConfiguration config) { + String basePath = "TradeSystem.TradeLog.log-filters."; + this.logTradeStarted = config.getBoolean(basePath + "log-trade-started", false); + this.logTradeFinished = config.getBoolean(basePath + "log-trade-finished", false); + this.logTradeCancelled = config.getBoolean(basePath + "log-trade-cancelled", false); + this.logItemsOffered = config.getBoolean(basePath + "log-items-offered", false); + this.logItemsReceived = config.getBoolean(basePath + "log-items-received", false); + } + + /** + * Determines if a message should be logged based on its content. + * + * @param message The message to check + * @return true if the message should be logged, false otherwise + */ + public boolean shouldLog(@NotNull String message) { + String lowerMessage = message.toLowerCase(); + + // Check for trade started + if (lowerMessage.contains("started")) { + return logTradeStarted; + } + + // Check for trade finished + if (lowerMessage.contains("finished")) { + return logTradeFinished; + } + + // Check for trade cancelled + if (lowerMessage.contains("cancelled")) { + return logTradeCancelled; + } + + // Check for items offered + if (lowerMessage.contains("offered")) { + return logItemsOffered; + } + + // Check for items received + if (lowerMessage.contains("received")) { + return logItemsReceived; + } + + // If no specific filter matches, log it by default + return true; + } + } +} diff --git a/TradeSystem-Spigot/src/main/java/de/codingair/tradesystem/spigot/extras/tradelog/TradeLogService.java b/TradeSystem-Spigot/src/main/java/de/codingair/tradesystem/spigot/extras/tradelog/TradeLogService.java index 3fd5d354..8a4d9fb4 100644 --- a/TradeSystem-Spigot/src/main/java/de/codingair/tradesystem/spigot/extras/tradelog/TradeLogService.java +++ b/TradeSystem-Spigot/src/main/java/de/codingair/tradesystem/spigot/extras/tradelog/TradeLogService.java @@ -51,12 +51,19 @@ public static void log(@NotNull String player1, @NotNull String player2, @Nullab } public static void logLater(@NotNull String player1, @NotNull String player2, @Nullable String message, long delay) { - if (message == null || !connected()) return; + if (message == null) return; Runnable runnable = () -> { if (getTradeLog().bukkitLogger) Bukkit.getLogger().info("TradeLog [" + player1 + ", " + player2 + "] " + message); - getTradeLogRepository().log(player1, player2, message); + + // Log to file if enabled + FileTradeLogger.getInstance().log(player1, player2, message); + + // Log to database if connected + if (connected()) { + getTradeLogRepository().log(player1, player2, message); + } }; //it will throw an error if the plugin is not enabled diff --git a/TradeSystem-Spigot/src/main/java/de/codingair/tradesystem/spigot/trade/PreviewTrade.java b/TradeSystem-Spigot/src/main/java/de/codingair/tradesystem/spigot/trade/PreviewTrade.java new file mode 100644 index 00000000..be832366 --- /dev/null +++ b/TradeSystem-Spigot/src/main/java/de/codingair/tradesystem/spigot/trade/PreviewTrade.java @@ -0,0 +1,178 @@ +package de.codingair.tradesystem.spigot.trade; + +import de.codingair.codingapi.API; +import de.codingair.codingapi.player.gui.anvil.AnvilGUI; +import de.codingair.codingapi.player.gui.inventory.PlayerInventory; +import de.codingair.codingapi.player.gui.inventory.v2.exceptions.AlreadyOpenedException; +import de.codingair.codingapi.player.gui.inventory.v2.exceptions.IsWaitingException; +import de.codingair.codingapi.player.gui.inventory.v2.exceptions.NoPageException; +import de.codingair.tradesystem.spigot.TradeSystem; +import de.codingair.tradesystem.spigot.trade.gui.TradingGUI; +import de.codingair.tradesystem.spigot.trade.gui.layout.utils.Perspective; +import de.codingair.tradesystem.spigot.utils.CompatibilityUtilPlayer; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Stream; + +/** + * Preview trade that allows a single player to view the trade GUI + * without requiring a second player. Used for testing layouts. + */ +public class PreviewTrade extends Trade { + private final Player player; + private final boolean isPreviewMode = true; + + public PreviewTrade(Player player) { + super(player.getName(), "Preview", true); + this.player = player; + } + + @Override + protected void initializeGUIs() { + // Only create GUI for the preview player + this.guis[0] = new TradingGUI(this.player, this, 0); + this.guis[1] = null; // No second player in preview mode + } + + @Override + protected void createGUIs() { + this.guis[0].prepareStart(); + } + + @Override + protected void startGUIs() { + try { + this.guis[0].open(); + } catch (AlreadyOpenedException | NoPageException | IsWaitingException e) { + throw new RuntimeException("Cannot open preview GUI.", e); + } + } + + @Override + public void updateDisplayItem(@NotNull Perspective perspective, int slotId, @Nullable ItemStack item) { + if (guis[0] != null && perspective.id() == 0) { + guis[0].setItem(otherSlots.get(slotId), item); + } + } + + @Override + public @Nullable ItemStack getCurrentOfferedItem(@NotNull Perspective perspective, int slotId) { + if (guis[0] != null && perspective.id() == 0) { + return guis[0].getItem(slots.get(slotId)); + } + return null; + } + + @Override + protected @Nullable ItemStack getCurrentDisplayedItem(@NotNull Perspective perspective, int slotId) { + if (guis[0] != null && perspective.id() == 0) { + return guis[0].getItem(otherSlots.get(slotId)); + } + return null; + } + + @Override + protected @NotNull CompletableFuture markAsInitialized() { + return CompletableFuture.completedFuture(null); + } + + @Override + protected void clearOpenAnvils() { + for (AnvilGUI gui : API.getRemovables(player, AnvilGUI.class)) { + gui.clearInventory(); + } + } + + @Override + public @Nullable Player getPlayer(@NotNull Perspective perspective) { + // Only return player for primary perspective + return perspective.id() == 0 ? player : null; + } + + @Override + public @NotNull String getWorld(@NotNull Perspective perspective) { + return player.getWorld().getName(); + } + + @Override + public @Nullable String getServer(@NotNull Perspective perspective) { + return TradeSystem.proxy().getServerName(); + } + + @Override + public @NotNull UUID getUniqueId(@NotNull Perspective perspective) { + return player.getUniqueId(); + } + + @Override + protected void onItemPickUp(@NotNull Perspective perspective) { + // Do nothing in preview mode + } + + @Override + protected boolean isActive() { + return guis[0] != null; + } + + @Override + protected boolean isPaused() { + return pause[0]; + } + + @Override + protected boolean isInitiator(@NotNull Perspective perspective) { + return perspective.isPrimary(); + } + + @Override + protected @NotNull PlayerInventory getPlayerInventory(@NotNull Perspective perspective) { + PlayerInventory inventory = new PlayerInventory(player, true); + + if (inventory.getPlayer() != null) { + ItemStack item = CompatibilityUtilPlayer.getCursor(inventory.getPlayer()); + if (item != null && item.getType() != Material.AIR) inventory.addItem(item); + } + + return inventory; + } + + @Override + public void synchronizePlayerInventory(@NotNull Perspective perspective) { + // Inventories are always synchronized in preview mode + } + + @Override + protected @Nullable ItemStack removeReceivedItem(@NotNull Perspective perspective, int slotId) { + // In preview mode, don't actually remove items + return null; + } + + @Override + protected @NotNull CompletableFuture canFinish() { + // Preview mode cannot finish normally + return CompletableFuture.completedFuture(false); + } + + @Override + protected @NotNull Stream getParticipants() { + return Stream.of(player); + } + + @Override + protected void onReadyStateChange(@NotNull Perspective perspective, boolean ready) { + // Ignore in preview mode + } + + /** + * @return true if this trade is in preview mode + */ + public boolean isPreview() { + return isPreviewMode; + } +} diff --git a/TradeSystem-Spigot/src/main/java/de/codingair/tradesystem/spigot/trade/TradeHandler.java b/TradeSystem-Spigot/src/main/java/de/codingair/tradesystem/spigot/trade/TradeHandler.java index 0f303c17..dc79f98c 100644 --- a/TradeSystem-Spigot/src/main/java/de/codingair/tradesystem/spigot/trade/TradeHandler.java +++ b/TradeSystem-Spigot/src/main/java/de/codingair/tradesystem/spigot/trade/TradeHandler.java @@ -369,6 +369,27 @@ private void startTrade(@NotNull Player player, @Nullable Player other, @NotNull trade.start(); } + /** + * Starts a preview trade for a single player to test the GUI layout. + * + * @param player The player who starts the preview. + */ + public void startPreviewTrade(@NotNull Player player) { + if (TradeSystem.handler().isTrading(player)) { + Lang.send(player, "Other_is_already_trading"); + return; + } + + player.closeInventory(); + + PreviewTrade trade = new PreviewTrade(player); + + //register + registerTrade(trade, player.getName()); + + trade.start(); + } + private void registerTrade(@NotNull Trade trade, @NotNull String player) { this.trades.put(player.toLowerCase(), trade); } diff --git a/TradeSystem-Spigot/src/main/java/de/codingair/tradesystem/spigot/utils/Lang.java b/TradeSystem-Spigot/src/main/java/de/codingair/tradesystem/spigot/utils/Lang.java index d39fea74..63991c07 100644 --- a/TradeSystem-Spigot/src/main/java/de/codingair/tradesystem/spigot/utils/Lang.java +++ b/TradeSystem-Spigot/src/main/java/de/codingair/tradesystem/spigot/utils/Lang.java @@ -23,7 +23,7 @@ public class Lang { private static final String[] LANGUAGES = { - "BR", "CHI", "CS", "ENG", "ES", "FR", "GER", "IT", "POL", "RUS", "TR", "UA", "VI", "JA" + "BR", "CHI", "CS", "ENG", "ES", "FA", "FR", "GER", "IT", "POL", "RUS", "TR", "UA", "VI", "JA" }; private static final String DEFAULT_LANG = "ENG"; private static final Map PLUGINS = new HashMap<>(); diff --git a/TradeSystem-Spigot/src/main/java/de/codingair/tradesystem/spigot/utils/Permissions.java b/TradeSystem-Spigot/src/main/java/de/codingair/tradesystem/spigot/utils/Permissions.java index 936fb848..21f17e1e 100644 --- a/TradeSystem-Spigot/src/main/java/de/codingair/tradesystem/spigot/utils/Permissions.java +++ b/TradeSystem-Spigot/src/main/java/de/codingair/tradesystem/spigot/utils/Permissions.java @@ -11,6 +11,7 @@ public class Permissions { public static final String PERMISSION_LOG = "TradeSystem.Log"; public static String PERMISSION = "TradeSystem.Trade"; public static String PERMISSION_INITIATE = "TradeSystem.Trade.Initiate"; + public static String PERMISSION_PREVIEW = "TradeSystem.Trade.Preview"; private static final String[] PLUGINS = { "LuckPerms", "PermissionsEx", "GroupManager", "Vault", "bPermissions", "PermissionsBukkit", diff --git a/TradeSystem-Spigot/src/main/resources/Config.yml b/TradeSystem-Spigot/src/main/resources/Config.yml index 55c9853b..6b76f1da 100644 --- a/TradeSystem-Spigot/src/main/resources/Config.yml +++ b/TradeSystem-Spigot/src/main/resources/Config.yml @@ -116,6 +116,21 @@ TradeSystem: TradeLog: # The bukkit logger works additionally to the database logging. Bukkit_logger: false + # Save trade logs to text files in the 'logs' folder (files named by date: yyyy-MM-dd.txt) + # This works independently of the database logging. + log-trade-files: false + # Configure what information should be logged to the files + log-filters: + # Log when a trade is started + log-trade-started: false + # Log when a trade is successfully finished + log-trade-finished: false + # Log when a trade is cancelled (with or without reason) + log-trade-cancelled: false + # Log when players offer items during the trade + log-items-offered: false + # Log when players receive items after trade completion + log-items-received: false # Sound names can be found here: https://github.com/CodingAir/CodingAPI/tree/master/src/main/java/de/codingair/codingapi/server/sounds/Sound.java Sounds: