feat: dynamic command registration + Maven publication setup

CRCore.registerCommand() now falls back to dynamic registration via the
server's internal CommandMap (reflection on CraftServer.commandMap) when
the command is absent from the host plugin's plugin.yml. Game plugins can
now use CR-Core with zero plugin.yml changes — just instantiate CRCore.
If the command IS declared in plugin.yml, CR-Core detects it and uses
setExecutor/setTabCompleter as before.

pom.xml: distributionManagement targeting Gitea Packages
(https://gitea.luc-rival.fr/api/packages/admin/maven), plus
maven-source-plugin (3.3.0) and maven-javadoc-plugin (3.6.3) so each
mvn deploy publishes the main jar, a -sources.jar and a -javadoc.jar.
doclint=none on javadoc to tolerate partial doc.

docs/setup.md: clarifies that the commands: entry in plugin.yml is now
optional. docs/decisions.md: new decision logged for the dynamic
registration approach.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Antone Barbaud
2026-06-09 11:33:45 +02:00
parent c1b414f400
commit 7ee349f206
4 changed files with 173 additions and 12 deletions
+21
View File
@@ -322,3 +322,24 @@ Format léger : une décision = un titre + contexte + choix + raison.
de jeu créent dans la même DB. CR-Core et le plugin de jeu partagent le
même fichier SQLite (par défaut `<dataFolder>/crcore.db`) ; le préfixe
isole proprement.
## 2026-06-09 — Enregistrement dynamique de la commande (plugin.yml optionnel)
- **Choix** : `CRCore.registerCommand()` tente d'abord
`plugin.getCommand(name)`. Si la commande n'est pas déclarée dans le
`plugin.yml` du plugin hôte, fallback sur enregistrement dynamique via le
`CommandMap` interne du serveur (accédé par réflexion sur
`CraftServer.commandMap`).
- **Raison** : un plugin de jeu peut maintenant utiliser CR-Core en
**changeant uniquement le `pom.xml` + une instanciation de `CRCore`**
zéro modification du `plugin.yml`. Si l'utilisateur veut customiser
description/aliases côté Bukkit, il peut quand même déclarer la commande
dans plugin.yml ; CR-Core détecte et utilise cette déclaration.
- **Wrapper Bukkit** : on crée une `org.bukkit.command.Command` anonyme
qui délègue `execute` / `tabComplete` au `CoreCommand` (qui est notre
`BaseCommand`). On copie nom + aliases + description depuis le
`CoreCommand` vers le wrapper.
- **Réflexion** : stable sur Paper 1.16.5 ; le champ `commandMap` de
`CraftServer` existe depuis longtemps. Si une version future cassait
l'accès, le code log un severe et continue (les autres features de
CRCore restent fonctionnelles).
+15 -5
View File
@@ -48,13 +48,23 @@ name: MyGame
main: fr.exemple.mygame.MyGamePlugin
version: 1.0
api-version: 1.16
commands:
core:
description: Commandes CR-Core
# aliases optionnels — équivalents au point de vue Bukkit
aliases: [cr, crcore]
```
> **Pas besoin de déclarer la commande `core`** : CR-Core l'enregistre
> dynamiquement via le `CommandMap` du serveur quand elle est absente du
> plugin.yml. Si tu préfères la déclarer quand même (pour customiser la
> description ou les aliases côté Bukkit), tu peux ajouter :
>
> ```yaml
> commands:
> core:
> description: Commandes CR-Core
> aliases: [cr, crcore]
> ```
>
> Dans ce cas, CR-Core détecte la commande déclarée et s'y branche
> normalement via `setExecutor` (pas d'enregistrement dynamique).
### Code minimal
```java
+71
View File
@@ -55,6 +55,26 @@
</dependency>
</dependencies>
<!--
Publication vers le registry Maven de Gitea (Gitea Packages).
URL = https://<gitea>/api/packages/<owner>/maven
Le même endpoint sert pour les releases et les snapshots ; Gitea
décide selon le suffixe -SNAPSHOT de la version.
L'<id> doit matcher l'entrée <server id="gitea-luc-rival"> dans
~/.m2/settings.xml (qui contient le PAT). Voir docs/setup.md.
-->
<distributionManagement>
<repository>
<id>gitea-luc-rival</id>
<url>https://gitea.luc-rival.fr/api/packages/admin/maven</url>
</repository>
<snapshotRepository>
<id>gitea-luc-rival</id>
<url>https://gitea.luc-rival.fr/api/packages/admin/maven</url>
</snapshotRepository>
</distributionManagement>
<build>
<finalName>${project.artifactId}-${project.version}</finalName>
<plugins>
@@ -67,6 +87,57 @@
<target>${maven.compiler.target}</target>
</configuration>
</plugin>
<!--
Publie un -sources.jar contenant le code source .java. Permet à
IntelliJ d'aller au source d'une classe CR-Core depuis un plugin
de jeu consommateur (Ctrl+Click).
-->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-source-plugin</artifactId>
<version>3.3.0</version>
<executions>
<execution>
<id>attach-sources</id>
<goals>
<goal>jar-no-fork</goal>
</goals>
</execution>
</executions>
</plugin>
<!--
Publie un -javadoc.jar contenant le HTML généré à partir des
commentaires /** ... */ du code. IntelliJ l'affiche en popup
au survol d'une méthode CR-Core.
doclint=none désactive les checks stricts (méthodes sans @param,
@return, etc.) — on accepte de la doc partielle plutôt que
bloquer le build.
-->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-javadoc-plugin</artifactId>
<version>3.6.3</version>
<configuration>
<source>${maven.compiler.source}</source>
<doclint>none</doclint>
<quiet>true</quiet>
<encoding>UTF-8</encoding>
<docencoding>UTF-8</docencoding>
<charset>UTF-8</charset>
<failOnError>false</failOnError>
</configuration>
<executions>
<execution>
<id>attach-javadocs</id>
<goals>
<goal>jar</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
+66 -7
View File
@@ -12,10 +12,16 @@ import fr.luc.crcore.team.InMemoryTeamRepository;
import fr.luc.crcore.team.SqliteTeamRepository;
import fr.luc.crcore.team.TeamRepository;
import fr.luc.crcore.team.TeamService;
import org.bukkit.Bukkit;
import org.bukkit.command.CommandMap;
import org.bukkit.command.CommandSender;
import org.bukkit.command.PluginCommand;
import org.bukkit.plugin.java.JavaPlugin;
import java.io.File;
import java.lang.reflect.Field;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
/**
@@ -147,16 +153,69 @@ public class CRCore {
return new CoreCommand(teamService, playerProfileService);
}
/**
* Enregistre {@link #coreCommand} sous le nom configuré, avec fallback
* dynamique. Stratégie :
* <ol>
* <li>Si la commande est déclarée dans le {@code plugin.yml} du plugin
* hôte ({@code plugin.getCommand(name)} non null), on s'y branche
* classiquement via {@code setExecutor} / {@code setTabCompleter}.</li>
* <li>Sinon, on l'enregistre <b>dynamiquement</b> via le
* {@link CommandMap} interne du serveur (accédé par réflexion sur
* le champ {@code commandMap} de {@code CraftServer}). Le plugin
* hôte n'a alors rien à mettre dans son {@code plugin.yml}.</li>
* </ol>
*/
private void registerCommand() {
PluginCommand cmd = plugin.getCommand(config.getCommandName());
if (cmd == null) {
plugin.getLogger().warning("Commande '" + config.getCommandName() +
"' absente du plugin.yml — /" + config.getCommandName() +
" ne sera pas reconnue.");
String name = config.getCommandName();
PluginCommand cmd = plugin.getCommand(name);
if (cmd != null) {
cmd.setExecutor(coreCommand);
cmd.setTabCompleter(coreCommand);
return;
}
cmd.setExecutor(coreCommand);
cmd.setTabCompleter(coreCommand);
registerDynamicCommand(name);
}
/**
* Enregistre la commande sans entrée plugin.yml en passant par le
* {@link CommandMap} interne du serveur. Wrappe le {@link CoreCommand}
* dans une {@link org.bukkit.command.Command} anonyme qui délègue à
* {@code onCommand} / {@code onTabComplete}.
*/
private void registerDynamicCommand(String name) {
try {
CommandMap commandMap = resolveCommandMap();
final CoreCommand executor = coreCommand;
org.bukkit.command.Command bukkitCommand = new org.bukkit.command.Command(name) {
@Override
public boolean execute(CommandSender sender, String label, String[] args) {
return executor.onCommand(sender, this, label, args);
}
@Override
public List<String> tabComplete(CommandSender sender, String alias, String[] args) {
List<String> result = executor.onTabComplete(sender, this, alias, args);
return result != null ? result : Collections.emptyList();
}
};
bukkitCommand.setDescription(executor.getDescription());
bukkitCommand.setAliases(executor.getAliases());
commandMap.register(plugin.getName().toLowerCase(), bukkitCommand);
plugin.getLogger().info("Commande /" + name + " enregistrée dynamiquement (plugin.yml non requis).");
} catch (Exception ex) {
plugin.getLogger().severe("Échec d'enregistrement dynamique de /" + name + " : " + ex.getMessage());
}
}
/**
* Récupère le {@link CommandMap} interne du serveur via réflexion sur
* {@code CraftServer.commandMap}. Stable sur Paper 1.16.5.
*/
private CommandMap resolveCommandMap() throws ReflectiveOperationException {
Field field = Bukkit.getServer().getClass().getDeclaredField("commandMap");
field.setAccessible(true);
return (CommandMap) field.get(Bukkit.getServer());
}
// ---- Getters ----