+ .subcommand(BetterMcCommands.subBuilder("admin")
+ .description("Commandes administratives")
+ .permission("bettermc.admin")
+ .subcommand(BetterMcCommands.subBuilder("rank")
+ .description("Gestion des rangs des joueurs")
+ .subcommand(BetterMcCommands.subBuilder("set")
+ .description("Définit le rang d'un joueur")
+ .argument(Arguments.player("joueur"))
+ .argument(Arguments.enumOf("grade", Rank.class))
+ .executes(context -> {
+ Player target = context.getTargetPlayer("joueur");
+ Rank rank = context.get("grade", Rank.class);
+
+ context.replySuccess("Le rang de " + target.getName() + " a été défini sur " + rank.name() + ".");
+ })
+ )
+ )
+ )
+
+ .build();
+ }
+}
diff --git a/src/main/java/fr/luc/bettermccommands/demo/commands/DemoListener.java b/src/main/java/fr/luc/bettermccommands/demo/commands/DemoListener.java
new file mode 100644
index 0000000..2693b4f
--- /dev/null
+++ b/src/main/java/fr/luc/bettermccommands/demo/commands/DemoListener.java
@@ -0,0 +1,62 @@
+package fr.luc.bettermccommands.demo.commands;
+
+import fr.luc.bettermccommands.event.*;
+import fr.luc.bettermccommands.event.annotation.CommandEventHandler;
+
+/**
+ * Exemple de classe d'écouteurs d'événements de commandes utilisant l'annotation {@link CommandEventHandler}.
+ */
+public class DemoListener {
+
+ /**
+ * Intercepte toutes les commandes avant leur exécution.
+ *
+ * @param event L'événement de pré-exécution.
+ */
+ @CommandEventHandler(priority = 10)
+ public void onAnyCommandPreExecute(CommandPreExecuteEvent event) {
+ System.out.println("[betterMcCommands Log] Exécution demandée pour : /" + event.getNode().getFullName() +
+ " par " + event.getSender().getName());
+ }
+
+ /**
+ * Intercepte spécifiquement les événements sur la commande 'commande-demo'.
+ *
+ * @param event L'événement de post-exécution.
+ */
+ @CommandEventHandler(command = "commande-demo")
+ public void onDemoPostExecute(CommandPostExecuteEvent event) {
+ if (event.isSuccessful()) {
+ System.out.println("[betterMcCommands Metric] /" + event.getNode().getFullName() +
+ " exécutée en " + event.getExecutionDuration().toMillis() + "ms.");
+ } else {
+ System.err.println("[betterMcCommands Alert] Ăchec d'exĂ©cution sur /" + event.getNode().getFullName());
+ }
+ }
+
+ /**
+ * Intercepte les refus de permission pour personnaliser le message ou jouer un son.
+ *
+ * @param event L'événement de permission refusée.
+ */
+ @CommandEventHandler
+ public void onPermissionDenied(CommandPermissionDeniedEvent event) {
+ if (event.getRequiredPermission() != null) {
+ event.setCustomErrorMessage("AccĂšs Restreint : Permission requise [" +
+ event.getRequiredPermission() + "].");
+ }
+ }
+
+ /**
+ * Intercepte les tentatives d'exécution sous cooldown.
+ *
+ * @param event L'événement de cooldown.
+ */
+ @CommandEventHandler
+ public void onCooldown(CommandCooldownEvent event) {
+ long seconds = event.getRemainingCooldown().toSeconds();
+ event.setCustomMessage("âł Doucement ! Vous devez encore attendre " +
+ (seconds > 0 ? seconds + "s" : event.getRemainingCooldown().toMillis() + "ms") +
+ " avant de réutiliser /" + event.getNode().getFullName() + ".");
+ }
+}
diff --git a/src/main/java/fr/luc/bettermccommands/event/CancellableCommandEvent.java b/src/main/java/fr/luc/bettermccommands/event/CancellableCommandEvent.java
new file mode 100644
index 0000000..4d0d2ed
--- /dev/null
+++ b/src/main/java/fr/luc/bettermccommands/event/CancellableCommandEvent.java
@@ -0,0 +1,35 @@
+package fr.luc.bettermccommands.event;
+
+import fr.luc.bettermccommands.api.CommandContext;
+import fr.luc.bettermccommands.api.CommandNode;
+import org.bukkit.command.CommandSender;
+import org.bukkit.event.Cancellable;
+
+/**
+ * Classe de base pour les Ă©vĂ©nements de commande pouvant ĂȘtre annulĂ©s par les Ă©couteurs.
+ */
+public abstract class CancellableCommandEvent extends CommandEvent implements Cancellable {
+
+ private boolean cancelled = false;
+
+ /**
+ * Crée un événement annulable.
+ *
+ * @param node Le nĆud de commande.
+ * @param sender L'émetteur.
+ * @param context Le contexte.
+ */
+ public CancellableCommandEvent(CommandNode node, CommandSender sender, CommandContext context) {
+ super(node, sender, context);
+ }
+
+ @Override
+ public boolean isCancelled() {
+ return cancelled;
+ }
+
+ @Override
+ public void setCancelled(boolean cancel) {
+ this.cancelled = cancel;
+ }
+}
diff --git a/src/main/java/fr/luc/bettermccommands/event/CommandCooldownEvent.java b/src/main/java/fr/luc/bettermccommands/event/CommandCooldownEvent.java
new file mode 100644
index 0000000..2d45446
--- /dev/null
+++ b/src/main/java/fr/luc/bettermccommands/event/CommandCooldownEvent.java
@@ -0,0 +1,54 @@
+package fr.luc.bettermccommands.event;
+
+import fr.luc.bettermccommands.api.CommandContext;
+import fr.luc.bettermccommands.api.CommandNode;
+import org.bukkit.command.CommandSender;
+
+import java.time.Duration;
+
+/**
+ * ĂvĂ©nement dĂ©clenchĂ© lorsqu'un joueur tente d'exĂ©cuter une commande soumise Ă un temps de recharge (Cooldown) actif.
+ *
+ * Si l'événement est annulé ({@code setCancelled(true)}), l'exécution de la commande est autorisée exceptionnellement.
+ */
+public class CommandCooldownEvent extends CancellableCommandEvent {
+
+ private final Duration remainingCooldown;
+ private String customMessage;
+
+ /**
+ * Crée un événement de cooldown de commande.
+ *
+ * @param node Le nĆud de commande.
+ * @param sender L'émetteur sous cooldown.
+ * @param context Le contexte de la commande.
+ * @param remainingCooldown La durée restante avant expiration.
+ */
+ public CommandCooldownEvent(CommandNode node, CommandSender sender, CommandContext context, Duration remainingCooldown) {
+ super(node, sender, context);
+ this.remainingCooldown = remainingCooldown;
+ }
+
+ /**
+ * @return La durée restante avant la fin du cooldown.
+ */
+ public Duration getRemainingCooldown() {
+ return remainingCooldown;
+ }
+
+ /**
+ * @return Le message personnalisé éventuel, ou {@code null}.
+ */
+ public String getCustomMessage() {
+ return customMessage;
+ }
+
+ /**
+ * Définit un message d'avertissement de cooldown sur-mesure.
+ *
+ * @param customMessage Le message au format MiniMessage ou couleurs Minecraft.
+ */
+ public void setCustomMessage(String customMessage) {
+ this.customMessage = customMessage;
+ }
+}
diff --git a/src/main/java/fr/luc/bettermccommands/event/CommandEvent.java b/src/main/java/fr/luc/bettermccommands/event/CommandEvent.java
new file mode 100644
index 0000000..33996ae
--- /dev/null
+++ b/src/main/java/fr/luc/bettermccommands/event/CommandEvent.java
@@ -0,0 +1,83 @@
+package fr.luc.bettermccommands.event;
+
+import fr.luc.bettermccommands.api.CommandContext;
+import fr.luc.bettermccommands.api.CommandNode;
+import org.bukkit.command.CommandSender;
+import org.bukkit.entity.Player;
+
+import java.util.Objects;
+
+/**
+ * Classe de base pour tous les événements du cycle de vie d'une commande.
+ */
+public abstract class CommandEvent {
+
+ private final CommandNode node;
+ private final CommandSender sender;
+ private final CommandContext context;
+
+ /**
+ * Crée un nouvel événement de commande.
+ *
+ * @param node Le nĆud de commande ciblĂ©.
+ * @param sender L'émetteur de la commande.
+ * @param context Le contexte d'exĂ©cution (peut ĂȘtre partiel ou null).
+ */
+ public CommandEvent(CommandNode node, CommandSender sender, CommandContext context) {
+ this.node = Objects.requireNonNull(node, "node cannot be null");
+ this.sender = Objects.requireNonNull(sender, "sender cannot be null");
+ this.context = context;
+ }
+
+ /**
+ * @return Le nĆud de commande concernĂ© par cet Ă©vĂ©nement.
+ */
+ public CommandNode getNode() {
+ return node;
+ }
+
+ /**
+ * @return L'émetteur ayant invoqué la commande.
+ */
+ public CommandSender getSender() {
+ return sender;
+ }
+
+ /**
+ * @return true si l'émetteur est un joueur.
+ */
+ public boolean isPlayer() {
+ return sender instanceof Player;
+ }
+
+ /**
+ * @return Le joueur émetteur.
+ * @throws IllegalStateException si l'émetteur n'est pas un joueur.
+ */
+ public Player getPlayer() {
+ if (!isPlayer()) {
+ throw new IllegalStateException("CommandSender is not a player: " + sender.getClass().getSimpleName());
+ }
+ return (Player) sender;
+ }
+
+ /**
+ * @return Le contexte d'exécution s'il est déjà instancié, sinon {@code null}.
+ */
+ public CommandContext getContext() {
+ return context;
+ }
+
+ /**
+ * Envoie une réponse textuelle formatée à l'émetteur de la commande.
+ *
+ * @param miniMessageText Le message au format MiniMessage ou couleurs Minecraft.
+ */
+ public void reply(String miniMessageText) {
+ if (context != null) {
+ context.reply(miniMessageText);
+ } else {
+ sender.sendMessage(miniMessageText);
+ }
+ }
+}
diff --git a/src/main/java/fr/luc/bettermccommands/event/CommandEventListener.java b/src/main/java/fr/luc/bettermccommands/event/CommandEventListener.java
new file mode 100644
index 0000000..224e6ae
--- /dev/null
+++ b/src/main/java/fr/luc/bettermccommands/event/CommandEventListener.java
@@ -0,0 +1,17 @@
+package fr.luc.bettermccommands.event;
+
+/**
+ * Interface fonctionnelle pour écouter un événement de commande typé.
+ *
+ * @param Le type d'événement écouté.
+ */
+@FunctionalInterface
+public interface CommandEventListener {
+
+ /**
+ * Invoqué lorsque l'événement de commande survient.
+ *
+ * @param event L'instance de l'événement.
+ */
+ void onEvent(T event);
+}
diff --git a/src/main/java/fr/luc/bettermccommands/event/CommandEventManager.java b/src/main/java/fr/luc/bettermccommands/event/CommandEventManager.java
new file mode 100644
index 0000000..9712b86
--- /dev/null
+++ b/src/main/java/fr/luc/bettermccommands/event/CommandEventManager.java
@@ -0,0 +1,139 @@
+package fr.luc.bettermccommands.event;
+
+import fr.luc.bettermccommands.event.annotation.CommandEventHandler;
+
+import java.lang.reflect.Method;
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+/**
+ * Gestionnaire et bus central des événements de commande.
+ * Supporte à la fois l'enregistrement fonctionnel (lambdas) et déclaratif (classes annotées avec {@link CommandEventHandler}).
+ */
+public class CommandEventManager {
+
+ private record ListenerRegistration(Class> eventType, CommandEventListener listener, String commandFilter, int priority) {}
+
+ private final List registrations = new CopyOnWriteArrayList<>();
+
+ /**
+ * Enregistre un écouteur fonctionnel pour un type d'événement donné.
+ *
+ * @param eventType Le type d'événement à écouter.
+ * @param listener Le consommateur de l'événement.
+ * @param Le type de l'événement.
+ */
+ @SuppressWarnings("unchecked")
+ public void register(Class eventType, CommandEventListener listener) {
+ register(eventType, listener, null, 0);
+ }
+
+ /**
+ * Enregistre un écouteur fonctionnel avec filtre sur le nom de commande et priorité.
+ *
+ * @param eventType Le type d'événement.
+ * @param listener Le callback.
+ * @param commandFilter Le filtre sur le nom de la commande (ou null).
+ * @param priority La priorité d'exécution.
+ * @param Le type de l'événement.
+ */
+ @SuppressWarnings("unchecked")
+ public void register(Class eventType, CommandEventListener listener, String commandFilter, int priority) {
+ Objects.requireNonNull(eventType, "eventType cannot be null");
+ Objects.requireNonNull(listener, "listener cannot be null");
+
+ registrations.add(new ListenerRegistration(
+ eventType,
+ (CommandEventListener) listener,
+ commandFilter != null && !commandFilter.isEmpty() ? commandFilter.toLowerCase() : null,
+ priority
+ ));
+ sortRegistrations();
+ }
+
+ /**
+ * Analyse une instance d'écouteur et enregistre toutes ses méthodes annotées avec {@link CommandEventHandler}.
+ *
+ * @param listenerInstance L'instance de la classe contenant des méthodes annotées.
+ */
+ public void registerListeners(Object listenerInstance) {
+ Objects.requireNonNull(listenerInstance, "listenerInstance cannot be null");
+
+ for (Method method : listenerInstance.getClass().getDeclaredMethods()) {
+ if (!method.isAnnotationPresent(CommandEventHandler.class)) {
+ continue;
+ }
+
+ CommandEventHandler annotation = method.getAnnotation(CommandEventHandler.class);
+ Class>[] params = method.getParameterTypes();
+ if (params.length != 1 || !CommandEvent.class.isAssignableFrom(params[0])) {
+ throw new IllegalArgumentException("Method " + method.getName() + " in " +
+ listenerInstance.getClass().getName() + " must have exactly 1 parameter extending CommandEvent.");
+ }
+
+ @SuppressWarnings("unchecked")
+ Class extends CommandEvent> eventType = (Class extends CommandEvent>) params[0];
+ method.setAccessible(true);
+
+ CommandEventListener listener = event -> {
+ try {
+ method.invoke(listenerInstance, event);
+ } catch (Exception e) {
+ System.err.println("[betterMcCommands] Error dispatching event " + event.getClass().getSimpleName() +
+ " to listener " + listenerInstance.getClass().getSimpleName() + "#" + method.getName());
+ e.printStackTrace();
+ }
+ };
+
+ registrations.add(new ListenerRegistration(
+ eventType,
+ listener,
+ annotation.command().isEmpty() ? null : annotation.command().toLowerCase(),
+ annotation.priority()
+ ));
+ }
+ sortRegistrations();
+ }
+
+ /**
+ * Déclenche un événement et notifie tous les écouteurs enregistrés correspondants.
+ *
+ * @param event L'événement à diffuser.
+ */
+ public void dispatch(CommandEvent event) {
+ if (event == null) {
+ return;
+ }
+
+ String cmdName = event.getNode().getRootName().toLowerCase();
+
+ for (ListenerRegistration reg : registrations) {
+ if (!reg.eventType().isInstance(event)) {
+ continue;
+ }
+
+ if (reg.commandFilter() != null && !reg.commandFilter().equalsIgnoreCase(cmdName)) {
+ continue;
+ }
+
+ try {
+ reg.listener().onEvent(event);
+ } catch (Exception e) {
+ System.err.println("[betterMcCommands] Exception in event listener for " + event.getClass().getSimpleName() + ": " + e.getMessage());
+ e.printStackTrace();
+ }
+ }
+ }
+
+ /**
+ * Supprime tous les écouteurs enregistrés.
+ */
+ public void unregisterAll() {
+ registrations.clear();
+ }
+
+ private void sortRegistrations() {
+ registrations.sort(Comparator.comparingInt(ListenerRegistration::priority));
+ }
+}
diff --git a/src/main/java/fr/luc/bettermccommands/event/CommandPermissionDeniedEvent.java b/src/main/java/fr/luc/bettermccommands/event/CommandPermissionDeniedEvent.java
new file mode 100644
index 0000000..0fb9b22
--- /dev/null
+++ b/src/main/java/fr/luc/bettermccommands/event/CommandPermissionDeniedEvent.java
@@ -0,0 +1,66 @@
+package fr.luc.bettermccommands.event;
+
+import fr.luc.bettermccommands.api.CommandContext;
+import fr.luc.bettermccommands.api.CommandNode;
+import fr.luc.bettermccommands.api.CommandSenderType;
+import org.bukkit.command.CommandSender;
+
+/**
+ * ĂvĂ©nement dĂ©clenchĂ© lorsqu'un Ă©metteur tente d'exĂ©cuter une commande sans disposer de la permission requise
+ * ou sans avoir le type d'émetteur attendu (ex: console voulant exécuter une commande réservée aux joueurs).
+ *
+ * Si l'événement est annulé ({@code setCancelled(true)}), le message d'erreur par défaut n'est pas envoyé,
+ * ce qui permet d'afficher un message ou de jouer un son personnalisé.
+ */
+public class CommandPermissionDeniedEvent extends CancellableCommandEvent {
+
+ private final String requiredPermission;
+ private final CommandSenderType requiredSenderType;
+ private String customErrorMessage;
+
+ /**
+ * Crée l'événement de refus de permission / restriction d'émetteur.
+ *
+ * @param node Le nĆud ciblĂ©.
+ * @param sender L'émetteur bloqué.
+ * @param context Le contexte d'exĂ©cution (peut ĂȘtre null).
+ * @param requiredPermission La permission manquante (ou null).
+ * @param requiredSenderType Le type d'émetteur requis.
+ */
+ public CommandPermissionDeniedEvent(CommandNode node, CommandSender sender, CommandContext context,
+ String requiredPermission, CommandSenderType requiredSenderType) {
+ super(node, sender, context);
+ this.requiredPermission = requiredPermission;
+ this.requiredSenderType = requiredSenderType;
+ }
+
+ /**
+ * @return La permission manquante, ou {@code null} s'il s'agit d'une restriction de type d'émetteur.
+ */
+ public String getRequiredPermission() {
+ return requiredPermission;
+ }
+
+ /**
+ * @return Le type d'émetteur requis par la commande.
+ */
+ public CommandSenderType getRequiredSenderType() {
+ return requiredSenderType;
+ }
+
+ /**
+ * @return Le message d'erreur personnalisé à envoyer, ou {@code null} pour le message par défaut.
+ */
+ public String getCustomErrorMessage() {
+ return customErrorMessage;
+ }
+
+ /**
+ * Définit un message d'erreur sur-mesure à envoyer au joueur.
+ *
+ * @param customErrorMessage Le message formaté.
+ */
+ public void setCustomErrorMessage(String customErrorMessage) {
+ this.customErrorMessage = customErrorMessage;
+ }
+}
diff --git a/src/main/java/fr/luc/bettermccommands/event/CommandPostExecuteEvent.java b/src/main/java/fr/luc/bettermccommands/event/CommandPostExecuteEvent.java
new file mode 100644
index 0000000..ee0c376
--- /dev/null
+++ b/src/main/java/fr/luc/bettermccommands/event/CommandPostExecuteEvent.java
@@ -0,0 +1,65 @@
+package fr.luc.bettermccommands.event;
+
+import fr.luc.bettermccommands.api.CommandContext;
+import fr.luc.bettermccommands.api.CommandNode;
+import fr.luc.bettermccommands.api.CommandResult;
+import org.bukkit.command.CommandSender;
+
+import java.time.Duration;
+
+/**
+ * ĂvĂ©nement dĂ©clenchĂ© aprĂšs l'exĂ©cution d'une commande (avec succĂšs ou Ă©chec).
+ * Utile pour les métriques, les journaux d'audit (logging) et les statistiques d'utilisation.
+ */
+public class CommandPostExecuteEvent extends CommandEvent {
+
+ private final CommandResult result;
+ private final Duration executionDuration;
+ private final Throwable exception;
+
+ /**
+ * Crée un événement de post-exécution.
+ *
+ * @param node Le nĆud exĂ©cutĂ©.
+ * @param sender L'émetteur.
+ * @param context Le contexte de la commande.
+ * @param result Le résultat final.
+ * @param executionDuration La durée totale d'exécution.
+ * @param exception L'exception éventuelle survenue lors de l'exécution (ou null).
+ */
+ public CommandPostExecuteEvent(CommandNode node, CommandSender sender, CommandContext context,
+ CommandResult result, Duration executionDuration, Throwable exception) {
+ super(node, sender, context);
+ this.result = result;
+ this.executionDuration = executionDuration;
+ this.exception = exception;
+ }
+
+ /**
+ * @return Le résultat d'exécution de la commande.
+ */
+ public CommandResult getResult() {
+ return result;
+ }
+
+ /**
+ * @return La durée nécessaire au traitement de la commande.
+ */
+ public Duration getExecutionDuration() {
+ return executionDuration;
+ }
+
+ /**
+ * @return L'exception levée lors de l'exécution, ou {@code null} en cas de succÚs.
+ */
+ public Throwable getException() {
+ return exception;
+ }
+
+ /**
+ * @return true si la commande s'est exécutée avec succÚs sans lever d'exception.
+ */
+ public boolean isSuccessful() {
+ return result == CommandResult.SUCCESS && exception == null;
+ }
+}
diff --git a/src/main/java/fr/luc/bettermccommands/event/CommandPreExecuteEvent.java b/src/main/java/fr/luc/bettermccommands/event/CommandPreExecuteEvent.java
new file mode 100644
index 0000000..34e5492
--- /dev/null
+++ b/src/main/java/fr/luc/bettermccommands/event/CommandPreExecuteEvent.java
@@ -0,0 +1,23 @@
+package fr.luc.bettermccommands.event;
+
+import fr.luc.bettermccommands.api.CommandContext;
+import fr.luc.bettermccommands.api.CommandNode;
+import org.bukkit.command.CommandSender;
+
+/**
+ * ĂvĂ©nement dĂ©clenchĂ© juste avant l'exĂ©cution du gestionnaire mĂ©tier de la commande.
+ * Permet d'annuler la commande, de vérifier des pré-conditions (état de combat, économie, inventaire) ou d'injecter des métadonnées.
+ */
+public class CommandPreExecuteEvent extends CancellableCommandEvent {
+
+ /**
+ * Construit l'événement de pré-exécution.
+ *
+ * @param node Le nĆud de commande sur le point d'ĂȘtre exĂ©cutĂ©.
+ * @param sender L'émetteur.
+ * @param context Le contexte complet avec les arguments résolus.
+ */
+ public CommandPreExecuteEvent(CommandNode node, CommandSender sender, CommandContext context) {
+ super(node, sender, context);
+ }
+}
diff --git a/src/main/java/fr/luc/bettermccommands/event/CommandSyntaxErrorEvent.java b/src/main/java/fr/luc/bettermccommands/event/CommandSyntaxErrorEvent.java
new file mode 100644
index 0000000..d14bbbf
--- /dev/null
+++ b/src/main/java/fr/luc/bettermccommands/event/CommandSyntaxErrorEvent.java
@@ -0,0 +1,64 @@
+package fr.luc.bettermccommands.event;
+
+import fr.luc.bettermccommands.api.CommandContext;
+import fr.luc.bettermccommands.api.CommandNode;
+import org.bukkit.command.CommandSender;
+
+/**
+ * ĂvĂ©nement dĂ©clenchĂ© lorsqu'une commande est appelĂ©e avec une syntaxe incorrecte
+ * (arguments obligatoires manquants, sous-commande inexistante ou erreur de conversion d'argument).
+ *
+ * Si l'événement est annulé ({@code setCancelled(true)}), le message d'aide automatique n'est pas envoyé.
+ */
+public class CommandSyntaxErrorEvent extends CancellableCommandEvent {
+
+ private final String errorReason;
+ private final String usage;
+ private String customMessage;
+
+ /**
+ * Crée un événement d'erreur de syntaxe.
+ *
+ * @param node Le nĆud de commande concernĂ©.
+ * @param sender L'émetteur.
+ * @param context Le contexte de commande (peut ĂȘtre null ou partiel).
+ * @param errorReason L'explication de l'erreur.
+ * @param usage La syntaxe attendue (ex: "/demo give [quantite]").
+ */
+ public CommandSyntaxErrorEvent(CommandNode node, CommandSender sender, CommandContext context,
+ String errorReason, String usage) {
+ super(node, sender, context);
+ this.errorReason = errorReason;
+ this.usage = usage;
+ }
+
+ /**
+ * @return La raison détaillée de l'erreur de syntaxe.
+ */
+ public String getErrorReason() {
+ return errorReason;
+ }
+
+ /**
+ * @return La ligne d'usage correcte pour cette commande.
+ */
+ public String getUsage() {
+ return usage;
+ }
+
+ /**
+ * @return Le message d'erreur personnalisé à envoyer, ou {@code null}.
+ */
+ public String getCustomMessage() {
+ return customMessage;
+ }
+
+ /**
+ * Définit un message personnalisé à la place de l'aide par défaut.
+ *
+ * @param customMessage Le message personnalisé.
+ */
+ public void setCustomMessage(String customMessage) {
+ this.customMessage = customMessage;
+ }
+}
diff --git a/src/main/java/fr/luc/bettermccommands/event/CommandTabCompleteEvent.java b/src/main/java/fr/luc/bettermccommands/event/CommandTabCompleteEvent.java
new file mode 100644
index 0000000..57e6e82
--- /dev/null
+++ b/src/main/java/fr/luc/bettermccommands/event/CommandTabCompleteEvent.java
@@ -0,0 +1,78 @@
+package fr.luc.bettermccommands.event;
+
+import fr.luc.bettermccommands.api.CommandContext;
+import fr.luc.bettermccommands.api.CommandNode;
+import fr.luc.bettermccommands.api.suggestion.Suggestion;
+import org.bukkit.command.CommandSender;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * ĂvĂ©nement dĂ©clenchĂ© lors du calcul des suggestions d'auto-complĂ©tion (Tab-Complete).
+ * Permet d'ajouter, modifier, filtrer ou réordonner les suggestions renvoyées au client.
+ */
+public class CommandTabCompleteEvent extends CancellableCommandEvent {
+
+ private final List suggestions;
+ private final String currentInput;
+ private final int argumentIndex;
+
+ /**
+ * Crée l'événement de complétion.
+ *
+ * @param node Le nĆud de commande concernĂ©.
+ * @param sender L'émetteur recevant les suggestions.
+ * @param context Le contexte partiel.
+ * @param suggestions La liste modifiable des suggestions calculées.
+ * @param currentInput La chaßne tapée en cours.
+ * @param argumentIndex L'index de l'argument complété.
+ */
+ public CommandTabCompleteEvent(CommandNode node, CommandSender sender, CommandContext context,
+ List suggestions, String currentInput, int argumentIndex) {
+ super(node, sender, context);
+ this.suggestions = new ArrayList<>(suggestions);
+ this.currentInput = currentInput != null ? currentInput : "";
+ this.argumentIndex = argumentIndex;
+ }
+
+ /**
+ * @return La liste modifiable des suggestions qui seront retournées au client.
+ */
+ public List getSuggestions() {
+ return suggestions;
+ }
+
+ /**
+ * Ajoute une suggestion simple Ă la liste.
+ *
+ * @param value La chaßne suggérée.
+ */
+ public void addSuggestion(String value) {
+ this.suggestions.add(Suggestion.of(value));
+ }
+
+ /**
+ * Ajoute une suggestion avec infobulle Ă la liste.
+ *
+ * @param value Le texte inséré.
+ * @param tooltip L'infobulle affichée.
+ */
+ public void addSuggestion(String value, String tooltip) {
+ this.suggestions.add(Suggestion.of(value, tooltip));
+ }
+
+ /**
+ * @return Le mot en cours de saisie par le joueur.
+ */
+ public String getCurrentInput() {
+ return currentInput;
+ }
+
+ /**
+ * @return L'index de l'argument en cours de frappe.
+ */
+ public int getArgumentIndex() {
+ return argumentIndex;
+ }
+}
diff --git a/src/main/java/fr/luc/bettermccommands/event/annotation/CommandEventHandler.java b/src/main/java/fr/luc/bettermccommands/event/annotation/CommandEventHandler.java
new file mode 100644
index 0000000..3ac0410
--- /dev/null
+++ b/src/main/java/fr/luc/bettermccommands/event/annotation/CommandEventHandler.java
@@ -0,0 +1,30 @@
+package fr.luc.bettermccommands.event.annotation;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Annote une méthode d'une classe d'écoute pour recevoir les événements de commande betterMcCommands.
+ * La méthode doit accepter un unique paramÚtre héritant de {@link fr.luc.bettermccommands.event.CommandEvent}.
+ */
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface CommandEventHandler {
+
+ /**
+ * Filtre optionnel sur le nom racine de la commande (ex: "commande-demo").
+ * Si laissé vide (""), la méthode recevra les événements de toutes les commandes.
+ *
+ * @return Le nom de commande filtré ou vide pour tous.
+ */
+ String command() default "";
+
+ /**
+ * Priorité d'exécution de l'écouteur (les valeurs plus petites sont exécutées en premier).
+ *
+ * @return La priorité numérique (défaut: 0).
+ */
+ int priority() default 0;
+}
diff --git a/src/main/java/fr/luc/bettermccommands/platform/CommandDispatcher.java b/src/main/java/fr/luc/bettermccommands/platform/CommandDispatcher.java
new file mode 100644
index 0000000..db9f308
--- /dev/null
+++ b/src/main/java/fr/luc/bettermccommands/platform/CommandDispatcher.java
@@ -0,0 +1,337 @@
+package fr.luc.bettermccommands.platform;
+
+import fr.luc.bettermccommands.BetterMcCommands;
+import fr.luc.bettermccommands.api.*;
+import fr.luc.bettermccommands.api.suggestion.Suggestion;
+import fr.luc.bettermccommands.argument.CommandArgument;
+import fr.luc.bettermccommands.argument.CommandArgumentParseException;
+import fr.luc.bettermccommands.event.*;
+import org.bukkit.command.CommandSender;
+import org.bukkit.entity.Player;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * Moteur central de routage, de validation, de parsing et d'exécution des commandes.
+ */
+public class CommandDispatcher {
+
+ private final BetterMcCommands manager;
+
+ /**
+ * Crée un nouveau dispatcher associé à un gestionnaire {@link BetterMcCommands}.
+ *
+ * @param manager L'instance du gestionnaire.
+ */
+ public CommandDispatcher(BetterMcCommands manager) {
+ this.manager = Objects.requireNonNull(manager, "manager cannot be null");
+ }
+
+ /**
+ * Exécute une commande racine avec les arguments bruts fournis.
+ *
+ * @param rootCommand La commande racine.
+ * @param sender L'émetteur de la commande.
+ * @param label Le label ou alias utilisé.
+ * @param args Les arguments bruts.
+ * @return Le résultat d'exécution {@link CommandResult}.
+ */
+ public CommandResult execute(Command rootCommand, CommandSender sender, String label, String[] args) {
+ Instant startTime = Instant.now();
+
+ // 1. Résolution de la sous-commande ciblée dans l'arborescence
+ CommandNode targetNode = rootCommand;
+ int argIndex = 0;
+
+ while (argIndex < args.length && targetNode.hasSubCommands()) {
+ String candidate = args[argIndex];
+ CommandNode sub = targetNode.findSubCommand(candidate);
+ if (sub != null) {
+ targetNode = sub;
+ argIndex++;
+ } else {
+ break;
+ }
+ }
+
+ // Arguments restants destinés aux paramÚtres de la commande ciblée
+ String[] remainingArgs = Arrays.copyOfRange(args, argIndex, args.length);
+
+ // 2. Vérification du type d'émetteur (Console vs Player)
+ if (!targetNode.getSenderType().isAllowed(sender)) {
+ CommandPermissionDeniedEvent event = new CommandPermissionDeniedEvent(
+ targetNode, sender, null, null, targetNode.getSenderType());
+ dispatchEvent(targetNode, event);
+
+ if (!event.isCancelled()) {
+ if (event.getCustomErrorMessage() != null) {
+ event.reply(event.getCustomErrorMessage());
+ } else if (targetNode.getSenderType() == CommandSenderType.PLAYER_ONLY) {
+ sender.sendMessage("§cCette commande est réservée aux joueurs en jeu.");
+ } else if (targetNode.getSenderType() == CommandSenderType.CONSOLE_ONLY) {
+ sender.sendMessage("§cCette commande ne peut ĂȘtre exĂ©cutĂ©e que depuis la console.");
+ }
+ }
+ return CommandResult.PERMISSION_DENIED;
+ }
+
+ // 3. Vérification des permissions
+ if (targetNode.getPermission() != null && !targetNode.getPermission().isEmpty()) {
+ if (!sender.hasPermission(targetNode.getPermission())) {
+ CommandPermissionDeniedEvent event = new CommandPermissionDeniedEvent(
+ targetNode, sender, null, targetNode.getPermission(), targetNode.getSenderType());
+ dispatchEvent(targetNode, event);
+
+ if (!event.isCancelled()) {
+ if (event.getCustomErrorMessage() != null) {
+ event.reply(event.getCustomErrorMessage());
+ } else {
+ sender.sendMessage("§cVous n'avez pas la permission requise (§7" + targetNode.getPermission() + "§c).");
+ }
+ }
+ return CommandResult.PERMISSION_DENIED;
+ }
+ }
+
+ // 4. Vérification du temps de recharge (Cooldown)
+ if (manager.getCooldownManager().isOnCooldown(targetNode, sender)) {
+ Duration remaining = manager.getCooldownManager().getRemainingCooldown(targetNode, sender);
+ CommandCooldownEvent event = new CommandCooldownEvent(targetNode, sender, null, remaining);
+ dispatchEvent(targetNode, event);
+
+ if (!event.isCancelled()) {
+ if (event.getCustomMessage() != null) {
+ event.reply(event.getCustomMessage());
+ } else {
+ long seconds = remaining.toSeconds();
+ String timeStr = seconds > 0 ? seconds + "s" : remaining.toMillis() + "ms";
+ sender.sendMessage("§cVeuillez patienter §6" + timeStr + " §cavant de réutiliser cette commande.");
+ }
+ return CommandResult.COOLDOWN;
+ }
+ }
+
+ // 5. Analyse et parsing des arguments
+ Map parsedArguments = new LinkedHashMap<>();
+ CommandContext partialContext = new CommandContext(sender, label, args, parsedArguments);
+
+ List> expectedArgs = targetNode.getArguments();
+ int expectedIndex = 0;
+ int remainingIndex = 0;
+
+ while (expectedIndex < expectedArgs.size()) {
+ CommandArgument> arg = expectedArgs.get(expectedIndex);
+
+ if (arg.isGreedy()) {
+ if (remainingIndex >= remainingArgs.length) {
+ if (!arg.isOptional()) {
+ return handleSyntaxError(targetNode, sender, partialContext,
+ "Argument gourmand manquant : " + arg.getName(), targetNode.getUsage());
+ } else {
+ parsedArguments.put(arg.getName(), arg.getDefaultValue());
+ }
+ } else {
+ String greedyInput = String.join(" ", Arrays.copyOfRange(remainingArgs, remainingIndex, remainingArgs.length));
+ try {
+ Object parsed = parseArgumentValue(arg, greedyInput, partialContext);
+ parsedArguments.put(arg.getName(), parsed);
+ } catch (CommandArgumentParseException e) {
+ return handleSyntaxError(targetNode, sender, partialContext, e.getMessage(), targetNode.getUsage());
+ }
+ remainingIndex = remainingArgs.length; // Tous les arguments sont consommés
+ }
+ expectedIndex++;
+ break;
+ }
+
+ if (remainingIndex >= remainingArgs.length) {
+ if (arg.isOptional()) {
+ parsedArguments.put(arg.getName(), arg.getDefaultValue());
+ } else {
+ return handleSyntaxError(targetNode, sender, partialContext,
+ "Argument obligatoire manquant : <" + arg.getName() + ">", targetNode.getUsage());
+ }
+ } else {
+ String input = remainingArgs[remainingIndex];
+ try {
+ Object parsed = parseArgumentValue(arg, input, partialContext);
+ parsedArguments.put(arg.getName(), parsed);
+ } catch (CommandArgumentParseException e) {
+ return handleSyntaxError(targetNode, sender, partialContext, e.getMessage(), targetNode.getUsage());
+ }
+ remainingIndex++;
+ }
+ expectedIndex++;
+ }
+
+ // Si des arguments surnuméraires ont été passés alors qu'aucun argument greedy n'est défini
+ if (remainingIndex < remainingArgs.length && targetNode.getExecutor() != null && !expectedArgs.isEmpty()
+ && !expectedArgs.get(expectedArgs.size() - 1).isGreedy()) {
+ // Trop d'arguments fournis
+ return handleSyntaxError(targetNode, sender, partialContext,
+ "Trop d'arguments fournis pour cette commande.", targetNode.getUsage());
+ }
+
+ // Si le nĆud n'a pas d'exĂ©cuteur mais a des sous-commandes, afficher l'aide des sous-commandes
+ if (targetNode.getExecutor() == null) {
+ sendSubcommandsHelp(targetNode, sender);
+ return CommandResult.SUCCESS;
+ }
+
+ // 6. Contexte final
+ CommandContext fullContext = new CommandContext(sender, label, args, parsedArguments);
+
+ // 7. ĂvĂ©nement PreExecute
+ CommandPreExecuteEvent preEvent = new CommandPreExecuteEvent(targetNode, sender, fullContext);
+ dispatchEvent(targetNode, preEvent);
+
+ if (preEvent.isCancelled()) {
+ return CommandResult.CANCELLED;
+ }
+
+ // 8. Exécution du gestionnaire métier
+ Throwable executionError = null;
+ CommandResult result;
+
+ try {
+ targetNode.getExecutor().execute(fullContext);
+ result = CommandResult.SUCCESS;
+ manager.getCooldownManager().applyCooldown(targetNode, sender);
+ } catch (Throwable t) {
+ executionError = t;
+ result = CommandResult.FAILED;
+ sender.sendMessage("§cUne erreur interne est survenue lors de l'exécution de la commande.");
+ t.printStackTrace();
+ }
+
+ // 9. ĂvĂ©nement PostExecute
+ Duration duration = Duration.between(startTime, Instant.now());
+ CommandPostExecuteEvent postEvent = new CommandPostExecuteEvent(
+ targetNode, sender, fullContext, result, duration, executionError);
+ dispatchEvent(targetNode, postEvent);
+
+ return result;
+ }
+
+ /**
+ * Calcule la liste de suggestions pour l'auto-complétion (Tab-Complete).
+ *
+ * @param rootCommand La commande racine.
+ * @param sender L'émetteur.
+ * @param alias L'alias utilisé.
+ * @param args Les arguments en cours de frappe.
+ * @return La liste des chaĂźnes de suggestion pour le client.
+ */
+ public List tabComplete(Command rootCommand, CommandSender sender, String alias, String[] args) {
+ if (args.length == 0) {
+ return Collections.emptyList();
+ }
+
+ CommandNode targetNode = rootCommand;
+ int argIndex = 0;
+
+ // Navigation dans les sous-commandes
+ while (argIndex < args.length - 1 && targetNode.hasSubCommands()) {
+ String candidate = args[argIndex];
+ CommandNode sub = targetNode.findSubCommand(candidate);
+ if (sub != null) {
+ targetNode = sub;
+ argIndex++;
+ } else {
+ break;
+ }
+ }
+
+ String currentInput = args[args.length - 1].toLowerCase();
+ int relativeArgIndex = args.length - 1 - argIndex;
+
+ List rawSuggestions = new ArrayList<>();
+
+ // Si le nĆud a des sous-commandes et qu'on est sur le premier mot aprĂšs le nĆud
+ if (relativeArgIndex == 0 && targetNode.hasSubCommands()) {
+ for (CommandNode sub : targetNode.getSubCommands()) {
+ if (sub.getPermission() == null || sender.hasPermission(sub.getPermission())) {
+ if (sub.getName().toLowerCase().startsWith(currentInput)) {
+ rawSuggestions.add(Suggestion.of(sub.getName(), sub.getDescription()));
+ }
+ }
+ }
+ }
+
+ // Si le nĆud a des arguments dĂ©finis Ă cet index
+ List> nodeArgs = targetNode.getArguments();
+ if (relativeArgIndex >= 0 && relativeArgIndex < nodeArgs.size()) {
+ CommandArgument> argument = nodeArgs.get(relativeArgIndex);
+ CommandContext partialContext = new CommandContext(sender, alias, args, Collections.emptyMap());
+ rawSuggestions.addAll(argument.getSuggestions(partialContext, currentInput));
+ }
+
+ // Déclenchement de l'événement TabComplete
+ CommandTabCompleteEvent tabEvent = new CommandTabCompleteEvent(
+ targetNode, sender, null, rawSuggestions, currentInput, relativeArgIndex);
+ dispatchEvent(targetNode, tabEvent);
+
+ if (tabEvent.isCancelled()) {
+ return Collections.emptyList();
+ }
+
+ return tabEvent.getSuggestions().stream()
+ .map(Suggestion::getValue)
+ .distinct()
+ .collect(Collectors.toList());
+ }
+
+ @SuppressWarnings("unchecked")
+ private T parseArgumentValue(CommandArgument arg, String input, CommandContext context) throws CommandArgumentParseException {
+ return arg.getType().parse(arg.getName(), input, context);
+ }
+
+ private CommandResult handleSyntaxError(CommandNode node, CommandSender sender, CommandContext context,
+ String reason, String usage) {
+ CommandSyntaxErrorEvent event = new CommandSyntaxErrorEvent(node, sender, context, reason, usage);
+ dispatchEvent(node, event);
+
+ if (!event.isCancelled()) {
+ if (event.getCustomMessage() != null) {
+ event.reply(event.getCustomMessage());
+ } else {
+ sender.sendMessage("§cSyntaxe incorrecte : " + reason);
+ sender.sendMessage("§7Utilisation : §e" + usage);
+ }
+ }
+ return CommandResult.SYNTAX_ERROR;
+ }
+
+ private void sendSubcommandsHelp(CommandNode node, CommandSender sender) {
+ sender.sendMessage("§6=== Aide : §e/" + node.getFullName() + " §6===");
+ for (CommandNode sub : node.getSubCommands()) {
+ if (sub.getPermission() == null || sender.hasPermission(sub.getPermission())) {
+ String desc = sub.getDescription().isEmpty() ? "" : " §7- " + sub.getDescription();
+ sender.sendMessage("§e" + sub.getUsage() + desc);
+ }
+ }
+ }
+
+ private void dispatchEvent(CommandNode node, CommandEvent event) {
+ // 1. Ăcouteurs locaux sur le nĆud
+ if (event instanceof CommandPreExecuteEvent pre) {
+ node.getPreExecuteListeners().forEach(l -> l.onEvent(pre));
+ } else if (event instanceof CommandPostExecuteEvent post) {
+ node.getPostExecuteListeners().forEach(l -> l.onEvent(post));
+ } else if (event instanceof CommandPermissionDeniedEvent perm) {
+ node.getPermissionDeniedListeners().forEach(l -> l.onEvent(perm));
+ } else if (event instanceof CommandSyntaxErrorEvent syn) {
+ node.getSyntaxErrorListeners().forEach(l -> l.onEvent(syn));
+ } else if (event instanceof CommandCooldownEvent cool) {
+ node.getCooldownListeners().forEach(l -> l.onEvent(cool));
+ } else if (event instanceof CommandTabCompleteEvent tab) {
+ node.getTabCompleteListeners().forEach(l -> l.onEvent(tab));
+ }
+
+ // 2. Ăcouteurs globaux dans le bus d'Ă©vĂ©nements
+ manager.getEventManager().dispatch(event);
+ }
+}
diff --git a/src/main/java/fr/luc/bettermccommands/platform/paper/PaperCommandMapInjector.java b/src/main/java/fr/luc/bettermccommands/platform/paper/PaperCommandMapInjector.java
new file mode 100644
index 0000000..aab70e8
--- /dev/null
+++ b/src/main/java/fr/luc/bettermccommands/platform/paper/PaperCommandMapInjector.java
@@ -0,0 +1,119 @@
+package fr.luc.bettermccommands.platform.paper;
+
+import fr.luc.bettermccommands.BetterMcCommands;
+import fr.luc.bettermccommands.api.Command;
+import fr.luc.bettermccommands.platform.CommandDispatcher;
+import org.bukkit.Bukkit;
+import org.bukkit.Server;
+import org.bukkit.command.CommandMap;
+import org.bukkit.command.SimpleCommandMap;
+
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Gestionnaire d'injection et de retrait dynamique des commandes dans la {@link CommandMap} de Paper / Bukkit.
+ * Permet l'enregistrement à chaud sans nécessiter de redémarrage ni de déclaration dans {@code plugin.yml}.
+ */
+public class PaperCommandMapInjector {
+
+ private final Map registeredWrappers = new ConcurrentHashMap<>();
+ private CommandMap commandMap;
+ private Map knownCommandsMap;
+
+ /**
+ * Initialise l'injecteur et résout la {@link CommandMap} du serveur.
+ */
+ public PaperCommandMapInjector() {
+ resolveCommandMap();
+ }
+
+ /**
+ * Enregistre une commande betterMcCommands auprĂšs du serveur Bukkit/Paper.
+ *
+ * @param command La commande racine Ă enregistrer.
+ * @param dispatcher Le dispatcher d'exécution.
+ * @param prefix Le préfixe d'enregistrement (ex: nom du plugin ou "bettermc").
+ */
+ public synchronized void register(Command command, CommandDispatcher dispatcher, String prefix) {
+ if (commandMap == null) {
+ resolveCommandMap();
+ }
+
+ if (commandMap == null) {
+ throw new IllegalStateException("Unable to resolve Bukkit CommandMap for dynamic registration.");
+ }
+
+ PaperCommandWrapper wrapper = new PaperCommandWrapper(command, dispatcher);
+ commandMap.register(prefix != null ? prefix : "bettermc", wrapper);
+ registeredWrappers.put(command.getName().toLowerCase(), wrapper);
+ command.setRegistered(true);
+ }
+
+ /**
+ * Désenregistre une commande à chaud du serveur Bukkit/Paper.
+ *
+ * @param command La commande Ă retirer.
+ */
+ public synchronized void unregister(Command command) {
+ PaperCommandWrapper wrapper = registeredWrappers.remove(command.getName().toLowerCase());
+ if (wrapper == null) {
+ return;
+ }
+
+ wrapper.unregister(commandMap);
+
+ if (knownCommandsMap != null) {
+ knownCommandsMap.remove(wrapper.getName().toLowerCase());
+ for (String alias : wrapper.getAliases()) {
+ knownCommandsMap.remove(alias.toLowerCase());
+ }
+ if (wrapper.getLabel() != null) {
+ knownCommandsMap.remove(wrapper.getLabel().toLowerCase());
+ }
+ }
+
+ command.setRegistered(false);
+ }
+
+ /**
+ * Désenregistre l'ensemble des commandes injectées.
+ */
+ public synchronized void unregisterAll() {
+ for (PaperCommandWrapper wrapper : registeredWrappers.values()) {
+ wrapper.getRootCommand().unregister();
+ }
+ registeredWrappers.clear();
+ }
+
+ @SuppressWarnings("unchecked")
+ private void resolveCommandMap() {
+ Server server = Bukkit.getServer();
+ if (server == null) {
+ return; // Environnement de test unitaire oĂč le serveur Bukkit n'est pas instanciĂ©
+ }
+
+ try {
+ // Tentative directe via la méthode getCommandMap() moderne
+ try {
+ Method getCommandMapMethod = server.getClass().getMethod("getCommandMap");
+ this.commandMap = (CommandMap) getCommandMapMethod.invoke(server);
+ } catch (NoSuchMethodException ignored) {
+ // Fallback réflexion sur le champ commandMap de CraftServer
+ Field commandMapField = server.getClass().getDeclaredField("commandMap");
+ commandMapField.setAccessible(true);
+ this.commandMap = (CommandMap) commandMapField.get(server);
+ }
+
+ if (this.commandMap instanceof SimpleCommandMap simpleMap) {
+ Field knownCommandsField = SimpleCommandMap.class.getDeclaredField("knownCommands");
+ knownCommandsField.setAccessible(true);
+ this.knownCommandsMap = (Map) knownCommandsField.get(simpleMap);
+ }
+ } catch (Exception e) {
+ System.err.println("[betterMcCommands] Failed to access Bukkit CommandMap via reflection: " + e.getMessage());
+ }
+ }
+}
diff --git a/src/main/java/fr/luc/bettermccommands/platform/paper/PaperCommandWrapper.java b/src/main/java/fr/luc/bettermccommands/platform/paper/PaperCommandWrapper.java
new file mode 100644
index 0000000..97f7ee9
--- /dev/null
+++ b/src/main/java/fr/luc/bettermccommands/platform/paper/PaperCommandWrapper.java
@@ -0,0 +1,50 @@
+package fr.luc.bettermccommands.platform.paper;
+
+import fr.luc.bettermccommands.api.Command;
+import fr.luc.bettermccommands.platform.CommandDispatcher;
+import org.bukkit.command.CommandSender;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Enveloppe Bukkit permettant d'enregistrer une commande {@link Command} betterMcCommands dans le systĂšme natif Bukkit/Paper.
+ */
+public class PaperCommandWrapper extends org.bukkit.command.Command {
+
+ private final Command rootCommand;
+ private final CommandDispatcher dispatcher;
+
+ /**
+ * Crée l'enveloppe de commande Bukkit.
+ *
+ * @param rootCommand La commande racine betterMcCommands.
+ * @param dispatcher Le dispatcher responsable de l'exécution.
+ */
+ public PaperCommandWrapper(Command rootCommand, CommandDispatcher dispatcher) {
+ super(rootCommand.getName(), rootCommand.getDescription(), rootCommand.getUsage(), new ArrayList<>(rootCommand.getAliases()));
+ this.rootCommand = rootCommand;
+ this.dispatcher = dispatcher;
+ if (rootCommand.getPermission() != null) {
+ setPermission(rootCommand.getPermission());
+ }
+ }
+
+ /**
+ * @return La commande racine betterMcCommands associée.
+ */
+ public Command getRootCommand() {
+ return rootCommand;
+ }
+
+ @Override
+ public boolean execute(CommandSender sender, String commandLabel, String[] args) {
+ dispatcher.execute(rootCommand, sender, commandLabel, args);
+ return true;
+ }
+
+ @Override
+ public List tabComplete(CommandSender sender, String alias, String[] args) throws IllegalArgumentException {
+ return dispatcher.tabComplete(rootCommand, sender, alias, args);
+ }
+}
diff --git a/src/test/java/fr/luc/bettermccommands/ArgumentParsingTest.java b/src/test/java/fr/luc/bettermccommands/ArgumentParsingTest.java
new file mode 100644
index 0000000..b831cf6
--- /dev/null
+++ b/src/test/java/fr/luc/bettermccommands/ArgumentParsingTest.java
@@ -0,0 +1,97 @@
+package fr.luc.bettermccommands;
+
+import fr.luc.bettermccommands.api.CommandContext;
+import fr.luc.bettermccommands.argument.CommandArgumentParseException;
+import fr.luc.bettermccommands.argument.type.*;
+import fr.luc.bettermccommands.mock.SampleRank;
+import org.bukkit.command.CommandSender;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.time.Duration;
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class ArgumentParsingTest {
+
+ private CommandContext dummyContext;
+
+ @BeforeEach
+ void setUp() {
+ CommandSender sender = Mockito.mock(CommandSender.class);
+ dummyContext = new CommandContext(sender, "test", new String[0], Collections.emptyMap());
+ }
+
+ @Test
+ @DisplayName("IntegerArgument doit parser les entiers valides et respecter les bornes")
+ void testIntegerArgument() throws Exception {
+ IntegerArgument arg = IntegerArgument.range(1, 10);
+
+ assertEquals(5, arg.parse("count", "5", dummyContext));
+ assertEquals(1, arg.parse("count", "1", dummyContext));
+ assertEquals(10, arg.parse("count", "10", dummyContext));
+
+ assertThrows(CommandArgumentParseException.class, () -> arg.parse("count", "0", dummyContext));
+ assertThrows(CommandArgumentParseException.class, () -> arg.parse("count", "11", dummyContext));
+ assertThrows(CommandArgumentParseException.class, () -> arg.parse("count", "notANumber", dummyContext));
+ }
+
+ @Test
+ @DisplayName("DoubleArgument doit supporter les formats avec point et virgule")
+ void testDoubleArgument() throws Exception {
+ DoubleArgument arg = DoubleArgument.min(0.5);
+
+ assertEquals(1.5, arg.parse("amount", "1.5", dummyContext), 0.001);
+ assertEquals(2.5, arg.parse("amount", "2,5", dummyContext), 0.001);
+
+ assertThrows(CommandArgumentParseException.class, () -> arg.parse("amount", "0.2", dummyContext));
+ assertThrows(CommandArgumentParseException.class, () -> arg.parse("amount", "abc", dummyContext));
+ }
+
+ @Test
+ @DisplayName("BooleanArgument doit convertir les formats true/false, oui/non, 1/0")
+ void testBooleanArgument() throws Exception {
+ BooleanArgument arg = BooleanArgument.bool();
+
+ assertTrue(arg.parse("flag", "true", dummyContext));
+ assertTrue(arg.parse("flag", "oui", dummyContext));
+ assertTrue(arg.parse("flag", "1", dummyContext));
+ assertTrue(arg.parse("flag", "yes", dummyContext));
+
+ assertFalse(arg.parse("flag", "false", dummyContext));
+ assertFalse(arg.parse("flag", "non", dummyContext));
+ assertFalse(arg.parse("flag", "0", dummyContext));
+ assertFalse(arg.parse("flag", "no", dummyContext));
+
+ assertThrows(CommandArgumentParseException.class, () -> arg.parse("flag", "invalid", dummyContext));
+ }
+
+ @Test
+ @DisplayName("EnumArgument doit convertir les constantes enum insensiblement Ă la casse")
+ void testEnumArgument() throws Exception {
+ EnumArgument arg = EnumArgument.of(SampleRank.class);
+
+ assertEquals(SampleRank.ADMIN, arg.parse("rank", "admin", dummyContext));
+ assertEquals(SampleRank.MEMBER, arg.parse("rank", "MEMBER", dummyContext));
+ assertEquals(SampleRank.VIP, arg.parse("rank", "Vip", dummyContext));
+
+ assertThrows(CommandArgumentParseException.class, () -> arg.parse("rank", "OWNER", dummyContext));
+ }
+
+ @Test
+ @DisplayName("DurationArgument doit parser les chaßnes temporelles avec unités variées")
+ void testDurationArgument() throws Exception {
+ DurationArgument arg = DurationArgument.duration();
+
+ assertEquals(Duration.ofSeconds(30), arg.parse("time", "30s", dummyContext));
+ assertEquals(Duration.ofMinutes(15), arg.parse("time", "15m", dummyContext));
+ assertEquals(Duration.ofHours(2), arg.parse("time", "2h", dummyContext));
+ assertEquals(Duration.ofDays(1), arg.parse("time", "1d", dummyContext));
+ assertEquals(Duration.ofSeconds(100), arg.parse("time", "1m 40s", dummyContext));
+
+ assertThrows(CommandArgumentParseException.class, () -> arg.parse("time", "invalid", dummyContext));
+ }
+}
diff --git a/src/test/java/fr/luc/bettermccommands/CommandEventLifecycleTest.java b/src/test/java/fr/luc/bettermccommands/CommandEventLifecycleTest.java
new file mode 100644
index 0000000..99a4917
--- /dev/null
+++ b/src/test/java/fr/luc/bettermccommands/CommandEventLifecycleTest.java
@@ -0,0 +1,120 @@
+package fr.luc.bettermccommands;
+
+import fr.luc.bettermccommands.api.Command;
+import fr.luc.bettermccommands.api.CommandResult;
+import fr.luc.bettermccommands.event.*;
+import fr.luc.bettermccommands.mock.SampleEventListener;
+import org.bukkit.entity.Player;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.time.Duration;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.when;
+
+public class CommandEventLifecycleTest {
+
+ private BetterMcCommands manager;
+
+ @BeforeEach
+ void setUp() {
+ manager = new BetterMcCommands("test");
+ }
+
+ @Test
+ @DisplayName("L'annulation de PreExecuteEvent doit bloquer l'exécution")
+ void testPreExecuteCancellation() {
+ AtomicBoolean executed = new AtomicBoolean(false);
+
+ Command command = BetterMcCommands.builder("tpall")
+ .onPreExecute(event -> event.setCancelled(true))
+ .executes(context -> executed.set(true))
+ .build();
+
+ Player player = Mockito.mock(Player.class);
+ when(player.getUniqueId()).thenReturn(UUID.randomUUID());
+
+ CommandResult result = manager.getDispatcher().execute(command, player, "tpall", new String[0]);
+
+ assertEquals(CommandResult.CANCELLED, result);
+ assertFalse(executed.get());
+ }
+
+ @Test
+ @DisplayName("PostExecuteEvent doit capturer le succÚs et la durée")
+ void testPostExecuteEvent() {
+ AtomicReference capturedEvent = new AtomicReference<>();
+
+ manager.on(CommandPostExecuteEvent.class, capturedEvent::set);
+
+ Command command = BetterMcCommands.builder("heal")
+ .executes(context -> {
+ // Simule un petit traitement
+ Thread.sleep(10);
+ })
+ .build();
+
+ Player player = Mockito.mock(Player.class);
+ when(player.getUniqueId()).thenReturn(UUID.randomUUID());
+
+ CommandResult result = manager.getDispatcher().execute(command, player, "heal", new String[0]);
+
+ assertEquals(CommandResult.SUCCESS, result);
+ assertNotNull(capturedEvent.get());
+ assertTrue(capturedEvent.get().isSuccessful());
+ assertTrue(capturedEvent.get().getExecutionDuration().toMillis() >= 5);
+ }
+
+ @Test
+ @DisplayName("Le systÚme de Cooldown doit bloquer les exécutions successives d'un joueur")
+ void testCommandCooldown() {
+ Command command = BetterMcCommands.builder("kit")
+ .cooldown(Duration.ofSeconds(60))
+ .executes(context -> {})
+ .build();
+
+ Player player = Mockito.mock(Player.class);
+ UUID uuid = UUID.randomUUID();
+ when(player.getUniqueId()).thenReturn(uuid);
+
+ // 1Úre exécution -> Réussie
+ CommandResult result1 = manager.getDispatcher().execute(command, player, "kit", new String[0]);
+ assertEquals(CommandResult.SUCCESS, result1);
+
+ // 2Úme exécution immédiate -> Bloquée par Cooldown
+ CommandResult result2 = manager.getDispatcher().execute(command, player, "kit", new String[0]);
+ assertEquals(CommandResult.COOLDOWN, result2);
+
+ // Reset cooldown
+ manager.getCooldownManager().resetCooldown(command, player);
+
+ // 3Úme exécution aprÚs reset -> Réussie
+ CommandResult result3 = manager.getDispatcher().execute(command, player, "kit", new String[0]);
+ assertEquals(CommandResult.SUCCESS, result3);
+ }
+
+ @Test
+ @DisplayName("Les Ă©couteurs annotĂ©s @CommandEventHandler doivent ĂȘtre appelĂ©s correctement")
+ void testAnnotatedEventListeners() {
+ SampleEventListener listener = new SampleEventListener();
+ manager.registerListeners(listener);
+
+ Command command = BetterMcCommands.builder("custom")
+ .executes(context -> {})
+ .build();
+
+ Player player = Mockito.mock(Player.class);
+ when(player.getUniqueId()).thenReturn(UUID.randomUUID());
+
+ manager.getDispatcher().execute(command, player, "custom", new String[0]);
+
+ assertTrue(listener.preFired.get());
+ assertTrue(listener.postFired.get());
+ }
+}
diff --git a/src/test/java/fr/luc/bettermccommands/CommandExecutionTest.java b/src/test/java/fr/luc/bettermccommands/CommandExecutionTest.java
new file mode 100644
index 0000000..1045ece
--- /dev/null
+++ b/src/test/java/fr/luc/bettermccommands/CommandExecutionTest.java
@@ -0,0 +1,139 @@
+package fr.luc.bettermccommands;
+
+import fr.luc.bettermccommands.api.Command;
+import fr.luc.bettermccommands.api.CommandResult;
+import fr.luc.bettermccommands.argument.Arguments;
+import fr.luc.bettermccommands.platform.CommandDispatcher;
+import org.bukkit.command.CommandSender;
+import org.bukkit.command.ConsoleCommandSender;
+import org.bukkit.entity.Player;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.when;
+
+public class CommandExecutionTest {
+
+ private BetterMcCommands manager;
+ private CommandDispatcher dispatcher;
+
+ @BeforeEach
+ void setUp() {
+ manager = new BetterMcCommands("test");
+ dispatcher = manager.getDispatcher();
+ }
+
+ @Test
+ @DisplayName("Exécution simple d'une commande racine")
+ void testRootCommandExecution() {
+ AtomicBoolean executed = new AtomicBoolean(false);
+
+ Command command = BetterMcCommands.builder("ping")
+ .executes(context -> executed.set(true))
+ .build();
+
+ CommandSender sender = Mockito.mock(CommandSender.class);
+ CommandResult result = dispatcher.execute(command, sender, "ping", new String[0]);
+
+ assertEquals(CommandResult.SUCCESS, result);
+ assertTrue(executed.get());
+ }
+
+ @Test
+ @DisplayName("Exécution d'une sous-commande avec arguments obligatoires et optionnels")
+ void testSubCommandWithArguments() {
+ AtomicReference targetPlayer = new AtomicReference<>();
+ AtomicInteger amountGiven = new AtomicInteger();
+
+ Command command = BetterMcCommands.builder("money")
+ .subcommand(BetterMcCommands.subBuilder("give")
+ .argument(Arguments.string("player"))
+ .argument(Arguments.integer("amount", 1, 1000).defaultValue(50))
+ .executes(context -> {
+ targetPlayer.set(context.getString("player"));
+ amountGiven.set(context.getInt("amount"));
+ })
+ )
+ .build();
+
+ CommandSender sender = Mockito.mock(CommandSender.class);
+
+ // Appel avec argument optionnel omis (valeur par défaut 50)
+ CommandResult result1 = dispatcher.execute(command, sender, "money", new String[]{"give", "Luc"});
+ assertEquals(CommandResult.SUCCESS, result1);
+ assertEquals("Luc", targetPlayer.get());
+ assertEquals(50, amountGiven.get());
+
+ // Appel avec argument optionnel spécifié (200)
+ CommandResult result2 = dispatcher.execute(command, sender, "money", new String[]{"give", "Alex", "200"});
+ assertEquals(CommandResult.SUCCESS, result2);
+ assertEquals("Alex", targetPlayer.get());
+ assertEquals(200, amountGiven.get());
+
+ // Appel avec argument obligatoire manquant
+ CommandResult result3 = dispatcher.execute(command, sender, "money", new String[]{"give"});
+ assertEquals(CommandResult.SYNTAX_ERROR, result3);
+ }
+
+ @Test
+ @DisplayName("ContrÎle des permissions d'exécution")
+ void testPermissionCheck() {
+ Command command = BetterMcCommands.builder("secret")
+ .permission("admin.secret")
+ .executes(context -> {})
+ .build();
+
+ CommandSender unauthorizedSender = Mockito.mock(CommandSender.class);
+ when(unauthorizedSender.hasPermission("admin.secret")).thenReturn(false);
+
+ CommandResult result1 = dispatcher.execute(command, unauthorizedSender, "secret", new String[0]);
+ assertEquals(CommandResult.PERMISSION_DENIED, result1);
+
+ CommandSender authorizedSender = Mockito.mock(CommandSender.class);
+ when(authorizedSender.hasPermission("admin.secret")).thenReturn(true);
+
+ CommandResult result2 = dispatcher.execute(command, authorizedSender, "secret", new String[0]);
+ assertEquals(CommandResult.SUCCESS, result2);
+ }
+
+ @Test
+ @DisplayName("Restriction par type d'émetteur (Player only)")
+ void testPlayerOnlySenderType() {
+ Command command = BetterMcCommands.builder("spawn")
+ .playerOnly()
+ .executes(context -> {})
+ .build();
+
+ ConsoleCommandSender consoleSender = Mockito.mock(ConsoleCommandSender.class);
+ CommandResult result1 = dispatcher.execute(command, consoleSender, "spawn", new String[0]);
+ assertEquals(CommandResult.PERMISSION_DENIED, result1);
+
+ Player playerSender = Mockito.mock(Player.class);
+ CommandResult result2 = dispatcher.execute(command, playerSender, "spawn", new String[0]);
+ assertEquals(CommandResult.SUCCESS, result2);
+ }
+
+ @Test
+ @DisplayName("Argument greedy consommant tout le reste de la ligne")
+ void testGreedyArgument() {
+ AtomicReference fullMessage = new AtomicReference<>();
+
+ Command command = BetterMcCommands.builder("say")
+ .argument(Arguments.greedyString("message"))
+ .executes(context -> fullMessage.set(context.getString("message")))
+ .build();
+
+ CommandSender sender = Mockito.mock(CommandSender.class);
+ CommandResult result = dispatcher.execute(command, sender, "say", new String[]{"Bonjour", "tout", "le", "monde", "!"});
+
+ assertEquals(CommandResult.SUCCESS, result);
+ assertEquals("Bonjour tout le monde !", fullMessage.get());
+ }
+}
diff --git a/src/test/java/fr/luc/bettermccommands/mock/SampleEventListener.java b/src/test/java/fr/luc/bettermccommands/mock/SampleEventListener.java
new file mode 100644
index 0000000..ea07c4f
--- /dev/null
+++ b/src/test/java/fr/luc/bettermccommands/mock/SampleEventListener.java
@@ -0,0 +1,26 @@
+package fr.luc.bettermccommands.mock;
+
+import fr.luc.bettermccommands.event.CommandPostExecuteEvent;
+import fr.luc.bettermccommands.event.CommandPreExecuteEvent;
+import fr.luc.bettermccommands.event.annotation.CommandEventHandler;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * Ăcouteur d'exemple pour les tests.
+ */
+public class SampleEventListener {
+
+ public final AtomicBoolean preFired = new AtomicBoolean(false);
+ public final AtomicBoolean postFired = new AtomicBoolean(false);
+
+ @CommandEventHandler
+ public void onPre(CommandPreExecuteEvent event) {
+ preFired.set(true);
+ }
+
+ @CommandEventHandler
+ public void onPost(CommandPostExecuteEvent event) {
+ postFired.set(true);
+ }
+}
diff --git a/src/test/java/fr/luc/bettermccommands/mock/SampleRank.java b/src/test/java/fr/luc/bettermccommands/mock/SampleRank.java
new file mode 100644
index 0000000..b70f011
--- /dev/null
+++ b/src/test/java/fr/luc/bettermccommands/mock/SampleRank.java
@@ -0,0 +1,8 @@
+package fr.luc.bettermccommands.mock;
+
+/**
+ * Enum d'exemple pour les tests unitaires.
+ */
+public enum SampleRank {
+ MEMBER, ADMIN, VIP
+}