diff --git a/README.md b/README.md index de3e13e8..565cc10d 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ A Spigot 26.2 plugin providing a high-difficulty survival experience inspired by - **`bedrock`**: Core framework module handling UI menus, action bars, announcements, scheduling, and utility systems. - **`blighted-smp`**: Primary SMP plugin module containing custom items, mobs, abilities, recipes, and gameplay mechanics. +- **`blighted-mod`**: Moderation plugin attaching to the SMP server. ## Requirements diff --git a/bedrock/pom.xml b/bedrock/pom.xml index 8bff1ce4..8ce34cc9 100644 --- a/bedrock/pom.xml +++ b/bedrock/pom.xml @@ -28,6 +28,16 @@ org.projectlombok lombok + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-engine + test + diff --git a/bedrock/src/main/java/fr/moussax/bedrock/commands/PlayerCommand.java b/bedrock/src/main/java/fr/moussax/bedrock/commands/PlayerCommand.java index f57b289c..140b19e1 100644 --- a/bedrock/src/main/java/fr/moussax/bedrock/commands/PlayerCommand.java +++ b/bedrock/src/main/java/fr/moussax/bedrock/commands/PlayerCommand.java @@ -63,11 +63,11 @@ protected abstract boolean execute( * *

If no matching player is found, a warning is sent to the command sender. * - * @param sender the player requesting the lookup + * @param sender the command sender requesting the lookup * @param name the exact username to resolve * @return the matching online player, or {@code null} if no player exists */ - protected final @Nullable Player requireTarget(Player sender, String name) { + protected final @Nullable Player requireTarget(CommandSender sender, String name) { Player target = Bukkit.getPlayerExact(name); if (target == null) { @@ -76,4 +76,17 @@ protected abstract boolean execute( return target; } + + /** + * Resolves an online player by their exact username. + * + *

If no matching player is found, a warning is sent to the command sender. + * + * @param sender the player requesting the lookup + * @param name the exact username to resolve + * @return the matching online player, or {@code null} if no player exists + */ + protected final @Nullable Player requireTarget(Player sender, String name) { + return requireTarget((CommandSender) sender, name); + } } diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/actionbar/ActionbarComposer.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/actionbar/ActionbarComposer.java index 10b482de..0d5a8a81 100644 --- a/bedrock/src/main/java/fr/moussax/bedrock/ui/actionbar/ActionbarComposer.java +++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/actionbar/ActionbarComposer.java @@ -5,12 +5,12 @@ import org.jspecify.annotations.NonNull; import java.time.Duration; +import java.util.ArrayList; import java.util.Comparator; +import java.util.List; import java.util.Map; -import java.util.Objects; import java.util.PriorityQueue; import java.util.concurrent.ConcurrentHashMap; -import java.util.stream.Collectors; /** * Assembles and formats per-player action bar text from active sections and temporary alerts. @@ -91,8 +91,10 @@ public void clearAlerts() { /** * Compiles the formatted action bar message string for a given player. * - *

If an active modal alert exists, its message is returned immediately. Otherwise, - * visible sections are sorted by priority and joined with the configured separator.

+ *

If an active modal alert exists, its message is returned immediately. Otherwise, if any + * visible exclusive sections produce non-empty text, the exclusive section with the highest priority + * is returned. If no exclusive section produces content, visible normal sections are sorted by + * rendering priority (lowest first) and joined with the configured separator.

* * @param player player for whom to compile action bar content * @return compiled action bar text, or an empty string if no content is visible @@ -106,21 +108,47 @@ public String compile(@NonNull Player player) { } } - return sections.values().stream() + List visibleSections = sections.values().stream() .filter(section -> section.visibility().test(player)) + .toList(); + + List exclusiveSections = visibleSections.stream() + .filter(ActionbarSection::exclusive) + .sorted(Comparator.comparingInt(ActionbarSection::priority).reversed() + .thenComparing(ActionbarSection::id)) + .toList(); + + for (ActionbarSection exclusiveSection : exclusiveSections) { + String text = evaluateSection(exclusiveSection, player); + if (text != null && !text.isEmpty()) { + return text; + } + } + + List normalSections = visibleSections.stream() + .filter(section -> !section.exclusive()) .sorted(Comparator.comparingInt(ActionbarSection::priority)) - .map(section -> { - TimedAlert alert = slotAlerts.get(section.id()); - if (alert != null) { - if (!alert.isExpired()) { - return alert.message(); - } - slotAlerts.remove(section.id()); - } - return section.textSupplier().apply(player); - }) - .filter(Objects::nonNull) - .filter(sectionText -> !sectionText.isEmpty()) - .collect(Collectors.joining(separator)); + .toList(); + + List evaluatedTexts = new ArrayList<>(normalSections.size()); + for (ActionbarSection section : normalSections) { + String text = evaluateSection(section, player); + if (text != null && !text.isEmpty()) { + evaluatedTexts.add(text); + } + } + + return String.join(separator, evaluatedTexts); + } + + private String evaluateSection(ActionbarSection section, Player player) { + TimedAlert alert = slotAlerts.get(section.id()); + if (alert != null) { + if (!alert.isExpired()) { + return alert.message(); + } + slotAlerts.remove(section.id()); + } + return section.textSupplier().apply(player); } } diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/actionbar/ActionbarSection.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/actionbar/ActionbarSection.java index af3232e7..e77d18ad 100644 --- a/bedrock/src/main/java/fr/moussax/bedrock/ui/actionbar/ActionbarSection.java +++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/actionbar/ActionbarSection.java @@ -10,17 +10,31 @@ /** * Represents a modular content section rendered within a player's action bar. * + *

For normal (non-exclusive) sections, priority determines left-to-right rendering position + * (lower values render further to the left). For exclusive sections, priority defines override precedence + * (higher values take precedence over lower priority exclusive sections when multiple exclusive sections produce non-empty text).

+ * * @param id unique identifier for this section - * @param priority rendering priority; lower values render further to the left + * @param priority rendering priority for normal sections or precedence for exclusive sections * @param textSupplier function producing the display text for a player, or {@code null} if empty * @param visibility predicate determining whether this section should render for a player + * @param exclusive whether this section overrides non-exclusive sections when visible with non-empty text */ public record ActionbarSection( @NonNull String id, int priority, @NonNull Function textSupplier, - @NonNull Predicate visibility + @NonNull Predicate visibility, + boolean exclusive ) { + public ActionbarSection( + @NonNull String id, + int priority, + @NonNull Function textSupplier, + @NonNull Predicate visibility + ) { + this(id, priority, textSupplier, visibility, false); + } /** * Creates an action bar section that is always visible for all players. * @@ -30,7 +44,7 @@ public record ActionbarSection( * @return created action bar section */ public static ActionbarSection of(String id, int priority, Function textSupplier) { - return new ActionbarSection(id, priority, textSupplier, player -> true); + return new ActionbarSection(id, priority, textSupplier, player -> true, false); } /** @@ -43,6 +57,19 @@ public static ActionbarSection of(String id, int priority, Function textSupplier, Predicate visibility) { - return new ActionbarSection(id, priority, textSupplier, visibility); + return new ActionbarSection(id, priority, textSupplier, visibility, false); + } + + /** + * Creates an exclusive action bar section that overrides non-exclusive sections when visible. + * + * @param id unique section identifier + * @param priority rendering priority + * @param textSupplier text supplier function + * @param visibility predicate controlling section display + * @return created exclusive action bar section + */ + public static ActionbarSection exclusiveOf(String id, int priority, Function textSupplier, Predicate visibility) { + return new ActionbarSection(id, priority, textSupplier, visibility, true); } } diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/actionbar/ActionbarService.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/actionbar/ActionbarService.java index 2ca22cbb..fab62c23 100644 --- a/bedrock/src/main/java/fr/moussax/bedrock/ui/actionbar/ActionbarService.java +++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/actionbar/ActionbarService.java @@ -25,6 +25,7 @@ */ public final class ActionbarService implements Listener { + // ponytail: kept — shared instance across plugins avoids actionbar packet collisions @Getter @Setter private static ActionbarService instance; @@ -52,10 +53,14 @@ public ActionbarService(@NonNull Plugin plugin) { * @param periodTicks interval between renders in server ticks */ public void start(long periodTicks) { - if (running) return; + if (running && tickerTask != null && !tickerTask.isCancelled()) return; running = true; - this.tickerTask = Bukkit.getScheduler().runTaskTimer(plugin, this::tickAll, periodTicks, periodTicks); + if (tickerTask != null) { + tickerTask.cancel(); + } + + this.tickerTask = Bukkit.getScheduler().runTaskTimer(plugin, this::tickAll, 0L, periodTicks); } /** @@ -178,7 +183,7 @@ private void tickAll() { try { renderPlayer(player); } catch (Exception exception) { - Log.warn("ActionbarService", "Failed to render for " + player.getName() + ": " + exception.getMessage()); + Log.warn("ActionbarService", "Failed to render actionbar for " + player.getName() + ": " + exception.getMessage()); } } } @@ -192,6 +197,7 @@ private ActionbarComposer getOrCreateComposer(UUID uuid) { } private void sendRawPacket(@NonNull Player player, @NonNull String text) { + if (text == null || text.isEmpty()) return; player.spigot().sendMessage(ChatMessageType.ACTION_BAR, new TextComponent(text)); } } diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/book/BookMenu.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/book/BookMenu.java index 14ebb177..6c90f182 100644 --- a/bedrock/src/main/java/fr/moussax/bedrock/ui/book/BookMenu.java +++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/book/BookMenu.java @@ -2,6 +2,7 @@ import fr.moussax.bedrock.text.InteractiveMessage; import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.chat.TextComponent; import org.bukkit.Material; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; @@ -20,7 +21,10 @@ */ public final class BookMenu { - private final List pages = new ArrayList<>(); + private static final int MAXIMUM_CHARACTERS_PER_LINE = 19; + private static final int MAXIMUM_LINES_PER_PAGE = 14; + + private final List rawPages = new ArrayList<>(); private BookMenu() { } @@ -41,7 +45,7 @@ public static BookMenu builder() { * @return this book menu */ public BookMenu addPage(@NonNull InteractiveMessage message) { - this.pages.add(message.build()); + this.rawPages.add(message.build()); return this; } @@ -57,14 +61,15 @@ public BookMenu addPage(@NonNull InteractiveMessage message) { public BookMenu addPage(@NonNull Consumer pageConfigurator) { InteractiveMessage message = InteractiveMessage.text(""); pageConfigurator.accept(message); - this.pages.add(message.build()); + this.rawPages.add(message.build()); return this; } /** * Opens the configured book for a player. * - *

The generated book uses the default BlightedMC title and author.

+ *

The generated book uses the default BlightedMC title and author, + * and automatically paginates content overflowing single-page dimensions.

* * @param player player receiving the book */ @@ -77,11 +82,195 @@ public void open(@NonNull Player player) { meta.setAuthor("BlightedMC"); meta.setGeneration(BookMeta.Generation.ORIGINAL); - for (BaseComponent[] pageComponents : pages) { + List paginatedPages = buildPaginatedPages(); + for (BaseComponent[] pageComponents : paginatedPages) { meta.spigot().addPage(pageComponents); } book.setItemMeta(meta); player.openBook(book); } + + private List buildPaginatedPages() { + List formattedPages = new ArrayList<>(); + + for (BaseComponent[] pageComponents : rawPages) { + List splitPages = splitSinglePageComponents(pageComponents); + formattedPages.addAll(splitPages); + } + + return formattedPages; + } + + private List splitSinglePageComponents(BaseComponent[] pageComponents) { + List leafComponents = flattenToLeafComponents(pageComponents); + + List> lines = new ArrayList<>(); + List currentLine = new ArrayList<>(); + int currentLineLength = 0; + + for (TextComponent component : leafComponents) { + String componentText = component.getText(); + if (componentText == null || componentText.isEmpty()) { + continue; + } + + String[] paragraphs = componentText.split("\r?\n", -1); + for (int paragraphIndex = 0; paragraphIndex < paragraphs.length; paragraphIndex++) { + if (paragraphIndex > 0) { + lines.add(currentLine); + currentLine = new ArrayList<>(); + currentLineLength = 0; + } + + String paragraphText = paragraphs[paragraphIndex]; + if (paragraphText.isEmpty()) { + continue; + } + + String[] words = paragraphText.split(" ", -1); + for (int wordIndex = 0; wordIndex < words.length; wordIndex++) { + String word = words[wordIndex]; + + if (word.isEmpty()) { + if (!currentLine.isEmpty() || wordIndex < words.length - 1) { + TextComponent spaceComponent = duplicateComponentWithText(component, " "); + currentLine.add(spaceComponent); + currentLineLength += 1; + } + continue; + } + + if (wordIndex > 0 && !currentLine.isEmpty() && !words[wordIndex - 1].isEmpty()) { + word = " " + word; + } + + while (word.length() > MAXIMUM_CHARACTERS_PER_LINE) { + String chunk = word.substring(0, MAXIMUM_CHARACTERS_PER_LINE); + word = word.substring(MAXIMUM_CHARACTERS_PER_LINE); + + if (currentLineLength + chunk.length() > MAXIMUM_CHARACTERS_PER_LINE && !currentLine.isEmpty()) { + lines.add(currentLine); + currentLine = new ArrayList<>(); + } + currentLine.add(duplicateComponentWithText(component, chunk)); + lines.add(currentLine); + currentLine = new ArrayList<>(); + currentLineLength = 0; + } + + if (currentLineLength + word.length() > MAXIMUM_CHARACTERS_PER_LINE && !currentLine.isEmpty()) { + lines.add(currentLine); + currentLine = new ArrayList<>(); + currentLineLength = 0; + if (word.startsWith(" ")) { + word = word.substring(1); + } + } + + TextComponent wordComponent = duplicateComponentWithText(component, word); + currentLine.add(wordComponent); + currentLineLength += word.length(); + } + } + } + + if (!currentLine.isEmpty()) { + lines.add(currentLine); + } + + if (lines.isEmpty()) { + return List.of(new BaseComponent[][]{pageComponents}); + } + + List resultPages = new ArrayList<>(); + for (int lineIndex = 0; lineIndex < lines.size(); lineIndex += MAXIMUM_LINES_PER_PAGE) { + int endIndex = Math.min(lineIndex + MAXIMUM_LINES_PER_PAGE, lines.size()); + List> pageLines = lines.subList(lineIndex, endIndex); + + List pageComponentsList = new ArrayList<>(); + for (int lineSubIndex = 0; lineSubIndex < pageLines.size(); lineSubIndex++) { + List line = pageLines.get(lineSubIndex); + pageComponentsList.addAll(line); + if (lineSubIndex < pageLines.size() - 1) { + pageComponentsList.add(new TextComponent("\n")); + } + } + + resultPages.add(pageComponentsList.toArray(new BaseComponent[0])); + } + + return resultPages; + } + + private List flattenToLeafComponents(BaseComponent[] components) { + List leafComponents = new ArrayList<>(); + for (BaseComponent component : components) { + collectLeafComponents(component, leafComponents); + } + return leafComponents; + } + + private void collectLeafComponents(BaseComponent component, List targetList) { + if (component instanceof TextComponent textComponent) { + processTextComponent(textComponent, targetList); + } + + if (component.getExtra() == null) { + return; + } + + for (BaseComponent extraComponent : component.getExtra()) { + if (extraComponent.getHoverEvent() == null) { + extraComponent.setHoverEvent(component.getHoverEvent()); + } + if (extraComponent.getClickEvent() == null) { + extraComponent.setClickEvent(component.getClickEvent()); + } + collectLeafComponents(extraComponent, targetList); + } + } + + private void processTextComponent(TextComponent textComponent, List targetList) { + String text = textComponent.getText(); + if (text == null || text.isEmpty()) { + return; + } + + BaseComponent legacyComponent = TextComponent.fromLegacy(text); + addParsedLeafComponents(legacyComponent, textComponent, targetList); + } + + private void addParsedLeafComponents(BaseComponent component, TextComponent parent, List targetList) { + if (component instanceof TextComponent textComponent) { + if (textComponent.getHoverEvent() == null) { + textComponent.setHoverEvent(parent.getHoverEvent()); + } + if (textComponent.getClickEvent() == null) { + textComponent.setClickEvent(parent.getClickEvent()); + } + if (textComponent.getText() != null && !textComponent.getText().isEmpty()) { + targetList.add(textComponent); + } + } + + if (component.getExtra() != null) { + for (BaseComponent extra : component.getExtra()) { + addParsedLeafComponents(extra, parent, targetList); + } + } + } + + private TextComponent duplicateComponentWithText(TextComponent source, String text) { + TextComponent copy = new TextComponent(text); + copy.setColor(source.getColorRaw()); + copy.setBold(source.isBoldRaw()); + copy.setItalic(source.isItalicRaw()); + copy.setUnderlined(source.isUnderlinedRaw()); + copy.setStrikethrough(source.isStrikethroughRaw()); + copy.setObfuscated(source.isObfuscatedRaw()); + copy.setHoverEvent(source.getHoverEvent()); + copy.setClickEvent(source.getClickEvent()); + return copy; + } } diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/menu/Menu.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/menu/Menu.java index fa954e71..5a0559a6 100644 --- a/bedrock/src/main/java/fr/moussax/bedrock/ui/menu/Menu.java +++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/menu/Menu.java @@ -7,13 +7,13 @@ import fr.moussax.bedrock.ui.menu.types.InteractiveMenu; import fr.moussax.bedrock.utils.ItemBuilder; import lombok.Getter; -import lombok.Setter; import org.bukkit.Bukkit; import org.bukkit.Material; import org.bukkit.entity.Player; import org.bukkit.event.inventory.ClickType; import org.bukkit.inventory.Inventory; import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.InventoryView; import org.bukkit.inventory.ItemStack; import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; @@ -715,19 +715,36 @@ public void refresh(@NonNull Player player) { slots.clear(); build(player); - if (player.getOpenInventory().getTopInventory().getHolder() == this) { - player.getOpenInventory().setTitle(this.title); + InventoryView openInventory = player.getOpenInventory(); + if (openInventory.getTopInventory().getHolder() == this && !openInventory.getTitle().equals(this.title)) { + openInventory.setTitle(this.title); } + boolean changed = false; for (int slot = 0; slot < size; slot++) { MenuSlot menuSlot = slots.get(slot); - if (menuSlot != null) { - inventory.setItem(slot, menuSlot.item); - } else if (!isInteractable(slot)) { - inventory.setItem(slot, null); + if (menuSlot == null) { + continue; + } + + ItemStack newItem = menuSlot.item; + ItemStack currentItem = inventory.getItem(slot); + + if (!isSameItem(currentItem, newItem)) { + inventory.setItem(slot, newItem); + changed = true; } } - player.updateInventory(); + + if (changed) { + player.updateInventory(); + } + } + + private boolean isSameItem(@Nullable ItemStack first, @Nullable ItemStack second) { + if (first == null && second == null) return true; + if (first == null || second == null) return false; + return first.getAmount() == second.getAmount() && first.isSimilar(second); } /** diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/menu/system/MenuSystem.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/menu/system/MenuSystem.java index 7e4f6445..9b7aff8e 100644 --- a/bedrock/src/main/java/fr/moussax/bedrock/ui/menu/system/MenuSystem.java +++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/menu/system/MenuSystem.java @@ -2,6 +2,7 @@ import fr.moussax.bedrock.ui.menu.Menu; import fr.moussax.bedrock.ui.menu.TickableMenu; +import fr.moussax.bedrock.utils.debug.Log; import lombok.Getter; import lombok.Setter; import org.bukkit.entity.Player; @@ -47,12 +48,16 @@ public MenuSystem(@NonNull Plugin plugin) { private void tickActiveMenus() { tickCounter++; for (Map.Entry entry : activeMenus.entrySet()) { - if (!(entry.getValue() instanceof TickableMenu tickable)) continue; - if (tickCounter % Math.max(1L, tickable.tickPeriodTicks()) != 0) continue; - - Player player = plugin.getServer().getPlayer(entry.getKey()); - if (player != null && player.isOnline()) { - tickable.onTick(player); + try { + if (!(entry.getValue() instanceof TickableMenu tickable)) continue; + if (tickCounter % Math.max(1L, tickable.tickPeriodTicks()) != 0) continue; + + Player player = plugin.getServer().getPlayer(entry.getKey()); + if (player != null && player.isOnline()) { + tickable.onTick(player); + } + } catch (Exception exception) { + Log.warn("MenuSystem", "Failed to tick menu for player " + entry.getKey() + ": " + exception.getMessage()); } } } diff --git a/bedrock/src/main/java/fr/moussax/bedrock/ui/menu/types/PaginatedMenu.java b/bedrock/src/main/java/fr/moussax/bedrock/ui/menu/types/PaginatedMenu.java index 1e653ae1..9661dca2 100644 --- a/bedrock/src/main/java/fr/moussax/bedrock/ui/menu/types/PaginatedMenu.java +++ b/bedrock/src/main/java/fr/moussax/bedrock/ui/menu/types/PaginatedMenu.java @@ -3,6 +3,7 @@ import fr.moussax.bedrock.ui.menu.Menu; import fr.moussax.bedrock.ui.menu.interaction.MenuElementPreset; import fr.moussax.bedrock.ui.menu.interaction.MenuItemInteraction; +import lombok.Getter; import org.bukkit.entity.Player; import org.bukkit.event.inventory.ClickType; import org.bukkit.inventory.ItemStack; @@ -19,9 +20,26 @@ */ public abstract class PaginatedMenu extends Menu { + public static final int[] INNER_GRID_SLOTS = { + 10, 11, 12, 13, 14, 15, 16, + 19, 20, 21, 22, 23, 24, 25, + 28, 29, 30, 31, 32, 33, 34, + 37, 38, 39, 40, 41, 42, 43 + }; + + @Getter protected int currentPage = 0; protected int totalItems = 0; + /** + * Returns the total number of items stored for the current page rendering. + * + * @return stored total item count + */ + public int getTotalItemsCount() { + return totalItems; + } + /** * Creates a paginated menu. * @@ -50,30 +68,33 @@ protected PaginatedMenu(String title, int size) { protected abstract ItemStack getItem(@NonNull Player player, int index); /** - * Returns the 0-based index of the current page. + * Returns the target inventory slot indices for displaying paginated items. + * + *

If {@code null}, items are placed sequentially starting at slot 0.

* - * @return current page index (0-based) + * @return slot index array, or {@code null} for default sequential placement */ - public int getCurrentPage() { - return currentPage; + protected int[] getDisplaySlots() { + return null; } /** - * Returns the 1-based number of the current page. + * Returns an item to display when the menu has no items, or {@code null}. * - * @return current page number (1-based) + * @param player player viewing the menu + * @return empty state item, or {@code null} if none */ - public int getCurrentPageNumber() { - return currentPage + 1; + protected ItemStack getEmptyStateItem(@NonNull Player player) { + return null; } /** - * Returns the total item count evaluated during the last build. + * Returns the 1-based number of the current page. * - * @return total item count + * @return current page number (1-based) */ - public int getTotalItems() { - return totalItems; + public int getCurrentPageNumber() { + return currentPage + 1; } /** @@ -95,6 +116,10 @@ public int getTotalPages() { * @return number of items displayed per page */ protected int getItemsPerPage() { + int[] displaySlots = getDisplaySlots(); + if (displaySlots != null) { + return displaySlots.length; + } return size - 9; } @@ -109,46 +134,96 @@ protected int getItemsPerPage() { @Override public void build(@NonNull Player viewer) { totalItems = Math.max(0, getTotalItems(viewer)); + slots.clear(); + + int closeSlot = size - 5; + int backSlot = size - 6; + int nextSlot = size - 4; + + ItemStack emptyItem = getEmptyStateItem(viewer); + if (totalItems == 0) { + if (emptyItem != null) { + int emptySlot = size >= 27 ? 22 : size / 2; + setItem(emptySlot, emptyItem, MenuItemInteraction.ANY_CLICK, (_, _) -> { }); + } + setCloseButton(closeSlot); + return; + } int itemsPerPage = getItemsPerPage(); - int maxPage = Math.max(0, (totalItems - 1) / itemsPerPage); + int maxPage = (totalItems - 1) / itemsPerPage; currentPage = Math.min(currentPage, maxPage); int startIndex = currentPage * itemsPerPage; int endIndex = Math.min(startIndex + itemsPerPage, totalItems); - int slot = 0; + int[] displaySlots = getDisplaySlots(); + int slotIndex = 0; for (int i = startIndex; i < endIndex; i++) { final int index = i; + int slot = displaySlots != null ? displaySlots[slotIndex++] : i - startIndex; setItem( - slot++, + slot, getItem(viewer, index), MenuItemInteraction.ANY_CLICK, - (player, click) -> onItemClick(player, index, click) + (player, click) -> { + if (click.isShiftClick()) { + onItemShiftClick(player, index); + } else if (click.isRightClick()) { + onItemRightClick(player, index); + } else if (click.isLeftClick()) { + onItemLeftClick(player, index); + } + onItemClick(player, index, click); + } ); } - // Previous page if (currentPage > 0) { - setBackButton(size - 9, - (player, type) -> { - currentPage--; - refresh(player); - } - ); + setBackButton(backSlot, (player, _) -> { + currentPage--; + refresh(player); + }); } - // Next page if (endIndex < totalItems) { - setItem(size - 1, MenuElementPreset.NEXT_BUTTON, - (player, type) -> { - currentPage++; - refresh(player); - } - ); + setItem(nextSlot, MenuElementPreset.NEXT_BUTTON, (player, _) -> { + currentPage++; + refresh(player); + }); } - setCloseButton(size - 5); + setCloseButton(closeSlot); + } + + /** + * Called when a paginated item is clicked with a left-click. + * + * @param player clicking player + * @param index global item index + */ + protected void onItemLeftClick(@NonNull Player player, int index) { + // override as needed + } + + /** + * Called when a paginated item is clicked with a right-click. + * + * @param player clicking player + * @param index global item index + */ + protected void onItemRightClick(@NonNull Player player, int index) { + // override as needed + } + + /** + * Called when a paginated item is clicked with a shift-click. + * + * @param player clicking player + * @param index global item index + */ + protected void onItemShiftClick(@NonNull Player player, int index) { + // override as needed } /** diff --git a/bedrock/src/main/java/fr/moussax/bedrock/utils/ItemBuilder.java b/bedrock/src/main/java/fr/moussax/bedrock/utils/ItemBuilder.java index a5165221..1b5c24f9 100644 --- a/bedrock/src/main/java/fr/moussax/bedrock/utils/ItemBuilder.java +++ b/bedrock/src/main/java/fr/moussax/bedrock/utils/ItemBuilder.java @@ -12,6 +12,7 @@ import org.bukkit.enchantments.Enchantment; import org.bukkit.entity.EntitySnapshot; import org.bukkit.entity.EntityType; +import org.bukkit.entity.Player; import org.bukkit.inventory.*; import org.bukkit.inventory.meta.*; import org.bukkit.inventory.meta.components.*; @@ -54,7 +55,7 @@ public class ItemBuilder { private boolean unstackable; private List bannerPatterns; - private UUID skullOwnerUUID; + private PlayerProfile skullOwnerProfile; private String base64Texture; private Color leatherColor; private Color potionColor; @@ -840,7 +841,23 @@ public ItemBuilder addBannerPatterns(List patterns) { */ public ItemBuilder setSkullOwner(@NonNull UUID playerId) { item.setType(Material.PLAYER_HEAD); - this.skullOwnerUUID = playerId; + this.skullOwnerProfile = Bukkit.createPlayerProfile(playerId); + this.base64Texture = null; + return this; + } + + /** + * Sets the owner of a player head by player name. + * + *

The item type is changed to {@link Material#PLAYER_HEAD}.

+ * + * @param playerName the owner's player name + * @return this builder + */ + public ItemBuilder setSkullOwner(@NonNull String playerName) { + item.setType(Material.PLAYER_HEAD); + Player onlinePlayer = Bukkit.getPlayerExact(playerName); + this.skullOwnerProfile = onlinePlayer != null ? onlinePlayer.getPlayerProfile() : Bukkit.createPlayerProfile(playerName); this.base64Texture = null; return this; } @@ -856,7 +873,7 @@ public ItemBuilder setSkullOwner(@NonNull UUID playerId) { public ItemBuilder setCustomSkullTexture(@NonNull String base64Texture) { item.setType(Material.PLAYER_HEAD); this.base64Texture = base64Texture; - this.skullOwnerUUID = null; + this.skullOwnerProfile = null; return this; } @@ -1004,9 +1021,8 @@ private void applyPotionProperties() { private void applySkullProperties() { if (item.getType() == Material.PLAYER_HEAD && itemMeta instanceof SkullMeta skullMeta) { - if (skullOwnerUUID != null) { - PlayerProfile profile = Bukkit.createPlayerProfile(skullOwnerUUID); - skullMeta.setOwnerProfile(profile); + if (skullOwnerProfile != null) { + skullMeta.setOwnerProfile(skullOwnerProfile); } else if (base64Texture != null) { applyBase64Texture(skullMeta, base64Texture); } diff --git a/bedrock/src/test/java/fr/moussax/bedrock/ui/actionbar/ActionbarComposerTest.java b/bedrock/src/test/java/fr/moussax/bedrock/ui/actionbar/ActionbarComposerTest.java new file mode 100644 index 00000000..9c8d7b97 --- /dev/null +++ b/bedrock/src/test/java/fr/moussax/bedrock/ui/actionbar/ActionbarComposerTest.java @@ -0,0 +1,98 @@ +package fr.moussax.bedrock.ui.actionbar; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class ActionbarComposerTest { + + @Test + @DisplayName("Expects the highest priority exclusive section to win over lower priority and normal sections") + void testHighestPriorityExclusiveWins() { + ActionbarComposer composer = new ActionbarComposer(); + + ActionbarSection lowPriorityExclusive = ActionbarSection.exclusiveOf( + "low_exclusive", + 10, + _ -> "Low Priority Exclusive", + _ -> true + ); + + ActionbarSection highPriorityExclusive = ActionbarSection.exclusiveOf( + "high_exclusive", + 100, + _ -> "High Priority Exclusive", + _ -> true + ); + + ActionbarSection normalSection = ActionbarSection.of( + "normal", + 0, + _ -> "Normal Text", + _ -> true + ); + + composer.registerSection(lowPriorityExclusive); + composer.registerSection(highPriorityExclusive); + composer.registerSection(normalSection); + + String result = composer.compile(null); + + assertEquals("High Priority Exclusive", result); + } + + @Test + @DisplayName("Expects an empty exclusive section to fall back to the next available exclusive section") + void testEmptyExclusiveFallsBack() { + ActionbarComposer composer = new ActionbarComposer(); + + ActionbarSection emptyHighExclusive = ActionbarSection.exclusiveOf( + "empty_high", + 200, + _ -> "", + _ -> true + ); + + ActionbarSection activeLowExclusive = ActionbarSection.exclusiveOf( + "active_low", + 50, + _ -> "Active Low Exclusive", + _ -> true + ); + + composer.registerSection(emptyHighExclusive); + composer.registerSection(activeLowExclusive); + + String result = composer.compile(null); + + assertEquals("Active Low Exclusive", result); + } + + @Test + @DisplayName("Expects normal sections to render in ascending priority order when no exclusive section is active") + void testNormalSectionsFollowPriority() { + ActionbarComposer composer = new ActionbarComposer(); + + ActionbarSection firstNormal = ActionbarSection.of( + "first", + 10, + _ -> "First Normal", + _ -> true + ); + + ActionbarSection secondNormal = ActionbarSection.of( + "second", + 20, + _ -> "Second Normal", + _ -> true + ); + + composer.registerSection(secondNormal); + composer.registerSection(firstNormal); + + String result = composer.compile(null); + + assertEquals("First Normal Second Normal", result); + } +} diff --git a/blighted-mod/pom.xml b/blighted-mod/pom.xml new file mode 100644 index 00000000..d0b6a1c4 --- /dev/null +++ b/blighted-mod/pom.xml @@ -0,0 +1,70 @@ + + + 4.0.0 + + + fr.moussax + blightedmc + 1.0-SNAPSHOT + + + blighted-mod + jar + + BlightedMod + + + + fr.moussax + bedrock + ${project.version} + + + org.spigotmc + spigot-api + + + org.spigotmc + spigot + + + org.projectlombok + lombok + + + org.xerial + sqlite-jdbc + + + + + clean package + + + org.apache.maven.plugins + maven-compiler-plugin + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.3 + + + package + + shade + + + + + + + + src/main/resources + true + + + + diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/BlightedMod.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/BlightedMod.java new file mode 100644 index 00000000..9acda84e --- /dev/null +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/BlightedMod.java @@ -0,0 +1,64 @@ +package fr.moussax.blightedMod; + +import fr.moussax.bedrock.scheduling.PluginContext; +import fr.moussax.bedrock.utils.debug.Log; +import fr.moussax.blightedMod.database.PluginDatabase; +import fr.moussax.blightedMod.moderator.ModerationManager; +import fr.moussax.blightedMod.moderator.punishments.PunishmentManager; +import fr.moussax.blightedMod.registry.CommandsRegistry; +import fr.moussax.blightedMod.registry.EventsRegistry; +import lombok.Getter; +import org.bukkit.plugin.java.JavaPlugin; + +import java.sql.SQLException; + +public final class BlightedMod extends JavaPlugin { + + @Getter + private static BlightedMod instance; + + @Getter + private PluginDatabase pluginDatabase; + @Getter + private ModerationManager moderationManager; + private EventsRegistry eventsRegistry; + + @Override + public void onEnable() { + instance = this; + PluginContext.bind(this); + + if (!getDataFolder().exists()) { + getDataFolder().mkdirs(); + } + + try { + pluginDatabase = new PluginDatabase(getDataFolder().getAbsolutePath() + "/blighted_mod.db"); + Log.success("Database", "Successfully connected to BlightedMod database."); + } catch (SQLException exception) { + Log.error("Database", "Unable to connect to BlightedMod database: " + exception.getMessage()); + getServer().getPluginManager().disablePlugin(this); + return; + } + + PunishmentManager punishmentManager = new PunishmentManager(pluginDatabase.getConnection()); + moderationManager = ModerationManager.init(punishmentManager); + moderationManager.startFreezeTask(this); + + CommandsRegistry.registerCommands(this); + eventsRegistry = new EventsRegistry(); + eventsRegistry.initializeListeners(); + } + + @Override + public void onDisable() { + if (eventsRegistry != null) { + eventsRegistry.shutdownMenus(); + eventsRegistry.cleanup(); + } + + if (pluginDatabase != null) { + pluginDatabase.closeConnection(); + } + } +} diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/ModerationCommand.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/ModerationCommand.java new file mode 100644 index 00000000..5d5bcf8e --- /dev/null +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/ModerationCommand.java @@ -0,0 +1,100 @@ +package fr.moussax.blightedMod.commands; + +import fr.moussax.blightedMod.moderator.ModerationManager; +import fr.moussax.blightedMod.moderator.punishments.PunishmentManager; +import org.bukkit.Bukkit; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +import static fr.moussax.bedrock.text.Messenger.warn; + +/** + * Base abstract class for all moderation commands in BlightedMod. + * Validates that the sender is a qualified moderator or console before delegating execution. + */ +public abstract class ModerationCommand implements CommandExecutor { + + @Override + public boolean onCommand(@NonNull CommandSender sender, @NonNull Command command, @NonNull String label, String @NonNull [] arguments) { + if (sender instanceof Player player) { + if (!getModerationManager().isModerator(player)) { + warn(player, "You must be MODERATOR or higher to use this command."); + return true; + } + return executeModeration(player, command, label, arguments); + } + + if (isConsoleAllowed()) { + return executeModeration(sender, command, label, arguments); + } + + warn(sender, "This command can only be executed by in-game moderators."); + return true; + } + + /** + * Executes the moderation command logic for in-game moderators. + * + * @param moderator validated player executing the moderation command + * @param command command executed + * @param label alias used to invoke the command + * @param arguments command arguments + * @return {@code true} if handled; otherwise {@code false} + */ + protected boolean executeModeration(Player moderator, Command command, String label, String[] arguments) { + return executeModeration((CommandSender) moderator, command, label, arguments); + } + + /** + * Executes the moderation command logic for any command sender (Player or Console). + * + * @param moderator validated command sender executing the moderation command + * @param command command executed + * @param label alias used to invoke the command + * @param arguments command arguments + * @return {@code true} if handled; otherwise {@code false} + */ + protected boolean executeModeration(CommandSender moderator, Command command, String label, String[] arguments) { + if (moderator instanceof Player player) { + return executeModeration(player, command, label, arguments); + } + warn(moderator, "This command can only be executed by in-game moderators."); + return true; + } + + /** + * Returns whether this moderation command can be executed from the server console. + * + * @return {@code true} if console execution is allowed; otherwise {@code false} + */ + protected boolean isConsoleAllowed() { + return false; + } + + protected final @Nullable Player requireTarget(CommandSender sender, String name) { + Player target = Bukkit.getPlayerExact(name); + + if (target == null) { + warn(sender, "Unable to find player §4" + name); + } + + return target; + } + + protected final @Nullable Player requireTarget(Player sender, String name) { + return requireTarget((CommandSender) sender, name); + } + + protected ModerationManager getModerationManager() { + return ModerationManager.getInstance(); + } + + protected PunishmentManager getPunishmentManager() { + return getModerationManager().getPunishmentManager(); + } +} + diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/BanCommand.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/BanCommand.java new file mode 100644 index 00000000..913e0835 --- /dev/null +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/BanCommand.java @@ -0,0 +1,181 @@ +package fr.moussax.blightedMod.commands.impl; + +import fr.moussax.bedrock.commands.CommandArgument; +import fr.moussax.blightedMod.commands.ModerationCommand; +import fr.moussax.blightedMod.moderator.punishments.DurationParser; +import fr.moussax.blightedMod.moderator.punishments.PunishmentArguments; +import fr.moussax.blightedMod.moderator.punishments.PunishmentData; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import java.util.Locale; +import java.util.UUID; + +import static fr.moussax.bedrock.text.Messenger.warn; + +@CommandArgument(position = 0, suggestions = {"$players"}) +public final class BanCommand extends ModerationCommand { + + @Override + protected boolean isConsoleAllowed() { + return true; + } + + @Override + protected boolean executeModeration(CommandSender moderator, Command command, String label, String[] arguments) { + return switch (label.toLowerCase(Locale.ROOT)) { + case "unban" -> handleUnban(moderator, arguments); + case "banip" -> handleBanIp(moderator, arguments); + case "unbanip" -> handleUnbanIp(moderator, arguments); + default -> handleBan(moderator, arguments); + }; + } + + private boolean handleBan(CommandSender moderator, String[] arguments) { + if (arguments.length < 1) { + warn(moderator, "Usage: /ban [duration] [reason]"); + moderator.sendMessage("§7Duration format: 1d, 3w, 1m, 1y (omit for permanent)"); + return false; + } + + Player target = requireTarget(moderator, arguments[0]); + if (target == null) { + return false; + } + + if (target.equals(moderator)) { + warn(moderator, "You cannot ban yourself."); + return false; + } + + if (moderator instanceof Player && getModerationManager().isModerator(target)) { + warn(moderator, "You cannot ban another moderator."); + return false; + } + + PunishmentArguments punishmentArguments = PunishmentArguments.parse(arguments, 1); + Long expiresAt = punishmentArguments.expiresAt(); + String reason = punishmentArguments.reason(); + + getPunishmentManager().addBan(target, moderator, reason, expiresAt); + + String durationText = expiresAt != null ? DurationParser.formatDuration(arguments[1]) : "Permanent"; + String banMessage = """ + §cYou are banned from this server! + + §7Reason: §f%s + §7Duration: §f%s + + §7Appeal on our Discord if you believe this was a mistake.""".formatted(reason, durationText); + + target.kickPlayer(banMessage); + + String durationString = expiresAt != null ? " for §6" + durationText + "§e" : " permanently"; + String notification = " §d§lSTAFF! §9" + moderator.getName() + "§e banned §d" + target.getName() + "§e" + durationString + " for §c" + reason + "§e."; + getModerationManager().broadcastToModerators(notification); + + return true; + } + + private boolean handleUnban(CommandSender moderator, String[] arguments) { + if (arguments.length < 1) { + warn(moderator, "Usage: /unban "); + return false; + } + + String targetName = arguments[0]; + UUID targetId = getPunishmentManager().resolveBannedUuidByName(targetName); + + if (targetId == null) { + warn(moderator, targetName + " is not banned."); + return false; + } + + getPunishmentManager().removePunishment(targetId, PunishmentData.PunishmentType.BAN); + + String notification = " §d§lSTAFF! §9" + moderator.getName() + "§e unbanned §d" + targetName + "§e."; + getModerationManager().broadcastToModerators(notification); + + return true; + } + + private boolean handleBanIp(CommandSender moderator, String[] arguments) { + if (arguments.length < 1) { + warn(moderator, "Usage: /banip [duration] [reason]"); + moderator.sendMessage("§7Duration format: 1d, 3w, 1m, 1y (omit for permanent)"); + return false; + } + + Player target = requireTarget(moderator, arguments[0]); + if (target == null) { + return false; + } + + if (target.equals(moderator)) { + warn(moderator, "You cannot IP ban yourself."); + return false; + } + + if (moderator instanceof Player && getModerationManager().isModerator(target)) { + warn(moderator, "You cannot IP ban another moderator."); + return false; + } + + PunishmentArguments punishmentArguments = PunishmentArguments.parse(arguments, 1); + Long expiresAt = punishmentArguments.expiresAt(); + String reason = punishmentArguments.reason(); + + getPunishmentManager().addIpBan(target, moderator, reason, expiresAt); + + String durationText = expiresAt != null ? DurationParser.formatDuration(arguments[1]) : "Permanent"; + String banMessage = """ + §cYour IP address is banned from this server! + + §7Reason: §f%s + §7Duration: §f%s + + §7Appeal on our Discord if you believe this was a mistake.""".formatted(reason, durationText); + + target.kickPlayer(banMessage); + + String notification = " §d§lSTAFF! §9" + moderator.getName() + "§e IP banned §d" + target.getName() + "§e for §c" + reason + "§e."; + getModerationManager().broadcastToModerators(notification); + + return true; + } + + private boolean handleUnbanIp(CommandSender moderator, String[] arguments) { + if (arguments.length < 1) { + warn(moderator, "Usage: /unbanip "); + return false; + } + + String input = arguments[0]; + String ipAddress = null; + String targetName = input; + + if (getPunishmentManager().isIpBanned(input)) { + ipAddress = input; + } else { + for (PunishmentData punishment : getPunishmentManager().getAllPunishments(targetName)) { + if (punishment.ipAddress() != null && !punishment.ipAddress().isEmpty() && !"0.0.0.0".equals(punishment.ipAddress())) { + ipAddress = punishment.ipAddress(); + break; + } + } + } + + if (ipAddress == null || !getPunishmentManager().isIpBanned(ipAddress)) { + warn(moderator, "This IP is not banned."); + return false; + } + + getPunishmentManager().removeIpPunishment(ipAddress); + + String notification = " §d§lSTAFF! §9" + moderator.getName() + "§e unbanned IP for §d" + targetName + "§e."; + getModerationManager().broadcastToModerators(notification); + + return true; + } +} diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/ChatCommand.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/ChatCommand.java new file mode 100644 index 00000000..c2625d58 --- /dev/null +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/ChatCommand.java @@ -0,0 +1,40 @@ +package fr.moussax.blightedMod.commands.impl; + +import fr.moussax.bedrock.commands.CommandArgument; +import fr.moussax.blightedMod.commands.ModerationCommand; +import fr.moussax.blightedMod.moderator.ModerationManager; +import org.bukkit.command.Command; +import org.bukkit.entity.Player; + +import static fr.moussax.bedrock.text.Messenger.inform; +import static fr.moussax.bedrock.text.Messenger.warn; + +@CommandArgument(position = 0, suggestions = {"$channels"}) +public final class ChatCommand extends ModerationCommand { + + @Override + protected boolean executeModeration(Player player, Command command, String label, String[] arguments) { + if (arguments.length == 0) { + ModerationManager.ChatChannel currentChannel = getModerationManager().getChatChannel(player); + inform(player, " §7You are now in the §d" + currentChannel.name() + " §7channel."); + warn(player, "Usage: /chat "); + return true; + } + + String targetChannel = arguments[0].toLowerCase(); + if (targetChannel.equals("staff") || targetChannel.equals("s")) { + getModerationManager().setChatChannel(player, ModerationManager.ChatChannel.STAFF); + inform(player, " §7You are now in the §dSTAFF §7channel."); + return true; + } + + if (targetChannel.equals("all") || targetChannel.equals("a") || targetChannel.equals("global") || targetChannel.equals("normal")) { + getModerationManager().setChatChannel(player, ModerationManager.ChatChannel.ALL); + inform(player, " §7You are now in the §dALL §7channel."); + return true; + } + + warn(player, "Unknown chat channel. Usage: /chat "); + return false; + } +} diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/CheckReportCommand.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/CheckReportCommand.java new file mode 100644 index 00000000..8fc558ec --- /dev/null +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/CheckReportCommand.java @@ -0,0 +1,38 @@ +package fr.moussax.blightedMod.commands.impl; + +import fr.moussax.blightedMod.commands.ModerationCommand; +import fr.moussax.blightedMod.moderator.menus.ReportsCenterMenu; +import fr.moussax.blightedMod.moderator.reports.ReportData; +import fr.moussax.blightedMod.moderator.reports.ReportManager; +import org.bukkit.command.Command; +import org.bukkit.entity.Player; + +import static fr.moussax.bedrock.text.Messenger.warn; + +public final class CheckReportCommand extends ModerationCommand { + + @Override + protected boolean executeModeration(Player moderator, Command command, String label, String[] arguments) { + if (arguments.length == 0) { + warn(moderator, "Usage: /checkreport "); + return false; + } + + int reportId; + try { + reportId = Integer.parseInt(arguments[0]); + } catch (NumberFormatException _) { + warn(moderator, "Invalid report ID format."); + return false; + } + + ReportData report = ReportManager.getInstance().getReportById(reportId); + if (report == null) { + warn(moderator, "Report #" + reportId + " not found or has been resolved."); + return true; + } + + ReportsCenterMenu.openReportBook(moderator, report); + return true; + } +} diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/EnderseeCommand.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/EnderseeCommand.java new file mode 100644 index 00000000..bbc89009 --- /dev/null +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/EnderseeCommand.java @@ -0,0 +1,34 @@ +package fr.moussax.blightedMod.commands.impl; + +import fr.moussax.bedrock.commands.CommandArgument; +import fr.moussax.blightedMod.commands.ModerationCommand; +import fr.moussax.blightedMod.moderator.menus.EnderseeMenu; +import org.bukkit.command.Command; +import org.bukkit.entity.Player; + +import static fr.moussax.bedrock.text.Messenger.warn; + +@CommandArgument(position = 0, suggestions = {"$players"}) +public final class EnderseeCommand extends ModerationCommand { + + @Override + protected boolean executeModeration(Player moderator, Command command, String label, String[] arguments) { + if (arguments.length == 0) { + warn(moderator, "Usage: /endersee "); + return false; + } + + Player target = requireTarget(moderator, arguments[0]); + if (target == null) { + return false; + } + + if (target.equals(moderator)) { + warn(moderator, "You cannot view your own ender chest."); + return false; + } + + new EnderseeMenu(target).open(moderator); + return true; + } +} diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/FreezeCommand.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/FreezeCommand.java new file mode 100644 index 00000000..f32d73a0 --- /dev/null +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/FreezeCommand.java @@ -0,0 +1,47 @@ +package fr.moussax.blightedMod.commands.impl; + +import fr.moussax.bedrock.commands.CommandArgument; +import fr.moussax.blightedMod.commands.ModerationCommand; +import org.bukkit.command.Command; +import org.bukkit.entity.Player; + +import static fr.moussax.bedrock.text.Messenger.warn; + +@CommandArgument(position = 0, suggestions = {"$players"}) +public final class FreezeCommand extends ModerationCommand { + + @Override + protected boolean executeModeration(Player moderator, Command command, String label, String[] arguments) { + if (arguments.length < 1) { + warn(moderator, "Usage: /freeze "); + return false; + } + + Player target = requireTarget(moderator, arguments[0]); + if (target == null) { + return false; + } + + if (target.equals(moderator)) { + warn(moderator, "You cannot freeze yourself."); + return false; + } + + boolean isNowFrozen = getModerationManager().toggleFreeze(target); + + if (isNowFrozen) { + target.setFreezeTicks(target.getMaxFreezeTicks()); + moderator.sendMessage("§b ❄ §eYou froze §d" + target.getName() + "§e."); + target.sendMessage(""); + target.sendMessage("§b ❄ §b§lYOU HAVE BEEN FROZEN BY A MODERATOR!"); + target.sendMessage("§c ⚠ Do not log out or you will be permanently banned."); + target.sendMessage(""); + } else { + target.setFreezeTicks(0); + moderator.sendMessage("§b ❄ §eYou unfroze §d" + target.getName() + "§e."); + target.sendMessage("§b ❄ §7You have been unfrozen, you can move again."); + } + + return true; + } +} diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/InvseeCommand.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/InvseeCommand.java new file mode 100644 index 00000000..6c084006 --- /dev/null +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/InvseeCommand.java @@ -0,0 +1,34 @@ +package fr.moussax.blightedMod.commands.impl; + +import fr.moussax.bedrock.commands.CommandArgument; +import fr.moussax.blightedMod.commands.ModerationCommand; +import fr.moussax.blightedMod.moderator.menus.InvSeeMenu; +import org.bukkit.command.Command; +import org.bukkit.entity.Player; + +import static fr.moussax.bedrock.text.Messenger.warn; + +@CommandArgument(position = 0, suggestions = {"$players"}) +public final class InvseeCommand extends ModerationCommand { + + @Override + protected boolean executeModeration(Player moderator, Command command, String label, String[] arguments) { + if (arguments.length == 0) { + warn(moderator, "Usage: /invsee "); + return false; + } + + Player target = requireTarget(moderator, arguments[0]); + if (target == null) { + return false; + } + + if (target.equals(moderator)) { + warn(moderator, "You cannot view your own inventory."); + return false; + } + + new InvSeeMenu(target).open(moderator); + return true; + } +} diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/KickCommand.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/KickCommand.java new file mode 100644 index 00000000..e22e5c6b --- /dev/null +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/KickCommand.java @@ -0,0 +1,74 @@ +package fr.moussax.blightedMod.commands.impl; + +import fr.moussax.bedrock.commands.CommandArgument; +import fr.moussax.blightedMod.commands.ModerationCommand; +import fr.moussax.blightedMod.moderator.punishments.PunishmentData; +import fr.moussax.blightedMod.moderator.punishments.PunishmentManager; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import java.util.Arrays; + +import static fr.moussax.bedrock.text.Messenger.warn; + +@CommandArgument(position = 0, suggestions = {"$players"}) +public final class KickCommand extends ModerationCommand { + + @Override + protected boolean isConsoleAllowed() { + return true; + } + + @Override + protected boolean executeModeration(CommandSender moderator, Command command, String label, String[] arguments) { + if (arguments.length < 1) { + warn(moderator, "Usage: /kick [reason]"); + return false; + } + + Player target = requireTarget(moderator, arguments[0]); + if (target == null) { + return false; + } + + if (target.equals(moderator)) { + warn(moderator, "You cannot kick yourself."); + return false; + } + + if (moderator instanceof Player && getModerationManager().isModerator(target)) { + warn(moderator, "You cannot kick another moderator."); + return false; + } + + String reason = arguments.length > 1 + ? String.join(" ", Arrays.copyOfRange(arguments, 1, arguments.length)) + : "No reason specified"; + String ipAddress = PunishmentManager.getPlayerIp(target); + + getPunishmentManager().addPunishment( + target.getUniqueId(), + target.getName(), + PunishmentData.PunishmentType.KICK, + reason, + PunishmentManager.getModeratorUuid(moderator), + moderator.getName(), + null, + ipAddress + ); + + String kickMessage = """ + §cYou are kicked from this server! + + §7Reason: §f%s + """.formatted(reason); + + target.kickPlayer(kickMessage); + + String notification = " §d§lSTAFF! §9" + moderator.getName() + "§e kicked §d" + target.getName() + "§e for §c" + reason + "§e."; + getModerationManager().broadcastToModerators(notification); + + return true; + } +} diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/ModCommand.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/ModCommand.java new file mode 100644 index 00000000..efea66e9 --- /dev/null +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/ModCommand.java @@ -0,0 +1,24 @@ +package fr.moussax.blightedMod.commands.impl; + +import fr.moussax.blightedMod.commands.ModerationCommand; +import org.bukkit.Bukkit; +import org.bukkit.command.Command; +import org.bukkit.entity.Player; + +public final class ModCommand extends ModerationCommand { + + @Override + protected boolean executeModeration(Player moderator, Command command, String label, String[] arguments) { + if (arguments.length > 0) { + String message = String.join(" ", arguments); + String formattedMessage = " §9§lMOD §9" + moderator.getName() + " §f§l» §e§l" + message; + Bukkit.broadcastMessage(" "); + Bukkit.broadcastMessage(formattedMessage); + Bukkit.broadcastMessage(" "); + return true; + } + + getModerationManager().toggleModerationMode(moderator); + return true; + } +} diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/ModTpCommands.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/ModTpCommands.java new file mode 100644 index 00000000..0dfdcece --- /dev/null +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/ModTpCommands.java @@ -0,0 +1,51 @@ +package fr.moussax.blightedMod.commands.impl; + +import fr.moussax.bedrock.commands.CommandArgument; +import fr.moussax.bedrock.text.InteractiveMessage; +import fr.moussax.blightedMod.commands.ModerationCommand; +import org.bukkit.command.Command; +import org.bukkit.entity.Player; + +import static fr.moussax.bedrock.text.Messenger.warn; + +@CommandArgument(position = 0, suggestions = {"$players"}) +public final class ModTpCommands extends ModerationCommand { + + @Override + protected boolean executeModeration(Player moderator, Command command, String label, String[] arguments) { + boolean isHere = label.equalsIgnoreCase("mtphere"); + + if (arguments.length == 0) { + warn(moderator, "Usage: /" + (isHere ? "mtphere" : "mtp") + " "); + return false; + } + + Player target = requireTarget(moderator, arguments[0]); + if (target == null) { + return false; + } + + if (target.equals(moderator)) { + warn(moderator, isHere ? "You cannot teleport yourself to yourself." : "You cannot teleport to yourself."); + return false; + } + + if (isHere) { + target.teleport(moderator.getLocation()); + } else { + moderator.teleport(target.getLocation()); + } + + getModerationManager().getModerator(moderator).setTargetPlayer(target); + + String textMessage = isHere + ? " §eTeleported §d" + target.getName() + "§e to you. " + : " §eTeleported to §d" + target.getName() + "§e. "; + + InteractiveMessage.text(textMessage) + .hoverAndExecute("§3[INFO]", "§fClick to view information about §d" + target.getName() + "§f.", "/userinfo " + target.getName()) + .send(moderator); + + return true; + } +} diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/MuteCommand.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/MuteCommand.java new file mode 100644 index 00000000..fb2ae10a --- /dev/null +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/MuteCommand.java @@ -0,0 +1,95 @@ +package fr.moussax.blightedMod.commands.impl; + +import fr.moussax.bedrock.commands.CommandArgument; +import fr.moussax.blightedMod.commands.ModerationCommand; +import fr.moussax.blightedMod.moderator.punishments.DurationParser; +import fr.moussax.blightedMod.moderator.punishments.PunishmentArguments; +import fr.moussax.blightedMod.moderator.punishments.PunishmentData; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import static fr.moussax.bedrock.text.Messenger.warn; + +@CommandArgument(position = 0, suggestions = {"$players"}) +public final class MuteCommand extends ModerationCommand { + + @Override + protected boolean isConsoleAllowed() { + return true; + } + + @Override + protected boolean executeModeration(CommandSender moderator, Command command, String label, String[] arguments) { + if (label.equalsIgnoreCase("unmute")) { + return handleUnmute(moderator, arguments); + } + + return handleMute(moderator, arguments); + } + + private boolean handleMute(CommandSender moderator, String[] arguments) { + if (arguments.length < 1) { + warn(moderator, "Usage: /mute [duration] [reason]"); + moderator.sendMessage("§7Duration format: 1d, 3w, 1m, 1y (omit for permanent)"); + return false; + } + + Player target = requireTarget(moderator, arguments[0]); + if (target == null) { + return false; + } + + if (target.equals(moderator)) { + warn(moderator, "You cannot mute yourself."); + return false; + } + + if (moderator instanceof Player && getModerationManager().isModerator(target)) { + warn(moderator, "You cannot mute another moderator."); + return false; + } + + PunishmentArguments punishmentArguments = PunishmentArguments.parse(arguments, 1); + Long expiresAt = punishmentArguments.expiresAt(); + String reason = punishmentArguments.reason(); + + getPunishmentManager().addMute(target, moderator, reason, expiresAt); + + String durationText = expiresAt != null ? "for §6" + DurationParser.formatDuration(arguments[1]) + " " : ""; + String notification = " §d§lSTAFF! §9" + moderator.getName() + "§e muted §d" + target.getName() + " §e" + durationText + "§efor §c" + reason + "§e."; + + getModerationManager().broadcastToModerators(notification); + if (expiresAt != null) { + target.sendMessage(" §c⌚ §cYou are muted for §d" + arguments[1] + " §cfor §b" + reason + "§c."); + } else { + target.sendMessage(" §c⌚ §cYou are muted §cfor §b" + reason + "§c."); + } + + return true; + } + + private boolean handleUnmute(CommandSender moderator, String[] arguments) { + if (arguments.length < 1) { + warn(moderator, "Usage: /unmute "); + return false; + } + + Player target = requireTarget(moderator, arguments[0]); + if (target == null) { + return false; + } + + if (!getPunishmentManager().isMuted(target.getUniqueId())) { + warn(moderator, target.getName() + " is not muted."); + return false; + } + + getPunishmentManager().removePunishment(target.getUniqueId(), PunishmentData.PunishmentType.MUTE); + + String notification = " §d§lSTAFF! §9" + moderator.getName() + "§e unmuted §d" + target.getName() + "§e."; + getModerationManager().broadcastToModerators(notification); + target.sendMessage(" §a⚑ §7You are no longer muted."); + return true; + } +} diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/ReportCommand.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/ReportCommand.java new file mode 100644 index 00000000..0971e069 --- /dev/null +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/ReportCommand.java @@ -0,0 +1,82 @@ +package fr.moussax.blightedMod.commands.impl; + +import fr.moussax.bedrock.commands.PlayerCommand; +import fr.moussax.blightedMod.moderator.menus.ReportMenu; +import fr.moussax.blightedMod.moderator.reports.ReportManager; +import org.bukkit.command.Command; +import org.bukkit.entity.Player; + +import java.util.Arrays; + +import static fr.moussax.bedrock.text.Messenger.warn; + +public final class ReportCommand extends PlayerCommand { + + @Override + protected boolean execute(Player player, Command command, String label, String[] arguments) { + if (arguments.length == 0) { + warn(player, "Usage: /report [reason] or click a chat flag ⚑."); + return false; + } + + String subAction = arguments[0]; + + if (subAction.equalsIgnoreCase("chat") && arguments.length >= 2) { + String targetName = arguments[1]; + String chatMessage = arguments.length > 2 + ? String.join(" ", Arrays.copyOfRange(arguments, 2, arguments.length)) + : "No message content"; + + if (player.getName().equalsIgnoreCase(targetName)) { + warn(player, "You cannot report yourself."); + return false; + } + + new ReportMenu(targetName, chatMessage).open(player); + return true; + } + + if (subAction.equalsIgnoreCase("submit") && arguments.length >= 3) { + Player target = requireTarget(player, arguments[1]); + if (target == null) { + return false; + } + + if (target.equals(player)) { + warn(player, "You cannot report yourself."); + return false; + } + + String reason = arguments[2]; + String chatMessage = arguments.length > 3 + ? String.join(" ", Arrays.copyOfRange(arguments, 3, arguments.length)) + : "No message content"; + + submitDirectReport(player, target.getName(), reason, chatMessage); + return true; + } + + Player target = requireTarget(player, subAction); + if (target == null) { + return false; + } + + if (target.equals(player)) { + warn(player, "You cannot report yourself."); + return false; + } + + if (arguments.length > 1) { + String reason = String.join(" ", Arrays.copyOfRange(arguments, 1, arguments.length)); + submitDirectReport(player, target.getName(), reason, "General player report"); + } else { + new ReportMenu(target.getName()).open(player); + } + + return true; + } + + private void submitDirectReport(Player reporter, String targetName, String reason, String chatMessage) { + ReportManager.getInstance().submitAndNotify(reporter, targetName, reason, chatMessage); + } +} diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/ReportsCommand.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/ReportsCommand.java new file mode 100644 index 00000000..5fb39b07 --- /dev/null +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/ReportsCommand.java @@ -0,0 +1,34 @@ +package fr.moussax.blightedMod.commands.impl; + +import fr.moussax.blightedMod.commands.ModerationCommand; +import fr.moussax.blightedMod.moderator.menus.ReportsCenterMenu; +import fr.moussax.blightedMod.moderator.reports.ReportManager; +import org.bukkit.command.Command; +import org.bukkit.entity.Player; + +import static fr.moussax.bedrock.text.Messenger.inform; +import static fr.moussax.bedrock.text.Messenger.warn; + +public final class ReportsCommand extends ModerationCommand { + + @Override + protected boolean executeModeration(Player moderator, Command command, String label, String[] arguments) { + if (arguments.length >= 2 && arguments[0].equalsIgnoreCase("dismiss")) { + try { + int reportId = Integer.parseInt(arguments[1]); + boolean dismissed = ReportManager.getInstance().dismissReport(reportId); + if (dismissed) { + inform(moderator, "§eDismissed report §d#" + reportId +"§e."); + } else { + warn(moderator, "Report #" + reportId + " not found or already dismissed."); + } + } catch (NumberFormatException _) { + warn(moderator, "Invalid report ID."); + } + return true; + } + + new ReportsCenterMenu().open(moderator); + return true; + } +} diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/SanctionsCommand.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/SanctionsCommand.java new file mode 100644 index 00000000..427e1708 --- /dev/null +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/SanctionsCommand.java @@ -0,0 +1,35 @@ +package fr.moussax.blightedMod.commands.impl; + +import fr.moussax.bedrock.commands.CommandArgument; +import fr.moussax.blightedMod.commands.ModerationCommand; +import fr.moussax.blightedMod.moderator.menus.SanctionsMenu; +import fr.moussax.blightedMod.moderator.punishments.PunishmentData; +import org.bukkit.command.Command; +import org.bukkit.entity.Player; + +import java.util.List; + +import static fr.moussax.bedrock.text.Messenger.warn; + +@CommandArgument(position = 0, suggestions = {"$players"}) +public final class SanctionsCommand extends ModerationCommand { + + @Override + protected boolean executeModeration(Player moderator, Command command, String label, String[] arguments) { + if (arguments.length == 0) { + warn(moderator, "Usage: /sanctions "); + return false; + } + + String targetName = arguments[0]; + List punishments = getPunishmentManager().getAllPunishments(targetName); + + if (punishments.isEmpty()) { + warn(moderator, "No recorded sanctions found for player §4" + targetName + "§c."); + return true; + } + + new SanctionsMenu(targetName, punishments).open(moderator); + return true; + } +} diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/SetSlowmodeCommand.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/SetSlowmodeCommand.java new file mode 100644 index 00000000..02f6cd17 --- /dev/null +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/SetSlowmodeCommand.java @@ -0,0 +1,50 @@ +package fr.moussax.blightedMod.commands.impl; + +import fr.moussax.blightedMod.commands.ModerationCommand; +import fr.moussax.blightedMod.moderator.punishments.DurationParser; +import org.bukkit.command.Command; +import org.bukkit.entity.Player; + +import static fr.moussax.bedrock.text.Messenger.inform; +import static fr.moussax.bedrock.text.Messenger.warn; + +public final class SetSlowmodeCommand extends ModerationCommand { + + @Override + protected boolean executeModeration(Player moderator, Command command, String label, String[] arguments) { + if (arguments.length == 0) { + int currentSlowmode = getModerationManager().getSlowmodeDelaySeconds(); + if (currentSlowmode > 0) { + inform(moderator, " §fChat Slowmode §eis currently §aENABLED §7(§e" + currentSlowmode + "s§7)."); + } else { + inform(moderator, " §fChat Slowmode §eis currently §cDISABLED§e."); + } + warn(moderator, "Usage: /slowmode "); + return true; + } + + String input = arguments[0]; + if (input.equalsIgnoreCase("off") || input.equalsIgnoreCase("disable") || input.equalsIgnoreCase("0")) { + getModerationManager().setSlowmodeDelaySeconds(0); + getModerationManager().broadcastToModerators(" §d§lSTAFF! §9" + moderator.getName() + " §ehas disabled §fChat Slowmode§e."); + return true; + } + + Long secondsDuration = DurationParser.parseDurationSeconds(input); + if (secondsDuration == null) { + warn(moderator, "Invalid duration format. Usage: /slowmode "); + return true; + } + + if (secondsDuration <= 0) { + getModerationManager().setSlowmodeDelaySeconds(0); + getModerationManager().broadcastToModerators(" §d§lSTAFF! §9" + moderator.getName() + " §ehas disabled §fChat Slowmode§e."); + return true; + } + + int seconds = (int) Math.min(secondsDuration, Integer.MAX_VALUE); + getModerationManager().setSlowmodeDelaySeconds(seconds); + getModerationManager().broadcastToModerators(" §d§lSTAFF! §9" + moderator.getName() + " §eset §fChat Slowmode §eto §6" + seconds + "s§e."); + return true; + } +} diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/SocialSpyCommand.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/SocialSpyCommand.java new file mode 100644 index 00000000..6ce0ecdc --- /dev/null +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/SocialSpyCommand.java @@ -0,0 +1,23 @@ +package fr.moussax.blightedMod.commands.impl; + +import fr.moussax.blightedMod.commands.ModerationCommand; +import fr.moussax.blightedMod.moderator.ModerationManager; +import org.bukkit.command.Command; +import org.bukkit.entity.Player; + +import static fr.moussax.bedrock.text.Messenger.inform; + +public final class SocialSpyCommand extends ModerationCommand { + + @Override + protected boolean executeModeration(Player moderator, Command command, String label, String[] arguments) { + ModerationManager manager = getModerationManager(); + boolean enabled = manager.toggleMessageInspect(moderator); + if (enabled) { + inform(moderator, " §6Social Spy §etoggled §aON§e."); + } else { + inform(moderator, " §6Social Spy §etoggled §cOFF§e."); + } + return true; + } +} diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/TargetCommand.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/TargetCommand.java new file mode 100644 index 00000000..46b4c12f --- /dev/null +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/TargetCommand.java @@ -0,0 +1,47 @@ +package fr.moussax.blightedMod.commands.impl; + +import fr.moussax.bedrock.commands.CommandArgument; +import fr.moussax.bedrock.text.InteractiveMessage; +import fr.moussax.blightedMod.commands.ModerationCommand; +import fr.moussax.blightedMod.moderator.BlightedModerator; +import org.bukkit.command.Command; +import org.bukkit.entity.Player; + +import static fr.moussax.bedrock.text.Messenger.inform; +import static fr.moussax.bedrock.text.Messenger.warn; + +@CommandArgument(position = 0, suggestions = {"$players"}) +public final class TargetCommand extends ModerationCommand { + + @Override + protected boolean executeModeration(Player moderator, Command command, String label, String[] arguments) { + if (!getModerationManager().isInModerationMode(moderator)) { + warn(moderator, "You must be in §dModeration Mode §cto target a player."); + return true; + } + + BlightedModerator blightedModerator = getModerationManager().getModerator(moderator); + + if (arguments.length == 0) { + blightedModerator.setTargetPlayer(null); + inform(moderator, " §eTarget cleared from §fModeration HUD§e."); + return true; + } + + Player target = requireTarget(moderator, arguments[0]); + if (target == null) { + return false; + } + + if (target.equals(moderator)) { + warn(moderator, "You cannot target yourself."); + return true; + } + + blightedModerator.setTargetPlayer(target); + InteractiveMessage.text(" §eTargeting §d" + target.getName() + " §ewith §fModeration HUD§e. ") + .hoverAndExecute("§3[INFO]", "§fClick to view information about §d" + target.getName() + "§f.", "/userinfo " + target.getName()) + .send(moderator); + return true; + } +} diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/UserinfoCommand.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/UserinfoCommand.java new file mode 100644 index 00000000..6f3b66f1 --- /dev/null +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/UserinfoCommand.java @@ -0,0 +1,89 @@ +package fr.moussax.blightedMod.commands.impl; + +import fr.moussax.bedrock.commands.CommandArgument; +import fr.moussax.blightedMod.commands.ModerationCommand; +import fr.moussax.blightedMod.moderator.punishments.PunishmentData; +import fr.moussax.blightedMod.moderator.punishments.PunishmentManager; +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.bukkit.Statistic; +import org.bukkit.command.Command; +import org.bukkit.entity.Player; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Locale; + +import static fr.moussax.bedrock.text.Messenger.warn; + +@CommandArgument(position = 0, suggestions = {"$players"}) +public final class UserInfoCommand extends ModerationCommand { + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("MMM d, yyyy", Locale.ENGLISH); + + @Override + protected boolean executeModeration(Player moderator, Command command, String label, String[] arguments) { + if (arguments.length == 0) { + warn(moderator, "Usage: /userinfo "); + return false; + } + + OfflinePlayer target = Bukkit.getOfflinePlayer(arguments[0]); + if (!target.hasPlayedBefore() && !target.isOnline()) { + warn(moderator, "Unable to find player §4" + arguments[0]); + return false; + } + + String targetName = target.getName() != null ? target.getName() : arguments[0]; + boolean isOnline = target.isOnline(); + String statusDot = isOnline ? "§a●" : "§c●"; + + Player targetPlayer = target.getPlayer(); + List punishments = getPunishmentManager().getAllPunishments(targetName); + String ipAddress = targetPlayer != null ? PunishmentManager.getPlayerIp(targetPlayer) : resolveOfflineIp(punishments); + + long mutesCount = punishments.stream().filter(punishment -> punishment.type() == PunishmentData.PunishmentType.MUTE).count(); + long bansCount = punishments.stream().filter(punishment -> punishment.type() == PunishmentData.PunishmentType.BAN || punishment.type() == PunishmentData.PunishmentType.IP_BAN).count(); + long kicksCount = punishments.stream().filter(punishment -> punishment.type() == PunishmentData.PunishmentType.KICK).count(); + + String firstConnection = formatFirstPlayed(target.getFirstPlayed()); + String totalPlaytime = formatPlaytime(target.getStatistic(Statistic.PLAY_ONE_MINUTE)); + + moderator.sendMessage(" "); + moderator.sendMessage(" "); + moderator.sendMessage(" §e§lINFO§f | §f" + targetName + " " + statusDot); + moderator.sendMessage(" "); + moderator.sendMessage("§7 • §fIP Address: §e" + ipAddress); + moderator.sendMessage("§7 • §fFirst Connection: §e" + firstConnection); + moderator.sendMessage("§7 • §fTotal Playtime: §d" + totalPlaytime); + moderator.sendMessage("§7 • §fSanctions: §c" + mutesCount + "§f Mutes §7| §c" + bansCount + "§f Bans §7| §c" + kicksCount + "§f Kicks"); + moderator.sendMessage(" "); + + return true; + } + + private String resolveOfflineIp(List punishments) { + return punishments.stream() + .map(PunishmentData::ipAddress) + .filter(ipAddress -> ipAddress != null && !ipAddress.isBlank() && !ipAddress.equals("0.0.0.0")) + .findFirst() + .orElse("Unknown"); + } + + private String formatFirstPlayed(long firstPlayedTimestamp) { + if (firstPlayedTimestamp <= 0) { + return "Unknown"; + } + Instant instant = Instant.ofEpochMilli(firstPlayedTimestamp); + return DATE_FORMATTER.format(instant.atZone(ZoneId.systemDefault())); + } + + private String formatPlaytime(int totalTicks) { + long totalSeconds = totalTicks / 20L; + long hours = totalSeconds / 3600L; + long minutes = (totalSeconds % 3600L) / 60L; + return hours + "h and " + minutes + "m"; + } +} diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/VanishCommand.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/VanishCommand.java new file mode 100644 index 00000000..c7843b7b --- /dev/null +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/commands/impl/VanishCommand.java @@ -0,0 +1,17 @@ +package fr.moussax.blightedMod.commands.impl; + +import fr.moussax.blightedMod.commands.ModerationCommand; +import fr.moussax.blightedMod.moderator.BlightedModerator; +import org.bukkit.command.Command; +import org.bukkit.entity.Player; + +public final class VanishCommand extends ModerationCommand { + + @Override + protected boolean executeModeration(Player moderator, Command command, String label, String[] arguments) { + BlightedModerator currentModerator = getModerationManager().getModerator(moderator); + boolean newVanishState = !currentModerator.isVanished(); + currentModerator.setVanished(newVanishState); + return true; + } +} diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/database/PluginDatabase.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/database/PluginDatabase.java new file mode 100644 index 00000000..f222bef0 --- /dev/null +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/database/PluginDatabase.java @@ -0,0 +1,56 @@ +package fr.moussax.blightedMod.database; + +import fr.moussax.bedrock.utils.debug.Log; +import lombok.Getter; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.sql.Statement; + +@Getter +public final class PluginDatabase { + private final Connection connection; + + public PluginDatabase(String path) throws SQLException { + this.connection = DriverManager.getConnection("jdbc:sqlite:" + path); + initializeSchema(); + } + + public void closeConnection() { + if (connection == null) return; + synchronized (connection) { + try { + if (!connection.isClosed()) { + connection.close(); + } + } catch (SQLException exception) { + Log.error("PluginDatabase", exception.getMessage()); + throw new RuntimeException("Unable to close the database connection", exception); + } + } + } + + private void initializeSchema() throws SQLException { + synchronized (connection) { + try (Statement statement = connection.createStatement()) { + statement.execute(""" + CREATE TABLE IF NOT EXISTS punishments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + player_uuid TEXT NOT NULL, + player_name TEXT NOT NULL, + punishment_type TEXT NOT NULL, + reason TEXT NOT NULL, + moderator_uuid TEXT NOT NULL, + moderator_name TEXT NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER, + is_active INTEGER NOT NULL DEFAULT 1, + ip_address TEXT + ) + """ + ); + } + } + } +} diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/BlightedModerator.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/BlightedModerator.java new file mode 100644 index 00000000..c4c640fb --- /dev/null +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/BlightedModerator.java @@ -0,0 +1,185 @@ +package fr.moussax.blightedMod.moderator; + +import fr.moussax.blightedMod.BlightedMod; +import fr.moussax.blightedMod.utils.PluginPermissions; +import lombok.Getter; +import org.bukkit.Bukkit; +import org.bukkit.GameMode; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.potion.PotionEffect; + +import java.util.Collection; + +public final class BlightedModerator { + + @Getter + private final Player player; + @Getter + private boolean isInModerationMode; + @Getter + private boolean isVanished; + @Getter + private Player targetPlayer; + + private ItemStack[] savedInventory; + private ItemStack[] savedArmor; + private ItemStack savedOffHand; + private GameMode savedGameMode; + private float savedExperience; + private int savedLevel; + private Collection savedPotionEffects; + private double savedHealth; + private int savedFoodLevel; + private boolean savedInvulnerable; + private boolean savedAllowFlight; + private boolean savedFlying; + + public BlightedModerator(Player player) { + this.player = player; + this.isInModerationMode = false; + this.isVanished = false; + } + + public void setTargetPlayer(Player newTargetPlayer) { + if (newTargetPlayer != null && newTargetPlayer.equals(player)) { + return; + } + if (this.targetPlayer != null && !this.targetPlayer.equals(newTargetPlayer)) { + ModerationGlowHelper.removePinkGlow(player, this.targetPlayer); + } + this.targetPlayer = newTargetPlayer; + if (newTargetPlayer != null) { + ModerationGlowHelper.applyPinkGlow(player, newTargetPlayer); + } + } + + public void onEnable() { + saveState(); + clearPlayerState(); + applyModerationState(); + setVanished(true, true); + giveModerationTools(); + this.isInModerationMode = true; + + player.sendMessage(" §dModeration Mode §etoggled §aON§e."); + } + + public void onDisable() { + if (this.targetPlayer != null) { + ModerationGlowHelper.removePinkGlow(player, this.targetPlayer); + this.targetPlayer = null; + } + player.getInventory().clear(); + restoreState(); + setVanished(false, true); + this.isInModerationMode = false; + + player.sendMessage(" §dModeration Mode §etoggled §cOFF§e."); + } + + private void saveState() { + PlayerInventory inventory = player.getInventory(); + this.savedInventory = inventory.getContents(); + this.savedArmor = inventory.getArmorContents(); + this.savedOffHand = inventory.getItemInOffHand(); + this.savedGameMode = player.getGameMode(); + this.savedExperience = player.getExp(); + this.savedLevel = player.getLevel(); + this.savedPotionEffects = player.getActivePotionEffects(); + this.savedHealth = player.getHealth(); + this.savedFoodLevel = player.getFoodLevel(); + this.savedInvulnerable = player.isInvulnerable(); + this.savedAllowFlight = player.getAllowFlight(); + this.savedFlying = player.isFlying(); + } + + private void restoreState() { + PlayerInventory inventory = player.getInventory(); + inventory.setContents(savedInventory); + inventory.setArmorContents(savedArmor); + inventory.setItemInOffHand(savedOffHand); + + player.setGameMode(savedGameMode); + player.setHealth(savedHealth); + player.setFoodLevel(savedFoodLevel); + player.setExp(savedExperience); + player.setLevel(savedLevel); + + player.getActivePotionEffects().forEach(effect -> player.removePotionEffect(effect.getType())); + player.addPotionEffects(savedPotionEffects); + player.setInvulnerable(savedInvulnerable); + player.setAllowFlight(savedAllowFlight); + if (savedAllowFlight) { + player.setFlying(savedFlying); + } else { + player.setFlying(false); + } + } + + private void clearPlayerState() { + player.getInventory().clear(); + player.setExp(0); + player.setLevel(0); + player.getActivePotionEffects().forEach(effect -> player.removePotionEffect(effect.getType())); + player.setHealth(20); + player.setFoodLevel(20); + } + + private void applyModerationState() { + player.setGameMode(GameMode.SURVIVAL); + player.setAllowFlight(true); + player.setFlying(true); + player.setInvulnerable(true); + } + + public void giveModerationTools() { + PlayerInventory inventory = player.getInventory(); + inventory.setItem(0, ModerationTools.getRandomTeleporter()); + inventory.setItem(1, ModerationTools.getReportViewer()); + inventory.setItem(2, ModerationTools.getSanctionsInspector()); + inventory.setItem(3, ModerationTools.getKnockbackStick()); + inventory.setItem(6, ModerationTools.getFreezer()); + inventory.setItem(7, ModerationTools.getInventoryInspector()); + inventory.setItem(8, ModerationTools.getVanishTool(isVanished)); + } + + public void setVanished(boolean vanished, boolean notifyPlayer) { + this.isVanished = vanished; + JavaPlugin instance = BlightedMod.getInstance(); + + if (vanished) { + hideFromNonModerators(instance); + if (!notifyPlayer) player.sendMessage(" §dVanish §etoggled §aON§e."); + } else { + showToAllPlayers(instance); + if (!notifyPlayer) player.sendMessage(" §dVanish §etoggled §cOFF§e."); + } + updateVanishTool(); + } + + public void setVanished(boolean vanished) { + setVanished(vanished, false); + } + + private void hideFromNonModerators(JavaPlugin instance) { + Bukkit.getOnlinePlayers().forEach(onlinePlayer -> { + if (!onlinePlayer.hasPermission(PluginPermissions.MODERATOR.toString())) { + onlinePlayer.hidePlayer(instance, player); + } + }); + } + + private void showToAllPlayers(JavaPlugin instance) { + Bukkit.getOnlinePlayers().forEach(onlinePlayer -> onlinePlayer.showPlayer(instance, player)); + } + + private void updateVanishTool() { + ItemStack currentItem = player.getInventory().getItem(8); + if (currentItem != null && currentItem.getType().name().contains("DYE")) { + player.getInventory().setItem(8, ModerationTools.getVanishTool(isVanished)); + } + } +} diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/ModerationGlowHelper.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/ModerationGlowHelper.java new file mode 100644 index 00000000..cae0bb9b --- /dev/null +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/ModerationGlowHelper.java @@ -0,0 +1,99 @@ +package fr.moussax.blightedMod.moderator; + +import net.minecraft.network.protocol.game.ClientboundSetEntityDataPacket; +import net.minecraft.network.syncher.EntityDataAccessor; +import net.minecraft.network.syncher.EntityDataSerializers; +import net.minecraft.network.syncher.SynchedEntityData; +import org.bukkit.Bukkit; +import org.bukkit.ChatColor; +import org.bukkit.craftbukkit.entity.CraftPlayer; +import org.bukkit.entity.Player; +import org.bukkit.scoreboard.Scoreboard; +import org.bukkit.scoreboard.Team; + +import org.bukkit.scoreboard.ScoreboardManager; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +public final class ModerationGlowHelper { + + private static final String GLOW_TEAM_NAME = "mod_glow_pink"; + private static final Map ORIGINAL_SCOREBOARDS = new ConcurrentHashMap<>(); + + private ModerationGlowHelper() { + } + + public static void applyPinkGlow(Player moderator, Player target) { + if (moderator == null || target == null || !moderator.isOnline() || !target.isOnline()) { + return; + } + + ScoreboardManager manager = Bukkit.getScoreboardManager(); + if (manager == null) { + return; + } + + Scoreboard scoreboard = moderator.getScoreboard(); + if (scoreboard.equals(manager.getMainScoreboard())) { + ORIGINAL_SCOREBOARDS.putIfAbsent(moderator.getUniqueId(), scoreboard); + scoreboard = manager.getNewScoreboard(); + moderator.setScoreboard(scoreboard); + } + + Team team = scoreboard.getTeam(GLOW_TEAM_NAME); + if (team == null) { + team = scoreboard.registerNewTeam(GLOW_TEAM_NAME); + team.setColor(ChatColor.LIGHT_PURPLE); + } + + if (!team.hasEntry(target.getName())) { + team.addEntry(target.getName()); + } + + sendGlowPacket(moderator, target, true); + } + + public static void removePinkGlow(Player moderator, Player target) { + if (moderator == null || target == null || !moderator.isOnline()) { + return; + } + + Scoreboard scoreboard = moderator.getScoreboard(); + Team team = scoreboard.getTeam(GLOW_TEAM_NAME); + if (team != null && team.hasEntry(target.getName())) { + team.removeEntry(target.getName()); + } + + if (target.isOnline()) { + sendGlowPacket(moderator, target, false); + } + + if (team == null || team.getEntries().isEmpty()) { + Scoreboard originalScoreboard = ORIGINAL_SCOREBOARDS.remove(moderator.getUniqueId()); + if (originalScoreboard != null) { + moderator.setScoreboard(originalScoreboard); + } + } + } + + private static void sendGlowPacket(Player moderator, Player target, boolean glowing) { + try { + CraftPlayer craftTarget = (CraftPlayer) target; + CraftPlayer craftModerator = (CraftPlayer) moderator; + + byte existingFlags = craftTarget.getHandle().getEntityData().get(new EntityDataAccessor<>(0, EntityDataSerializers.BYTE)); + byte updatedFlags = glowing ? (byte) (existingFlags | 0x40) : (byte) (existingFlags & ~0x40); + + List> dataValues = new ArrayList<>(); + dataValues.add(SynchedEntityData.DataValue.create(new EntityDataAccessor<>(0, EntityDataSerializers.BYTE), updatedFlags)); + + ClientboundSetEntityDataPacket packet = new ClientboundSetEntityDataPacket(target.getEntityId(), dataValues); + craftModerator.getHandle().connection.send(packet); + } catch (Throwable _) { + } + } +} diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/ModerationManager.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/ModerationManager.java new file mode 100644 index 00000000..129d137b --- /dev/null +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/ModerationManager.java @@ -0,0 +1,218 @@ +package fr.moussax.blightedMod.moderator; + +import fr.moussax.bedrock.text.InteractiveMessage; +import fr.moussax.blightedMod.moderator.punishments.PunishmentManager; +import fr.moussax.blightedMod.utils.PluginPermissions; +import lombok.Getter; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.entity.Player; + +import java.util.*; + +import java.util.concurrent.ConcurrentHashMap; + +public final class ModerationManager { + private static ModerationManager instance; + + public enum ChatChannel { + ALL, + STAFF + } + + @Getter + private final PunishmentManager punishmentManager; + private final Map moderators = new ConcurrentHashMap<>(); + private final Set frozenPlayers = new HashSet<>(); + private final Map frozenLocations = new ConcurrentHashMap<>(); + private final Map lastChatTimestamps = new ConcurrentHashMap<>(); + private final Map playerChatChannels = new ConcurrentHashMap<>(); + private final Set messageInspectEnabled = ConcurrentHashMap.newKeySet(); + private final Map lastMessageTargets = new ConcurrentHashMap<>(); + @Getter + private int slowmodeDelaySeconds = 0; + + private ModerationManager(PunishmentManager punishmentManager) { + this.punishmentManager = punishmentManager; + } + + public static ModerationManager init(PunishmentManager punishmentManager) { + if (instance != null) { + throw new IllegalStateException("ModerationManager already initialized"); + } + instance = new ModerationManager(punishmentManager); + return instance; + } + + public static ModerationManager getInstance() { + if (instance == null) { + throw new IllegalStateException("ModerationManager not initialized yet"); + } + return instance; + } + + public BlightedModerator getModerator(Player player) { + return moderators.computeIfAbsent(player.getUniqueId(), _ -> new BlightedModerator(player)); + } + + public BlightedModerator getModeratorIfPresent(Player player) { + return moderators.get(player.getUniqueId()); + } + + public boolean isModerator(Player player) { + return player.hasPermission(PluginPermissions.MODERATOR.toString()); + } + + public boolean isInModerationMode(Player player) { + BlightedModerator moderator = moderators.get(player.getUniqueId()); + return moderator != null && moderator.isInModerationMode(); + } + + public void toggleModerationMode(Player player) { + BlightedModerator moderator = getModerator(player); + if (moderator.isInModerationMode()) { + moderator.onDisable(); + } else { + moderator.onEnable(); + } + } + + public void disableModeration(Player player) { + BlightedModerator moderator = moderators.get(player.getUniqueId()); + if (moderator != null && moderator.isInModerationMode()) moderator.onDisable(); + } + + public boolean isFrozen(Player player) { + return frozenPlayers.contains(player.getUniqueId()); + } + + public Location getFrozenLocation(Player player) { + return frozenLocations.get(player.getUniqueId()); + } + + public boolean toggleFreeze(Player player) { + UUID playerId = player.getUniqueId(); + if (frozenPlayers.contains(playerId)) { + frozenPlayers.remove(playerId); + frozenLocations.remove(playerId); + player.setFreezeTicks(0); + return false; + } + frozenPlayers.add(playerId); + frozenLocations.put(playerId, player.getLocation()); + player.setFreezeTicks(139); + return true; + } + + public void startFreezeTask(org.bukkit.plugin.Plugin plugin) { + Bukkit.getScheduler().runTaskTimer(plugin, () -> { + for (UUID frozenId : frozenPlayers) { + Player player = Bukkit.getPlayer(frozenId); + if (player != null && player.isOnline()) { + player.setFreezeTicks(139); + } + } + }, 0L, 5L); + } + + public void setSlowmodeDelaySeconds(int seconds) { + this.slowmodeDelaySeconds = Math.max(0, seconds); + } + + public boolean isSlowmodeEnabled() { + return slowmodeDelaySeconds > 0; + } + + public int getRemainingSlowmodeCooldown(Player player) { + if (slowmodeDelaySeconds <= 0 || isModerator(player)) { + return 0; + } + + Long lastChatTime = lastChatTimestamps.get(player.getUniqueId()); + if (lastChatTime == null) { + return 0; + } + + long elapsedSeconds = (System.currentTimeMillis() - lastChatTime) / 1000L; + long remainingSeconds = slowmodeDelaySeconds - elapsedSeconds; + return (int) Math.max(0, remainingSeconds); + } + + public void recordPlayerChat(Player player) { + lastChatTimestamps.put(player.getUniqueId(), System.currentTimeMillis()); + } + + public void broadcastToModerators(String message) { + Bukkit.getOnlinePlayers().stream() + .filter(this::isModerator) + .forEach(moderator -> moderator.sendMessage(message)); + } + + public void broadcastToModerators(InteractiveMessage interactiveMessage) { + Bukkit.getOnlinePlayers().stream() + .filter(this::isModerator) + .forEach(interactiveMessage::send); + } + + public ChatChannel getChatChannel(Player player) { + return playerChatChannels.getOrDefault(player.getUniqueId(), ChatChannel.ALL); + } + + public void setChatChannel(Player player, ChatChannel channel) { + if (channel == ChatChannel.ALL) { + playerChatChannels.remove(player.getUniqueId()); + } else { + playerChatChannels.put(player.getUniqueId(), channel); + } + } + + public void handlePlayerQuit(Player player) { + UUID playerId = player.getUniqueId(); + BlightedModerator moderator = moderators.get(playerId); + if (moderator != null && moderator.isInModerationMode()) { + moderator.onDisable(); + } + moderators.remove(playerId); + frozenPlayers.remove(playerId); + frozenLocations.remove(playerId); + lastChatTimestamps.remove(playerId); + playerChatChannels.remove(playerId); + messageInspectEnabled.remove(playerId); + lastMessageTargets.remove(playerId); + } + + public boolean isMessageInspectEnabled(Player player) { + return messageInspectEnabled.contains(player.getUniqueId()); + } + + public boolean toggleMessageInspect(Player player) { + UUID playerId = player.getUniqueId(); + if (messageInspectEnabled.contains(playerId)) { + messageInspectEnabled.remove(playerId); + return false; + } + messageInspectEnabled.add(playerId); + return true; + } + + public void setLastMessageTarget(UUID senderId, UUID recipientId) { + lastMessageTargets.put(senderId, recipientId); + lastMessageTargets.put(recipientId, senderId); + } + + public UUID getLastMessageTarget(UUID playerId) { + return lastMessageTargets.get(playerId); + } + + public void broadcastSpyMessage(String senderName, String recipientName, String content) { + String formatted = " §6§lSPY! §f" + senderName + " §d➟ §f" + recipientName + " §7§l» §d" + content; + Bukkit.getOnlinePlayers().stream() + .filter(this::isModerator) + .filter(this::isMessageInspectEnabled) + .forEach(moderator -> moderator.sendMessage(formatted)); + } + + public Map getModeratorsView() { + return Collections.unmodifiableMap(moderators); + } +} diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/ModerationTools.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/ModerationTools.java new file mode 100644 index 00000000..383aa06c --- /dev/null +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/ModerationTools.java @@ -0,0 +1,62 @@ +package fr.moussax.blightedMod.moderator; + +import fr.moussax.bedrock.utils.ItemBuilder; +import org.bukkit.Material; +import org.bukkit.enchantments.Enchantment; +import org.bukkit.inventory.ItemFlag; +import org.bukkit.inventory.ItemStack; + +public final class ModerationTools { + public static ItemStack getRandomTeleporter() { + return new ItemBuilder(Material.ENDER_EYE) + .setDisplayName("§dRandom Teleport §7(Right Click)") + .addLore("§7Teleport to a random player.") + .addEnchantmentGlint() + .toItemStack(); + } + + public static ItemStack getReportViewer() { + return new ItemBuilder(Material.BOOK) + .setDisplayName("§dReport Center §7(Right Click)") + .addLore("§7View and manage player reports.") + .toItemStack(); + } + + public static ItemStack getSanctionsInspector() { + return new ItemBuilder(Material.ENCHANTED_BOOK) + .setDisplayName("§dSanctions Inspector §7(Right Click)") + .addLore("§7View target player's sanctions history.") + .setEnchantmentGlint(false) + .toItemStack(); + } + + public static ItemStack getKnockbackStick() { + return new ItemBuilder(Material.STICK) + .setDisplayName("§dAnti Knockback") + .addLore("§7Test if target has Anti-KB Cheat.") + .addEnchantment(Enchantment.KNOCKBACK, 4) + .addItemFlag(ItemFlag.HIDE_ENCHANTS) + .toItemStack(); + } + + public static ItemStack getFreezer() { + return new ItemBuilder(Material.PACKED_ICE) + .setDisplayName("§dFreeze Target §7(Right Click)") + .addLore("§7Prevent the target from moving.") + .toItemStack(); + } + + public static ItemStack getInventoryInspector() { + return new ItemBuilder(Material.CHEST) + .setDisplayName("§dInspect Inventory §7(Right Click)") + .addLore("§7View target inventory's inventory contents.") + .toItemStack(); + } + + public static ItemStack getVanishTool(boolean isVanished) { + return new ItemBuilder(isVanished ? Material.PURPLE_DYE : Material.GRAY_DYE) + .setDisplayName(isVanished ? "§aBecome Visible §7(Right Click)" : "§cBecome Invisible §7(Right Click)") + .addLore("§7Current status: " + (isVanished ? "§dVanished" : "§cVisible")) + .toItemStack(); + } +} \ No newline at end of file diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/hud/ModerationHud.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/hud/ModerationHud.java new file mode 100644 index 00000000..ef6607b7 --- /dev/null +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/hud/ModerationHud.java @@ -0,0 +1,56 @@ +package fr.moussax.blightedMod.moderator.hud; + +import fr.moussax.bedrock.ui.actionbar.ActionbarSection; +import fr.moussax.blightedMod.moderator.BlightedModerator; +import fr.moussax.blightedMod.moderator.ModerationManager; +import org.bukkit.attribute.Attribute; +import org.bukkit.entity.Player; + +import java.util.Locale; +import java.util.Objects; + +public final class ModerationHud { + public static final String SECTION_ID = "moderation_hud"; + public static final int PRIORITY = 100; + + private ModerationHud() { + } + + public static ActionbarSection createSection(ModerationManager moderationManager) { + return ActionbarSection.exclusiveOf(SECTION_ID, PRIORITY, + player -> render(moderationManager, player), + moderationManager::isInModerationMode + ); + } + + private static String render(ModerationManager moderationManager, Player player) { + BlightedModerator moderator = moderationManager.getModerator(player); + if (moderator == null) { + return null; + } + + Player target = moderator.getTargetPlayer(); + if (target == null || !target.isOnline()) { + return null; + } + + double maxHealth = Objects.requireNonNull(target.getAttribute(Attribute.MAX_HEALTH)).getValue(); + int healthPercent = (int) Math.round((target.getHealth() / maxHealth) * 100.0); + String healthColor = healthPercent >= 60 ? "§a" : healthPercent >= 30 ? "§e" : "§c"; + + String distanceText = player.getWorld().equals(target.getWorld()) + ? String.format(Locale.ROOT, "%.2f blocks", player.getLocation().distance(target.getLocation())) + : "N/A"; + + String frozenText = moderationManager.isFrozen(target) ? "§b§lYUP!" : "§c§lNOPE!"; + int ping = target.getPing(); + String pingColor = ping < 100 ? "§a" : ping < 200 ? "§e" : ping < 300 ? "§6" : ping < 500 ? "§c" : "§4"; + String pingText = pingColor + ping + "ms"; + + return "§fTarget: §d" + target.getName() + + " §fHP: " + healthColor + healthPercent + "%" + + " §fDistance: §d" + distanceText + + " §fPing: " + pingText + + " §fFrozen: " + frozenText; + } +} diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/listeners/InteractiveChatListener.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/listeners/InteractiveChatListener.java new file mode 100644 index 00000000..73f586cf --- /dev/null +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/listeners/InteractiveChatListener.java @@ -0,0 +1,36 @@ +package fr.moussax.blightedMod.moderator.listeners; + +import fr.moussax.bedrock.text.InteractiveMessage; +import fr.moussax.blightedMod.moderator.ModerationManager; +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.AsyncPlayerChatEvent; + +public final class InteractiveChatListener implements Listener { + + private final ModerationManager moderationManager; + + public InteractiveChatListener(ModerationManager moderationManager) { + this.moderationManager = moderationManager; + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onNormalPlayerChat(AsyncPlayerChatEvent event) { + Player sender = event.getPlayer(); + String chatMessage = event.getMessage(); + String messageColor = moderationManager.isInModerationMode(sender) ? "§b" : "§f"; + + InteractiveMessage interactiveMessage = InteractiveMessage.text(" ") + .hoverAndExecute("§c⚑ ", "§cClick to report §d" + sender.getName() + "§c.", + "/report chat " + sender.getName() + " " + chatMessage + ) + .append("§7" + sender.getName() + " §8§l» " + messageColor + chatMessage); + + for (Player recipient : event.getRecipients()) { + interactiveMessage.send(recipient); + } + event.getRecipients().clear(); + } +} diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/listeners/ModerationListener.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/listeners/ModerationListener.java new file mode 100644 index 00000000..cc52ed13 --- /dev/null +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/listeners/ModerationListener.java @@ -0,0 +1,438 @@ +package fr.moussax.blightedMod.moderator.listeners; + +import fr.moussax.bedrock.text.InteractiveMessage; +import fr.moussax.blightedMod.BlightedMod; +import fr.moussax.blightedMod.moderator.BlightedModerator; +import fr.moussax.blightedMod.moderator.ModerationManager; +import fr.moussax.blightedMod.moderator.menus.InvSeeMenu; +import fr.moussax.blightedMod.moderator.punishments.DurationParser; +import fr.moussax.blightedMod.moderator.punishments.PunishmentData; +import fr.moussax.blightedMod.moderator.punishments.PunishmentManager; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.block.Action; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.entity.EntityDamageByEntityEvent; +import org.bukkit.event.entity.EntityDamageEvent; +import org.bukkit.event.entity.EntityPickupItemEvent; +import org.bukkit.event.entity.FoodLevelChangeEvent; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.player.*; +import org.bukkit.event.server.ServerListPingEvent; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.ItemStack; + +import java.util.*; + +import static fr.moussax.bedrock.text.Messenger.warn; + +public final class ModerationListener implements Listener { + + private final ModerationManager moderationManager; + private final PunishmentManager punishmentManager; + private final Random random = new Random(); + + public ModerationListener(ModerationManager moderationManager) { + this.moderationManager = moderationManager; + this.punishmentManager = moderationManager.getPunishmentManager(); + } + + @EventHandler(priority = EventPriority.HIGHEST) + public void onPlayerChat(AsyncPlayerChatEvent event) { + Player player = event.getPlayer(); + + if (!moderationManager.isModerator(player) && punishmentManager.isMuted(player.getUniqueId())) { + event.setCancelled(true); + PunishmentData mute = punishmentManager.getActivePunishment(player.getUniqueId(), PunishmentData.PunishmentType.MUTE); + if (mute != null) { + if (mute.isPermanent()) { + player.sendMessage(" §c⌚ §cYou are muted §cfor §b" + mute.reason() + "§c."); + } else { + long remainingSeconds = Math.max(1, (mute.expiresAt() - System.currentTimeMillis()) / 1000L); + String durationText = DurationParser.formatShortDuration(remainingSeconds); + player.sendMessage(" §c⌚ §cYou are muted for §d" + durationText + " §cfor §b" + mute.reason() + "§c."); + } + } + return; + } + + if (moderationManager.isModerator(player) && moderationManager.getChatChannel(player) == ModerationManager.ChatChannel.STAFF) { + event.setCancelled(true); + String formatted = " §d§lSTAFF! §9" + player.getName() + "§f§l » §b" + event.getMessage(); + moderationManager.broadcastToModerators(formatted); + return; + } + + int remainingSlowmodeSeconds = moderationManager.getRemainingSlowmodeCooldown(player); + if (remainingSlowmodeSeconds > 0) { + event.setCancelled(true); + player.sendMessage(" §c⌚ §cPlease wait §d" + remainingSlowmodeSeconds + "s §cbefore chatting again."); + return; + } + + String message = event.getMessage(); + if (message.startsWith("!!") && moderationManager.isModerator(player)) { + event.setCancelled(true); + String moderatorMessage = message.substring(2).trim(); + String formatted = " §d§lSTAFF! §9" + player.getName() + "§f§l » §b" + moderatorMessage; + moderationManager.broadcastToModerators(formatted); + return; + } + + moderationManager.recordPlayerChat(player); + } + + @EventHandler(priority = EventPriority.HIGHEST) + public void onPlayerLogin(PlayerLoginEvent event) { + Player player = event.getPlayer(); + if (moderationManager.isModerator(player)) { + return; + } + + String ipAddress = event.getAddress().getHostAddress() != null + ? event.getAddress().getHostAddress() + : PunishmentManager.getPlayerIp(player); + + PunishmentData ipBan = punishmentManager.getActiveIpBan(ipAddress); + if (ipBan != null) { + event.disallow(PlayerLoginEvent.Result.KICK_BANNED, buildBanMessage(ipBan, "IP BANNED")); + return; + } + + PunishmentData ban = punishmentManager.getActivePunishment(player.getUniqueId(), PunishmentData.PunishmentType.BAN); + if (ban != null) { + event.disallow(PlayerLoginEvent.Result.KICK_BANNED, buildBanMessage(ban, "BANNED")); + } + } + + private String buildBanMessage(PunishmentData ban, String title) { + String durationText = ban.isPermanent() ? "Permanent" : "Temporary"; + return """ + §c§l%s + + §7Reason: §f%s + §7Duration: §f%s + + §7Appeal on our Discord if you believe this was a mistake.""".formatted(title, ban.reason(), durationText); + } + + @EventHandler + public void onToolInteract(PlayerInteractEvent event) { + if (event.getHand() != EquipmentSlot.HAND) return; + Player player = event.getPlayer(); + if (!moderationManager.isInModerationMode(player)) return; + + event.setCancelled(true); + + ItemStack item = event.getItem(); + if (item == null) return; + + Action action = event.getAction(); + if (action != Action.RIGHT_CLICK_AIR && action != Action.RIGHT_CLICK_BLOCK) return; + + handleToolInteraction(player, item.getType()); + } + + @EventHandler + public void onEntityInteract(PlayerInteractEntityEvent event) { + if (event.getHand() != EquipmentSlot.HAND) return; + Player player = event.getPlayer(); + if (!moderationManager.isInModerationMode(player)) return; + + event.setCancelled(true); + + if (!(event.getRightClicked() instanceof Player target) || target.equals(player)) return; + + BlightedModerator moderator = moderationManager.getModerator(player); + boolean alreadyTargeted = Objects.equals(moderator.getTargetPlayer(), target); + moderator.setTargetPlayer(target); + + Material tool = player.getInventory().getItemInMainHand().getType(); + if (tool != Material.CHEST && tool != Material.PACKED_ICE && tool != Material.ENCHANTED_BOOK && !alreadyTargeted) { + InteractiveMessage.text(" §eTargeting §d" + target.getName() + " §ewith §fModeration HUD§e. ") + .hoverAndExecute("§3[INFO]", "§fClick to view information about §d" + target.getName() + "§f.", "/userinfo " + target.getName()) + .send(player); + } + } + + @EventHandler + public void onPlayerJoin(PlayerJoinEvent event) { + Player joiningPlayer = event.getPlayer(); + if (moderationManager.isModerator(joiningPlayer)) { + BlightedModerator moderator = moderationManager.getModerator(joiningPlayer); + if (moderator.isVanished()) { + event.setJoinMessage(null); + } + return; + } + + moderationManager.getModeratorsView().values().stream() + .filter(BlightedModerator::isVanished) + .forEach(moderator -> joiningPlayer.hidePlayer( + BlightedMod.getInstance(), moderator.getPlayer())); + } + + @EventHandler + public void onPlayerQuit(PlayerQuitEvent event) { + Player quittingPlayer = event.getPlayer(); + if (moderationManager.isModerator(quittingPlayer)) { + BlightedModerator moderator = moderationManager.getModerator(quittingPlayer); + if (moderator.isVanished()) { + event.setQuitMessage(null); + } + } + + if (!moderationManager.isModerator(quittingPlayer) && moderationManager.isFrozen(quittingPlayer)) { + moderationManager.toggleFreeze(quittingPlayer); + String reason = "Disconnecting while frozen by a moderator"; + String ipAddress = PunishmentManager.getPlayerIp(quittingPlayer); + punishmentManager.addPunishment( + quittingPlayer.getUniqueId(), + quittingPlayer.getName(), + PunishmentData.PunishmentType.BAN, + reason, + PunishmentManager.CONSOLE_UUID, + "CONSOLE", + null, + ipAddress + ); + + String notification = " §6§lALERT! §d" + quittingPlayer.getName() + "§e was automatically banned for §fdisconnecting §ewhile being frozen by a moderator."; + moderationManager.broadcastToModerators(notification); + } + + moderationManager.handlePlayerQuit(quittingPlayer); + } + + @EventHandler + public void onServerListPing(ServerListPingEvent event) { + Iterator iterator = event.iterator(); + while (iterator.hasNext()) { + Player player = iterator.next(); + if (moderationManager.isModerator(player)) { + BlightedModerator moderator = moderationManager.getModeratorIfPresent(player); + if (moderator != null && moderator.isVanished()) { + iterator.remove(); + } + } + } + } + + @EventHandler + public void onFrozenPlayerMove(PlayerMoveEvent event) { + Player player = event.getPlayer(); + if (moderationManager.isModerator(player) || !moderationManager.isFrozen(player)) return; + + org.bukkit.Location frozenLocation = moderationManager.getFrozenLocation(player); + if (frozenLocation == null) return; + + org.bukkit.Location to = event.getTo(); + if (to == null) return; + + double deltaX = to.getX() - frozenLocation.getX(); + double deltaY = to.getY() - frozenLocation.getY(); + double deltaZ = to.getZ() - frozenLocation.getZ(); + double distanceSquared = deltaX * deltaX + deltaY * deltaY + deltaZ * deltaZ; + + if (distanceSquared > 0.05) { + org.bukkit.Location returnLocation = frozenLocation.clone(); + returnLocation.setYaw(to.getYaw()); + returnLocation.setPitch(to.getPitch()); + event.setTo(returnLocation); + } + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onPrivateMessageSpy(PlayerCommandPreprocessEvent event) { + String rawCommandLine = event.getMessage(); + if (!rawCommandLine.startsWith("/")) { + return; + } + + String[] parts = rawCommandLine.substring(1).trim().split("\\s+"); + if (parts.length == 0 || parts[0].isEmpty()) { + return; + } + + String commandLabel = parts[0].toLowerCase(Locale.ROOT); + Player sender = event.getPlayer(); + + if (Set.of("msg", "tell", "w", "whisper", "pm").contains(commandLabel)) { + if (parts.length < 3) { + return; + } + + String targetName = parts[1]; + Player targetPlayer = Bukkit.getPlayer(targetName); + String recipientName = targetPlayer != null ? targetPlayer.getName() : targetName; + String messageContent = String.join(" ", Arrays.copyOfRange(parts, 2, parts.length)); + + if (targetPlayer != null) { + moderationManager.setLastMessageTarget(sender.getUniqueId(), targetPlayer.getUniqueId()); + } + + moderationManager.broadcastSpyMessage(sender.getName(), recipientName, messageContent); + } else if (Set.of("r", "reply").contains(commandLabel)) { + if (parts.length < 2) { + return; + } + + UUID lastTargetId = moderationManager.getLastMessageTarget(sender.getUniqueId()); + Player lastTargetPlayer = lastTargetId != null ? Bukkit.getPlayer(lastTargetId) : null; + String recipientName = lastTargetPlayer != null ? lastTargetPlayer.getName() : "Unknown"; + + String messageContent = String.join(" ", Arrays.copyOfRange(parts, 1, parts.length)); + moderationManager.broadcastSpyMessage(sender.getName(), recipientName, messageContent); + } + } + + @EventHandler + public void onModeratorDropItem(PlayerDropItemEvent event) { + if (moderationManager.isInModerationMode(event.getPlayer())) { + event.setCancelled(true); + } + } + + @EventHandler + public void onModeratorPickupItem(EntityPickupItemEvent event) { + if (event.getEntity() instanceof Player player && moderationManager.isInModerationMode(player)) { + event.setCancelled(true); + } + } + + @EventHandler + public void onModeratorBreakBlock(BlockBreakEvent event) { + if (moderationManager.isInModerationMode(event.getPlayer())) event.setCancelled(true); + } + + @EventHandler + public void onModeratorPlaceBlock(BlockPlaceEvent event) { + if (moderationManager.isInModerationMode(event.getPlayer())) event.setCancelled(true); + } + + /** + * Non-entity damage (fall, fire, drown, freeze, etc.) against a moderator in moderation mode or a frozen player. + */ + @EventHandler + public void onModeratorTakeDamage(EntityDamageEvent event) { + if (event.getEntity() instanceof Player player) { + if (moderationManager.isInModerationMode(player) || moderationManager.isFrozen(player)) { + event.setCancelled(true); + } + } + } + + @EventHandler + public void onModeratorEntityDamage(EntityDamageByEntityEvent event) { + if (event.getEntity() instanceof Player victim && moderationManager.isInModerationMode(victim)) { + event.setCancelled(true); + return; + } + + if (!(event.getDamager() instanceof Player attacker)) return; + if (!moderationManager.isInModerationMode(attacker)) return; + + Material tool = attacker.getInventory().getItemInMainHand().getType(); + if (tool == Material.STICK && event.getEntity() instanceof Player victim) { + moderationManager.getModerator(attacker).setTargetPlayer(victim); + event.setDamage(0); + return; + } + + event.setCancelled(true); + } + + @EventHandler + public void onModeratorInventoryClick(InventoryClickEvent event) { + if (event.getWhoClicked() instanceof Player player && moderationManager.isInModerationMode(player)) { + event.setCancelled(true); + } + } + + @EventHandler + public void onModeratorFoodLevelChange(FoodLevelChangeEvent event) { + if (event.getEntity() instanceof Player player && moderationManager.isInModerationMode(player)) { + event.setCancelled(true); + } + } + + private void handleToolInteraction(Player player, Material tool) { + switch (tool) { + case ENDER_EYE -> teleportToRandomPlayer(player); + case BOOK, NETHER_STAR -> openReportsMenu(player); + case PURPLE_DYE, GRAY_DYE -> toggleVanish(player); + case PACKED_ICE -> { + Player target = getActiveTarget(player); + if (target != null) { + player.performCommand("freeze " + target.getName()); + } + } + case CHEST -> { + Player target = getActiveTarget(player); + if (target != null) { + openInventory(player, target); + } + } + case ENCHANTED_BOOK -> { + Player target = getActiveTarget(player); + if (target != null) { + openSanctionsMenu(player, target); + } + } + default -> { + } + } + } + + private Player getActiveTarget(Player moderator) { + Player target = moderationManager.getModerator(moderator).getTargetPlayer(); + if (target == null || !target.isOnline()) { + warn(moderator, "No target player selected. Right-click a player or use §e/target §c."); + return null; + } + return target; + } + + private void openReportsMenu(Player moderator) { + moderator.performCommand("reports"); + } + + + private void toggleVanish(Player player) { + BlightedModerator moderator = moderationManager.getModerator(player); + moderator.setVanished(!moderator.isVanished()); + } + + private void openInventory(Player moderator, Player target) { + moderationManager.getModerator(moderator).setTargetPlayer(target); + new InvSeeMenu(target).open(moderator); + } + + private void openSanctionsMenu(Player moderator, Player target) { + moderationManager.getModerator(moderator).setTargetPlayer(target); + moderator.performCommand("sanctions " + target.getName()); + } + + private void teleportToRandomPlayer(Player moderator) { + List eligiblePlayers = new ArrayList<>(Bukkit.getOnlinePlayers()); + eligiblePlayers.remove(moderator); + + if (eligiblePlayers.isEmpty()) { + moderator.sendMessage("§cNo other players online."); + return; + } + + Player target = eligiblePlayers.get(random.nextInt(eligiblePlayers.size())); + moderator.teleport(target.getLocation()); + moderationManager.getModerator(moderator).setTargetPlayer(target); + + InteractiveMessage.text(" §eRandomly teleported to §d" + target.getName() + "§e. ") + .hoverAndExecute("§3[INFO]", "§fClick to view information about §d" + target.getName() + "§f.", "/userinfo " + target.getName()) + .send(moderator); + } +} diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/player/menus/EnderSeeMenu.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/menus/EnderseeMenu.java similarity index 90% rename from blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/player/menus/EnderSeeMenu.java rename to blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/menus/EnderseeMenu.java index 0953e724..74d3595a 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/player/menus/EnderSeeMenu.java +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/menus/EnderseeMenu.java @@ -1,4 +1,4 @@ -package fr.moussax.blightedSMP.engine.player.menus; +package fr.moussax.blightedMod.moderator.menus; import fr.moussax.bedrock.ui.menu.Menu; import fr.moussax.bedrock.ui.menu.TickableMenu; @@ -11,15 +11,15 @@ import java.util.Objects; -public final class EnderSeeMenu extends Menu implements TickableMenu { +public final class EnderseeMenu extends Menu implements TickableMenu { private final Player target; private final Menu previousMenu; - public EnderSeeMenu(Player target) { + public EnderseeMenu(Player target) { this(target, null); } - public EnderSeeMenu(Player target, @Nullable Menu previousMenu) { + public EnderseeMenu(Player target, @Nullable Menu previousMenu) { super(target.getName() + "'s Ender Chest", 36); this.target = target; this.previousMenu = previousMenu; diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/player/menus/InvSeeMenu.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/menus/InvSeeMenu.java similarity index 68% rename from blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/player/menus/InvSeeMenu.java rename to blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/menus/InvSeeMenu.java index 2ed23650..8d4c165f 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/player/menus/InvSeeMenu.java +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/menus/InvSeeMenu.java @@ -1,16 +1,18 @@ -package fr.moussax.blightedSMP.engine.player.menus; +package fr.moussax.blightedMod.moderator.menus; import fr.moussax.bedrock.ui.menu.Menu; import fr.moussax.bedrock.ui.menu.TickableMenu; import fr.moussax.bedrock.ui.menu.interaction.MenuElementPreset; import fr.moussax.bedrock.utils.ItemBuilder; +import fr.moussax.blightedMod.moderator.ModerationManager; import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.attribute.Attribute; import org.bukkit.entity.Player; import org.bukkit.inventory.Inventory; import org.bukkit.inventory.ItemFlag; import org.bukkit.inventory.ItemStack; -import java.net.InetSocketAddress; import java.util.Objects; public final class InvSeeMenu extends Menu implements TickableMenu { @@ -42,7 +44,7 @@ public void build(Player player) { .setDisplayName("§dView Ender Chest") .addLore("§7Click to view ender chest.") .toItemStack(), - (_, _) -> openSubMenu(new EnderSeeMenu(target, this)) + (_, _) -> openSubMenu(new EnderseeMenu(target, this)) ); updateContents(); @@ -58,15 +60,29 @@ public void onTick(Player player) { } private void updateContents() { + boolean isInModerationMode = ModerationManager.getInstance().isInModerationMode(target); + String gameModeText = isInModerationMode ? "§dMODERATOR" : "§f" + target.getGameMode().name(); + + double maxHealth = Objects.requireNonNull(target.getAttribute(Attribute.MAX_HEALTH)).getValue(); + int currentHealth = (int) Math.round(target.getHealth()); + int maxHp = (int) Math.round(maxHealth); + + int level = target.getLevel(); + int xpPercent = Math.round(target.getExp() * 100.0f); + + int ping = target.getPing(); + String dimensionName = formatDimension(target.getWorld().getEnvironment()); + ItemStack playerInformation = new ItemBuilder(Material.PLAYER_HEAD) .setDisplayName("§d" + target.getName()) - .addLore( "", - " §7Health: §f" + (int) target.getHealth() + "§c❤", - " §7Food: §f" + target.getFoodLevel() + "§c\uD83C\uDF56", - " §7Gamemode: §f" + target.getGameMode().name() + " ", - "", - " §7Ping: §d" + target.getPing() + "ms", - " §7IP Address: §d" + formatAddress(target.getAddress()) + " ", + .addLore("", + " §7Health: §f" + currentHealth + "§7/§f" + maxHp + " §c❤", + " §7Food: §f" + target.getFoodLevel() + "§7/20 §c\uD83C\uDF56", + " §7XP: §fLevel " + level + " §8(" + xpPercent + "%)", + " §7Gamemode: " + gameModeText, + " §7Dimension: §f" + dimensionName, + " §7Location: §f" + target.getLocation().getBlockX() + ", " + target.getLocation().getBlockY() + ", " + target.getLocation().getBlockZ() + " ", + " §7Ping: §d" + ping + "ms", "" ) .setSkullOwner(target.getUniqueId()) @@ -86,6 +102,15 @@ private void updateContents() { } } + private String formatDimension(World.Environment environment) { + return switch (environment) { + case NORMAL -> "OVERWORLD"; + case NETHER -> "NETHER"; + case THE_END -> "THE END"; + default -> environment.name(); + }; + } + private void updateSlot(int menuSlot, ItemStack item, ItemStack placeholder) { ItemStack displayItem = isEmpty(item) ? placeholder : item; ItemStack currentItem = getInventory().getItem(menuSlot); @@ -95,13 +120,6 @@ private void updateSlot(int menuSlot, ItemStack item, ItemStack placeholder) { } } - private String formatAddress(InetSocketAddress socketAddress) { - if (socketAddress == null || socketAddress.getAddress() == null) { - return "Unknown"; - } - return socketAddress.getAddress().getHostAddress(); - } - private boolean isEmpty(ItemStack item) { return item == null || item.getType() == Material.AIR; } diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/menus/ReportMenu.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/menus/ReportMenu.java new file mode 100644 index 00000000..88dfd63f --- /dev/null +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/menus/ReportMenu.java @@ -0,0 +1,93 @@ +package fr.moussax.blightedMod.moderator.menus; + +import fr.moussax.bedrock.ui.menu.Menu; +import fr.moussax.bedrock.utils.ItemBuilder; +import fr.moussax.blightedMod.moderator.reports.ReportManager; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemFlag; +import org.bukkit.inventory.ItemStack; +import org.jspecify.annotations.NonNull; + +public final class ReportMenu extends Menu { + + private record ReportCategory(int slot, Material material, String reason, String line1, String line2) {} + + private static final ReportCategory[] CATEGORIES = { + new ReportCategory(20, Material.WRITABLE_BOOK, "Chat Abuse / Scam", + "§7Using offensive chat, scamming,", "§7or engaging in offensive chat behavior."), + new ReportCategory(21, Material.DIAMOND_SWORD, "Cheating / Hacks", + "§7Using illegal modifications, movement", "§7hacks, or combat advantages."), + new ReportCategory(22, Material.NAME_TAG, "Inappropriate Skin / Name", + "§7Using offensive, inappropriate, or", "§7rule-breaking skins or usernames."), + new ReportCategory(23, Material.CHEST, "Griefing / Theft", + "§7Destroying claims, stealing items,", "§7or damaging another player's property."), + new ReportCategory(24, Material.COMPASS, "Cross-Teaming / Exploits", + "§7Teaming across alliances or abusing", "§7server bugs and game exploits."), + new ReportCategory(29, Material.SCAFFOLDING, "Inappropriate Build", + "§7Building offensive structures, hate", "§7symbols, or rule-breaking builds."), + new ReportCategory(30, Material.CLOCK, "Spam / Chat Flooding", + "§7Flooding chat with repeated messages", "§7or excessive unwanted content."), + new ReportCategory(31, Material.WITHER_ROSE, "Harassment / Toxicity", + "§7Targeted harassment, hate speech,", "§7or persistently toxic behavior."), + new ReportCategory(32, Material.PAPER, "Other", + "§7Any rule-breaking behavior not", "§7covered by the categories above."), + }; + + private final String targetName; + private final String attachedMessage; + + public ReportMenu(String targetName) { + this(targetName, "General player report"); + } + + public ReportMenu(String targetName, String attachedMessage) { + super("Report " + targetName, 54); + this.targetName = targetName; + this.attachedMessage = attachedMessage != null ? attachedMessage : "General player report"; + } + + @Override + public void build(@NonNull Player reporter) { + renderTargetHead(); + + for (ReportCategory category : CATEGORIES) { + setItem(category.slot(), createCategoryItem(category.material(), "§c" + category.reason(), category.line1(), category.line2()), + (player, _) -> handleReportSubmit(player, category.reason())); + } + + setCloseButton(49); + } + + private void renderTargetHead() { + ItemStack headItem = new ItemBuilder(Material.PLAYER_HEAD) + .setSkullOwner(targetName) + .setDisplayName("§d" + targetName) + .addLore( + "§8/report " + targetName, "", + " §7This is the player you are ", + " §7reporting to online staff.", + "" + ) + .addItemFlag(ItemFlag.HIDE_PROFILE) + .toItemStack(); + + setItem(4, headItem); + } + + private ItemStack createCategoryItem(Material material, String title, String line1, String line2) { + return new ItemBuilder(material) + .setDisplayName(title) + .addLore(line1, line2, + "", + "§eClick to select." + ) + .addItemFlag(ItemFlag.HIDE_ATTRIBUTES) + .toItemStack(); + } + + private void handleReportSubmit(Player reporter, String categoryReason) { + ReportManager.getInstance().submitAndNotify(reporter, targetName, categoryReason, attachedMessage); + close(); + } +} diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/menus/ReportsCenterMenu.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/menus/ReportsCenterMenu.java new file mode 100644 index 00000000..23f4b561 --- /dev/null +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/menus/ReportsCenterMenu.java @@ -0,0 +1,169 @@ +package fr.moussax.blightedMod.moderator.menus; + +import fr.moussax.bedrock.ui.book.BookMenu; +import fr.moussax.bedrock.ui.menu.TickableMenu; +import fr.moussax.bedrock.ui.menu.types.PaginatedMenu; +import fr.moussax.bedrock.utils.ItemBuilder; +import fr.moussax.blightedMod.moderator.reports.ReportData; +import fr.moussax.blightedMod.moderator.reports.ReportManager; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemFlag; +import org.bukkit.inventory.ItemStack; +import org.jspecify.annotations.NonNull; + +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +import static fr.moussax.bedrock.text.Messenger.inform; + +public final class ReportsCenterMenu extends PaginatedMenu implements TickableMenu { + + private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("MMM dd, yyyy 'at' hh:mm a", Locale.US); + + public ReportsCenterMenu() { + super("Reports Center", 54); + } + + @Override + public void onTick(Player player) { + refresh(player); + } + + @Override + protected int getTotalItems(@NonNull Player player) { + return ReportManager.getInstance().getActiveReports().size(); + } + + @Override + protected int[] getDisplaySlots() { + return INNER_GRID_SLOTS; + } + + @Override + protected ItemStack getEmptyStateItem(@NonNull Player player) { + return new ItemBuilder(Material.RED_STAINED_GLASS_PANE) + .setDisplayName("§cNo Active Reports") + .addLore("§7There are currently no", "§7active reports.") + .toItemStack(); + } + + @Override + protected ItemStack getItem(@NonNull Player player, int index) { + List reports = ReportManager.getInstance().getActiveReports(); + if (index >= reports.size()) { + return new ItemStack(Material.AIR); + } + + ReportData report = reports.get(index); + long elapsedSeconds = (System.currentTimeMillis() - report.timestamp()) / 1000L; + String timeAgo = elapsedSeconds < 60 ? elapsedSeconds + "s ago" : (elapsedSeconds / 60) + "m ago"; + String dateText = DATE_FORMAT.format(new Date(report.timestamp())); + + ItemBuilder builder = new ItemBuilder(Material.PLAYER_HEAD) + .setSkullOwner(report.targetName()) + .setDisplayName("§b" + report.targetName() + " §7(#" + report.id() + ")") + .addLore( + "", + " §7Status: §a§lACTIVE", + " §7Reporter: §e" + report.reporterName(), + " §7Reason: §c" + report.reason(), + " §7Submitted: §f" + dateText + " §7(" + timeAgo + ")" + ); + + if (report.message() != null && !report.message().isBlank()) { + String displayMessage = report.message(); + if (displayMessage.length() > 40) { + displayMessage = displayMessage.substring(0, 37) + "..."; + } + builder.addLore(" §7Message: §f\"" + displayMessage + "\""); + } + + builder.addItemFlag(ItemFlag.HIDE_PROFILE); + builder.addLore( + "", + "§dLeft-click to teleport to target!", + "§bShift-Left-Click to view report details!", + "§cRight-click to dismiss report!" + ); + + return builder.toItemStack(); + } + + @Override + public void build(@NonNull Player viewer) { + super.build(viewer); + renderHeader(); + } + + private void renderHeader() { + int totalReports = ReportManager.getInstance().getActiveReports().size(); + ItemStack headerItem = new ItemBuilder(Material.WRITABLE_BOOK) + .setDisplayName("§eReports Center") + .addLore( + "", + " §7View and manage every report ", + " §7submitted by players, then act", + " §7on them from this menu.", + "", + " §7Active Reports: §d" + totalReports, + "" + ) + .toItemStack(); + setItem(4, headerItem); + } + + @Override + protected void onItemLeftClick(@NonNull Player moderator, int index) { + List reports = ReportManager.getInstance().getActiveReports(); + if (index < 0 || index >= reports.size()) { + return; + } + ReportData report = reports.get(index); + close(); + moderator.performCommand("mtp " + report.targetName()); + } + + @Override + protected void onItemRightClick(@NonNull Player moderator, int index) { + List reports = ReportManager.getInstance().getActiveReports(); + if (index < 0 || index >= reports.size()) { + return; + } + ReportData report = reports.get(index); + ReportManager.getInstance().dismissReport(report.id()); + inform(moderator, " §eYou dismissed §creport #" + report.id() + " §eagainst §d" + report.targetName() + "§e."); + refresh(moderator); + } + + @Override + protected void onItemShiftClick(@NonNull Player moderator, int index) { + List reports = ReportManager.getInstance().getActiveReports(); + if (index < 0 || index >= reports.size()) { + return; + } + ReportData report = reports.get(index); + openReportBook(moderator, report); + } + + public static void openReportBook(Player moderator, ReportData report) { + String dateText = DATE_FORMAT.format(new Date(report.timestamp())); + BookMenu bookMenu = BookMenu.builder() + .addPage(page -> { + page.append(" §0§lREPORT DETAILS\n\n"); + page.append("§0" + report.reporterName() + " §0reported §6" + report.targetName() + " §0for §c" + report.reason() + "§0.\n\n"); + + if (report.message() != null && !report.message().isBlank() && !report.message().equalsIgnoreCase("General player report") && !report.message().equalsIgnoreCase("None")) { + page.append("§0Message:\n"); + page.append("§8\"§3" + report.message() + "§8\"\n\n"); + } + + page.append("§0Submitted: §8" + dateText + "\n\n"); + page.hoverAndExecute("§4▶ Dismiss Report", "§eClick to dismiss this report", "/reports dismiss " + report.id()); + }); + + bookMenu.open(moderator); + } +} diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/menus/SanctionsMenu.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/menus/SanctionsMenu.java new file mode 100644 index 00000000..92e28f6f --- /dev/null +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/menus/SanctionsMenu.java @@ -0,0 +1,253 @@ +package fr.moussax.blightedMod.moderator.menus; + +import fr.moussax.bedrock.ui.menu.TickableMenu; +import fr.moussax.bedrock.ui.menu.types.PaginatedMenu; +import fr.moussax.bedrock.utils.ItemBuilder; +import fr.moussax.blightedMod.moderator.punishments.PunishmentData; +import lombok.Getter; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemFlag; +import org.bukkit.inventory.ItemStack; +import org.jspecify.annotations.NonNull; + +import java.text.SimpleDateFormat; +import java.util.Comparator; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +public final class SanctionsMenu extends PaginatedMenu implements TickableMenu { + + private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("MMM dd, yyyy 'at' hh:mm a", Locale.US); + + @Getter + public enum SanctionFilter { + ALL("All Sanctions"), + ACTIVE("Active Only"), + BAN("Bans Only"), + MUTE("Mutes Only"), + KICK("Kicks Only"); + + private final String displayName; + + SanctionFilter(String displayName) { + this.displayName = displayName; + } + + public SanctionFilter next() { + SanctionFilter[] values = values(); + return values[(ordinal() + 1) % values.length]; + } + + public SanctionFilter previous() { + SanctionFilter[] values = values(); + return values[(ordinal() - 1 + values.length) % values.length]; + } + } + + private final String targetName; + private final List punishments; + private SanctionFilter currentFilter = SanctionFilter.ALL; + private List cachedFilteredPunishments; + + public SanctionsMenu(String targetName, List punishments) { + super(targetName + "'s sanctions", 54); + this.targetName = targetName; + this.punishments = punishments; + } + + @Override + public void onTick(Player player) { + refresh(player); + } + + private void updateFilteredPunishmentsCache() { + this.cachedFilteredPunishments = punishments.stream() + .filter(punishment -> switch (currentFilter) { + case ALL -> true; + case ACTIVE -> punishment.active() && !punishment.isExpired(); + case BAN -> punishment.type() == PunishmentData.PunishmentType.BAN || punishment.type() == PunishmentData.PunishmentType.IP_BAN; + case MUTE -> punishment.type() == PunishmentData.PunishmentType.MUTE; + case KICK -> punishment.type() == PunishmentData.PunishmentType.KICK; + }) + .sorted(Comparator.comparingInt(PunishmentData::id)) + .toList(); + } + + @Override + protected int getTotalItems(@NonNull Player player) { + if (cachedFilteredPunishments == null) { + updateFilteredPunishmentsCache(); + } + return cachedFilteredPunishments.size(); + } + + @Override + protected ItemStack getItem(@NonNull Player player, int index) { + if (cachedFilteredPunishments == null) { + updateFilteredPunishmentsCache(); + } + if (index >= cachedFilteredPunishments.size()) { + return new ItemStack(Material.AIR); + } + + PunishmentData punishment = cachedFilteredPunishments.get(index); + + String typeTitle = switch (punishment.type()) { + case BAN -> "§c§lBAN"; + case IP_BAN -> "§c§lIP BAN"; + case MUTE -> "§e§lMUTE"; + case KICK -> "§f§lKICK"; + }; + + Material icon = switch (punishment.type()) { + case BAN, IP_BAN -> Material.REDSTONE_BLOCK; + case MUTE -> Material.WRITABLE_BOOK; + case KICK -> Material.COPPER_BOOTS; + }; + + boolean isActive = punishment.active() && !punishment.isExpired(); + String activeStatus = isActive ? "§a§lACTIVE" : "§c§lEXPIRED"; + String dateText = DATE_FORMAT.format(new Date(punishment.createdAt())); + + ItemBuilder builder = new ItemBuilder(icon) + .setDisplayName(typeTitle + " §7(#" + punishment.id() + ")"); + + if (punishment.type() != PunishmentData.PunishmentType.KICK) { + builder.addLore( + "", + " §7Status: " + activeStatus, + " §7Reason: §f" + punishment.reason(), + "", + " §7Issued By: §e" + punishment.moderatorName(), + " §7Issued At: §f" + dateText + " " + ); + + String durationText = punishment.isPermanent() + ? "§cPermanent" + : formatDurationSeconds(Math.max(1, (punishment.expiresAt() - punishment.createdAt()) / 1000L)); + + builder.addLore(" §7Duration: §d" + durationText); + + if (punishment.expiresAt() != null) { + builder.addLore(" §7Expires At: §f" + DATE_FORMAT.format(new Date(punishment.expiresAt())) + " "); + } + + if (punishment.type() == PunishmentData.PunishmentType.IP_BAN && punishment.ipAddress() != null && !punishment.ipAddress().equals("0.0.0.0")) { + builder.addLore(" §7IP Address: §d" + punishment.ipAddress()); + } + } else { + builder.addLore( + "", + " §7Reason: §f" + punishment.reason(), + "", + " §7Issued By: §e" + punishment.moderatorName(), + " §7Issued At: §f" + dateText + " " + ); + } + + builder.addLore(""); + builder.addItemFlag(ItemFlag.HIDE_ATTRIBUTES); + return builder.toItemStack(); + } + + private static String formatDurationSeconds(long totalSeconds) { + if (totalSeconds >= 31536000L) { + long years = totalSeconds / 31536000L; + return years + " year" + (years > 1 ? "s" : ""); + } + if (totalSeconds >= 604800L) { + long weeks = totalSeconds / 604800L; + return weeks + " week" + (weeks > 1 ? "s" : ""); + } + if (totalSeconds >= 86400L) { + long days = totalSeconds / 86400L; + return days + " day" + (days > 1 ? "s" : ""); + } + if (totalSeconds >= 3600L) { + long hours = totalSeconds / 3600L; + return hours + " hour" + (hours > 1 ? "s" : ""); + } + if (totalSeconds >= 60L) { + long minutes = totalSeconds / 60L; + return minutes + " minute" + (minutes > 1 ? "s" : ""); + } + return totalSeconds + " second" + (totalSeconds > 1 ? "s" : ""); + } + + @Override + protected int[] getDisplaySlots() { + return INNER_GRID_SLOTS; + } + + @Override + protected ItemStack getEmptyStateItem(@NonNull Player player) { + return new ItemBuilder(Material.RED_STAINED_GLASS_PANE) + .setDisplayName("§cNo Sanctions Found") + .addLore("§7No sanctions match the current filter.") + .toItemStack(); + } + + @Override + public void build(@NonNull Player viewer) { + updateFilteredPunishmentsCache(); + super.build(viewer); + renderTargetHead(); + renderFilterHopper(); + } + + private void renderTargetHead() { + long activeBans = punishments.stream().filter(punishment -> (punishment.type() == PunishmentData.PunishmentType.BAN || punishment.type() == PunishmentData.PunishmentType.IP_BAN) && punishment.active() && !punishment.isExpired()).count(); + long activeMutes = punishments.stream().filter(punishment -> punishment.type() == PunishmentData.PunishmentType.MUTE && punishment.active() && !punishment.isExpired()).count(); + long totalKicks = punishments.stream().filter(punishment -> punishment.type() == PunishmentData.PunishmentType.KICK).count(); + + ItemStack headItem = new ItemBuilder(Material.PLAYER_HEAD) + .setSkullOwner(targetName) + .setDisplayName("§d" + targetName) + .addLore( + "", + " §7Total Sanctions: §e" + punishments.size() + " ", + "", + " §7Active Bans: §c" + activeBans, + " §7Active Mutes: §e" + activeMutes, + " §7Total Kicks: §f" + totalKicks, + "" + ) + .addItemFlag(ItemFlag.HIDE_PROFILE) + .toItemStack(); + + setItem(4, headItem); + } + + private void renderFilterHopper() { + ItemBuilder hopperBuilder = new ItemBuilder(Material.HOPPER) + .setDisplayName("§fFilter") + .addLore("") + .addLore(currentFilter == SanctionFilter.ALL ? " §b▶ All Sanctions" : "§7 All Sanctions") + .addLore(currentFilter == SanctionFilter.ACTIVE ? " §b▶ Active Only" : "§7 Active Only") + .addLore(currentFilter == SanctionFilter.BAN ? " §b▶ Bans Only" : "§7 Bans Only") + .addLore(currentFilter == SanctionFilter.MUTE ? " §b▶ Mutes Only" : "§7 Mutes Only") + .addLore(currentFilter == SanctionFilter.KICK ? " §b▶ Kicks Only" : "§7 Kicks Only") + .addLore("") + .addLore("§bRight-click to go backwards!") + .addLore("§eClick to switch filter!"); + + setItem( + 53, + hopperBuilder.toItemStack(), + (player, _) -> { + currentFilter = currentFilter.next(); + currentPage = 0; + updateFilteredPunishmentsCache(); + refresh(player); + }, + (player, _) -> { + currentFilter = currentFilter.previous(); + currentPage = 0; + updateFilteredPunishmentsCache(); + refresh(player); + } + ); + } +} diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/punishments/DurationParser.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/punishments/DurationParser.java new file mode 100644 index 00000000..966538fe --- /dev/null +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/punishments/DurationParser.java @@ -0,0 +1,86 @@ +package fr.moussax.blightedMod.moderator.punishments; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class DurationParser { + private static final Pattern DURATION_PATTERN = Pattern.compile("(\\d+)([smhdwy])"); + + public static Long parseDurationSeconds(String input) { + if (input == null || input.isEmpty()) { + return null; + } + + Matcher matcher = DURATION_PATTERN.matcher(input.toLowerCase()); + if (!matcher.matches()) { + try { + return Long.parseLong(input); + } catch (NumberFormatException _) { + return null; + } + } + + long value = Long.parseLong(matcher.group(1)); + String unit = matcher.group(2); + + return switch (unit) { + case "s" -> value; + case "m" -> value * 60L; + case "h" -> value * 3600L; + case "d" -> value * 86400L; + case "w" -> value * 604800L; + case "y" -> value * 31536000L; + default -> 0L; + }; + } + + public static Long parseDuration(String input) { + Long durationSeconds = parseDurationSeconds(input); + if (durationSeconds == null) { + return null; + } + return System.currentTimeMillis() + (durationSeconds * 1000L); + } + + public static String formatDuration(String input) { + Matcher matcher = DURATION_PATTERN.matcher(input.toLowerCase()); + if (!matcher.matches()) { + return input; + } + + String value = matcher.group(1); + String unit = matcher.group(2); + + String unitName = switch (unit) { + case "s" -> "second"; + case "m" -> "minute"; + case "h" -> "hour"; + case "d" -> "day"; + case "w" -> "week"; + case "y" -> "year"; + default -> ""; + }; + + long parsedAmount = Long.parseLong(value); + if (parsedAmount > 1) { + unitName += "s"; + } + return value + " " + unitName; + } + + public static String formatShortDuration(long seconds) { + if (seconds < 60) { + return seconds + "s"; + } + long minutes = seconds / 60; + if (minutes < 60) { + return minutes + "m"; + } + long hours = minutes / 60; + if (hours < 24) { + return hours + "h"; + } + long days = hours / 24; + return days + "d"; + } +} diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/punishments/PunishmentArguments.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/punishments/PunishmentArguments.java new file mode 100644 index 00000000..57e36407 --- /dev/null +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/punishments/PunishmentArguments.java @@ -0,0 +1,31 @@ +package fr.moussax.blightedMod.moderator.punishments; + +import java.util.Arrays; +import org.jspecify.annotations.Nullable; + +/** + * Encapsulates parsed optional expiration timestamp and reason from punishment command arguments. + * + * @param expiresAt expiration timestamp in milliseconds, or {@code null} if permanent + * @param reason punishment reason specified, or "No reason specified" if omitted + */ +public record PunishmentArguments(@Nullable Long expiresAt, String reason) { + + /** + * Parses expiration duration and reason from command arguments starting at a specified index. + * + * @param arguments raw command arguments + * @param startIndex index after target player argument (typically 1) + * @return parsed punishment arguments context + */ + public static PunishmentArguments parse(String[] arguments, int startIndex) { + Long expiresAt = arguments.length > startIndex ? DurationParser.parseDuration(arguments[startIndex]) : null; + int reasonStartIndex = expiresAt != null ? startIndex + 1 : startIndex; + + String reason = arguments.length > reasonStartIndex + ? String.join(" ", Arrays.copyOfRange(arguments, reasonStartIndex, arguments.length)) + : "No reason specified"; + + return new PunishmentArguments(expiresAt, reason); + } +} diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/punishments/PunishmentData.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/punishments/PunishmentData.java new file mode 100644 index 00000000..55700b43 --- /dev/null +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/punishments/PunishmentData.java @@ -0,0 +1,30 @@ +package fr.moussax.blightedMod.moderator.punishments; + +import java.util.UUID; + +/** + * Record data carrier for player punishment records. + */ +public record PunishmentData( + int id, + UUID playerUuid, + String playerName, + PunishmentType type, + String reason, + UUID moderatorUuid, + String moderatorName, + long createdAt, + Long expiresAt, + boolean active, + String ipAddress +) { + public boolean isPermanent() { + return expiresAt == null; + } + + public boolean isExpired() { + return expiresAt != null && System.currentTimeMillis() > expiresAt; + } + + public enum PunishmentType {MUTE, KICK, BAN, IP_BAN} +} diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/punishments/PunishmentManager.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/punishments/PunishmentManager.java new file mode 100644 index 00000000..b5db1752 --- /dev/null +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/punishments/PunishmentManager.java @@ -0,0 +1,300 @@ +package fr.moussax.blightedMod.moderator.punishments; + +import org.bukkit.entity.Player; + +import java.net.InetSocketAddress; +import java.sql.*; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +public final class PunishmentManager { + private final Connection connection; + + public PunishmentManager(Connection connection) { + this.connection = connection; + } + + public void addPunishment(UUID playerUuid, String playerName, PunishmentData.PunishmentType type, + String reason, UUID moderatorUuid, String moderatorName, + Long expiresAt, String ipAddress) { + String query = """ + INSERT INTO punishments (player_uuid, player_name, punishment_type, reason, + moderator_uuid, moderator_name, created_at, expires_at, ip_address) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """; + + synchronized (connection) { + try (PreparedStatement statement = connection.prepareStatement(query)) { + statement.setString(1, playerUuid.toString()); + statement.setString(2, playerName); + statement.setString(3, type.name()); + statement.setString(4, reason); + statement.setString(5, moderatorUuid.toString()); + statement.setString(6, moderatorName); + statement.setLong(7, System.currentTimeMillis()); + if (expiresAt != null) statement.setLong(8, expiresAt); + else statement.setNull(8, Types.INTEGER); + statement.setString(9, ipAddress); + statement.executeUpdate(); + } catch (SQLException exception) { + throw new RuntimeException("Failed to add punishment", exception); + } + } + } + + public void removePunishment(UUID playerUuid, PunishmentData.PunishmentType type) { + String query = """ + UPDATE punishments SET is_active = 0 + WHERE player_uuid = ? AND punishment_type = ? AND is_active = 1 + """; + + synchronized (connection) { + try (PreparedStatement statement = connection.prepareStatement(query)) { + statement.setString(1, playerUuid.toString()); + statement.setString(2, type.name()); + statement.executeUpdate(); + } catch (SQLException exception) { + throw new RuntimeException("Failed to remove punishment", exception); + } + } + } + + public void removeIpPunishment(String ipAddress) { + String query = """ + UPDATE punishments SET is_active = 0 + WHERE ip_address = ? AND punishment_type = 'IP_BAN' AND is_active = 1 + """; + + synchronized (connection) { + try (PreparedStatement statement = connection.prepareStatement(query)) { + statement.setString(1, ipAddress); + statement.executeUpdate(); + } catch (SQLException exception) { + throw new RuntimeException("Failed to remove IP ban", exception); + } + } + } + + private void deactivatePunishment(int id) { + String query = """ + UPDATE punishments SET is_active = 0 + WHERE id = ? AND is_active = 1 + """; + + synchronized (connection) { + try (PreparedStatement statement = connection.prepareStatement(query)) { + statement.setInt(1, id); + statement.executeUpdate(); + } catch (SQLException exception) { + throw new RuntimeException("Failed to deactivate punishment", exception); + } + } + } + + public PunishmentData getActivePunishment(UUID playerUuid, PunishmentData.PunishmentType type) { + String query = """ + SELECT * FROM punishments + WHERE player_uuid = ? AND punishment_type = ? AND is_active = 1 + ORDER BY created_at DESC LIMIT 1 + """; + + PunishmentData punishment; + synchronized (connection) { + try (PreparedStatement statement = connection.prepareStatement(query)) { + statement.setString(1, playerUuid.toString()); + statement.setString(2, type.name()); + + try (ResultSet resultSet = statement.executeQuery()) { + if (!resultSet.next()) return null; + punishment = mapResultSet(resultSet); + } + } catch (SQLException exception) { + throw new RuntimeException("Failed to get punishment", exception); + } + } + + if (punishment.isExpired()) { + deactivatePunishment(punishment.id()); + return null; + } + return punishment; + } + + public PunishmentData getActiveIpBan(String ipAddress) { + String query = """ + SELECT * FROM punishments + WHERE ip_address = ? AND punishment_type = 'IP_BAN' AND is_active = 1 + ORDER BY created_at DESC LIMIT 1 + """; + + PunishmentData punishment; + synchronized (connection) { + try (PreparedStatement statement = connection.prepareStatement(query)) { + statement.setString(1, ipAddress); + + try (ResultSet resultSet = statement.executeQuery()) { + if (!resultSet.next()) return null; + punishment = mapResultSet(resultSet); + } + } catch (SQLException exception) { + throw new RuntimeException("Failed to get IP ban", exception); + } + } + + if (punishment.isExpired()) { + deactivatePunishment(punishment.id()); + return null; + } + return punishment; + } + + /** + * Resolves the UUID of an actively banned player by name, using persisted punishment + * data instead of a live Mojang lookup. + * + *

Used to unban players who are offline — {@code requireTarget} only resolves + * online players, but a banned player is disconnected by the time {@code /unban} + * is run against them.

+ * + * @param playerName the banned player's name + * @return the player's UUID, or {@code null} if no active ban matches + */ + public UUID resolveBannedUuidByName(String playerName) { + String query = """ + SELECT * FROM punishments + WHERE LOWER(player_name) = LOWER(?) AND punishment_type = 'BAN' AND is_active = 1 + ORDER BY created_at DESC LIMIT 1 + """; + + PunishmentData punishment; + synchronized (connection) { + try (PreparedStatement statement = connection.prepareStatement(query)) { + statement.setString(1, playerName); + try (ResultSet resultSet = statement.executeQuery()) { + if (!resultSet.next()) return null; + punishment = mapResultSet(resultSet); + } + } catch (SQLException exception) { + throw new RuntimeException("Failed to resolve banned player", exception); + } + } + + if (punishment.isExpired()) { + deactivatePunishment(punishment.id()); + return null; + } + return punishment.playerUuid(); + } + + public boolean isMuted(UUID playerUuid) { + return getActivePunishment(playerUuid, PunishmentData.PunishmentType.MUTE) != null; + } + + public boolean isBanned(UUID playerUuid) { + return getActivePunishment(playerUuid, PunishmentData.PunishmentType.BAN) != null; + } + + public boolean isIpBanned(String ipAddress) { + return getActiveIpBan(ipAddress) != null; + } + + public static final UUID CONSOLE_UUID = UUID.nameUUIDFromBytes("CONSOLE".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + + public static UUID getModeratorUuid(org.bukkit.command.CommandSender sender) { + return sender instanceof Player player ? player.getUniqueId() : CONSOLE_UUID; + } + + public void addBan(Player target, org.bukkit.command.CommandSender moderator, String reason, Long expiresAt) { + addPunishment( + target.getUniqueId(), + target.getName(), + PunishmentData.PunishmentType.BAN, + reason, + getModeratorUuid(moderator), + moderator.getName(), + expiresAt, + getPlayerIp(target) + ); + } + + public void addMute(Player target, org.bukkit.command.CommandSender moderator, String reason, Long expiresAt) { + addPunishment( + target.getUniqueId(), + target.getName(), + PunishmentData.PunishmentType.MUTE, + reason, + getModeratorUuid(moderator), + moderator.getName(), + expiresAt, + getPlayerIp(target) + ); + } + + public void addIpBan(Player target, org.bukkit.command.CommandSender moderator, String reason, Long expiresAt) { + addPunishment( + target.getUniqueId(), + target.getName(), + PunishmentData.PunishmentType.IP_BAN, + reason, + getModeratorUuid(moderator), + moderator.getName(), + expiresAt, + getPlayerIp(target) + ); + } + + private PunishmentData mapResultSet(ResultSet resultSet) throws SQLException { + Long expiresAt = resultSet.getLong("expires_at"); + if (resultSet.wasNull()) expiresAt = null; + + return new PunishmentData( + resultSet.getInt("id"), + UUID.fromString(resultSet.getString("player_uuid")), + resultSet.getString("player_name"), + PunishmentData.PunishmentType.valueOf(resultSet.getString("punishment_type")), + resultSet.getString("reason"), + UUID.fromString(resultSet.getString("moderator_uuid")), + resultSet.getString("moderator_name"), + resultSet.getLong("created_at"), + expiresAt, + resultSet.getInt("is_active") == 1, + resultSet.getString("ip_address") + ); + } + + public List getAllPunishments(String playerName) { + String query = """ + SELECT * FROM punishments + WHERE LOWER(player_name) = LOWER(?) + ORDER BY created_at DESC + """; + + List list = new ArrayList<>(); + synchronized (connection) { + try (PreparedStatement statement = connection.prepareStatement(query)) { + statement.setString(1, playerName); + try (ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + list.add(mapResultSet(resultSet)); + } + } + } catch (SQLException exception) { + throw new RuntimeException("Failed to fetch player punishments", exception); + } + } + return list; + } + + public static String getPlayerIp(Player player) { + try { + if (player == null) return "0.0.0.0"; + InetSocketAddress address = player.getAddress(); + if (address == null || address.getAddress() == null) return "0.0.0.0"; + String host = address.getAddress().getHostAddress(); + return host != null ? host : "0.0.0.0"; + } catch (Throwable _) { + return "0.0.0.0"; + } + } +} diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/reports/ReportData.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/reports/ReportData.java new file mode 100644 index 00000000..3202635f --- /dev/null +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/reports/ReportData.java @@ -0,0 +1,14 @@ +package fr.moussax.blightedMod.moderator.reports; + +/** + * Immutable data carrier for a player report. + */ +public record ReportData( + int id, + String reporterName, + String targetName, + String reason, + String message, + long timestamp +) { +} diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/reports/ReportManager.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/reports/ReportManager.java new file mode 100644 index 00000000..a4148e05 --- /dev/null +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/moderator/reports/ReportManager.java @@ -0,0 +1,72 @@ +package fr.moussax.blightedMod.moderator.reports; + +import fr.moussax.bedrock.text.InteractiveMessage; +import fr.moussax.blightedMod.moderator.ModerationManager; +import org.bukkit.entity.Player; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static fr.moussax.bedrock.text.Messenger.inform; + +public final class ReportManager { + private static final ReportManager INSTANCE = new ReportManager(); + + private final List reports = Collections.synchronizedList(new ArrayList<>()); + private final AtomicInteger idCounter = new AtomicInteger(1); + + private ReportManager() { + } + + public static ReportManager getInstance() { + return INSTANCE; + } + + public ReportData submitReport(String reporterName, String targetName, String reason, String message) { + ReportData report = new ReportData( + idCounter.getAndIncrement(), + reporterName, + targetName, + reason, + message, + System.currentTimeMillis() + ); + reports.addFirst(report); + return report; + } + + public void submitAndNotify(Player reporter, String targetName, String reason, String message) { + ReportData report = submitReport(reporter.getName(), targetName, reason, message); + inform(reporter, " §a⚑ §7Your report §7has been submitted to online staff."); + + InteractiveMessage notificationMessage = InteractiveMessage.text(" §d§lSTAFF! §f" + reporter.getName() + " §ereported §d" + targetName + " §efor §c" + reason + "§e. ") + .hoverAndExecute("§6[DETAILS]", "§fClick to view §dreport §fdetails.", "/checkreport " + report.id()) + .append(" ") + .hoverAndExecute("§b[MTP]", "§fClick to teleport to §d" + targetName + "§f.", "/mtp " + targetName) + .append(" ") + .hoverAndExecute("§3[INFO]", "§fClick to view information about §d" + targetName + "§f.", "/userinfo " + targetName); + + ModerationManager.getInstance().broadcastToModerators(notificationMessage); + } + + public List getActiveReports() { + synchronized (reports) { + return new ArrayList<>(reports); + } + } + + public boolean dismissReport(int reportId) { + return reports.removeIf(report -> report.id() == reportId); + } + + public ReportData getReportById(int reportId) { + synchronized (reports) { + return reports.stream() + .filter(report -> report.id() == reportId) + .findFirst() + .orElse(null); + } + } +} diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/registry/CommandsRegistry.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/registry/CommandsRegistry.java new file mode 100644 index 00000000..1d368a9f --- /dev/null +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/registry/CommandsRegistry.java @@ -0,0 +1,96 @@ +package fr.moussax.blightedMod.registry; + +import fr.moussax.bedrock.commands.CommandRegistrar; +import fr.moussax.bedrock.commands.TabSuggestionRegistry; +import fr.moussax.blightedMod.commands.impl.BanCommand; +import fr.moussax.blightedMod.commands.impl.CheckReportCommand; +import fr.moussax.blightedMod.commands.impl.EnderseeCommand; +import fr.moussax.blightedMod.commands.impl.FreezeCommand; +import fr.moussax.blightedMod.commands.impl.InvseeCommand; +import fr.moussax.blightedMod.commands.impl.KickCommand; +import fr.moussax.blightedMod.commands.impl.ModCommand; +import fr.moussax.blightedMod.commands.impl.ModTpCommands; +import fr.moussax.blightedMod.commands.impl.MuteCommand; +import fr.moussax.blightedMod.commands.impl.ReportCommand; +import fr.moussax.blightedMod.commands.impl.ReportsCommand; +import fr.moussax.blightedMod.commands.impl.SanctionsCommand; +import fr.moussax.blightedMod.commands.impl.SetSlowmodeCommand; +import fr.moussax.blightedMod.commands.impl.SocialSpyCommand; +import fr.moussax.blightedMod.commands.impl.TargetCommand; +import fr.moussax.blightedMod.commands.impl.UserInfoCommand; +import fr.moussax.blightedMod.commands.impl.VanishCommand; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; + +public final class CommandsRegistry { + + private CommandsRegistry() { + } + + public static void registerCommands(JavaPlugin plugin) { + TabSuggestionRegistry suggestions = createSuggestionRegistry(); + CommandRegistrar registrar = new CommandRegistrar(plugin, suggestions); + + ModTpCommands teleportCommands = new ModTpCommands(); + registrar.register("mtp", teleportCommands); + registrar.register("mtphere", teleportCommands); + registrar.register("vanish", new VanishCommand()); + + MuteCommand muteCommand = new MuteCommand(); + registrar.register("mute", muteCommand); + registrar.register("unmute", muteCommand); + + registrar.register("mod", new ModCommand()); + registrar.register("invsee", new InvseeCommand()); + registrar.register("endersee", new EnderseeCommand()); + registrar.register("kick", new KickCommand()); + + BanCommand banCommand = new BanCommand(); + registrar.register("ban", banCommand); + registrar.register("unban", banCommand); + registrar.register("banip", banCommand); + registrar.register("unbanip", banCommand); + + registrar.register("freeze", new FreezeCommand()); + registrar.register("report", new ReportCommand()); + + SetSlowmodeCommand slowmodeCommand = new SetSlowmodeCommand(); + registrar.register("slowmode", slowmodeCommand); + registrar.register("setslowmode", slowmodeCommand); + + registrar.register("reports", new ReportsCommand()); + registrar.register("checkreport", new CheckReportCommand()); + registrar.register("target", new TargetCommand()); + registrar.register("sanctions", new SanctionsCommand()); + registrar.register("userinfo", new UserInfoCommand()); + registrar.register("chat", new fr.moussax.blightedMod.commands.impl.ChatCommand()); + + SocialSpyCommand socialSpyCommand = new SocialSpyCommand(); + registrar.register("msginspect", socialSpyCommand); + registrar.register("spy", socialSpyCommand); + registrar.register("socialspy", socialSpyCommand); + } + + + + + + + + + + private static TabSuggestionRegistry createSuggestionRegistry() { + TabSuggestionRegistry suggestions = new TabSuggestionRegistry(); + + suggestions.register("$players", () -> Bukkit.getOnlinePlayers() + .stream() + .map(Player::getName) + .toList() + ); + + suggestions.register("$channels", () -> java.util.List.of("staff", "all")); + + return suggestions; + } +} diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/registry/EventsRegistry.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/registry/EventsRegistry.java new file mode 100644 index 00000000..e0b8282f --- /dev/null +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/registry/EventsRegistry.java @@ -0,0 +1,54 @@ +package fr.moussax.blightedMod.registry; + +import fr.moussax.bedrock.ui.actionbar.ActionbarService; +import fr.moussax.bedrock.ui.menu.system.MenuListener; +import fr.moussax.bedrock.ui.menu.system.MenuSystem; +import fr.moussax.bedrock.ui.sign.SignInputListener; +import fr.moussax.blightedMod.BlightedMod; +import fr.moussax.blightedMod.moderator.listeners.InteractiveChatListener; +import fr.moussax.blightedMod.moderator.listeners.ModerationListener; +import org.bukkit.Bukkit; +import org.bukkit.plugin.PluginManager; + +import fr.moussax.blightedMod.moderator.hud.ModerationHud; + +public final class EventsRegistry { + + private final BlightedMod instance = BlightedMod.getInstance(); + private MenuSystem menuSystem; + private SignInputListener signInputListener; + private ActionbarService actionBarService; + + public void initializeListeners() { + PluginManager pluginManager = Bukkit.getPluginManager(); + + signInputListener = new SignInputListener(); + menuSystem = new MenuSystem(instance); + actionBarService = new ActionbarService(instance); + actionBarService.start(10L); + + actionBarService.registerSection(ModerationHud.createSection(instance.getModerationManager())); + + pluginManager.registerEvents(signInputListener, instance); + pluginManager.registerEvents(new MenuListener(menuSystem), instance); + pluginManager.registerEvents(new ModerationListener(instance.getModerationManager()), instance); + pluginManager.registerEvents(new InteractiveChatListener(instance.getModerationManager()), instance); + } + + + public void cleanup() { + if (signInputListener != null) { + signInputListener.cleanup(); + } + if (actionBarService != null) { + actionBarService.stop(); + } + } + + public void shutdownMenus() { + if (menuSystem != null) { + menuSystem.shutdown(); + } + } +} + diff --git a/blighted-mod/src/main/java/fr/moussax/blightedMod/utils/PluginPermissions.java b/blighted-mod/src/main/java/fr/moussax/blightedMod/utils/PluginPermissions.java new file mode 100644 index 00000000..f2c74bd8 --- /dev/null +++ b/blighted-mod/src/main/java/fr/moussax/blightedMod/utils/PluginPermissions.java @@ -0,0 +1,17 @@ +package fr.moussax.blightedMod.utils; + +public enum PluginPermissions { + MODERATOR("blightedmod.moderator"), + ADMIN("blightedmod.administrator"); + + private final String node; + + PluginPermissions(String node) { + this.node = node; + } + + @Override + public String toString() { + return node; + } +} diff --git a/blighted-mod/src/main/resources/plugin.yml b/blighted-mod/src/main/resources/plugin.yml new file mode 100644 index 00000000..ea659872 --- /dev/null +++ b/blighted-mod/src/main/resources/plugin.yml @@ -0,0 +1,80 @@ +name: BlightedMod +version: '${version}' +authors: [ MrInspection ] + +main: fr.moussax.blightedMod.BlightedMod +api-version: '26.2' +load: STARTUP + +commands: + mtp: + description: Teleport to a player. + permission: blightedmod.moderator + mtphere: + description: Teleport a player to you. + permission: blightedmod.moderator + vanish: + description: Toggle vanish mode. + aliases: [v] + permission: blightedmod.moderator + mute: + description: Mute a player. + permission: blightedmod.moderator + unmute: + description: Unmute a player. + permission: blightedmod.moderator + mod: + description: Toggle moderation mode or broadcast staff announcement. + permission: blightedmod.moderator + invsee: + description: View and inspect a player's inventory. + permission: blightedmod.moderator + endersee: + description: View and inspect a player's ender chest. + permission: blightedmod.moderator + kick: + description: Kick a player from the server. + permission: blightedmod.moderator + ban: + description: Ban a player. + permission: blightedmod.moderator + unban: + description: Unban a player. + permission: blightedmod.moderator + banip: + description: IP ban a player. + permission: blightedmod.moderator + unbanip: + description: Unban an IP address. + permission: blightedmod.moderator + freeze: + description: Toggle freeze state for a player. + permission: blightedmod.moderator + report: + description: Report a player or message to online moderators. + slowmode: + description: Set or toggle chat slowmode delay in seconds. + aliases: [setslowmode] + permission: blightedmod.moderator + reports: + description: View active player reports. + permission: blightedmod.moderator + checkreport: + description: Inspect detailed information for a specific report. + permission: blightedmod.moderator + target: + description: Manually set or clear targeted player for moderation HUD. + permission: blightedmod.moderator + sanctions: + description: View all recorded sanctions for a player. + permission: blightedmod.moderator + userinfo: + description: Get detailed user information. + permission: blightedmod.moderator + chat: + description: Switch active chat channel (staff, all). + aliases: [c] + msginspect: + description: Toggle private message inspection (SocialSpy). + aliases: [spy, socialspy] + permission: blightedmod.moderator \ No newline at end of file diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/BlightedSMP.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/BlightedSMP.java index 10c2325d..2630c6a8 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/BlightedSMP.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/BlightedSMP.java @@ -13,7 +13,9 @@ import fr.moussax.bedrock.scheduling.PluginContext; import fr.moussax.bedrock.utils.debug.Log; import lombok.Getter; +import fr.moussax.blightedSMP.engine.player.BlightedPlayer; import org.bukkit.Bukkit; +import org.bukkit.entity.Player; import org.bukkit.plugin.java.JavaPlugin; import java.sql.SQLException; @@ -58,6 +60,10 @@ public void onEnable() { eventsRegistry.initializeListeners(); eventsRegistry.buildSpawnCache(); + for (Player onlinePlayer : Bukkit.getOnlinePlayers()) { + BlightedPlayer.get(onlinePlayer); + } + BlightedServer.rehydrateEntitiesOnLoadedChunks(this); new BlightedSpawnEngine().runTaskTimer(this, 100L, 20L); } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/commands/impl/EnderseeCommand.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/commands/impl/EnderseeCommand.java deleted file mode 100644 index 782f5a70..00000000 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/commands/impl/EnderseeCommand.java +++ /dev/null @@ -1,23 +0,0 @@ -package fr.moussax.blightedSMP.commands.impl; - -import fr.moussax.blightedSMP.commands.AdminCommand; -import fr.moussax.blightedSMP.engine.player.menus.EnderSeeMenu; -import fr.moussax.bedrock.text.Messenger; -import org.bukkit.command.Command; -import org.bukkit.entity.Player; - -public final class EnderseeCommand extends AdminCommand { - @Override - protected boolean executeAdmin(Player player, Command command, String label, String[] args) { - if (args.length == 0) { - Messenger.warn(player, "Usage: /endersee "); - return false; - } - - Player target = requireTarget(player, args[0]); - if (target == null) return false; - - new EnderSeeMenu(target).open(player); - return true; - } -} diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/commands/impl/GemsCommand.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/commands/impl/GemsCommand.java index da2985f5..84327520 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/commands/impl/GemsCommand.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/commands/impl/GemsCommand.java @@ -20,15 +20,7 @@ public final class GemsCommand extends AdminCommand { @Override protected boolean executeAdmin(Player player, Command command, String label, String[] args) { if (args.length == 0 || args[0].equalsIgnoreCase("help")) { - inform(player, "§e§lGEMS CURRENCY §7- Subcommands:"); - inform(player, " §f • §e/gems add [amount] §f» §7Give gems to a player."); - inform(player, " §f • §e/gems remove [amount] §f» §7Take gems from a player."); - inform(player, " §f • §e/gems set [amount] §f» §7Set gems for a player."); - inform(player, " §f • §e/gems reset §f» §7Reset gems for a player."); - inform(player, " §f • §e/gems giveall [amount] §f» §7Give gems to everyone."); - inform(player, " §f • §e/gems resetall §f» §7Reset everyone's balance."); - inform(player, " §f • §e/gems help §f» §7Prints this help message."); - + sendHelpMenu(player); return false; } @@ -46,9 +38,47 @@ protected boolean executeAdmin(Player player, Command command, String label, Str }; } + private void sendHelpMenu(Player player) { + player.sendMessage(" "); + player.sendMessage(" "); + player.sendMessage(" §e§lGEMS CURRENCY§f | §7Subcommands"); + player.sendMessage(" "); + player.sendMessage("§f • " + formatSyntax("gems add ") + " §f§l» §7Give gems to a player."); + player.sendMessage("§f • " + formatSyntax("gems remove ") + " §f§l» §7Take gems from a player."); + player.sendMessage("§f • " + formatSyntax("gems set ") + " §f§l» §7Set gems for a player."); + player.sendMessage("§f • " + formatSyntax("gems reset ") + " §f§l» §7Reset gems for a player."); + player.sendMessage("§f • " + formatSyntax("gems giveall ") + " §f§l» §7Give gems to everyone."); + player.sendMessage("§f • " + formatSyntax("gems resetall") + " §f§l» §7Reset everyone's balance."); + player.sendMessage("§f • " + formatSyntax("gems help") + " §f§l» §7Prints this help message."); + player.sendMessage(" "); + } + + private String formatSyntax(String syntax) { + StringBuilder builder = new StringBuilder("§e/"); + String[] arguments = syntax.strip().split("\\s+"); + + for (int index = 0; index < arguments.length; index++) { + if (index > 0) { + builder.append(' '); + } + builder.append(formatToken(arguments[index])); + } + return builder.toString(); + } + + private String formatToken(String token) { + if (token.length() > 1 && token.charAt(0) == '<' && token.charAt(token.length() - 1) == '>') { + return "§e<§f" + token.substring(1, token.length() - 1) + "§e>"; + } + if (token.length() > 1 && token.charAt(0) == '[' && token.charAt(token.length() - 1) == ']') { + return "§e[§f" + token.substring(1, token.length() - 1) + "§e]"; + } + return "§f" + token; + } + private boolean handleModify(Player sender, String[] args, boolean add) { if (args.length < 3) { - warn(sender, "Usage: /gems " + (add ? "add" : "remove") + " [amount]"); + warn(sender, "Usage: /gems " + (add ? "add" : "remove") + " "); return false; } @@ -74,7 +104,7 @@ private boolean handleModify(Player sender, String[] args, boolean add) { private boolean handleSet(Player sender, String[] args) { if (args.length < 3) { - warn(sender, "Usage: /gems set [amount]"); + warn(sender, "Usage: /gems set "); return false; } @@ -118,7 +148,7 @@ private boolean handleResetAll(Player sender) { private boolean handleGiveAll(Player sender, String[] args) { if (args.length < 2) { - warn(sender, "Usage: /gems giveall [amount]"); + warn(sender, "Usage: /gems giveall "); return false; } diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/commands/impl/InvseeCommand.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/commands/impl/InvseeCommand.java deleted file mode 100644 index 6fac58c0..00000000 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/commands/impl/InvseeCommand.java +++ /dev/null @@ -1,25 +0,0 @@ -package fr.moussax.blightedSMP.commands.impl; - -import fr.moussax.blightedSMP.commands.AdminCommand; -import fr.moussax.blightedSMP.engine.player.menus.InvSeeMenu; -import org.bukkit.command.Command; -import org.bukkit.entity.Player; - -import static fr.moussax.bedrock.text.Messenger.warn; - -public final class InvseeCommand extends AdminCommand { - - @Override - protected boolean executeAdmin(Player player, Command command, String label, String[] args) { - if (args.length != 1) { - warn(player, "Usage: /invsee "); - return true; - } - - Player target = requireTarget(player, args[0]); - if (target == null) return true; - - new InvSeeMenu(target).open(player); - return true; - } -} diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/registry/menu/ItemRegistryMenu.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/registry/menu/ItemRegistryMenu.java index c34dcf5b..10a810a0 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/registry/menu/ItemRegistryMenu.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/items/registry/menu/ItemRegistryMenu.java @@ -6,7 +6,6 @@ import fr.moussax.bedrock.text.Formatter; import fr.moussax.bedrock.ui.menu.Menu; import fr.moussax.bedrock.ui.menu.types.PaginatedMenu; -import fr.moussax.bedrock.ui.menu.interaction.MenuElementPreset; import fr.moussax.bedrock.ui.menu.interaction.MenuItemInteraction; import fr.moussax.bedrock.ui.sign.SignInputMenu; import fr.moussax.bedrock.utils.ItemBuilder; @@ -24,15 +23,8 @@ import java.util.function.Predicate; public final class ItemRegistryMenu { - private static final int[] CATEGORY_SLOTS = { - 10, 11, 12, 13, 14, 15, 16, - 19, 20, 21, 22, 23, 24, 25, - 28, 29, 30, 31, 32, 33, 34, - 37, 38, 39, 40, 41, 42, 43 - }; - + private static final int[] CATEGORY_SLOTS = PaginatedMenu.INNER_GRID_SLOTS; private static final int SEARCH_SLOT = 41; - private static final int[] ITEM_SLOTS = CATEGORY_SLOTS; private static ItemBuilder hideAllItemFlags(ItemBuilder builder) { return builder.addItemFlag( @@ -152,8 +144,15 @@ protected int getTotalItems(@NonNull Player player) { } @Override - protected int getItemsPerPage() { - return 28; + protected int[] getDisplaySlots() { + return INNER_GRID_SLOTS; + } + + @Override + protected ItemStack getEmptyStateItem(@NonNull Player player) { + return buildMenuItem(new ItemStack(Material.RED_STAINED_GLASS_PANE), + "§cNo Items Found", + List.of("§7No items match the criteria")); } @Override @@ -178,43 +177,10 @@ protected ItemStack getItem(@NonNull Player player, int index) { @Override public void build(@NonNull Player player) { - int totalCount = getTotalItems(player); - int start = currentPage * getItemsPerPage(); - int end = Math.min(start + getItemsPerPage(), totalCount); - - if (blightedItems.isEmpty()) { - setItem(22, buildMenuItem(new ItemStack(Material.RED_STAINED_GLASS_PANE), - "§cNo Items Found", - List.of("§7No items match the criteria")), MenuItemInteraction.ANY_CLICK, (clickingPlayer, _) -> { - }); - } else { - for (int slotIndex = 0, i = start; i < end && slotIndex < ITEM_SLOTS.length; i++, slotIndex++) { - final int itemIndex = i; - setItem(ITEM_SLOTS[slotIndex], getItem(player, itemIndex), MenuItemInteraction.ANY_CLICK, - (clickingPlayer, clickType) -> onItemClick(clickingPlayer, itemIndex, clickType)); - } - } - setNavigation(player, totalCount); - } - - private void setNavigation(Player player, int totalItemsCount) { - if (currentPage > 0) { - setBackButton(48, (clickingPlayer, _) -> { - currentPage--; - refresh(clickingPlayer); - }); - } else if (previousMenu != null) { + super.build(player); + if (currentPage == 0 && previousMenu != null) { setBackButton(48, previousMenu); } - - if ((currentPage + 1) * getItemsPerPage() < totalItemsCount) { - setItem(50, MenuElementPreset.NEXT_BUTTON, (clickingPlayer, _) -> { - currentPage++; - refresh(clickingPlayer); - }); - } - - setCloseButton(49); } @Override diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/player/BlightedPlayer.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/player/BlightedPlayer.java index 6577da0a..ec333410 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/player/BlightedPlayer.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/player/BlightedPlayer.java @@ -13,6 +13,7 @@ import org.bukkit.inventory.ItemStack; import java.util.*; +import java.util.concurrent.ConcurrentHashMap; /** * Server-side domain context for a connected {@link Player}. @@ -23,7 +24,7 @@ */ public final class BlightedPlayer { - private static final Map players = new HashMap<>(); + private static final Map players = new ConcurrentHashMap<>(); private static final double DEFAULT_MAX_MANA = 100; private static final double DEFAULT_MANA_REGEN_RATE = 0.5; @@ -54,11 +55,14 @@ public final class BlightedPlayer { private int forgeFuel; /** - * Constructs and registers a player context for an online Bukkit player. + * Constructs a player context for an online Bukkit player. + * + *

This constructor is private; instances are instantiated exclusively through + * {@link #get(Player)} to ensure registration in the active players map.

* * @param player player represented by this context */ - public BlightedPlayer(Player player) { + private BlightedPlayer(Player player) { this.player = player; this.playerId = player.getUniqueId(); this.dataHandler = new PlayerDataHandler(playerId, player.getName()); @@ -72,8 +76,6 @@ public BlightedPlayer(Player player) { setCurrentMana(dataHandler.getSavedMana()); this.forgeFuel = dataHandler.getSavedForgeFuel(); - players.put(playerId, this); - ArmorManager.updatePlayerArmor(this); } @@ -91,7 +93,8 @@ public void tick() { * @return registered player context, or {@code null} if no context exists */ public static BlightedPlayer get(Player player) { - return player != null ? players.get(player.getUniqueId()) : null; + if (player == null || !player.isOnline()) return null; + return players.computeIfAbsent(player.getUniqueId(), _ -> new BlightedPlayer(player)); } /** diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/player/PlayerListener.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/player/PlayerListener.java index 08551d46..2a15077e 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/player/PlayerListener.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/engine/player/PlayerListener.java @@ -20,7 +20,7 @@ public final class PlayerListener implements Listener { @EventHandler public void onJoin(PlayerJoinEvent event) { Player player = event.getPlayer(); - new BlightedPlayer(event.getPlayer()); + BlightedPlayer.get(event.getPlayer()); event.setJoinMessage(" §f" + event.getPlayer().getName() + " §7joined the SMP."); if (!player.hasPlayedBefore()) { diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/registry/CommandsRegistry.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/registry/CommandsRegistry.java index fad5028d..ef0ff73c 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/registry/CommandsRegistry.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/registry/CommandsRegistry.java @@ -1,6 +1,5 @@ package fr.moussax.blightedSMP.registry; -import fr.moussax.blightedSMP.BlightedSMP; import fr.moussax.bedrock.commands.CommandRegistrar; import fr.moussax.bedrock.commands.TabSuggestionRegistry; import fr.moussax.blightedSMP.commands.impl.*; @@ -10,6 +9,7 @@ import fr.moussax.blightedSMP.engine.items.registry.ItemRegistry; import org.bukkit.Bukkit; import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; /** * Central registry for all plugin commands. @@ -28,35 +28,31 @@ private CommandsRegistry() { * * @param plugin plugin instance used by the command registrar */ - public static void register(BlightedSMP plugin) { + public static void register(JavaPlugin plugin) { TabSuggestionRegistry suggestions = createSuggestionRegistry(); - CommandRegistrar commands = new CommandRegistrar(plugin, suggestions); + CommandRegistrar registrar = new CommandRegistrar(plugin, suggestions); - commands.register("altar", new AltarCommand()); - commands.register("craft", new CraftCommand()); - commands.register("forge", new ForgeCommand()); + registrar.register("altar", new AltarCommand()); + registrar.register("craft", new CraftCommand()); + registrar.register("forge", new ForgeCommand()); // Administrator Commands - commands.register("spawncustommob", new SpawnCustomMobCommand()); - commands.register("giveitem", new GiveItemCommand()); - commands.register("gems", new GemsCommand()); - commands.register("tppos", new TeleportPositionCommand()); - commands.register("invsee", new InvseeCommand()); - commands.register("endersee", new EnderseeCommand()); - - // Administrator Utilities + registrar.register("spawncustommob", new SpawnCustomMobCommand()); + registrar.register("giveitem", new GiveItemCommand()); + registrar.register("gems", new GemsCommand()); + registrar.register("tppos", new TeleportPositionCommand()); GamemodeCommands gamemodeCommands = new GamemodeCommands(); - commands.register("gmc", gamemodeCommands); - commands.register("gms", gamemodeCommands); - commands.register("gma", gamemodeCommands); - commands.register("gmspec", gamemodeCommands); - commands.register("fly", new FlyCommand()); - commands.register("god", new GodCommand()); - commands.register("nightvision", new NightVisionCommand()); - commands.register("speed", new SpeedCommand()); - commands.register("butcher", new ButcherCommand()); - commands.register("loop", new LoopCommand()); - commands.register("test", new TestCommand()); + registrar.register("gmc", gamemodeCommands); + registrar.register("gms", gamemodeCommands); + registrar.register("gma", gamemodeCommands); + registrar.register("gmspec", gamemodeCommands); + registrar.register("fly", new FlyCommand()); + registrar.register("god", new GodCommand()); + registrar.register("nightvision", new NightVisionCommand()); + registrar.register("speed", new SpeedCommand()); + registrar.register("butcher", new ButcherCommand()); + registrar.register("loop", new LoopCommand()); + registrar.register("test", new TestCommand()); } /** @@ -70,28 +66,22 @@ public static void register(BlightedSMP plugin) { private static TabSuggestionRegistry createSuggestionRegistry() { TabSuggestionRegistry suggestions = new TabSuggestionRegistry(); - suggestions.register( - "$players", - () -> Bukkit.getOnlinePlayers() - .stream() - .map(Player::getName) - .toList() + suggestions.register("$players", () -> Bukkit.getOnlinePlayers() + .stream() + .map(Player::getName) + .toList() ); - suggestions.register( - "$items", - () -> ItemRegistry.getAllItems() - .stream() - .map(BlightedItem::getItemId) - .toList() + suggestions.register("$items", () -> ItemRegistry.getAllItems() + .stream() + .map(BlightedItem::getItemId) + .toList() ); - suggestions.register( - "$entities", - () -> EntitiesRegistry.getAll() - .stream() - .map(BlightedEntity::getEntityId) - .toList() + suggestions.register("$entities", () -> EntitiesRegistry.getAll() + .stream() + .map(BlightedEntity::getEntityId) + .toList() ); return suggestions; diff --git a/blighted-smp/src/main/java/fr/moussax/blightedSMP/registry/EventsRegistry.java b/blighted-smp/src/main/java/fr/moussax/blightedSMP/registry/EventsRegistry.java index 00683f15..61a0d332 100644 --- a/blighted-smp/src/main/java/fr/moussax/blightedSMP/registry/EventsRegistry.java +++ b/blighted-smp/src/main/java/fr/moussax/blightedSMP/registry/EventsRegistry.java @@ -20,7 +20,6 @@ import fr.moussax.bedrock.ui.menu.system.MenuListener; import fr.moussax.bedrock.ui.menu.system.MenuSystem; import fr.moussax.bedrock.ui.sign.SignInputListener; -import lombok.Getter; import org.bukkit.Bukkit; import org.bukkit.plugin.PluginManager; diff --git a/blighted-smp/src/main/resources/plugin.yml b/blighted-smp/src/main/resources/plugin.yml index a80a1e53..b5c97c38 100644 --- a/blighted-smp/src/main/resources/plugin.yml +++ b/blighted-smp/src/main/resources/plugin.yml @@ -1,4 +1,4 @@ -name: BlightedMC +name: BlightedSMP version: '${version}' authors: [ MrInspection ] @@ -24,13 +24,7 @@ commands: tppos: description: Teleport to a position forge: - description: Forge command altar: - description: Altar command - endersee: - description: Moderator command - invsee: - description: Moderator command gms: description: Switch to survival mode gmc: diff --git a/pom.xml b/pom.xml index 4fd3d70e..a9911c3c 100644 --- a/pom.xml +++ b/pom.xml @@ -14,6 +14,7 @@ bedrock blighted-smp + blighted-mod @@ -21,6 +22,8 @@ UTF-8 26.2-R0.1-SNAPSHOT 1.18.46 + 3.45.3.0 + 5.10.2 @@ -54,6 +57,23 @@ ${lombok.version} provided + + org.xerial + sqlite-jdbc + ${sqlite-jdbc.version} + + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + test + + + org.junit.jupiter + junit-jupiter-engine + ${junit.version} + test +