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()); + } +}