diff --git a/README.md b/README.md
index 3755c82..5c75881 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,10 @@ Une bibliothèque Java moderne, fluide et événementielle permettant de créer,
- [1. Commande de Démonstration Complète (`/commande-demo`)](#1-commande-de-démonstration-complète-commande-demo)
- [2. Sous-Commandes Imbriquées](#2-sous-commandes-imbriquées)
- [3. Arguments Gourmands (Greedy String)](#3-arguments-gourmands-greedy-string)
- - [4. Temps de Recharge (Cooldowns)](#4-temps-de-recharge-cooldowns)
+ - [4. Système de Validation Métier (Pré-requis)](#4-système-de-validation-métier-pré-requis)
+ - [5. Confirmation Préalable & Double Validation (`.requireConfirmation`)](#5-confirmation-préalable--double-validation-requireconfirmation)
+ - [6. Répétition & Cadence de Commandes (`CommandRepeater`)](#6-répétition--cadence-de-commandes-commandrepeater)
+ - [7. Temps de Recharge (Cooldowns)](#7-temps-de-recharge-cooldowns)
- [🧩 Catalogue des Arguments Typés](#-catalogue-des-arguments-typés)
- [🎯 Système d'Événements & Cycle de Vie](#-système-dévénements--cycle-de-vie)
- [Liste des Événements Disponibles](#liste-des-événements-disponibles)
@@ -31,6 +34,11 @@ Une bibliothèque Java moderne, fluide et événementielle permettant de créer,
- **DSL Fluide & Déclaratif** : Déclaration intuitive basée sur le pattern Builder chainable (`BetterMcCommands.builder("ma-commande")`).
- **Zéro `plugin.yml`** : Injection directe par réflexion dans la `CommandMap` du serveur Minecraft avec support du dé-enregistrement à chaud.
+- **Validation Métier Directe & Extensible** :
+ - Validateurs au niveau commande (`.validate(ctx -> condition, "Erreur...")`)
+ - Validateurs au niveau argument (`argument.validate(val -> condition, "Erreur...")`)
+- **Confirmation Préalable (Double validation)** : Sécurisation des commandes critiques (`/disband`, `/delete-data`) avec expiration (`.requireConfirmation(Duration.ofSeconds(15))`).
+- **Répétition & Tâches Cadencées (`CommandRepeater`)** : Répétition automatique d'une action à intervalles réguliers avec suivi de progression et annulation.
- **Système d'Arguments Typés & Auto-complétion** :
- Conversion et validation automatiques avec messages d'erreurs clairs.
- Auto-complétion dynamique (Tab-Complete) intelligente selon le type de chaque argument.
@@ -58,10 +66,16 @@ flowchart TD
PermCheck -- Valide --> CoolCheck{"Vérification Cooldown"}
CoolCheck -- Actif --> EvtCool["Dispatch CommandCooldownEvent"]
- CoolCheck -- Inactif --> ArgParse{"Parsing des Arguments Typés"}
- ArgParse -- Erreur format / manquant --> EvtSyntax["Dispatch CommandSyntaxErrorEvent"]
+ CoolCheck -- Inactif --> ArgParse{"Parsing & Validation des Arguments"}
+ ArgParse -- Erreur format / invalide --> EvtSyntax["Dispatch CommandSyntaxErrorEvent"]
- ArgParse -- Succès --> EvtPre{"Dispatch CommandPreExecuteEvent"}
+ ArgParse -- Succès --> ConfCheck{"Confirmation Requise ?"}
+ ConfCheck -- Non confirmée --> EvtConf["Dispatch ConfirmationRequiredEvent & Invite à retaper"]
+
+ ConfCheck -- Confirmée ou Non requise --> ValCheck{"Validateurs (CommandValidator)"}
+ ValCheck -- Invalide --> ValErr["Envoi Message d'Erreur & Arrêt"]
+
+ ValCheck -- Valide --> EvtPre{"Dispatch CommandPreExecuteEvent"}
EvtPre -- Annulé (setCancelled) --> Cancelled["Arrêt de l'exécution"]
EvtPre -- Non annulé --> Exec["Exécution du CommandExecutor métier"]
@@ -220,7 +234,75 @@ BetterMcCommands.builder("broadcast")
---
-### 4. Temps de Recharge (Cooldowns)
+### 4. Système de Validation Métier (Pré-requis)
+
+Vous pouvez attacher des validateurs à vos commandes et arguments :
+
+```java
+// Validation au niveau de la commande
+BetterMcCommands.builder("level-reward")
+ .playerOnly()
+ .validate(ctx -> ctx.getPlayer().getLevel() >= 10, "Vous devez être niveau 10 minimum !")
+ .executes(ctx -> {
+ ctx.replySuccess("Récompense de niveau réclamée !");
+ })
+ .register();
+
+// Validation au niveau d'un argument
+BetterMcCommands.builder("setprice")
+ .argument(Arguments.decimal("prix").validate(prix -> prix > 0, "Le prix doit être strictement positif !"))
+ .executes(ctx -> {
+ double prix = ctx.getDouble("prix");
+ ctx.replySuccess("Prix configuré à " + prix + " €.");
+ })
+ .register();
+```
+
+---
+
+### 5. Confirmation Préalable & Double Validation (`.requireConfirmation`)
+
+Sécurisez les commandes destructrices ou sensibles. Le joueur doit ré-exécuter la commande dans le temps imparti pour confirmer l'action :
+
+```java
+BetterMcCommands.builder("disband")
+ .description("Dissout votre guilde")
+ .playerOnly()
+ .requireConfirmation(Duration.ofSeconds(15), "ATTENTION : Cette action est irréversible ! Retapez /disband dans les 15s pour confirmer.")
+ .executes(context -> {
+ context.replySuccess("Votre guilde a été dissoute.");
+ })
+ .register();
+```
+
+---
+
+### 6. Répétition & Cadence de Commandes (`CommandRepeater`)
+
+Permet de répéter une action à intervalle régulier (ex: compte à rebours, diffusion répétée, téléportation avec pulsation) :
+
+```java
+BetterMcCommands.builder("countdown")
+ .playerOnly()
+ .executes(context -> {
+ // Répète 5 fois toutes les 1 seconde
+ CommandRepeater.repeat(monPlugin, context, 5, Duration.ofSeconds(1),
+ progress -> {
+ int restants = progress.getTotalRuns() - progress.getCurrentRun() + 1;
+ context.reply("Lancement dans " + restants + "...");
+ },
+ () -> {
+ // Action finale
+ context.replySuccess("PARTEZ !");
+ }
+ );
+ })
+ .register();
+```
+
+---
+
+### 7. Temps de Recharge (Cooldowns)
```java
BetterMcCommands.builder("kit")
@@ -264,6 +346,7 @@ BetterMcCommands.builder("kit")
|---|---|:---:|
| `CommandPreExecuteEvent` | Déclenché juste avant l'exécution métier. Permet d'annuler ou modifier l'action. | ✅ Oui |
| `CommandPostExecuteEvent` | Déclenché après l'exécution. Contient la durée d'exécution et le statut final. | ❌ Non |
+| `CommandConfirmationRequiredEvent` | Déclenché lors d'une demande de confirmation préalable pour personnaliser l'invite. | ✅ Oui |
| `CommandPermissionDeniedEvent` | Déclenché si la permission est manquante ou si l'émetteur n'est pas autorisé. | ✅ Oui |
| `CommandSyntaxErrorEvent` | Déclenché lors d'une erreur de syntaxe ou argument manquant. | ✅ Oui |
| `CommandCooldownEvent` | Déclenché lorsqu'un joueur tente d'exécuter une commande sous cooldown actif. | ✅ Oui |
@@ -333,11 +416,22 @@ fr.luc.bettermccommands/
│ ├── ArgumentType.java
│ ├── Arguments.java (Factory d'arguments)
│ └── type/ (String, Integer, Player, Duration, Enum, etc.)
+ ├── validation/ (Moteur de validation de commande et d'argument)
+ │ ├── CommandValidator.java
+ │ ├── ArgumentValidator.java
+ │ └── ValidationResult.java
+ ├── confirmation/ (Système de confirmation / Double validation)
+ │ ├── ConfirmationManager.java
+ │ └── PendingConfirmation.java
+ ├── repeat/ (Tâches répétées & cadence de commandes)
+ │ ├── CommandRepeater.java
+ │ └── CommandRepeatProgress.java
├── event/ (Bus d'événements & Lifecycle)
│ ├── CommandEvent.java
│ ├── CancellableCommandEvent.java
│ ├── CommandPreExecuteEvent.java
│ ├── CommandPostExecuteEvent.java
+ │ ├── CommandConfirmationRequiredEvent.java
│ ├── CommandPermissionDeniedEvent.java
│ ├── CommandSyntaxErrorEvent.java
│ ├── CommandCooldownEvent.java
diff --git a/src/main/java/fr/luc/bettermccommands/BetterMcCommands.java b/src/main/java/fr/luc/bettermccommands/BetterMcCommands.java
index 67524d1..3d09ee2 100644
--- a/src/main/java/fr/luc/bettermccommands/BetterMcCommands.java
+++ b/src/main/java/fr/luc/bettermccommands/BetterMcCommands.java
@@ -27,6 +27,7 @@ public class BetterMcCommands {
private final String pluginPrefix;
private final CommandEventManager eventManager;
private final CooldownManager cooldownManager;
+ private final fr.luc.bettermccommands.confirmation.ConfirmationManager confirmationManager;
private final CommandDispatcher dispatcher;
private final PaperCommandMapInjector injector;
private final Map registeredCommands = new ConcurrentHashMap<>();
@@ -40,6 +41,7 @@ public class BetterMcCommands {
this.pluginPrefix = pluginPrefix != null ? pluginPrefix : "bettermc";
this.eventManager = new CommandEventManager();
this.cooldownManager = new CooldownManager();
+ this.confirmationManager = new fr.luc.bettermccommands.confirmation.ConfirmationManager();
this.dispatcher = new CommandDispatcher(this);
this.injector = new PaperCommandMapInjector();
@@ -155,6 +157,13 @@ public class BetterMcCommands {
return cooldownManager;
}
+ /**
+ * @return Le gestionnaire des confirmations d'exécution.
+ */
+ public fr.luc.bettermccommands.confirmation.ConfirmationManager getConfirmationManager() {
+ return confirmationManager;
+ }
+
/**
* @return Le dispatcher d'exécution.
*/
diff --git a/src/main/java/fr/luc/bettermccommands/api/CommandNode.java b/src/main/java/fr/luc/bettermccommands/api/CommandNode.java
index 451daba..e72330e 100644
--- a/src/main/java/fr/luc/bettermccommands/api/CommandNode.java
+++ b/src/main/java/fr/luc/bettermccommands/api/CommandNode.java
@@ -24,6 +24,12 @@ public class CommandNode {
protected Duration cooldown = Duration.ZERO;
protected String cooldownBypassPermission;
+ // Validation et confirmation
+ protected final List validators = new ArrayList<>();
+ protected boolean confirmationRequired = false;
+ protected Duration confirmationTimeout = Duration.ofSeconds(15);
+ protected String confirmationPrompt;
+
// Écouteurs d'événements locaux attachés directement à ce nœud
protected final List> preExecuteListeners = new CopyOnWriteArrayList<>();
protected final List> postExecuteListeners = new CopyOnWriteArrayList<>();
@@ -225,6 +231,38 @@ public class CommandNode {
return builder.toString();
}
+ public void addValidator(fr.luc.bettermccommands.validation.CommandValidator validator) {
+ this.validators.add(Objects.requireNonNull(validator, "Validator cannot be null"));
+ }
+
+ public List getValidators() {
+ return Collections.unmodifiableList(validators);
+ }
+
+ public boolean isConfirmationRequired() {
+ return confirmationRequired;
+ }
+
+ public void setConfirmationRequired(boolean confirmationRequired) {
+ this.confirmationRequired = confirmationRequired;
+ }
+
+ public Duration getConfirmationTimeout() {
+ return confirmationTimeout;
+ }
+
+ public void setConfirmationTimeout(Duration confirmationTimeout) {
+ this.confirmationTimeout = confirmationTimeout;
+ }
+
+ public String getConfirmationPrompt() {
+ return confirmationPrompt;
+ }
+
+ public void setConfirmationPrompt(String confirmationPrompt) {
+ this.confirmationPrompt = confirmationPrompt;
+ }
+
// Gestion des écouteurs locaux
public List> getPreExecuteListeners() {
return preExecuteListeners;
diff --git a/src/main/java/fr/luc/bettermccommands/argument/CommandArgument.java b/src/main/java/fr/luc/bettermccommands/argument/CommandArgument.java
index a8e60c4..961b4af 100644
--- a/src/main/java/fr/luc/bettermccommands/argument/CommandArgument.java
+++ b/src/main/java/fr/luc/bettermccommands/argument/CommandArgument.java
@@ -5,6 +5,7 @@ import fr.luc.bettermccommands.api.suggestion.Suggestion;
import fr.luc.bettermccommands.api.suggestion.SuggestionProvider;
import java.util.Collection;
+import java.util.Collections;
import java.util.Objects;
import java.util.function.Supplier;
@@ -22,6 +23,7 @@ public class CommandArgument {
private Supplier defaultValueSupplier;
private SuggestionProvider customSuggestionProvider;
private boolean greedy;
+ private final java.util.List> validators = new java.util.ArrayList<>();
/**
* Crée un argument obligatoire avec son nom et son type.
@@ -173,6 +175,35 @@ public class CommandArgument {
return type.suggest(context, currentInput);
}
+ /**
+ * Ajoute un validateur personnalisé pour cet argument.
+ *
+ * @param validator Le validateur d'argument.
+ * @return Cette instance d'argument.
+ */
+ public CommandArgument validate(fr.luc.bettermccommands.validation.ArgumentValidator validator) {
+ this.validators.add(Objects.requireNonNull(validator, "Validator cannot be null"));
+ return this;
+ }
+
+ /**
+ * Ajoute une règle de validation simple basée sur un prédicat.
+ *
+ * @param predicate Le prédicat retournant true si la valeur est valide.
+ * @param errorMessage Le message d'erreur en cas d'échec.
+ * @return Cette instance d'argument.
+ */
+ public CommandArgument validate(java.util.function.Predicate predicate, String errorMessage) {
+ return validate(fr.luc.bettermccommands.validation.ArgumentValidator.of(predicate, errorMessage));
+ }
+
+ /**
+ * @return La liste des validateurs associés à cet argument.
+ */
+ public java.util.List> getValidators() {
+ return Collections.unmodifiableList(validators);
+ }
+
/**
* Retourne la représentation textuelle de l'argument pour l'aide.
*
diff --git a/src/main/java/fr/luc/bettermccommands/builder/AbstractCommandBuilder.java b/src/main/java/fr/luc/bettermccommands/builder/AbstractCommandBuilder.java
index 0f5d8a1..03e3856 100644
--- a/src/main/java/fr/luc/bettermccommands/builder/AbstractCommandBuilder.java
+++ b/src/main/java/fr/luc/bettermccommands/builder/AbstractCommandBuilder.java
@@ -30,6 +30,11 @@ public abstract class AbstractCommandBuilder validators = new ArrayList<>();
+ protected boolean confirmationRequired = false;
+ protected Duration confirmationTimeout = Duration.ofSeconds(15);
+ protected String confirmationPrompt;
+
protected final List> preExecuteListeners = new ArrayList<>();
protected final List> postExecuteListeners = new ArrayList<>();
protected final List> permissionDeniedListeners = new ArrayList<>();
@@ -249,6 +254,61 @@ public abstract class AbstractCommandBuilder predicate, String errorMessage) {
+ return validate(fr.luc.bettermccommands.validation.CommandValidator.of(predicate, errorMessage));
+ }
+
+ /**
+ * Exige une confirmation préalable du joueur avant d'exécuter l'action (délai par défaut: 15s).
+ *
+ * @return Cette instance du builder.
+ */
+ public B requireConfirmation() {
+ return requireConfirmation(Duration.ofSeconds(15), null);
+ }
+
+ /**
+ * Exige une confirmation préalable du joueur avec un délai personnalisé.
+ *
+ * @param timeout La durée maximale pour confirmer.
+ * @return Cette instance du builder.
+ */
+ public B requireConfirmation(Duration timeout) {
+ return requireConfirmation(timeout, null);
+ }
+
+ /**
+ * Exige une confirmation préalable du joueur avec un délai et message personnalisés.
+ *
+ * @param timeout La durée maximale pour confirmer.
+ * @param promptMessage Le message de demande de confirmation.
+ * @return Cette instance du builder.
+ */
+ public B requireConfirmation(Duration timeout, String promptMessage) {
+ this.confirmationRequired = true;
+ this.confirmationTimeout = timeout != null ? timeout : Duration.ofSeconds(15);
+ this.confirmationPrompt = promptMessage;
+ return (B) this;
+ }
+
/**
* Construit et configure l'instance du nœud de commande.
*
@@ -271,6 +331,13 @@ public abstract class AbstractCommandBuilder pendingConfirmations = new ConcurrentHashMap<>();
+
+ /**
+ * Enregistre une demande de confirmation pour un joueur.
+ *
+ * @param player Le joueur devant confirmer.
+ * @param node Le nœud de commande concerné.
+ * @param context Le contexte de la commande.
+ * @param timeout Le délai maximum accordé pour confirmer.
+ */
+ public void requestConfirmation(Player player, CommandNode node, CommandContext context, Duration timeout) {
+ if (player == null || node == null) return;
+ Instant expiresAt = Instant.now().plus(timeout != null ? timeout : Duration.ofSeconds(15));
+ pendingConfirmations.put(player.getUniqueId(), new PendingConfirmation(node, context, expiresAt));
+ }
+
+ /**
+ * Récupère la confirmation en attente pour un joueur s'il y en a une valide et non expirée.
+ *
+ * @param player Le joueur.
+ * @return Un {@link Optional} contenant la confirmation valide.
+ */
+ public Optional getPendingConfirmation(Player player) {
+ if (player == null) return Optional.empty();
+ PendingConfirmation pending = pendingConfirmations.get(player.getUniqueId());
+ if (pending == null) {
+ return Optional.empty();
+ }
+
+ if (pending.isExpired()) {
+ pendingConfirmations.remove(player.getUniqueId());
+ return Optional.empty();
+ }
+
+ return Optional.of(pending);
+ }
+
+ /**
+ * Valide et consomme la confirmation en attente pour un joueur.
+ *
+ * @param player Le joueur confirmant l'action.
+ * @return La {@link PendingConfirmation} validée, ou {@link Optional#empty()} si aucune confirmation active.
+ */
+ public Optional consumeConfirmation(Player player) {
+ if (player == null) return Optional.empty();
+ PendingConfirmation pending = pendingConfirmations.remove(player.getUniqueId());
+ if (pending == null || pending.isExpired()) {
+ return Optional.empty();
+ }
+ return Optional.of(pending);
+ }
+
+ /**
+ * Annule la confirmation en attente pour un joueur.
+ *
+ * @param player Le joueur.
+ */
+ public void cancelConfirmation(Player player) {
+ if (player != null) {
+ pendingConfirmations.remove(player.getUniqueId());
+ }
+ }
+
+ /**
+ * Nettoie toutes les confirmations expirées en mémoire.
+ */
+ public void cleanupExpired() {
+ pendingConfirmations.entrySet().removeIf(entry -> entry.getValue().isExpired());
+ }
+
+ /**
+ * Supprime toutes les confirmations en mémoire.
+ */
+ public void clearAll() {
+ pendingConfirmations.clear();
+ }
+}
diff --git a/src/main/java/fr/luc/bettermccommands/confirmation/PendingConfirmation.java b/src/main/java/fr/luc/bettermccommands/confirmation/PendingConfirmation.java
new file mode 100644
index 0000000..c401f9c
--- /dev/null
+++ b/src/main/java/fr/luc/bettermccommands/confirmation/PendingConfirmation.java
@@ -0,0 +1,70 @@
+package fr.luc.bettermccommands.confirmation;
+
+import fr.luc.bettermccommands.api.CommandContext;
+import fr.luc.bettermccommands.api.CommandNode;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Objects;
+
+/**
+ * Représente une demande de confirmation en attente d'approbation par un joueur.
+ */
+public class PendingConfirmation {
+
+ private final CommandNode node;
+ private final CommandContext context;
+ private final Instant expiresAt;
+
+ /**
+ * Crée une nouvelle confirmation en attente.
+ *
+ * @param node Le nœud de commande en attente.
+ * @param context Le contexte de commande capturé.
+ * @param expiresAt La date/heure d'expiration.
+ */
+ public PendingConfirmation(CommandNode node, CommandContext context, Instant expiresAt) {
+ this.node = Objects.requireNonNull(node, "node cannot be null");
+ this.context = Objects.requireNonNull(context, "context cannot be null");
+ this.expiresAt = Objects.requireNonNull(expiresAt, "expiresAt cannot be null");
+ }
+
+ /**
+ * @return Le nœud de commande concerné.
+ */
+ public CommandNode getNode() {
+ return node;
+ }
+
+ /**
+ * @return Le contexte d'exécution capturé lors de l'appel initial.
+ */
+ public CommandContext getContext() {
+ return context;
+ }
+
+ /**
+ * @return La date/heure d'expiration de la confirmation.
+ */
+ public Instant getExpiresAt() {
+ return expiresAt;
+ }
+
+ /**
+ * @return true si le délai de confirmation est dépassé, sinon false.
+ */
+ public boolean isExpired() {
+ return Instant.now().isAfter(expiresAt);
+ }
+
+ /**
+ * @return La durée restante avant expiration.
+ */
+ public Duration getRemaining() {
+ Instant now = Instant.now();
+ if (now.isAfter(expiresAt)) {
+ return Duration.ZERO;
+ }
+ return Duration.between(now, expiresAt);
+ }
+}
diff --git a/src/main/java/fr/luc/bettermccommands/demo/commands/DemoBaseCommand.java b/src/main/java/fr/luc/bettermccommands/demo/commands/DemoBaseCommand.java
index 9bdfffe..ec61b55 100644
--- a/src/main/java/fr/luc/bettermccommands/demo/commands/DemoBaseCommand.java
+++ b/src/main/java/fr/luc/bettermccommands/demo/commands/DemoBaseCommand.java
@@ -131,6 +131,27 @@ public class DemoBaseCommand {
)
)
+ // 6. Sous-commande avec confirmation préalable (Double validation) : /demo delete-data
+ .subcommand(BetterMcCommands.subBuilder("delete-data")
+ .description("Action sensible nécessitant une confirmation")
+ .permission("bettermc.demo.admin")
+ .playerOnly()
+ .requireConfirmation(Duration.ofSeconds(15), "ATTENTION : Cette action est irréversible ! Retapez /demo delete-data dans les 15s pour confirmer la suppression.")
+ .executes(context -> {
+ context.replySuccess("Données supprimées avec succès après confirmation !");
+ })
+ )
+
+ // 7. Sous-commande avec validateur de pré-requis : /demo level-reward
+ .subcommand(BetterMcCommands.subBuilder("level-reward")
+ .description("Récompense réservée aux joueurs de niveau 5 ou plus")
+ .playerOnly()
+ .validate(ctx -> ctx.getPlayer().getLevel() >= 5, "Vous devez posséder au minimum le niveau d'expérience 5 pour réclamer cette récompense !")
+ .executes(context -> {
+ context.replySuccess("Félicitations ! Vous avez réclamé votre récompense de niveau.");
+ })
+ )
+
.build();
}
}
diff --git a/src/main/java/fr/luc/bettermccommands/event/CommandConfirmationRequiredEvent.java b/src/main/java/fr/luc/bettermccommands/event/CommandConfirmationRequiredEvent.java
new file mode 100644
index 0000000..42085e5
--- /dev/null
+++ b/src/main/java/fr/luc/bettermccommands/event/CommandConfirmationRequiredEvent.java
@@ -0,0 +1,53 @@
+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'une commande nécessite une confirmation avant d'être exécutée.
+ * Permet de personnaliser le message de confirmation ou d'annuler la demande de confirmation.
+ */
+public class CommandConfirmationRequiredEvent extends CancellableCommandEvent {
+
+ private final Duration timeout;
+ private String customPromptMessage;
+
+ /**
+ * Crée l'événement de demande de confirmation.
+ *
+ * @param node Le nœud de commande concerné.
+ * @param sender L'émetteur (joueur).
+ * @param context Le contexte de commande capturé.
+ * @param timeout Le délai imparti pour confirmer.
+ */
+ public CommandConfirmationRequiredEvent(CommandNode node, CommandSender sender, CommandContext context, Duration timeout) {
+ super(node, sender, context);
+ this.timeout = timeout;
+ }
+
+ /**
+ * @return La durée impartie pour confirmer l'action.
+ */
+ public Duration getTimeout() {
+ return timeout;
+ }
+
+ /**
+ * @return Le message personnalisé de confirmation, ou {@code null} pour le message par défaut.
+ */
+ public String getCustomPromptMessage() {
+ return customPromptMessage;
+ }
+
+ /**
+ * Définit un message de confirmation personnalisé.
+ *
+ * @param customPromptMessage Le message au format MiniMessage ou couleurs Minecraft.
+ */
+ public void setCustomPromptMessage(String customPromptMessage) {
+ this.customPromptMessage = customPromptMessage;
+ }
+}
diff --git a/src/main/java/fr/luc/bettermccommands/platform/CommandDispatcher.java b/src/main/java/fr/luc/bettermccommands/platform/CommandDispatcher.java
index db9f308..c703046 100644
--- a/src/main/java/fr/luc/bettermccommands/platform/CommandDispatcher.java
+++ b/src/main/java/fr/luc/bettermccommands/platform/CommandDispatcher.java
@@ -184,6 +184,44 @@ public class CommandDispatcher {
// 6. Contexte final
CommandContext fullContext = new CommandContext(sender, label, args, parsedArguments);
+ // 6.5. Vérification de la confirmation requise (Double validation)
+ if (targetNode.isConfirmationRequired() && sender instanceof Player player) {
+ Optional pendingOpt = manager.getConfirmationManager().getPendingConfirmation(player);
+ if (pendingOpt.isPresent() && pendingOpt.get().getNode().getFullName().equalsIgnoreCase(targetNode.getFullName())) {
+ // Confirmation reçue et validée !
+ manager.getConfirmationManager().consumeConfirmation(player);
+ } else {
+ // Enregistrement de la confirmation en attente
+ manager.getConfirmationManager().requestConfirmation(player, targetNode, fullContext, targetNode.getConfirmationTimeout());
+ CommandConfirmationRequiredEvent confEvent = new CommandConfirmationRequiredEvent(
+ targetNode, player, fullContext, targetNode.getConfirmationTimeout());
+ dispatchEvent(targetNode, confEvent);
+
+ if (!confEvent.isCancelled()) {
+ if (confEvent.getCustomPromptMessage() != null) {
+ confEvent.reply(confEvent.getCustomPromptMessage());
+ } else if (targetNode.getConfirmationPrompt() != null) {
+ fullContext.reply(targetNode.getConfirmationPrompt());
+ } else {
+ long secs = targetNode.getConfirmationTimeout().toSeconds();
+ fullContext.reply("⚠ Confirmation requise : " +
+ "Retapez la commande /" + label + (args.length > 0 ? " " + String.join(" ", args) : "") +
+ " dans les " + secs + "s pour valider l'exécution.");
+ }
+ }
+ return CommandResult.CANCELLED;
+ }
+ }
+
+ // 6.6. Exécution des validateurs de commande (CommandValidator)
+ for (fr.luc.bettermccommands.validation.CommandValidator validator : targetNode.getValidators()) {
+ fr.luc.bettermccommands.validation.ValidationResult valRes = validator.validate(fullContext);
+ if (!valRes.isValid()) {
+ fullContext.replyError(valRes.getErrorMessage());
+ return CommandResult.FAILED;
+ }
+ }
+
// 7. Événement PreExecute
CommandPreExecuteEvent preEvent = new CommandPreExecuteEvent(targetNode, sender, fullContext);
dispatchEvent(targetNode, preEvent);
@@ -286,7 +324,14 @@ public class CommandDispatcher {
@SuppressWarnings("unchecked")
private T parseArgumentValue(CommandArgument arg, String input, CommandContext context) throws CommandArgumentParseException {
- return arg.getType().parse(arg.getName(), input, context);
+ T parsed = arg.getType().parse(arg.getName(), input, context);
+ for (fr.luc.bettermccommands.validation.ArgumentValidator validator : arg.getValidators()) {
+ fr.luc.bettermccommands.validation.ValidationResult result = validator.validate(parsed, context);
+ if (!result.isValid()) {
+ throw new CommandArgumentParseException(arg.getName(), input, arg.getType().getTypeName(), result.getErrorMessage());
+ }
+ }
+ return parsed;
}
private CommandResult handleSyntaxError(CommandNode node, CommandSender sender, CommandContext context,
diff --git a/src/main/java/fr/luc/bettermccommands/repeat/CommandRepeatProgress.java b/src/main/java/fr/luc/bettermccommands/repeat/CommandRepeatProgress.java
new file mode 100644
index 0000000..0e8d4b0
--- /dev/null
+++ b/src/main/java/fr/luc/bettermccommands/repeat/CommandRepeatProgress.java
@@ -0,0 +1,69 @@
+package fr.luc.bettermccommands.repeat;
+
+import fr.luc.bettermccommands.api.CommandContext;
+
+/**
+ * Informations de progression d'une exécution de commande répétée.
+ */
+public class CommandRepeatProgress {
+
+ private final CommandContext context;
+ private final int currentRun;
+ private final int totalRuns;
+ private boolean cancelled = false;
+
+ /**
+ * Crée un objet de suivi d'exécution de commande répétée.
+ *
+ * @param context Le contexte de la commande.
+ * @param currentRun Le numéro de l'itération actuelle (1-indexed).
+ * @param totalRuns Le nombre total d'itérations prévues.
+ */
+ public CommandRepeatProgress(CommandContext context, int currentRun, int totalRuns) {
+ this.context = context;
+ this.currentRun = currentRun;
+ this.totalRuns = totalRuns;
+ }
+
+ /**
+ * @return Le contexte d'exécution de la commande.
+ */
+ public CommandContext getContext() {
+ return context;
+ }
+
+ /**
+ * @return Le numéro de l'itération en cours (ex: 1 pour la première).
+ */
+ public int getCurrentRun() {
+ return currentRun;
+ }
+
+ /**
+ * @return Le nombre total d'exécutions programmées.
+ */
+ public int getTotalRuns() {
+ return totalRuns;
+ }
+
+ /**
+ * @return true s'il s'agit de la dernière itération de la boucle.
+ */
+ public boolean isLastRun() {
+ return currentRun >= totalRuns;
+ }
+
+ /**
+ * @return true si la répétition a été interrompue.
+ */
+ public boolean isCancelled() {
+ return cancelled;
+ }
+
+ /**
+ * Interrompt immédiatement les itérations restantes de la boucle.
+ */
+ public void cancel() {
+ this.cancelled = true;
+ }
+}
diff --git a/src/main/java/fr/luc/bettermccommands/repeat/CommandRepeater.java b/src/main/java/fr/luc/bettermccommands/repeat/CommandRepeater.java
new file mode 100644
index 0000000..21bf5b8
--- /dev/null
+++ b/src/main/java/fr/luc/bettermccommands/repeat/CommandRepeater.java
@@ -0,0 +1,83 @@
+package fr.luc.bettermccommands.repeat;
+
+import fr.luc.bettermccommands.api.CommandContext;
+import org.bukkit.Bukkit;
+import org.bukkit.plugin.Plugin;
+import org.bukkit.scheduler.BukkitTask;
+
+import java.time.Duration;
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Consumer;
+
+/**
+ * Utilitaire permettant d'exécuter une commande ou une action associée de manière répétée / cadencée dans le temps.
+ */
+public class CommandRepeater {
+
+ /**
+ * Répète une action liée au contexte de commande à intervalle régulier.
+ *
+ * @param plugin Le plugin Bukkit gérant les tâches.
+ * @param context Le contexte de la commande.
+ * @param totalRuns Le nombre total de répétitions.
+ * @param interval L'intervalle de temps entre chaque exécution.
+ * @param onRun L'action à exécuter à chaque itération.
+ * @return L'instance de {@link BukkitTask} créée.
+ */
+ public static BukkitTask repeat(Plugin plugin, CommandContext context, int totalRuns, Duration interval,
+ Consumer onRun) {
+ return repeat(plugin, context, totalRuns, interval, onRun, null);
+ }
+
+ /**
+ * Répète une action avec rappel final à l'issue de toutes les répétitions.
+ *
+ * @param plugin Le plugin Bukkit gérant les tâches.
+ * @param context Le contexte de la commande.
+ * @param totalRuns Le nombre total d'exécutions (ex: 5).
+ * @param interval La durée entre deux itérations.
+ * @param onRun L'action exécutée à chaque itération.
+ * @param onComplete L'action finale appelée lorsque toutes les répétitions sont terminées (ou null).
+ * @return L'instance de {@link BukkitTask} correspondante.
+ */
+ public static BukkitTask repeat(Plugin plugin, CommandContext context, int totalRuns, Duration interval,
+ Consumer onRun, Runnable onComplete) {
+ Objects.requireNonNull(plugin, "plugin cannot be null");
+ Objects.requireNonNull(context, "context cannot be null");
+ Objects.requireNonNull(onRun, "onRun cannot be null");
+
+ long ticks = Math.max(1, (interval != null ? interval.toMillis() : 1000) / 50);
+ AtomicInteger runCount = new AtomicInteger(0);
+
+ final BukkitTask[] taskHolder = new BukkitTask[1];
+
+ taskHolder[0] = Bukkit.getScheduler().runTaskTimer(plugin, () -> {
+ int current = runCount.incrementAndGet();
+ CommandRepeatProgress progress = new CommandRepeatProgress(context, current, totalRuns);
+
+ try {
+ onRun.accept(progress);
+ } catch (Exception e) {
+ context.replyError("Erreur lors de l'exécution répétée : " + e.getMessage());
+ e.printStackTrace();
+ progress.cancel();
+ }
+
+ if (progress.isCancelled() || current >= totalRuns) {
+ if (taskHolder[0] != null) {
+ taskHolder[0].cancel();
+ }
+ if (!progress.isCancelled() && onComplete != null) {
+ try {
+ onComplete.run();
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+ }
+ }, 0L, ticks);
+
+ return taskHolder[0];
+ }
+}
diff --git a/src/main/java/fr/luc/bettermccommands/validation/ArgumentValidator.java b/src/main/java/fr/luc/bettermccommands/validation/ArgumentValidator.java
new file mode 100644
index 0000000..856e0e9
--- /dev/null
+++ b/src/main/java/fr/luc/bettermccommands/validation/ArgumentValidator.java
@@ -0,0 +1,40 @@
+package fr.luc.bettermccommands.validation;
+
+import fr.luc.bettermccommands.api.CommandContext;
+
+import java.util.function.Predicate;
+
+/**
+ * Validateur spécifique pour la valeur d'un argument typé.
+ *
+ * @param Le type de l'argument.
+ */
+@FunctionalInterface
+public interface ArgumentValidator {
+
+ /**
+ * Valide la valeur de l'argument analysé.
+ *
+ * @param value La valeur convertie de l'argument.
+ * @param context Le contexte d'exécution.
+ * @return Le résultat de validation.
+ */
+ ValidationResult validate(T value, CommandContext context);
+
+ /**
+ * Crée un validateur d'argument simple à partir d'un prédicat.
+ *
+ * @param predicate Le prédicat testant la valeur.
+ * @param errorMessage Le message d'erreur si le test échoue.
+ * @param Le type d'argument.
+ * @return Le validateur configuré.
+ */
+ static ArgumentValidator of(Predicate predicate, String errorMessage) {
+ return (value, context) -> {
+ if (predicate.test(value)) {
+ return ValidationResult.valid();
+ }
+ return ValidationResult.invalid(errorMessage);
+ };
+ }
+}
diff --git a/src/main/java/fr/luc/bettermccommands/validation/CommandValidator.java b/src/main/java/fr/luc/bettermccommands/validation/CommandValidator.java
new file mode 100644
index 0000000..d8ab242
--- /dev/null
+++ b/src/main/java/fr/luc/bettermccommands/validation/CommandValidator.java
@@ -0,0 +1,36 @@
+package fr.luc.bettermccommands.validation;
+
+import fr.luc.bettermccommands.api.CommandContext;
+
+import java.util.function.Predicate;
+
+/**
+ * Validateur fonctionnel permettant de vérifier des conditions métier avant l'exécution d'une commande.
+ */
+@FunctionalInterface
+public interface CommandValidator {
+
+ /**
+ * Valide le contexte d'exécution de la commande.
+ *
+ * @param context Le contexte complet de la commande.
+ * @return Le {@link ValidationResult} indiquant le succès ou l'échec avec message.
+ */
+ ValidationResult validate(CommandContext context);
+
+ /**
+ * Crée un validateur à partir d'un prédicat simple et d'un message d'erreur.
+ *
+ * @param predicate Le prédicat retournant true si la condition est remplie.
+ * @param errorMessage Le message d'erreur envoyé en cas d'échec.
+ * @return L'instance de {@link CommandValidator}.
+ */
+ static CommandValidator of(Predicate predicate, String errorMessage) {
+ return context -> {
+ if (predicate.test(context)) {
+ return ValidationResult.valid();
+ }
+ return ValidationResult.invalid(errorMessage);
+ };
+ }
+}
diff --git a/src/main/java/fr/luc/bettermccommands/validation/ValidationResult.java b/src/main/java/fr/luc/bettermccommands/validation/ValidationResult.java
new file mode 100644
index 0000000..fe78964
--- /dev/null
+++ b/src/main/java/fr/luc/bettermccommands/validation/ValidationResult.java
@@ -0,0 +1,50 @@
+package fr.luc.bettermccommands.validation;
+
+import java.util.Objects;
+
+/**
+ * Représente le résultat d'une validation de commande ou d'argument.
+ */
+public class ValidationResult {
+
+ private static final ValidationResult VALID = new ValidationResult(true, null);
+
+ private final boolean valid;
+ private final String errorMessage;
+
+ private ValidationResult(boolean valid, String errorMessage) {
+ this.valid = valid;
+ this.errorMessage = errorMessage;
+ }
+
+ /**
+ * @return Un résultat indiquant que la validation a réussi.
+ */
+ public static ValidationResult valid() {
+ return VALID;
+ }
+
+ /**
+ * Crée un résultat d'échec de validation avec un message explicatif.
+ *
+ * @param errorMessage Le message d'erreur formaté (MiniMessage ou texte).
+ * @return Le résultat d'échec.
+ */
+ public static ValidationResult invalid(String errorMessage) {
+ return new ValidationResult(false, Objects.requireNonNull(errorMessage, "errorMessage cannot be null"));
+ }
+
+ /**
+ * @return true si la validation est passée avec succès, sinon false.
+ */
+ public boolean isValid() {
+ return valid;
+ }
+
+ /**
+ * @return Le message d'erreur en cas d'échec, ou {@code null} si valide.
+ */
+ public String getErrorMessage() {
+ return errorMessage;
+ }
+}
diff --git a/src/test/java/fr/luc/bettermccommands/CommandValidationAndConfirmationTest.java b/src/test/java/fr/luc/bettermccommands/CommandValidationAndConfirmationTest.java
new file mode 100644
index 0000000..046198b
--- /dev/null
+++ b/src/test/java/fr/luc/bettermccommands/CommandValidationAndConfirmationTest.java
@@ -0,0 +1,111 @@
+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.validation.ValidationResult;
+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 static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.Mockito.when;
+
+public class CommandValidationAndConfirmationTest {
+
+ private BetterMcCommands manager;
+
+ @BeforeEach
+ void setUp() {
+ manager = new BetterMcCommands("test");
+ }
+
+ @Test
+ @DisplayName("La validation de commande (CommandValidator) doit bloquer l'exécution si invalide")
+ void testCommandValidation() {
+ AtomicBoolean executed = new AtomicBoolean(false);
+ AtomicBoolean allowExecution = new AtomicBoolean(false);
+
+ Command command = BetterMcCommands.builder("trade")
+ .validate(ctx -> {
+ if (!allowExecution.get()) {
+ return ValidationResult.invalid("Échange temporairement indisponible.");
+ }
+ return ValidationResult.valid();
+ })
+ .executes(ctx -> executed.set(true))
+ .build();
+
+ Player player = Mockito.mock(Player.class);
+ when(player.getUniqueId()).thenReturn(UUID.randomUUID());
+
+ // 1. Validation échoue
+ CommandResult res1 = manager.getDispatcher().execute(command, player, "trade", new String[0]);
+ assertEquals(CommandResult.FAILED, res1);
+ assertFalse(executed.get());
+
+ // 2. Validation réussit
+ allowExecution.set(true);
+ CommandResult res2 = manager.getDispatcher().execute(command, player, "trade", new String[0]);
+ assertEquals(CommandResult.SUCCESS, res2);
+ assertTrue(executed.get());
+ }
+
+ @Test
+ @DisplayName("La validation d'argument (ArgumentValidator) doit rejeter les entrées non conformes")
+ void testArgumentValidation() {
+ AtomicBoolean executed = new AtomicBoolean(false);
+
+ Command command = BetterMcCommands.builder("rename")
+ .argument(Arguments.string("nom")
+ .validate(nom -> !nom.equalsIgnoreCase("root"), "Le nom 'root' est interdit."))
+ .executes(ctx -> executed.set(true))
+ .build();
+
+ Player player = Mockito.mock(Player.class);
+ when(player.getUniqueId()).thenReturn(UUID.randomUUID());
+
+ // Nom interdit "root" -> Erreur
+ CommandResult res1 = manager.getDispatcher().execute(command, player, "rename", new String[]{"root"});
+ assertEquals(CommandResult.SYNTAX_ERROR, res1);
+ assertFalse(executed.get());
+
+ // Nom valide "Guerrier" -> Succès
+ CommandResult res2 = manager.getDispatcher().execute(command, player, "rename", new String[]{"Guerrier"});
+ assertEquals(CommandResult.SUCCESS, res2);
+ assertTrue(executed.get());
+ }
+
+ @Test
+ @DisplayName("Le système de confirmation doit demander confirmation puis exécuter à la seconde tentative")
+ void testConfirmationWorkflow() {
+ AtomicBoolean executed = new AtomicBoolean(false);
+
+ Command command = BetterMcCommands.builder("disband")
+ .requireConfirmation(Duration.ofSeconds(10))
+ .executes(ctx -> executed.set(true))
+ .build();
+
+ Player player = Mockito.mock(Player.class);
+ UUID uuid = UUID.randomUUID();
+ when(player.getUniqueId()).thenReturn(uuid);
+
+ // 1ère tentative : Enregistre la confirmation et bloque l'exécution
+ CommandResult res1 = manager.getDispatcher().execute(command, player, "disband", new String[0]);
+ assertEquals(CommandResult.CANCELLED, res1);
+ assertFalse(executed.get());
+ assertTrue(manager.getConfirmationManager().getPendingConfirmation(player).isPresent());
+
+ // 2ème tentative immédiate : Valide et exécute
+ CommandResult res2 = manager.getDispatcher().execute(command, player, "disband", new String[0]);
+ assertEquals(CommandResult.SUCCESS, res2);
+ assertTrue(executed.get());
+ assertFalse(manager.getConfirmationManager().getPendingConfirmation(player).isPresent());
+ }
+}