diff --git a/.kiro/specs/lorep-report-plugin/tasks.md b/.kiro/specs/lorep-report-plugin/tasks.md index 0263679..42959fd 100644 --- a/.kiro/specs/lorep-report-plugin/tasks.md +++ b/.kiro/specs/lorep-report-plugin/tasks.md @@ -116,22 +116,41 @@ - **Property 8: Permission Enforcement** - **Validates: Requirements 6.1, 6.2, 6.3** -- [ ] 7. Реализация GUI +- [x] 7. Реализация GUI + + + + - [ ] 7.1 Создать ReportGui с пагинацией - Создать инвентарь 54 слота - Отображение голов игроков с их скинами - Lore с информацией о репорте + + - Кнопки навигации по страницам - _Requirements: 4.1, 4.2, 4.3, 4.4_ - - [ ] 7.2 Создать GuiClickListener + + - [-] 7.2 Создать GuiClickListener + + + - Обработка кликов по страницам + + - Отмена выноса предметов + - _Requirements: 4.4_ + + - [ ] 7.3 Создать ReportGuiCommand (/report gui) - Проверка прав и открытие GUI + + - _Requirements: 4.1, 6.2_ + - [ ] 7.4 Написать property test для пагинации - **Property 6: Pagination Correctness** + - **Validates: Requirements 4.4** - [ ] 8. Утилиты и сообщения diff --git a/build.gradle.kts b/build.gradle.kts index 475f665..69039bf 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -28,6 +28,7 @@ dependencies { testImplementation("net.jqwik:jqwik:1.8.4") testImplementation("org.yaml:snakeyaml:2.2") testImplementation("org.xerial:sqlite-jdbc:3.45.1.0") + testCompileOnly("io.papermc.paper:paper-api:1.21-R0.1-SNAPSHOT") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } diff --git a/src/main/java/dev/loki/lorep/LorepPlugin.java b/src/main/java/dev/loki/lorep/LorepPlugin.java index 5d20b25..f835007 100644 --- a/src/main/java/dev/loki/lorep/LorepPlugin.java +++ b/src/main/java/dev/loki/lorep/LorepPlugin.java @@ -1,7 +1,11 @@ package dev.loki.lorep; +import dev.loki.lorep.command.ReportCommand; import dev.loki.lorep.config.ConfigManager; import dev.loki.lorep.database.DatabaseManager; +import dev.loki.lorep.database.PostgreSQLDatabaseManager; +import dev.loki.lorep.database.SQLiteDatabaseManager; +import dev.loki.lorep.gui.GuiClickListener; import dev.loki.lorep.webhook.DiscordWebhook; import org.bukkit.plugin.java.JavaPlugin; @@ -17,11 +21,58 @@ public final class LorepPlugin extends JavaPlugin { instance = this; getLogger().info("lorep plugin enabling..."); - // Initialize managers (will be implemented later) + // Initialize config + configManager = new ConfigManager(this); + + // Initialize database + initializeDatabase(); + + // Initialize webhook + String webhookUrl = configManager.getWebhookUrl(); + if (webhookUrl != null && !webhookUrl.isEmpty()) { + discordWebhook = new DiscordWebhook(webhookUrl, getLogger()); + getLogger().info("Discord webhook configured"); + } else { + getLogger().warning("Discord webhook URL not configured"); + } + + // Register commands + ReportCommand reportCommand = new ReportCommand(this); + getCommand("report").setExecutor(reportCommand); + getCommand("report").setTabCompleter(reportCommand); + + // Register listeners + getServer().getPluginManager().registerEvents(new GuiClickListener(this), this); getLogger().info("lorep plugin enabled!"); } + private void initializeDatabase() { + String dbType = configManager.getDatabaseType(); + + if ("postgresql".equalsIgnoreCase(dbType)) { + databaseManager = new PostgreSQLDatabaseManager( + configManager.getPostgresHost(), + configManager.getPostgresPort(), + configManager.getPostgresDatabase(), + configManager.getPostgresUsername(), + configManager.getPostgresPassword(), + configManager.getPostgresPoolSize(), + getLogger() + ); + getLogger().info("Using PostgreSQL database"); + } else { + databaseManager = new SQLiteDatabaseManager( + getDataFolder(), + configManager.getSqliteFile(), + getLogger() + ); + getLogger().info("Using SQLite database"); + } + + databaseManager.initialize(); + } + @Override public void onDisable() { if (databaseManager != null) { diff --git a/src/main/java/dev/loki/lorep/command/ReportCommand.java b/src/main/java/dev/loki/lorep/command/ReportCommand.java index 611afda..4f20bf7 100644 --- a/src/main/java/dev/loki/lorep/command/ReportCommand.java +++ b/src/main/java/dev/loki/lorep/command/ReportCommand.java @@ -109,8 +109,7 @@ public class ReportCommand implements CommandExecutor, TabCompleter { return true; } - // GUI will be implemented in task 7 - player.sendMessage("§eGUI будет реализован позже"); + new dev.loki.lorep.gui.ReportGui(plugin, 0).open(player); return true; } diff --git a/src/main/java/dev/loki/lorep/gui/GuiClickListener.java b/src/main/java/dev/loki/lorep/gui/GuiClickListener.java new file mode 100644 index 0000000..be27115 --- /dev/null +++ b/src/main/java/dev/loki/lorep/gui/GuiClickListener.java @@ -0,0 +1,59 @@ +package dev.loki.lorep.gui; + +import dev.loki.lorep.LorepPlugin; +import dev.loki.lorep.util.PaginationUtil; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryDragEvent; + +public class GuiClickListener implements Listener { + + private final LorepPlugin plugin; + + public GuiClickListener(LorepPlugin plugin) { + this.plugin = plugin; + } + + @EventHandler + public void onInventoryClick(InventoryClickEvent event) { + if (!(event.getInventory().getHolder() instanceof ReportGui gui)) { + return; + } + + event.setCancelled(true); + + if (!(event.getWhoClicked() instanceof Player player)) { + return; + } + + int slot = event.getRawSlot(); + + // Navigation buttons are in bottom row (slots 45-53) + switch (slot) { + case 45 -> { // Previous page + if (gui.getPage() > 0) { + new ReportGui(plugin, gui.getPage() - 1).open(player); + } + } + case 47 -> { // Close + player.closeInventory(); + } + case 53 -> { // Next page + int totalReports = plugin.getDatabaseManager().getTotalReportCount(); + int totalPages = PaginationUtil.calculateTotalPages(totalReports, ReportGui.PAGE_SIZE); + if (gui.getPage() < totalPages - 1) { + new ReportGui(plugin, gui.getPage() + 1).open(player); + } + } + } + } + + @EventHandler + public void onInventoryDrag(InventoryDragEvent event) { + if (event.getInventory().getHolder() instanceof ReportGui) { + event.setCancelled(true); + } + } +} diff --git a/src/main/java/dev/loki/lorep/gui/ReportGui.java b/src/main/java/dev/loki/lorep/gui/ReportGui.java new file mode 100644 index 0000000..04589f3 --- /dev/null +++ b/src/main/java/dev/loki/lorep/gui/ReportGui.java @@ -0,0 +1,135 @@ +package dev.loki.lorep.gui; + +import dev.loki.lorep.LorepPlugin; +import dev.loki.lorep.database.Report; +import dev.loki.lorep.util.TimeUtil; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.inventory.meta.SkullMeta; +import org.bukkit.entity.Player; + +import java.util.ArrayList; +import java.util.List; + +public class ReportGui implements InventoryHolder { + + public static final int PAGE_SIZE = 45; // 5 rows for reports + public static final int INVENTORY_SIZE = 54; // 6 rows total + + private final LorepPlugin plugin; + private final int page; + private Inventory inventory; + + public ReportGui(LorepPlugin plugin, int page) { + this.plugin = plugin; + this.page = page; + } + + public void open(Player player) { + String title = plugin.getConfigManager().getMessage("gui-title", "%page%", String.valueOf(page + 1)); + this.inventory = Bukkit.createInventory(this, INVENTORY_SIZE, title); + + List reports = plugin.getDatabaseManager().getReportsPaginated(page, PAGE_SIZE); + int totalReports = plugin.getDatabaseManager().getTotalReportCount(); + + // Add report items + for (int i = 0; i < reports.size(); i++) { + Report report = reports.get(i); + int targetReportCount = plugin.getDatabaseManager().getReportCount(report.targetUuid()); + inventory.setItem(i, createReportItem(report, targetReportCount)); + } + + // Add navigation buttons + addNavigationButtons(totalReports); + + player.openInventory(inventory); + } + + private ItemStack createReportItem(Report report, int totalReports) { + ItemStack head = new ItemStack(Material.PLAYER_HEAD); + SkullMeta meta = (SkullMeta) head.getItemMeta(); + + if (meta != null) { + // Set skull owner for skin + meta.setOwningPlayer(Bukkit.getOfflinePlayer(report.targetUuid())); + + // Display name + meta.setDisplayName("§c" + report.targetName()); + + // Lore with report info + List lore = new ArrayList<>(); + lore.add("§7"); + lore.add("§eОтправитель: §f" + report.reporterName()); + lore.add("§ePричина: §f" + report.reason()); + lore.add("§eВремя: §f" + TimeUtil.formatTimeAgo(report.createdAt().toEpochMilli())); + lore.add("§7"); + lore.add("§eВсего репортов: §c" + totalReports); + + meta.setLore(lore); + head.setItemMeta(meta); + } + + return head; + } + + private void addNavigationButtons(int totalReports) { + int totalPages = (int) Math.ceil((double) totalReports / PAGE_SIZE); + + // Previous page button (slot 45) + if (page > 0) { + ItemStack prevButton = new ItemStack(Material.ARROW); + ItemMeta prevMeta = prevButton.getItemMeta(); + if (prevMeta != null) { + prevMeta.setDisplayName("§a← Предыдущая страница"); + prevButton.setItemMeta(prevMeta); + } + inventory.setItem(45, prevButton); + } + + // Page info (slot 49) + ItemStack pageInfo = new ItemStack(Material.PAPER); + ItemMeta pageMeta = pageInfo.getItemMeta(); + if (pageMeta != null) { + pageMeta.setDisplayName("§eСтраница " + (page + 1) + " из " + Math.max(1, totalPages)); + List lore = new ArrayList<>(); + lore.add("§7Всего репортов: " + totalReports); + pageMeta.setLore(lore); + pageInfo.setItemMeta(pageMeta); + } + inventory.setItem(49, pageInfo); + + // Next page button (slot 53) + if (page < totalPages - 1) { + ItemStack nextButton = new ItemStack(Material.ARROW); + ItemMeta nextMeta = nextButton.getItemMeta(); + if (nextMeta != null) { + nextMeta.setDisplayName("§aСледующая страница →"); + nextButton.setItemMeta(nextMeta); + } + inventory.setItem(53, nextButton); + } + + // Close button (slot 47) + ItemStack closeButton = new ItemStack(Material.BARRIER); + ItemMeta closeMeta = closeButton.getItemMeta(); + if (closeMeta != null) { + closeMeta.setDisplayName("§cЗакрыть"); + closeButton.setItemMeta(closeMeta); + } + inventory.setItem(47, closeButton); + } + + public int getPage() { + return page; + } + + @Override + public Inventory getInventory() { + return inventory; + } + +} diff --git a/src/main/java/dev/loki/lorep/util/MessageUtil.java b/src/main/java/dev/loki/lorep/util/MessageUtil.java new file mode 100644 index 0000000..60411d0 --- /dev/null +++ b/src/main/java/dev/loki/lorep/util/MessageUtil.java @@ -0,0 +1,34 @@ +package dev.loki.lorep.util; + +import org.bukkit.ChatColor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +public class MessageUtil { + + private static final String PREFIX = "§8[§clorep§8] §r"; + + public static String colorize(String message) { + return ChatColor.translateAlternateColorCodes('&', message); + } + + public static void send(CommandSender sender, String message) { + sender.sendMessage(PREFIX + colorize(message)); + } + + public static void sendRaw(CommandSender sender, String message) { + sender.sendMessage(colorize(message)); + } + + public static void sendNoPrefix(Player player, String message) { + player.sendMessage(colorize(message)); + } + + public static String format(String message, String... replacements) { + String result = message; + for (int i = 0; i < replacements.length - 1; i += 2) { + result = result.replace(replacements[i], replacements[i + 1]); + } + return colorize(result); + } +} diff --git a/src/main/java/dev/loki/lorep/util/PaginationUtil.java b/src/main/java/dev/loki/lorep/util/PaginationUtil.java new file mode 100644 index 0000000..95eb3eb --- /dev/null +++ b/src/main/java/dev/loki/lorep/util/PaginationUtil.java @@ -0,0 +1,19 @@ +package dev.loki.lorep.util; + +public class PaginationUtil { + + public static int calculateTotalPages(int totalItems, int pageSize) { + if (totalItems <= 0) return 1; + return (int) Math.ceil((double) totalItems / pageSize); + } + + public static int calculateStartIndex(int page, int pageSize) { + return page * pageSize; + } + + public static int calculateItemsOnPage(int page, int pageSize, int totalItems) { + int startIndex = calculateStartIndex(page, pageSize); + int remaining = totalItems - startIndex; + return Math.min(pageSize, Math.max(0, remaining)); + } +} diff --git a/src/test/java/dev/loki/lorep/gui/PaginationTest.java b/src/test/java/dev/loki/lorep/gui/PaginationTest.java new file mode 100644 index 0000000..5315d38 --- /dev/null +++ b/src/test/java/dev/loki/lorep/gui/PaginationTest.java @@ -0,0 +1,102 @@ +package dev.loki.lorep.gui; + +import dev.loki.lorep.util.PaginationUtil; +import net.jqwik.api.*; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * **Feature: lorep-report-plugin, Property 6: Pagination Correctness** + * **Validates: Requirements 4.4** + */ +public class PaginationTest { + + private static final int PAGE_SIZE = 45; + + /** + * **Property 6: Pagination Correctness** + * For any list of N reports and page size P, requesting page K SHALL return + * at most P reports starting from index K*P, and the total page count SHALL + * equal ceil(N/P). + * **Validates: Requirements 4.4** + */ + @Property(tries = 100) + void paginationCorrectness( + @ForAll("totalReports") int totalReports, + @ForAll("pageNumbers") int page + ) { + int totalPages = PaginationUtil.calculateTotalPages(totalReports, PAGE_SIZE); + int startIndex = PaginationUtil.calculateStartIndex(page, PAGE_SIZE); + int itemsOnPage = PaginationUtil.calculateItemsOnPage(page, PAGE_SIZE, totalReports); + + // Total pages should equal ceil(N/P) + int expectedTotalPages = totalReports <= 0 ? 1 : (int) Math.ceil((double) totalReports / PAGE_SIZE); + assertEquals(expectedTotalPages, totalPages, + "Total pages should equal ceil(totalReports/pageSize)"); + + // Start index should be page * pageSize + assertEquals(page * PAGE_SIZE, startIndex, + "Start index should be page * pageSize"); + + // Items on page should be at most PAGE_SIZE + assertTrue(itemsOnPage <= PAGE_SIZE, + "Items on page should not exceed page size"); + + // Items on page should be non-negative + assertTrue(itemsOnPage >= 0, + "Items on page should be non-negative"); + + // For valid pages, items should match expected + if (page < totalPages && page >= 0) { + int expectedItems = Math.min(PAGE_SIZE, totalReports - startIndex); + assertEquals(Math.max(0, expectedItems), itemsOnPage, + "Items on valid page should match expected count"); + } + } + + @Property(tries = 100) + void allItemsCoveredByPages(@ForAll("totalReports") int totalReports) { + Assume.that(totalReports > 0); + + int totalPages = PaginationUtil.calculateTotalPages(totalReports, PAGE_SIZE); + int totalItemsCovered = 0; + + for (int page = 0; page < totalPages; page++) { + totalItemsCovered += PaginationUtil.calculateItemsOnPage(page, PAGE_SIZE, totalReports); + } + + assertEquals(totalReports, totalItemsCovered, + "Sum of items across all pages should equal total reports"); + } + + @Provide + Arbitrary totalReports() { + return Arbitraries.integers().between(0, 500); + } + + @Provide + Arbitrary pageNumbers() { + return Arbitraries.integers().between(0, 20); + } + + @Test + void emptyReportsHasOnePage() { + assertEquals(1, PaginationUtil.calculateTotalPages(0, PAGE_SIZE)); + assertEquals(0, PaginationUtil.calculateItemsOnPage(0, PAGE_SIZE, 0)); + } + + @Test + void exactlyOnePageOfReports() { + assertEquals(1, PaginationUtil.calculateTotalPages(PAGE_SIZE, PAGE_SIZE)); + assertEquals(PAGE_SIZE, PaginationUtil.calculateItemsOnPage(0, PAGE_SIZE, PAGE_SIZE)); + } + + @Test + void lastPageHasRemainingItems() { + int totalReports = PAGE_SIZE + 10; + assertEquals(2, PaginationUtil.calculateTotalPages(totalReports, PAGE_SIZE)); + assertEquals(PAGE_SIZE, PaginationUtil.calculateItemsOnPage(0, PAGE_SIZE, totalReports)); + assertEquals(10, PaginationUtil.calculateItemsOnPage(1, PAGE_SIZE, totalReports)); + } +}