feat: chef commands moved to admin + PlaceholderAPI integration

Chef → admin: the chef role no longer grants any command privilege.
All team-management subcommands now take <team> as an argument and are
gated by their crcore.team.<action> permission only:
- add <team> <player>
- remove <team> <player>
- transfer <team> <player>
- visibility <team> <PUBLIC|PRIVATE>
- setspawn <team> (still player-only — needs admin's location)

The LEADER role is kept in the data model (Team / TeamMember) and remains
usable by game plugins via the API, but does not unlock any default
command. Future work can re-introduce chef-specific commands if needed.

PlaceholderAPI: auto-detected at CRCore.enable(). If the PAPI plugin is
present on the server, CRCorePlaceholderExpansion registers automatically;
otherwise the lib runs without it (no NoClassDefFoundError thanks to the
indirection through doRegisterPlaceholderHook).

Placeholders exposed:
- Team: %crcore_team%, %crcore_team_name/tag/color/color_chat/size/
  visibility/leader_name/total_score%, %crcore_team_score_<name>%
- Player: %crcore_player_score_<name>%, %crcore_player_score_total%

Dependency: me.clip:placeholderapi:2.11.6, scope provided. New repo:
https://repo.extendedclip.com/content/repositories/placeholderapi/.

docs/features.md, decisions.md and the builtin-commands diagram updated to
reflect the simpler admin/player two-tier model and the PAPI section.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Antone Barbaud
2026-06-09 15:05:02 +02:00
parent 002fefdc02
commit 8b7cad3fce
12 changed files with 376 additions and 108 deletions
+32
View File
@@ -117,11 +117,43 @@ public class CRCore {
this.coreCommand = buildCoreCommand(teamService, playerProfileService);
registerCommand();
registerPlaceholderHook();
plugin.getLogger().info("CR-Core activé.");
enabled = true;
return this;
}
/**
* Enregistre l'expansion PlaceholderAPI {@code %crcore_*%} si le plugin
* PAPI est installé sur le serveur. Si absent, no-op silencieux — la
* lib reste fonctionnelle sans.
*
* <p>Le chargement de la classe d'expansion est différé via une indirection
* (méthode {@code doRegisterPlaceholderHook}) pour que le bytecode
* référençant {@code me.clip.placeholderapi.*} ne soit pas vérifié si
* PAPI n'est pas présent.
*/
protected void registerPlaceholderHook() {
if (plugin.getServer().getPluginManager().getPlugin("PlaceholderAPI") == null) {
return;
}
try {
doRegisterPlaceholderHook();
plugin.getLogger().info("PlaceholderAPI détecté — placeholders %crcore_*% enregistrés.");
} catch (Throwable t) {
plugin.getLogger().warning(
"Échec d'enregistrement des placeholders PAPI : " + t.getMessage());
}
}
/** Indirection pour différer le chargement des classes PAPI (cf. {@link #registerPlaceholderHook}). */
private void doRegisterPlaceholderHook() {
new fr.luc.crcore.placeholder.CRCorePlaceholderExpansion(
teamService, playerProfileService, plugin.getDescription().getVersion()
).register();
}
/** Libère les ressources (ferme la DB notamment). Idempotent. */
public void disable() {
if (!enabled) return;
@@ -11,10 +11,10 @@ import org.bukkit.entity.Player;
import java.util.Objects;
/**
* {@code /core team add <player>}
* {@code /core team add <team> <player>}
*
* <p>Le chef ajoute un joueur à son équipe. Marche que la team soit PUBLIC ou
* PRIVATE — c'est une action chef, pas un auto-join.
* <p><b>Admin uniquement</b>. Ajoute un joueur connecté à l'équipe spécifiée,
* quelle que soit sa visibilité.
*/
public class TeamAddSubCommand extends SubCommand {
@@ -23,24 +23,16 @@ public class TeamAddSubCommand extends SubCommand {
public TeamAddSubCommand(TeamService service) {
super("add");
this.service = Objects.requireNonNull(service, "service");
description("Ajouter un joueur à son équipe (chef uniquement)");
description("Ajouter un joueur à une équipe (admin)");
permission("crcore.team.add");
playerOnly();
argument("team", TeamArgumentTypes.teamByName(service));
argument("player", ArgumentTypes.ONLINE_PLAYER);
}
@Override
public CommandResult execute(CommandContext ctx) {
Player executor = ctx.requirePlayer();
Team team = ctx.get("team");
Player target = ctx.get("player");
Team team = service.getTeamOfPlayer(executor.getUniqueId()).orElse(null);
if (team == null) {
return CommandResult.failure("Vous n'appartenez à aucune équipe.");
}
if (!team.isLeader(executor.getUniqueId())) {
return CommandResult.failure("Seul le chef peut ajouter des membres.");
}
if (service.getTeamOfPlayer(target.getUniqueId()).isPresent()) {
return CommandResult.failure(target.getName() + " est déjà dans une équipe.");
}
@@ -8,15 +8,15 @@ import fr.luc.crcore.team.Team;
import fr.luc.crcore.team.TeamService;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
import org.bukkit.entity.Player;
import java.util.Objects;
/**
* {@code /core team remove <player>}
* {@code /core team remove <team> <player>}
*
* <p>Le chef retire un membre. Accepte les joueurs offline (utilise leur nom
* pour résoudre l'UUID via {@link Bukkit#getOfflinePlayer(String)}).
* <p><b>Admin uniquement</b>. Retire un joueur (online ou offline) de
* l'équipe spécifiée. Refuse si le joueur ciblé est le chef — l'admin doit
* d'abord transférer ou réassigner via {@code setleader}.
*/
public class TeamRemoveSubCommand extends SubCommand {
@@ -25,34 +25,27 @@ public class TeamRemoveSubCommand extends SubCommand {
public TeamRemoveSubCommand(TeamService service) {
super("remove");
this.service = Objects.requireNonNull(service, "service");
description("Retirer un joueur de son équipe (chef uniquement)");
description("Retirer un joueur d'une équipe (admin)");
permission("crcore.team.remove");
playerOnly();
argument("team", TeamArgumentTypes.teamByName(service));
argument("player", ArgumentTypes.STRING);
}
@Override
public CommandResult execute(CommandContext ctx) {
Player executor = ctx.requirePlayer();
Team team = ctx.get("team");
String targetName = ctx.get("player");
Team team = service.getTeamOfPlayer(executor.getUniqueId()).orElse(null);
if (team == null) {
return CommandResult.failure("Vous n'appartenez à aucune équipe.");
}
if (!team.isLeader(executor.getUniqueId())) {
return CommandResult.failure("Seul le chef peut retirer des membres.");
}
@SuppressWarnings("deprecation")
OfflinePlayer target = Bukkit.getOfflinePlayer(targetName);
if (target.getUniqueId().equals(executor.getUniqueId())) {
return CommandResult.failure("Pour quitter l'équipe en tant que chef, transférez d'abord le leadership.");
}
if (!team.hasMember(target.getUniqueId())) {
return CommandResult.failure(targetName + " n'est pas dans votre équipe.");
return CommandResult.failure(targetName + " n'est pas dans l'équipe " + team.getName() + ".");
}
if (team.isLeader(target.getUniqueId())) {
return CommandResult.failure(
"Impossible de retirer le chef. Réassignez-le d'abord via /core team setleader.");
}
service.removeMember(team.getId(), target.getUniqueId());
return CommandResult.success(targetName + " retiré de l'équipe.");
return CommandResult.success(targetName + " retiré de l'équipe " + team.getName() + ".");
}
}
@@ -10,9 +10,11 @@ import org.bukkit.entity.Player;
import java.util.Objects;
/**
* {@code /core team setspawn}
* {@code /core team setspawn <team>}
*
* <p>Définit le point de spawn de l'équipe à la position courante du chef.
* <p><b>Admin uniquement, player-only</b>. Définit le point de spawn de
* l'équipe spécifiée à la position courante de l'admin (où il/elle se trouve).
* Doit être lancé en jeu — la console n'a pas de location.
*/
public class TeamSetSpawnSubCommand extends SubCommand {
@@ -21,22 +23,17 @@ public class TeamSetSpawnSubCommand extends SubCommand {
public TeamSetSpawnSubCommand(TeamService service) {
super("setspawn");
this.service = Objects.requireNonNull(service, "service");
description("Définir le point de spawn de l'équipe (chef uniquement)");
description("Définir le point de spawn d'une équipe (admin, en jeu)");
permission("crcore.team.setspawn");
playerOnly();
argument("team", TeamArgumentTypes.teamByName(service));
}
@Override
public CommandResult execute(CommandContext ctx) {
Player player = ctx.requirePlayer();
Team team = service.getTeamOfPlayer(player.getUniqueId()).orElse(null);
if (team == null) {
return CommandResult.failure("Vous n'appartenez à aucune équipe.");
}
if (!team.isLeader(player.getUniqueId())) {
return CommandResult.failure("Seul le chef peut définir le spawn.");
}
service.setSpawnPoint(team.getId(), player.getLocation());
return CommandResult.success("Spawn de l'équipe défini à votre position.");
Player admin = ctx.requirePlayer();
Team team = ctx.get("team");
service.setSpawnPoint(team.getId(), admin.getLocation());
return CommandResult.success("Spawn de " + team.getName() + " défini à votre position.");
}
}
@@ -8,14 +8,19 @@ import fr.luc.crcore.team.Team;
import fr.luc.crcore.team.TeamService;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
import org.bukkit.entity.Player;
import java.util.Objects;
/**
* {@code /core team transfer <player>}
* {@code /core team transfer <team> <player>}
*
* <p>Le chef transmet son rôle à un autre membre existant de son équipe.
* <p><b>Admin uniquement</b>. Transfère le rôle de chef à un membre existant
* de l'équipe. <i>Strict</i> : le joueur cible doit déjà être membre, et
* l'équipe doit avoir un chef actuel.
*
* <p>Pour un cas plus permissif (assigner un chef sur une équipe leaderless,
* ou auto-ajouter un non-membre comme chef), utiliser
* {@code /core team setleader}.
*/
public class TeamTransferSubCommand extends SubCommand {
@@ -24,29 +29,29 @@ public class TeamTransferSubCommand extends SubCommand {
public TeamTransferSubCommand(TeamService service) {
super("transfer");
this.service = Objects.requireNonNull(service, "service");
description("Transférer le rôle de chef à un autre membre (chef uniquement)");
description("Transférer le rôle de chef à un membre (admin, strict)");
permission("crcore.team.transfer");
playerOnly();
argument("team", TeamArgumentTypes.teamByName(service));
argument("player", ArgumentTypes.STRING);
}
@Override
public CommandResult execute(CommandContext ctx) {
Player executor = ctx.requirePlayer();
Team team = ctx.get("team");
String targetName = ctx.get("player");
Team team = service.getTeamOfPlayer(executor.getUniqueId()).orElse(null);
if (team == null) {
return CommandResult.failure("Vous n'appartenez à aucune équipe.");
}
if (!team.isLeader(executor.getUniqueId())) {
return CommandResult.failure("Seul le chef peut transférer le leadership.");
}
@SuppressWarnings("deprecation")
OfflinePlayer target = Bukkit.getOfflinePlayer(targetName);
if (!team.hasMember(target.getUniqueId())) {
return CommandResult.failure(targetName + " n'est pas dans votre équipe.");
return CommandResult.failure(
targetName + " n'est pas membre de " + team.getName() +
" — utilisez /core team setleader pour un cas plus général.");
}
service.transferLeadership(team.getId(), target.getUniqueId());
return CommandResult.success("Leadership transféré à " + targetName + ".");
try {
service.transferLeadership(team.getId(), target.getUniqueId());
} catch (IllegalStateException ex) {
return CommandResult.failure(ex.getMessage());
}
return CommandResult.success(targetName + " est désormais chef de " + team.getName() + ".");
}
}
@@ -7,15 +7,14 @@ import fr.luc.crcore.command.SubCommand;
import fr.luc.crcore.team.Team;
import fr.luc.crcore.team.TeamService;
import fr.luc.crcore.team.TeamVisibility;
import org.bukkit.entity.Player;
import java.util.Objects;
/**
* {@code /core team visibility <PUBLIC|PRIVATE>}
* {@code /core team visibility <team> <PUBLIC|PRIVATE>}
*
* <p>Le chef change la visibilité de son équipe. PUBLIC = les autres joueurs
* peuvent rejoindre avec {@code /core team join}.
* <p><b>Admin uniquement</b>. Change la visibilité d'une équipe. PUBLIC permet
* aux joueurs de la rejoindre avec {@code /core team join}.
*/
public class TeamVisibilitySubCommand extends SubCommand {
@@ -24,24 +23,17 @@ public class TeamVisibilitySubCommand extends SubCommand {
public TeamVisibilitySubCommand(TeamService service) {
super("visibility");
this.service = Objects.requireNonNull(service, "service");
description("Changer la visibilité de son équipe (chef uniquement)");
description("Changer la visibilité d'une équipe (admin)");
permission("crcore.team.visibility");
playerOnly();
argument("team", TeamArgumentTypes.teamByName(service));
argument("visibility", ArgumentTypes.enumOf(TeamVisibility.class));
}
@Override
public CommandResult execute(CommandContext ctx) {
Player player = ctx.requirePlayer();
Team team = ctx.get("team");
TeamVisibility visibility = ctx.get("visibility");
Team team = service.getTeamOfPlayer(player.getUniqueId()).orElse(null);
if (team == null) {
return CommandResult.failure("Vous n'appartenez à aucune équipe.");
}
if (!team.isLeader(player.getUniqueId())) {
return CommandResult.failure("Seul le chef peut changer la visibilité.");
}
service.setVisibility(team.getId(), visibility);
return CommandResult.success("Visibilité réglée sur " + visibility + ".");
return CommandResult.success("Visibilité de " + team.getName() + " réglée sur " + visibility + ".");
}
}
@@ -0,0 +1,136 @@
package fr.luc.crcore.placeholder;
import fr.luc.crcore.player.PlayerProfileService;
import fr.luc.crcore.team.Team;
import fr.luc.crcore.team.TeamService;
import me.clip.placeholderapi.expansion.PlaceholderExpansion;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
import org.bukkit.entity.Player;
import java.util.Objects;
import java.util.Optional;
/**
* Expansion PlaceholderAPI exposant les données CR-Core (équipe et profil
* joueur) via des placeholders {@code %crcore_*%}.
*
* <h2>Placeholders Team</h2>
* <ul>
* <li>{@code %crcore_team%} — récap formaté avec couleur :
* {@code §c[#WOLF] Wolves}</li>
* <li>{@code %crcore_team_name%} — nom de l'équipe</li>
* <li>{@code %crcore_team_tag%} — tag court</li>
* <li>{@code %crcore_team_color%} — nom de la couleur ({@code Red},
* {@code Blue}, …)</li>
* <li>{@code %crcore_team_color_chat%} — code couleur ChatColor (§c, §9, …)</li>
* <li>{@code %crcore_team_size%} — nombre de membres</li>
* <li>{@code %crcore_team_visibility%} — {@code PUBLIC} ou {@code PRIVATE}</li>
* <li>{@code %crcore_team_leader_name%} — nom du chef (vide si leaderless)</li>
* <li>{@code %crcore_team_score_<name>%} — score nommé de l'équipe
* (ex. {@code %crcore_team_score_kills%})</li>
* </ul>
*
* <h2>Placeholders Player</h2>
* <ul>
* <li>{@code %crcore_player_score_<name>%} — score nommé du joueur
* (ex. {@code %crcore_player_score_kills%})</li>
* <li>{@code %crcore_player_score_total%} — somme de tous les scores du joueur</li>
* </ul>
*
* <p>Si le joueur n'est dans aucune équipe, tous les {@code %crcore_team_*%}
* renvoient une chaîne vide. Si le placeholder est inconnu, on renvoie
* {@code null} → PAPI laisse le placeholder brut.
*/
public class CRCorePlaceholderExpansion extends PlaceholderExpansion {
private final TeamService teamService;
private final PlayerProfileService playerProfileService;
private final String version;
public CRCorePlaceholderExpansion(TeamService teamService,
PlayerProfileService playerProfileService,
String version) {
this.teamService = Objects.requireNonNull(teamService, "teamService");
this.playerProfileService = Objects.requireNonNull(playerProfileService, "playerProfileService");
this.version = Objects.requireNonNullElse(version, "1.0");
}
@Override
public String getIdentifier() {
return "crcore";
}
@Override
public String getAuthor() {
return "luc";
}
@Override
public String getVersion() {
return version;
}
/** Garde l'enregistrement vivant à travers les {@code /papi reload}. */
@Override
public boolean persist() {
return true;
}
@Override
public String onPlaceholderRequest(Player player, String params) {
if (player == null || params == null) return "";
String p = params.toLowerCase();
// Player scores : %crcore_player_score_<name>% / %crcore_player_score_total%
if (p.startsWith("player_score_")) {
String scoreName = p.substring("player_score_".length());
if (scoreName.equals("total")) {
return playerProfileService.getProfile(player.getUniqueId())
.map(profile -> String.valueOf(profile.getTotalScore()))
.orElse("0");
}
return String.valueOf(playerProfileService.getScore(player.getUniqueId(), scoreName));
}
// Team placeholders : récap %crcore_team% ou détail %crcore_team_*%
Optional<Team> teamOpt = teamService.getTeamOfPlayer(player.getUniqueId());
if (p.equals("team")) {
return teamOpt.map(team ->
team.getColor().getChatColor() + "[#" + team.getTag() + "] " + team.getName()
).orElse("");
}
if (p.startsWith("team_")) {
if (teamOpt.isEmpty()) return "";
Team team = teamOpt.get();
String key = p.substring("team_".length());
switch (key) {
case "name": return team.getName();
case "tag": return team.getTag();
case "color": return team.getColor().getDisplayName();
case "color_chat": return team.getColor().getChatColor().toString();
case "size": return String.valueOf(team.size());
case "visibility": return team.getVisibility().name();
case "leader_name": return resolveLeaderName(team);
case "total_score": return String.valueOf(team.getTotalScore());
}
if (key.startsWith("score_")) {
String scoreName = key.substring("score_".length());
return String.valueOf(team.getScore(scoreName));
}
}
// Placeholder inconnu — laisse PAPI le rendre brut.
return null;
}
private String resolveLeaderName(Team team) {
return team.getLeaderId()
.map(id -> {
OfflinePlayer leader = Bukkit.getOfflinePlayer(id);
String name = leader.getName();
return name != null ? name : "";
})
.orElse("");
}
}