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
+