From 361040136ee4e22d366158d51697b05af17c501c Mon Sep 17 00:00:00 2001 From: j-gaertig-co-dev Date: Thu, 13 Aug 2026 18:54:42 +0200 Subject: [PATCH 1/8] feat(team): add Team module for 0.3.0-Beta - Teams defined in modules/team.yml (name, MiniMessage color incl. gradients/rainbow) - Runtime membership/roles/invites/join-requests persisted by UUID under data/teams/ - Roles per team: MEMBER / ADMIN. OPs and plainbase.team.admin bypass to admin on any team - Commands: /team list, /team info, /team invite|add|kick|setrole|request, /team accept|deny|leave [team] - Every command has its own permission node, independent of team membership - Teams are mirrored to a real vanilla scoreboard team (pb_) so any command with a target selector works, e.g. /gamemode creative @a[team=pb_team1] (documented limitation: a scoreboard entry can only be on one team at a time, matters only if max-teams-per-player > 1) - New PlaceholderAPI placeholders: team_names, team_count, team_primary, team_pending_invites, teams_count, team_role_, team_members_ - config.yml version 1.6 -> 1.7 (new team module entry) - pom.xml version -> 0.3.0-Beta --- pom.xml | 2 +- .../java/de/jgaertig/plainBase/PlainBase.java | 73 ++ .../placeholder/PlainBaseExpansion.java | 43 +- .../jgaertig/plainBase/team/TeamListener.java | 21 + .../jgaertig/plainBase/team/TeamManager.java | 642 ++++++++++++++++++ .../plainBase/team/commands/TeamCommand.java | 211 ++++++ src/main/resources/config.yml | 3 +- src/main/resources/modules/team.yml | 102 +++ 8 files changed, 1094 insertions(+), 3 deletions(-) create mode 100644 src/main/java/de/jgaertig/plainBase/team/TeamListener.java create mode 100644 src/main/java/de/jgaertig/plainBase/team/TeamManager.java create mode 100644 src/main/java/de/jgaertig/plainBase/team/commands/TeamCommand.java create mode 100644 src/main/resources/modules/team.yml diff --git a/pom.xml b/pom.xml index af9a350..f4c8b3a 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ de.jgaertig PlainBase - 0.2.0-Beta + 0.3.0-Beta jar PlainBase diff --git a/src/main/java/de/jgaertig/plainBase/PlainBase.java b/src/main/java/de/jgaertig/plainBase/PlainBase.java index 7d04040..aba35c6 100644 --- a/src/main/java/de/jgaertig/plainBase/PlainBase.java +++ b/src/main/java/de/jgaertig/plainBase/PlainBase.java @@ -20,6 +20,9 @@ import de.jgaertig.plainBase.moderation.commands.*; import de.jgaertig.plainBase.placeholder.PlaceholderBridge; import de.jgaertig.plainBase.placeholder.PlainBaseExpansion; +import de.jgaertig.plainBase.team.TeamListener; +import de.jgaertig.plainBase.team.TeamManager; +import de.jgaertig.plainBase.team.commands.TeamCommand; import de.jgaertig.plainBase.vanish.VanishListener; import de.jgaertig.plainBase.vanish.VanishManager; import de.jgaertig.plainBase.vanish.commands.VanishCommand; @@ -49,6 +52,7 @@ public final class PlainBase extends JavaPlugin { private VanishManager vanishManager; private MenuManager menuManager; private BanManager banManager; + private TeamManager teamManager; private boolean placeholdersRegistered = false; private boolean commandsRegistered = false; @@ -67,6 +71,7 @@ public void onEnable() { latestVersions.put("vanish.yml", 1.1); latestVersions.put("menu.yml", 1.1); latestVersions.put("moderation.yml", 2.0); + latestVersions.put("team.yml", 1.0); registerPlaceholderExpansion(); @@ -80,6 +85,7 @@ public void onEnable() { r.register("plainbase", new PlainBaseCommand(this)); r.register("vanish", new VanishCommand(this)); r.register("menu", new MenuCommand(this)); + r.register("team", new TeamCommand(this)); r.register("ban", new BanCommand(this)); r.register("tempban", new TempBanCommand(this)); @@ -244,6 +250,41 @@ private void setupPermissions() { getServer().getPluginManager().addPermission( new Permission("plainbase.moderation.unbanip", "PlainBase: Allows access to /unbanip", PermissionDefault.OP) ); + + // team module + getServer().getPluginManager().addPermission( + new Permission("plainbase.team.admin", "PlainBase: Bypass — acts as team-admin on any team regardless of membership", PermissionDefault.OP) + ); + getServer().getPluginManager().addPermission( + new Permission("plainbase.team.invite", "PlainBase: Allows access to /team invite", PermissionDefault.OP) + ); + getServer().getPluginManager().addPermission( + new Permission("plainbase.team.add", "PlainBase: Allows access to /team add", PermissionDefault.OP) + ); + getServer().getPluginManager().addPermission( + new Permission("plainbase.team.kick", "PlainBase: Allows access to /team kick", PermissionDefault.OP) + ); + getServer().getPluginManager().addPermission( + new Permission("plainbase.team.setrole", "PlainBase: Allows access to /team setrole", PermissionDefault.OP) + ); + getServer().getPluginManager().addPermission( + new Permission("plainbase.team.request", "PlainBase: Allows access to /team request", PermissionDefault.OP) + ); + getServer().getPluginManager().addPermission( + new Permission("plainbase.team.accept", "PlainBase: Allows access to /team accept", PermissionDefault.OP) + ); + getServer().getPluginManager().addPermission( + new Permission("plainbase.team.deny", "PlainBase: Allows access to /team deny (and /team deny-request)", PermissionDefault.OP) + ); + getServer().getPluginManager().addPermission( + new Permission("plainbase.team.leave", "PlainBase: Allows access to /team leave", PermissionDefault.OP) + ); + getServer().getPluginManager().addPermission( + new Permission("plainbase.team.list", "PlainBase: Allows access to /team list", PermissionDefault.OP) + ); + getServer().getPluginManager().addPermission( + new Permission("plainbase.team.info", "PlainBase: Allows access to /team info", PermissionDefault.OP) + ); } public void reloadModules() { @@ -257,6 +298,7 @@ public void reloadModules() { if (getConfig().getBoolean("modules.vanish", true)) setupVanish(); if (getConfig().getBoolean("modules.menu", true)) setupMenu(); if (getConfig().getBoolean("modules.moderation", true)) setupModeration(); + if (getConfig().getBoolean("modules.team", true)) setupTeam(); } public void stopModules() { @@ -287,6 +329,14 @@ public void stopModules() { banManager.shutdown(); } banManager = null; + + // Unregisters the mirrored vanilla scoreboard teams so a disabled/reloaded + // team module doesn't leave stale "pb_" teams around. + if (teamManager != null) { + teamManager.shutdown(); + } + teamManager = null; + org.bukkit.event.HandlerList.unregisterAll(this); } @@ -454,6 +504,21 @@ public void setupModeration() { getServer().getPluginManager().registerEvents(new ModerationListener(this), this); } + public void setupTeam() { + loadModuleConfig("team.yml"); + + teamManager = new TeamManager(this); + + getServer().getPluginManager().registerEvents(new TeamListener(this), this); + + // Re-sync scoreboard entries for already-online players after a reload + // (their teams were just re-loaded from disk into a fresh TeamManager). + // No invite reminders here — they don't need to be re-notified on reload. + for (Player player : Bukkit.getOnlinePlayers()) { + teamManager.resyncScoreboard(player); + } + } + /** * Registers the %plainbase_*% PlaceholderAPI expansion when PlaceholderAPI * is present. Safe no-op otherwise (soft dependency). @@ -500,6 +565,14 @@ public FileConfiguration getModerationConfig() { return configs.get("moderation.yml"); } + public TeamManager getTeamManager() { + return teamManager; + } + + public FileConfiguration getTeamConfig() { + return configs.get("team.yml"); + } + public void saveMenuConfig() { try { FileConfiguration config = getMenuConfig(); diff --git a/src/main/java/de/jgaertig/plainBase/placeholder/PlainBaseExpansion.java b/src/main/java/de/jgaertig/plainBase/placeholder/PlainBaseExpansion.java index fdab20d..3e98af0 100644 --- a/src/main/java/de/jgaertig/plainBase/placeholder/PlainBaseExpansion.java +++ b/src/main/java/de/jgaertig/plainBase/placeholder/PlainBaseExpansion.java @@ -89,7 +89,48 @@ public String onPlaceholderRequest(Player player, @NotNull String params) { case "menu_count" -> plugin.getMenuManager() != null ? String.valueOf(plugin.getMenuManager().getMenuNames().size()) : "0"; - default -> null; + // Team module (static placeholders) + case "team_names" -> player != null && plugin.getTeamManager() != null + ? String.join(", ", plugin.getTeamManager().getPlayerTeams(player.getUniqueId())) : ""; + case "team_count" -> player != null && plugin.getTeamManager() != null + ? String.valueOf(plugin.getTeamManager().getPlayerTeams(player.getUniqueId()).size()) : "0"; + case "team_primary" -> player != null && plugin.getTeamManager() != null + ? plugin.getTeamManager().getPlayerTeams(player.getUniqueId()).stream().findFirst().orElse("") : ""; + case "team_pending_invites" -> player != null && plugin.getTeamManager() != null + ? String.valueOf(pendingInviteCount(player)) : "0"; + case "teams_count" -> plugin.getTeamManager() != null + ? String.valueOf(plugin.getTeamManager().getTeams().size()) : "0"; + + // Team module (parameterized: %plainbase_team_role_% / %plainbase_team_members_%) + default -> handleTeamParameterized(player, params.toLowerCase()); }; } + + private String handleTeamParameterized(Player player, String params) { + if (plugin.getTeamManager() == null) return null; + + if (params.startsWith("team_role_")) { + String teamId = params.substring("team_role_".length()); + if (player == null) return "none"; + var role = plugin.getTeamManager().getRole(player.getUniqueId(), teamId); + return role != null ? role.name().toLowerCase() : "none"; + } + + if (params.startsWith("team_members_")) { + String teamId = params.substring("team_members_".length()); + return String.valueOf(plugin.getTeamManager().getMembers(teamId).size()); + } + + return null; + } + + private int pendingInviteCount(Player player) { + int count = 0; + for (var def : plugin.getTeamManager().getTeams()) { + // getPlayerTeams only returns confirmed memberships, so we count + // invites the same way the manager tracks them per team below. + if (plugin.getTeamManager().isInvited(player.getUniqueId(), def.id())) count++; + } + return count; + } } \ No newline at end of file diff --git a/src/main/java/de/jgaertig/plainBase/team/TeamListener.java b/src/main/java/de/jgaertig/plainBase/team/TeamListener.java new file mode 100644 index 0000000..fe55f33 --- /dev/null +++ b/src/main/java/de/jgaertig/plainBase/team/TeamListener.java @@ -0,0 +1,21 @@ +package de.jgaertig.plainBase.team; + +import de.jgaertig.plainBase.PlainBase; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; + +public class TeamListener implements Listener { + + private final PlainBase plugin; + + public TeamListener(PlainBase plugin) { + this.plugin = plugin; + } + + @EventHandler + public void onJoin(PlayerJoinEvent event) { + if (plugin.getTeamManager() == null) return; + plugin.getTeamManager().handleJoin(event.getPlayer()); + } +} diff --git a/src/main/java/de/jgaertig/plainBase/team/TeamManager.java b/src/main/java/de/jgaertig/plainBase/team/TeamManager.java new file mode 100644 index 0000000..e2a4e79 --- /dev/null +++ b/src/main/java/de/jgaertig/plainBase/team/TeamManager.java @@ -0,0 +1,642 @@ +package de.jgaertig.plainBase.team; + +import de.jgaertig.plainBase.PlainBase; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextColor; +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.bukkit.command.CommandSender; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.scoreboard.Scoreboard; +import org.bukkit.scoreboard.Team; + +import java.io.File; +import java.io.IOException; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Core logic for the Team module: team definitions (config-only), runtime + * membership/roles/invites/join-requests (persisted by UUID under + * data/teams/), and mirroring memberships to a real vanilla scoreboard team + * so any command that accepts a target selector (e.g. /gamemode, /tp, /give) + * can target a PlainBase team via {@code @a[team=pb_]}. + *

+ * Vanilla limitation: a scoreboard entry (player name) can only belong to + * ONE scoreboard team at a time. With {@code max-teams-per-player: 1} + * (the default) this never matters. If an admin raises that limit, only the + * player's most-recently-joined team is mirrored to the scoreboard for + * selector purposes — all other memberships are still fully tracked by + * PlainBase (roles, /team info, placeholders), just not selector-visible + * at the same time. This is a Minecraft engine constraint, not a bug. + */ +public class TeamManager { + + public enum Role { MEMBER, ADMIN } + + public record TeamDefinition(String id, String displayName, String color, NamedTextColor vanillaColor) { + public Component renderDisplayName(PlainBase plugin) { + return plugin.getMiniMessage().deserialize(displayName); + } + } + + private final PlainBase plugin; + private final Map teams = new LinkedHashMap<>(); + + // teamId -> (uuid -> role) + private final Map> memberships = new ConcurrentHashMap<>(); + // uuid -> pending invite team ids + private final Map> invites = new ConcurrentHashMap<>(); + // teamId -> pending join-request uuids + private final Map> requests = new ConcurrentHashMap<>(); + // uuid -> the team currently mirrored on the vanilla scoreboard (see class javadoc) + private final Map scoreboardTeamOf = new ConcurrentHashMap<>(); + + private Scoreboard scoreboard; + + public TeamManager(PlainBase plugin) { + this.plugin = plugin; + loadTeamDefinitions(); + loadState(); + this.scoreboard = Bukkit.getScoreboardManager() != null ? Bukkit.getScoreboardManager().getMainScoreboard() : null; + syncScoreboardTeamDefinitions(); + for (String teamId : memberships.keySet()) refreshScoreboardEntries(teamId); + } + + // --------------------------------------------------------------- + // Definitions + // --------------------------------------------------------------- + + private void loadTeamDefinitions() { + teams.clear(); + FileConfiguration config = plugin.getTeamConfig(); + if (config == null) return; + ConfigurationSection section = config.getConfigurationSection("teams"); + if (section == null) return; + + for (String rawId : section.getKeys(false)) { + String id = rawId.toLowerCase(); + String displayName = section.getString(rawId + ".display-name", id); + String color = section.getString(rawId + ".color", ""); + NamedTextColor vanillaColor = resolveVanillaColor(color); + teams.put(id, new TeamDefinition(id, displayName, color, vanillaColor)); + } + } + + private NamedTextColor resolveVanillaColor(String miniMessageColor) { + try { + Component sample = plugin.getMiniMessage().deserialize(miniMessageColor + "X"); + TextColor found = findFirstColor(sample); + return found != null ? NamedTextColor.nearestTo(found) : NamedTextColor.WHITE; + } catch (Exception e) { + return NamedTextColor.WHITE; + } + } + + private TextColor findFirstColor(Component component) { + if (component.color() != null) return component.color(); + for (Component child : component.children()) { + TextColor found = findFirstColor(child); + if (found != null) return found; + } + return null; + } + + public boolean teamExists(String id) { + return teams.containsKey(id.toLowerCase()); + } + + public TeamDefinition getTeam(String id) { + return teams.get(id.toLowerCase()); + } + + public Collection getTeams() { + return teams.values(); + } + + public int getMaxTeamsPerPlayer() { + return Math.max(1, plugin.getTeamConfig().getInt("team.max-teams-per-player", 1)); + } + + // --------------------------------------------------------------- + // Membership queries + // --------------------------------------------------------------- + + public boolean isMember(UUID uuid, String teamId) { + Map members = memberships.get(teamId.toLowerCase()); + return members != null && members.containsKey(uuid); + } + + public Role getRole(UUID uuid, String teamId) { + Map members = memberships.get(teamId.toLowerCase()); + return members != null ? members.get(uuid) : null; + } + + /** + * True if the sender is allowed to perform admin actions on this team: + * server console, OP, plainbase.team.admin bypass, or an actual stored + * ADMIN role in this specific team. + */ + public boolean isTeamAdmin(CommandSender sender, String teamId) { + if (!(sender instanceof Player player)) return true; // console always allowed + if (player.isOp()) return true; + if (player.hasPermission("plainbase.admin") || player.hasPermission("plainbase.team.admin")) return true; + return getRole(player.getUniqueId(), teamId.toLowerCase()) == Role.ADMIN; + } + + public Set getPlayerTeams(UUID uuid) { + Set result = new LinkedHashSet<>(); + for (Map.Entry> entry : memberships.entrySet()) { + if (entry.getValue().containsKey(uuid)) result.add(entry.getKey()); + } + return result; + } + + public Map getMembers(String teamId) { + return memberships.getOrDefault(teamId.toLowerCase(), Map.of()); + } + + public boolean isInvited(UUID uuid, String teamId) { + return invites.getOrDefault(uuid, Set.of()).contains(teamId.toLowerCase()); + } + + // --------------------------------------------------------------- + // Actions (send their own feedback messages, matching TPAManager/VanishManager style) + // --------------------------------------------------------------- + + public void invite(CommandSender staff, String teamId, String targetName) { + String id = teamId.toLowerCase(); + OfflinePlayer target = resolveTarget(staff, targetName); + if (target == null) return; + UUID uuid = target.getUniqueId(); + + if (isMember(uuid, id)) { + staff.sendMessage(msg("already-in-team", "player", targetName, "team", id)); + return; + } + if (invites.getOrDefault(uuid, Set.of()).contains(id)) { + staff.sendMessage(msg("invite-already-pending", "player", targetName, "team", id)); + return; + } + if (getPlayerTeams(uuid).size() >= getMaxTeamsPerPlayer()) { + staff.sendMessage(msg("max-teams-reached", "player", targetName, "max", String.valueOf(getMaxTeamsPerPlayer()))); + return; + } + + invites.computeIfAbsent(uuid, k -> ConcurrentHashMap.newKeySet()).add(id); + saveInvites(); + staff.sendMessage(msg("invite-sent", "player", targetName, "team", id)); + + Player online = Bukkit.getPlayer(uuid); + if (online != null) { + online.sendMessage(msg("invite-received", "team", id)); + } + } + + public void accept(Player player, String teamIdOrNull) { + UUID uuid = player.getUniqueId(); + Set pending = invites.getOrDefault(uuid, Set.of()); + String id = resolveSingle(player, pending, teamIdOrNull, "invite-not-found"); + if (id == null) return; + + if (getPlayerTeams(uuid).size() >= getMaxTeamsPerPlayer()) { + player.sendMessage(msg("max-teams-reached", "player", player.getName(), "max", String.valueOf(getMaxTeamsPerPlayer()))); + return; + } + + invites.get(uuid).remove(id); + saveInvites(); + setMember(uuid, id, Role.MEMBER); + player.sendMessage(msg("invite-accepted", "team", id)); + } + + public void deny(Player player, String teamIdOrNull) { + UUID uuid = player.getUniqueId(); + Set pending = invites.getOrDefault(uuid, Set.of()); + String id = resolveSingle(player, pending, teamIdOrNull, "invite-not-found"); + if (id == null) return; + + invites.get(uuid).remove(id); + saveInvites(); + player.sendMessage(msg("invite-denied", "team", id)); + } + + public void add(CommandSender staff, String teamId, String targetName) { + String id = teamId.toLowerCase(); + OfflinePlayer target = resolveTarget(staff, targetName); + if (target == null) return; + UUID uuid = target.getUniqueId(); + + if (isMember(uuid, id)) { + staff.sendMessage(msg("already-in-team", "player", targetName, "team", id)); + return; + } + if (getPlayerTeams(uuid).size() >= getMaxTeamsPerPlayer()) { + staff.sendMessage(msg("max-teams-reached", "player", targetName, "max", String.valueOf(getMaxTeamsPerPlayer()))); + return; + } + + // Adding directly also clears any pending invite/request for this team. + invites.getOrDefault(uuid, Set.of()).remove(id); + requests.getOrDefault(id, Set.of()).remove(uuid); + saveInvites(); + saveRequests(); + + setMember(uuid, id, Role.MEMBER); + staff.sendMessage(msg("add-success", "player", targetName, "team", id)); + + Player online = Bukkit.getPlayer(uuid); + if (online != null) online.sendMessage(msg("add-success", "player", online.getName(), "team", id)); + } + + public void kick(CommandSender staff, String teamId, String targetName) { + String id = teamId.toLowerCase(); + OfflinePlayer target = resolveTarget(staff, targetName); + if (target == null) return; + UUID uuid = target.getUniqueId(); + + if (!isMember(uuid, id)) { + staff.sendMessage(msg("not-in-team", "team", id)); + return; + } + + removeMember(uuid, id); + staff.sendMessage(msg("kick-success", "player", targetName, "team", id)); + + Player online = Bukkit.getPlayer(uuid); + if (online != null) online.sendMessage(msg("kick-success", "player", online.getName(), "team", id)); + } + + public void leave(Player player, String teamIdOrNull) { + UUID uuid = player.getUniqueId(); + Set memberOf = getPlayerTeams(uuid); + String id; + if (teamIdOrNull != null) { + id = teamIdOrNull.toLowerCase(); + if (!memberOf.contains(id)) { + player.sendMessage(msg("not-in-team", "team", id)); + return; + } + } else if (memberOf.size() == 1) { + id = memberOf.iterator().next(); + } else if (memberOf.isEmpty()) { + player.sendMessage(msg("not-in-team", "team", "?")); + return; + } else { + player.sendMessage(msg("leave-usage-multiple")); + return; + } + + removeMember(uuid, id); + player.sendMessage(msg("leave-success", "team", id)); + } + + public void request(Player player, String teamId) { + String id = teamId.toLowerCase(); + UUID uuid = player.getUniqueId(); + + if (isMember(uuid, id)) { + player.sendMessage(msg("already-member", "team", id)); + return; + } + if (requests.getOrDefault(id, Set.of()).contains(uuid)) { + player.sendMessage(msg("request-already-pending", "team", id)); + return; + } + if (getPlayerTeams(uuid).size() >= getMaxTeamsPerPlayer()) { + player.sendMessage(msg("max-teams-reached", "player", player.getName(), "max", String.valueOf(getMaxTeamsPerPlayer()))); + return; + } + + requests.computeIfAbsent(id, k -> ConcurrentHashMap.newKeySet()).add(uuid); + saveRequests(); + player.sendMessage(msg("request-sent", "team", id)); + + Component notice = msg("request-received", "player", player.getName(), "team", id); + for (Map.Entry entry : getMembers(id).entrySet()) { + if (entry.getValue() != Role.ADMIN) continue; + Player admin = Bukkit.getPlayer(entry.getKey()); + if (admin != null) admin.sendMessage(notice); + } + } + + public void denyRequest(CommandSender staff, String teamId, String targetName) { + String id = teamId.toLowerCase(); + OfflinePlayer target = resolveTarget(staff, targetName); + if (target == null) return; + UUID uuid = target.getUniqueId(); + + Set pending = requests.getOrDefault(id, Set.of()); + if (!pending.contains(uuid)) { + staff.sendMessage(msg("request-not-found", "player", targetName, "team", id)); + return; + } + pending.remove(uuid); + saveRequests(); + staff.sendMessage(msg("kick-success", "player", targetName, "team", id)); // reuse: "removed/rejected" + } + + public void setRole(CommandSender staff, String teamId, String targetName, String roleStr) { + String id = teamId.toLowerCase(); + Role role; + try { + role = Role.valueOf(roleStr.toUpperCase()); + } catch (IllegalArgumentException e) { + staff.sendMessage(msg("invalid-role")); + return; + } + + OfflinePlayer target = resolveTarget(staff, targetName); + if (target == null) return; + UUID uuid = target.getUniqueId(); + + if (!isMember(uuid, id)) { + staff.sendMessage(msg("not-in-team", "team", id)); + return; + } + + setMember(uuid, id, role); + staff.sendMessage(msg("setrole-success", "player", targetName, "team", id, "role", role.name().toLowerCase())); + + Player online = Bukkit.getPlayer(uuid); + if (online != null) { + online.sendMessage(msg("setrole-success", "player", online.getName(), "team", id, "role", role.name().toLowerCase())); + } + } + + public void listTeams(CommandSender sender) { + sender.sendMessage(msg("list-header")); + for (TeamDefinition def : teams.values()) { + int count = getMembers(def.id()).size(); + String displayLegacy = plugin.getTeamConfig().getString("messages.list-entry", "%team-display% (%count% members)"); + String rendered = displayLegacy + .replace("%team-display%", def.color() + def.displayName()) + .replace("%count%", String.valueOf(count)); + sender.sendMessage(plugin.getMiniMessage().deserialize(rendered)); + } + } + + public void info(CommandSender sender, String teamId) { + TeamDefinition def = getTeam(teamId); + Map members = getMembers(teamId.toLowerCase()); + + String header = plugin.getTeamConfig().getString("messages.info-header", "--- %team-display% ---") + .replace("%team-display%", def.color() + def.displayName()); + sender.sendMessage(plugin.getMiniMessage().deserialize(header)); + + if (members.isEmpty()) { + sender.sendMessage(msg("info-empty")); + return; + } + for (Map.Entry entry : members.entrySet()) { + String name = Optional.ofNullable(Bukkit.getOfflinePlayer(entry.getKey()).getName()).orElse(entry.getKey().toString()); + sender.sendMessage(msg("info-member", "player", name, "role", entry.getValue().name().toLowerCase())); + } + } + + /** + * Called on player join: delivers reminders for any pending invites and + * re-syncs this player's scoreboard entry under their current name. + */ + public void handleJoin(Player player) { + Set pending = invites.get(player.getUniqueId()); + if (pending != null) { + for (String teamId : pending) { + player.sendMessage(msg("invite-reminder-on-join", "team", teamId)); + } + } + resyncScoreboard(player); + } + + /** + * Re-applies this player's scoreboard team entry without sending any + * invite reminders — used after a config/module reload for players who + * are already online (they don't need to be re-notified about invites + * they've already seen). + */ + public void resyncScoreboard(Player player) { + for (String teamId : getPlayerTeams(player.getUniqueId())) { + assignScoreboardTeam(player.getUniqueId(), teamId); + } + } + + // --------------------------------------------------------------- + // Internal helpers + // --------------------------------------------------------------- + + private String resolveSingle(Player player, Set pending, String teamIdOrNull, String notFoundKey) { + if (teamIdOrNull != null) { + String id = teamIdOrNull.toLowerCase(); + if (!pending.contains(id)) { + player.sendMessage(msg(notFoundKey, "team", id)); + return null; + } + return id; + } + if (pending.size() == 1) return pending.iterator().next(); + if (pending.isEmpty()) { + player.sendMessage(msg(notFoundKey, "team", "?")); + return null; + } + player.sendMessage(msg("leave-usage-multiple")); + return null; + } + + private OfflinePlayer resolveTarget(CommandSender staff, String name) { + @SuppressWarnings("deprecation") + OfflinePlayer target = Bukkit.getOfflinePlayer(name); + if (target.getName() == null && !target.hasPlayedBefore()) { + staff.sendMessage(msg("player-not-found", "player", name)); + return null; + } + return target; + } + + private void setMember(UUID uuid, String teamId, Role role) { + memberships.computeIfAbsent(teamId, k -> new ConcurrentHashMap<>()).put(uuid, role); + saveMemberships(); + refreshScoreboardEntries(teamId); + assignScoreboardTeam(uuid, teamId); + } + + private void removeMember(UUID uuid, String teamId) { + Map members = memberships.get(teamId); + if (members != null) members.remove(uuid); + saveMemberships(); + refreshScoreboardEntries(teamId); + if (teamId.equals(scoreboardTeamOf.get(uuid))) { + scoreboardTeamOf.remove(uuid); + // fall back to another team this player is still in, if any + Set remaining = getPlayerTeams(uuid); + if (!remaining.isEmpty()) assignScoreboardTeam(uuid, remaining.iterator().next()); + } + } + + private Component msg(String key, String... placeholders) { + String raw = plugin.getTeamConfig().getString("messages." + key, key); + for (int i = 0; i + 1 < placeholders.length; i += 2) { + raw = raw.replace("%" + placeholders[i] + "%", placeholders[i + 1]); + } + return plugin.getMiniMessage().deserialize(raw); + } + + // --------------------------------------------------------------- + // Scoreboard mirroring (see class javadoc for the single-team-per-player limitation) + // --------------------------------------------------------------- + + private void syncScoreboardTeamDefinitions() { + if (scoreboard == null) return; + + Set validNames = new HashSet<>(); + for (TeamDefinition def : teams.values()) { + String name = scoreboardName(def.id()); + validNames.add(name); + Team team = scoreboard.getTeam(name); + if (team == null) team = scoreboard.registerNewTeam(name); + team.color(def.vanillaColor()); + team.prefix(plugin.getMiniMessage().deserialize(def.color() + "[" + def.id() + "] ")); + } + + for (Team team : new ArrayList<>(scoreboard.getTeams())) { + if (team.getName().startsWith("pb_") && !validNames.contains(team.getName())) { + team.unregister(); + } + } + } + + private void refreshScoreboardEntries(String teamId) { + if (scoreboard == null) return; + Team team = scoreboard.getTeam(scoreboardName(teamId)); + if (team == null) return; + + for (String entry : new HashSet<>(team.getEntries())) { + team.removeEntry(entry); + } + for (UUID uuid : getMembers(teamId).keySet()) { + // Only the team currently assigned for selector purposes gets the entry + // (a scoreboard entry can only be on one team at a time). + if (!teamId.equals(scoreboardTeamOf.getOrDefault(uuid, teamId))) continue; + String name = Bukkit.getOfflinePlayer(uuid).getName(); + if (name != null) team.addEntry(name); + } + } + + private void assignScoreboardTeam(UUID uuid, String teamId) { + if (scoreboard == null) return; + String previous = scoreboardTeamOf.put(uuid, teamId); + if (previous != null && !previous.equals(teamId)) { + refreshScoreboardEntries(previous); + } + refreshScoreboardEntries(teamId); + } + + public void shutdown() { + if (scoreboard == null) return; + for (Team team : new ArrayList<>(scoreboard.getTeams())) { + if (team.getName().startsWith("pb_")) team.unregister(); + } + } + + private String scoreboardName(String teamId) { + String name = "pb_" + teamId; + return name.length() > 40 ? name.substring(0, 40) : name; // generous modern limit, just a sanity cap + } + + // --------------------------------------------------------------- + // Persistence (plugins/PlainBase/data/teams/*.yml) + // --------------------------------------------------------------- + + private File dataFile(String name) { + File folder = new File(plugin.getDataFolder(), "data/teams"); + if (!folder.exists()) folder.mkdirs(); + return new File(folder, name); + } + + private void loadState() { + memberships.clear(); + FileConfiguration members = YamlConfiguration.loadConfiguration(dataFile("members.yml")); + for (String teamId : members.getKeys(false)) { + ConfigurationSection section = members.getConfigurationSection(teamId); + if (section == null) continue; + Map map = new ConcurrentHashMap<>(); + for (String uuidStr : section.getKeys(false)) { + try { + map.put(UUID.fromString(uuidStr), Role.valueOf(section.getString(uuidStr, "MEMBER"))); + } catch (IllegalArgumentException ignored) { + } + } + memberships.put(teamId, map); + } + + invites.clear(); + FileConfiguration invitesConfig = YamlConfiguration.loadConfiguration(dataFile("invites.yml")); + for (String uuidStr : invitesConfig.getKeys(false)) { + try { + UUID uuid = UUID.fromString(uuidStr); + invites.put(uuid, ConcurrentHashMap.newKeySet()); + invites.get(uuid).addAll(invitesConfig.getStringList(uuidStr)); + } catch (IllegalArgumentException ignored) { + } + } + + requests.clear(); + FileConfiguration requestsConfig = YamlConfiguration.loadConfiguration(dataFile("requests.yml")); + for (String teamId : requestsConfig.getKeys(false)) { + Set set = ConcurrentHashMap.newKeySet(); + for (String uuidStr : requestsConfig.getStringList(teamId)) { + try { + set.add(UUID.fromString(uuidStr)); + } catch (IllegalArgumentException ignored) { + } + } + requests.put(teamId, set); + } + } + + private void saveMemberships() { + Bukkit.getAsyncScheduler().runNow(plugin, task -> { + YamlConfiguration config = new YamlConfiguration(); + for (Map.Entry> teamEntry : memberships.entrySet()) { + for (Map.Entry memberEntry : teamEntry.getValue().entrySet()) { + config.set(teamEntry.getKey() + "." + memberEntry.getKey(), memberEntry.getValue().name()); + } + } + saveQuietly(config, "members.yml"); + }); + } + + private void saveInvites() { + Bukkit.getAsyncScheduler().runNow(plugin, task -> { + YamlConfiguration config = new YamlConfiguration(); + for (Map.Entry> entry : invites.entrySet()) { + if (!entry.getValue().isEmpty()) config.set(entry.getKey().toString(), new ArrayList<>(entry.getValue())); + } + saveQuietly(config, "invites.yml"); + }); + } + + private void saveRequests() { + Bukkit.getAsyncScheduler().runNow(plugin, task -> { + YamlConfiguration config = new YamlConfiguration(); + for (Map.Entry> entry : requests.entrySet()) { + if (!entry.getValue().isEmpty()) { + List uuids = entry.getValue().stream().map(UUID::toString).toList(); + config.set(entry.getKey(), uuids); + } + } + saveQuietly(config, "requests.yml"); + }); + } + + private void saveQuietly(YamlConfiguration config, String fileName) { + try { + config.save(dataFile(fileName)); + } catch (IOException e) { + plugin.getLogger().severe("Could not save " + fileName + ": " + e.getMessage()); + } + } +} diff --git a/src/main/java/de/jgaertig/plainBase/team/commands/TeamCommand.java b/src/main/java/de/jgaertig/plainBase/team/commands/TeamCommand.java new file mode 100644 index 0000000..7b5b8c5 --- /dev/null +++ b/src/main/java/de/jgaertig/plainBase/team/commands/TeamCommand.java @@ -0,0 +1,211 @@ +package de.jgaertig.plainBase.team.commands; + +import de.jgaertig.plainBase.PlainBase; +import de.jgaertig.plainBase.team.TeamManager; +import io.papermc.paper.command.brigadier.BasicCommand; +import io.papermc.paper.command.brigadier.CommandSourceStack; +import org.bukkit.Bukkit; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; + +/** + * Single entry point for every /team subcommand. Two argument shapes: + * - global actions first: /team accept|deny|leave|list [team] + * - team-scoped actions: /team invite|add|kick|setrole|request|info|deny-request ... + */ +public class TeamCommand implements BasicCommand { + + private static final Set GLOBAL_ACTIONS = Set.of("accept", "deny", "leave", "list"); + private static final Set TEAM_ACTIONS = Set.of("invite", "add", "kick", "setrole", "request", "info", "deny-request"); + + private final PlainBase plugin; + + public TeamCommand(PlainBase plugin) { + this.plugin = plugin; + } + + @Override + public void execute(@NotNull CommandSourceStack stack, @NotNull String @NotNull [] args) { + CommandSender sender = stack.getSender(); + TeamManager teams = plugin.getTeamManager(); + + if (!plugin.getConfig().getBoolean("modules.team", true)) { + sender.sendMessage(plugin.getMiniMessage().deserialize("This module is currently disabled.")); + return; + } + if (!plugin.getTeamConfig().getBoolean("team.enabled", true) || teams == null) { + sender.sendMessage(plugin.getMiniMessage().deserialize("The team system has been disabled.")); + return; + } + if (!plugin.getTeamConfig().getBoolean("team.commands.team.enabled", true)) { + sender.sendMessage(plugin.getMiniMessage().deserialize("This command has been disabled.")); + return; + } + + if (args.length == 0) { + sendUsage(sender); + return; + } + + String first = args[0].toLowerCase(); + + if (GLOBAL_ACTIONS.contains(first)) { + handleGlobalAction(sender, teams, first, args); + return; + } + + // Otherwise args[0] must be a team id. + if (!teams.teamExists(first)) { + sender.sendMessage(plugin.getMiniMessage().deserialize("Unknown team or subcommand: " + args[0])); + return; + } + if (args.length < 2) { + sendUsage(sender); + return; + } + handleTeamAction(sender, teams, first, args[1].toLowerCase(), args); + } + + private void handleGlobalAction(CommandSender sender, TeamManager teams, String action, String[] args) { + switch (action) { + case "list" -> { + if (!checkPermission(sender, "plainbase.team.list")) return; + teams.listTeams(sender); + } + case "accept", "deny", "leave" -> { + if (!checkPermission(sender, "plainbase.team." + action)) return; + if (!(sender instanceof Player player)) { + sender.sendMessage(plugin.getMiniMessage().deserialize("This command can only be executed by players.")); + return; + } + String teamArg = args.length > 1 ? args[1].toLowerCase() : null; + switch (action) { + case "accept" -> teams.accept(player, teamArg); + case "deny" -> teams.deny(player, teamArg); + case "leave" -> teams.leave(player, teamArg); + } + } + } + } + + private void handleTeamAction(CommandSender sender, TeamManager teams, String teamId, String action, String[] args) { + if (!TEAM_ACTIONS.contains(action)) { + sendUsage(sender); + return; + } + if (!checkPermission(sender, "plainbase.team." + (action.equals("deny-request") ? "deny" : action))) return; + + switch (action) { + case "info" -> teams.info(sender, teamId); + case "request" -> { + if (!(sender instanceof Player player)) { + sender.sendMessage(plugin.getMiniMessage().deserialize("This command can only be executed by players.")); + return; + } + teams.request(player, teamId); + } + case "invite", "add", "kick", "setrole", "deny-request" -> { + if (!teams.isTeamAdmin(sender, teamId)) { + sender.sendMessage(plugin.getMiniMessage().deserialize("You must be a team admin of " + teamId + " to do this.")); + return; + } + handleAdminAction(sender, teams, teamId, action, args); + } + } + } + + private void handleAdminAction(CommandSender sender, TeamManager teams, String teamId, String action, String[] args) { + switch (action) { + case "invite" -> { + if (args.length < 3) { usageTeam(sender, teamId, "invite "); return; } + teams.invite(sender, teamId, args[2]); + } + case "add" -> { + if (args.length < 3) { usageTeam(sender, teamId, "add "); return; } + teams.add(sender, teamId, args[2]); + } + case "kick" -> { + if (args.length < 3) { usageTeam(sender, teamId, "kick "); return; } + teams.kick(sender, teamId, args[2]); + } + case "deny-request" -> { + if (args.length < 3) { usageTeam(sender, teamId, "deny-request "); return; } + teams.denyRequest(sender, teamId, args[2]); + } + case "setrole" -> { + if (args.length < 4) { usageTeam(sender, teamId, "setrole "); return; } + teams.setRole(sender, teamId, args[2], args[3]); + } + } + } + + private void sendUsage(CommandSender sender) { + sender.sendMessage(plugin.getMiniMessage().deserialize( + "Usage: /team [team] or /team ..." + )); + } + + private void usageTeam(CommandSender sender, String teamId, String rest) { + sender.sendMessage(plugin.getMiniMessage().deserialize("Usage: /team " + teamId + " " + rest)); + } + + private boolean checkPermission(CommandSender sender, String permission) { + if (!sender.hasPermission("plainbase.admin") + && !sender.hasPermission("plainbase.team.admin") + && !sender.hasPermission(permission)) { + sender.sendMessage(plugin.getMiniMessage().deserialize("No permission!")); + return false; + } + return true; + } + + @Override + public @NotNull List suggest(@NotNull CommandSourceStack stack, @NotNull String @NotNull [] args) { + TeamManager teams = plugin.getTeamManager(); + if (teams == null) return List.of(); + + if (args.length <= 1) { + String input = args.length == 1 ? args[0].toLowerCase() : ""; + List options = new ArrayList<>(GLOBAL_ACTIONS); + for (TeamManager.TeamDefinition def : teams.getTeams()) options.add(def.id()); + return options.stream().filter(s -> s.startsWith(input)).toList(); + } + + String first = args[0].toLowerCase(); + + if (first.equals("accept") || first.equals("deny") || first.equals("leave")) { + if (args.length == 2) { + String input = args[1].toLowerCase(); + return teams.getTeams().stream().map(TeamManager.TeamDefinition::id) + .filter(id -> id.startsWith(input)).toList(); + } + return List.of(); + } + + if (teams.teamExists(first)) { + if (args.length == 2) { + String input = args[1].toLowerCase(); + return TEAM_ACTIONS.stream().filter(s -> s.startsWith(input)).toList(); + } + if (args.length == 3) { + String action = args[1].toLowerCase(); + if (Set.of("invite", "add", "kick", "setrole", "deny-request").contains(action)) { + String input = args[2].toLowerCase(); + return Bukkit.getOnlinePlayers().stream().map(Player::getName) + .filter(n -> n.toLowerCase().startsWith(input)).toList(); + } + } + if (args.length == 4 && args[1].equalsIgnoreCase("setrole")) { + String input = args[3].toLowerCase(); + return Stream.of("member", "admin").filter(s -> s.startsWith(input)).toList(); + } + } + return List.of(); + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 30618dc..4142bb1 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -7,6 +7,7 @@ modules: vanish: false # More Settings in vanish.yml menu: false # More Settings in menu.yml moderation: false # More Settings in moderation.yml + team: false # More Settings in team.yml # DO NOT EDIT BELOW UNLESS YOU KNOW WHAT YOU ARE DOING -version: 1.6 \ No newline at end of file +version: 1.7 \ No newline at end of file diff --git a/src/main/resources/modules/team.yml b/src/main/resources/modules/team.yml new file mode 100644 index 0000000..8465373 --- /dev/null +++ b/src/main/resources/modules/team.yml @@ -0,0 +1,102 @@ +team: + enabled: true + + # How many teams a single player can be a member of at the same time. + max-teams-per-player: 1 + + commands: + team: + enabled: true + +# Teams can ONLY be created/removed here — membership, roles, invites and +# join requests are all managed at runtime via /team and are stored per +# player (by UUID) in plugins/PlainBase/data/playerdata/.yml, not here. +# +# "color" is a MiniMessage string used for the team's display name wherever +# PlainBase shows it (chat, /team list|info, PAPI placeholders) — full +# gradients and are supported there. +# +# Teams are also mirrored to a real vanilla scoreboard team (named "pb_" +# on the server's main scoreboard), so you can target a team's members in +# ANY command that accepts a vanilla target selector, e.g.: +# /gamemode creative @a[team=pb_team1] +# /tp @a[team=pb_team1] 0 100 0 +# Vanilla scoreboard teams only support ONE flat color (no gradients/rainbow) +# for things like glow/name-tint — PlainBase picks the closest vanilla color +# to your "color" automatically. This does NOT affect the selector working, +# only the small vanilla color accent. +teams: + red: + display-name: "Red Team" + color: "" + blue: + display-name: "Blue Team" + color: "" + rainbow-example: + display-name: "Rainbow Team" + color: "" + +# All player-facing text below is MiniMessage. %player%, %team%, %role%, +# %staff% are simple placeholders resolved before MiniMessage parsing. +messages: + unknown-team: "Unknown team: %team%" + not-in-team: "You are not in team %team%." + already-in-team: "%player% is already in team %team%." + already-member: "You are already a member of %team%." + max-teams-reached: "%player% is already in the maximum number of teams (%max%)." + not-admin: "You must be a team admin of %team% to do this." + player-not-found: "Could not resolve player: %player%" + + invite-sent: "Invited %player% to %team%." + invite-received: "You have been invited to join %team%! Use /team accept %team% or /team deny %team%." + invite-reminder-on-join: "You still have a pending invite to %team%. Use /team accept %team% or /team deny %team%." + invite-not-found: "No pending invite to %team%." + invite-accepted: "You joined %team%!" + invite-denied: "You declined the invite to %team%." + invite-already-pending: "%player% already has a pending invite to %team%." + + add-success: "Added %player% to %team%." + kick-success: "Removed %player% from %team%." + leave-success: "You left %team%." + leave-usage-multiple: "You're in multiple teams — specify one: /team leave " + + request-sent: "Join request for %team% sent to its admins." + request-received: "%player% wants to join %team%! Use /team %team% add %player% to accept or /team %team% deny %player% to reject." + request-not-found: "No pending join request from %player% for %team%." + request-already-pending: "You already have a pending join request for %team%." + + setrole-success: "%player% is now %role% in %team%." + invalid-role: "Invalid role. Use member or admin." + + list-header: "--- Teams ---" + list-entry: "%team-display% (%count% members)" + info-header: "--- %team-display% ---" + info-member: "- %player% (%role%)" + info-empty: "This team has no members yet." + +commands: + team: + enabled: true + invite: + enabled: true + add: + enabled: true + kick: + enabled: true + leave: + enabled: true + request: + enabled: true + accept: + enabled: true + deny: + enabled: true + setrole: + enabled: true + list: + enabled: true + info: + enabled: true + +# DO NOT EDIT BELOW UNLESS YOU KNOW WHAT YOU ARE DOING +version: 1.0 From 596cf57e276f96aac8c343fd20d97a3356d4f3bd Mon Sep 17 00:00:00 2001 From: j-gaertig-co-dev Date: Thu, 13 Aug 2026 19:12:58 +0200 Subject: [PATCH 2/8] fix(team): avoid crash on Folia + fix add() on immutable empty sets - Folia currently considers ALL scoreboard API broken (confirmed via PaperMC/Folia repo). Registering/looking up scoreboard teams there throws UnsupportedOperationException and disabled the whole plugin. TeamManager now detects Folia and skips the vanilla-scoreboard mirror entirely there; membership/roles/invites/commands/placeholders are unaffected, only @a[team=pb_] selectors are unavailable on Folia. - add() crashed with UnsupportedOperationException when a player had no prior invite/join-request for that team: Map.getOrDefault(k, Set.of()) returns the shared immutable empty set, and .remove() on it throws. Now only mutates a set that's actually present. --- .../jgaertig/plainBase/team/TeamManager.java | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/src/main/java/de/jgaertig/plainBase/team/TeamManager.java b/src/main/java/de/jgaertig/plainBase/team/TeamManager.java index e2a4e79..05bd012 100644 --- a/src/main/java/de/jgaertig/plainBase/team/TeamManager.java +++ b/src/main/java/de/jgaertig/plainBase/team/TeamManager.java @@ -62,9 +62,31 @@ public TeamManager(PlainBase plugin) { this.plugin = plugin; loadTeamDefinitions(); loadState(); - this.scoreboard = Bukkit.getScoreboardManager() != null ? Bukkit.getScoreboardManager().getMainScoreboard() : null; - syncScoreboardTeamDefinitions(); - for (String teamId : memberships.keySet()) refreshScoreboardEntries(teamId); + + // Folia currently considers ALL scoreboard API broken (global state it + // hasn't figured out region ownership for yet — not something we can + // work around by rescheduling). On Folia we skip the vanilla-scoreboard + // mirror entirely: memberships/roles/invites/commands/placeholders keep + // working, only the "/gamemode creative @a[team=pb_x]" selector trick + // is unavailable there. + if (isFolia()) { + this.scoreboard = null; + plugin.getLogger().info("Team module: running on Folia, scoreboard-based team selectors (@a[team=pb_]) are disabled " + + "because Folia's scoreboard API is currently unsupported. Team membership, roles, commands and placeholders are unaffected."); + } else { + this.scoreboard = Bukkit.getScoreboardManager() != null ? Bukkit.getScoreboardManager().getMainScoreboard() : null; + syncScoreboardTeamDefinitions(); + for (String teamId : memberships.keySet()) refreshScoreboardEntries(teamId); + } + } + + private static boolean isFolia() { + try { + Class.forName("io.papermc.paper.threadedregions.RegionizedServer"); + return true; + } catch (ClassNotFoundException e) { + return false; + } } // --------------------------------------------------------------- @@ -241,8 +263,12 @@ public void add(CommandSender staff, String teamId, String targetName) { } // Adding directly also clears any pending invite/request for this team. - invites.getOrDefault(uuid, Set.of()).remove(id); - requests.getOrDefault(id, Set.of()).remove(uuid); + // (getOrDefault falls back to the immutable Set.of() when there's no + // entry yet — remove() on that throws, so only touch a real set.) + Set pendingInvites = invites.get(uuid); + if (pendingInvites != null) pendingInvites.remove(id); + Set pendingRequests = requests.get(id); + if (pendingRequests != null) pendingRequests.remove(uuid); saveInvites(); saveRequests(); From 938c524f60af5470019da2d022a4f59dd05e4c1a Mon Sep 17 00:00:00 2001 From: j-gaertig-co-dev Date: Thu, 13 Aug 2026 19:12:58 +0200 Subject: [PATCH 3/8] docs: mention Team module in README table --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index c9fd30b..58af429 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ | **Vanish** | Hide players from other players, the tab list and mobs. | | **Menu** | Config-driven, locked GUI menus. | | **Moderation** | Ban / tempban / unban / kick / IP-ban with database storage (SQLite/MySQL, cross-server). | +| **Team** | Config-defined player groups with MiniMessage colors (gradients/rainbow included), invite/accept/deny/request flow, per-team Member/Admin roles, and a real vanilla scoreboard mirror for `@a[team=pb_]` selectors. | Also supports **PlaceholderAPI** (optional, not a toggleable module) — auto-detected if installed, adds the `%plainbase_*%` expansion, zero effect if it's missing. From 3971629cd9b6112234943c06a5082d72162d077f Mon Sep 17 00:00:00 2001 From: j-gaertig-co-dev Date: Thu, 13 Aug 2026 20:02:27 +0200 Subject: [PATCH 4/8] feat(team): redesign /team commands to a uniform action-first grammar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Old schema mixed two orders (verb-first for self actions, team-first for staff actions) and had no self-documenting help, no way to list pending join requests, and dead per-command config toggles (declared in team.yml but never actually read anywhere). - Every subcommand is now /team [team] [player] [role] — table- driven via TeamCommand.ACTIONS, so adding a new one later is one map entry + one switch case instead of touching parsing logic in multiple places. - Bare /team prints a permission-aware help listing (only shows what the sender can actually run). - New /team requests (admin: see who's waiting to join) and /team invites (self: see your own pending invites) — previously you only found out about these reactively via chat notifications. - /team info with no team now summarizes the sender's own memberships + roles instead of erroring. - Renamed deny-request -> reject with its own permission node (plainbase.team.reject) instead of silently sharing plainbase.team.deny. - team.yml's commands: block is now actually nested under team: (where the code reads it from) — it used to be a sibling top-level key, so every per-command enabled/disabled toggle was silently a no-op. Bumped to version 1.1. - PlaceholderAPI: %plainbase_team_pending_invites% now uses the new TeamManager#getPendingInvites() instead of an O(teams) scan loop. Tested end-to-end on Paper 26.2 + Folia 26.2 (RCON + a mineflayer bot as a real player): help output, every subcommand, the per-action config toggle actually taking effect, and the full invite/accept/request/add flow. --- .../java/de/jgaertig/plainBase/PlainBase.java | 15 +- .../placeholder/PlainBaseExpansion.java | 12 +- .../plainBase/team/commands/TeamCommand.java | 277 ++++++++++-------- src/main/resources/modules/team.yml | 72 +++-- 4 files changed, 215 insertions(+), 161 deletions(-) diff --git a/src/main/java/de/jgaertig/plainBase/PlainBase.java b/src/main/java/de/jgaertig/plainBase/PlainBase.java index aba35c6..ba363ee 100644 --- a/src/main/java/de/jgaertig/plainBase/PlainBase.java +++ b/src/main/java/de/jgaertig/plainBase/PlainBase.java @@ -71,7 +71,7 @@ public void onEnable() { latestVersions.put("vanish.yml", 1.1); latestVersions.put("menu.yml", 1.1); latestVersions.put("moderation.yml", 2.0); - latestVersions.put("team.yml", 1.0); + latestVersions.put("team.yml", 1.1); registerPlaceholderExpansion(); @@ -274,7 +274,10 @@ private void setupPermissions() { new Permission("plainbase.team.accept", "PlainBase: Allows access to /team accept", PermissionDefault.OP) ); getServer().getPluginManager().addPermission( - new Permission("plainbase.team.deny", "PlainBase: Allows access to /team deny (and /team deny-request)", PermissionDefault.OP) + new Permission("plainbase.team.deny", "PlainBase: Allows access to /team deny", PermissionDefault.OP) + ); + getServer().getPluginManager().addPermission( + new Permission("plainbase.team.reject", "PlainBase: Allows access to /team reject (reject a pending join request)", PermissionDefault.OP) ); getServer().getPluginManager().addPermission( new Permission("plainbase.team.leave", "PlainBase: Allows access to /team leave", PermissionDefault.OP) @@ -283,7 +286,13 @@ private void setupPermissions() { new Permission("plainbase.team.list", "PlainBase: Allows access to /team list", PermissionDefault.OP) ); getServer().getPluginManager().addPermission( - new Permission("plainbase.team.info", "PlainBase: Allows access to /team info", PermissionDefault.OP) + new Permission("plainbase.team.info", "PlainBase: Allows access to /team info", PermissionDefault.OP) + ); + getServer().getPluginManager().addPermission( + new Permission("plainbase.team.invites", "PlainBase: Allows access to /team invites (list your own pending invites)", PermissionDefault.OP) + ); + getServer().getPluginManager().addPermission( + new Permission("plainbase.team.requests", "PlainBase: Allows access to /team requests (list a team's pending join requests)", PermissionDefault.OP) ); } diff --git a/src/main/java/de/jgaertig/plainBase/placeholder/PlainBaseExpansion.java b/src/main/java/de/jgaertig/plainBase/placeholder/PlainBaseExpansion.java index 3e98af0..3b02697 100644 --- a/src/main/java/de/jgaertig/plainBase/placeholder/PlainBaseExpansion.java +++ b/src/main/java/de/jgaertig/plainBase/placeholder/PlainBaseExpansion.java @@ -97,7 +97,7 @@ public String onPlaceholderRequest(Player player, @NotNull String params) { case "team_primary" -> player != null && plugin.getTeamManager() != null ? plugin.getTeamManager().getPlayerTeams(player.getUniqueId()).stream().findFirst().orElse("") : ""; case "team_pending_invites" -> player != null && plugin.getTeamManager() != null - ? String.valueOf(pendingInviteCount(player)) : "0"; + ? String.valueOf(plugin.getTeamManager().getPendingInvites(player.getUniqueId()).size()) : "0"; case "teams_count" -> plugin.getTeamManager() != null ? String.valueOf(plugin.getTeamManager().getTeams().size()) : "0"; @@ -123,14 +123,4 @@ private String handleTeamParameterized(Player player, String params) { return null; } - - private int pendingInviteCount(Player player) { - int count = 0; - for (var def : plugin.getTeamManager().getTeams()) { - // getPlayerTeams only returns confirmed memberships, so we count - // invites the same way the manager tracks them per team below. - if (plugin.getTeamManager().isInvited(player.getUniqueId(), def.id())) count++; - } - return count; - } } \ No newline at end of file diff --git a/src/main/java/de/jgaertig/plainBase/team/commands/TeamCommand.java b/src/main/java/de/jgaertig/plainBase/team/commands/TeamCommand.java index 7b5b8c5..beda272 100644 --- a/src/main/java/de/jgaertig/plainBase/team/commands/TeamCommand.java +++ b/src/main/java/de/jgaertig/plainBase/team/commands/TeamCommand.java @@ -10,19 +10,79 @@ import org.jetbrains.annotations.NotNull; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; -import java.util.Set; +import java.util.Locale; +import java.util.Map; import java.util.stream.Stream; /** - * Single entry point for every /team subcommand. Two argument shapes: - * - global actions first: /team accept|deny|leave|list [team] - * - team-scoped actions: /team invite|add|kick|setrole|request|info|deny-request ... + * Single entry point for every /team subcommand. Uniform grammar, always + * action-first: {@code /team [team] [player] [role]}. Every action + * is described once in {@link #ACTIONS} — that table drives permission + * checks, per-action config toggles, tab-completion and the built-in + * "/team" help listing, so adding a new subcommand later is a single map + * entry plus one switch case instead of touching parsing logic in several + * places. */ public class TeamCommand implements BasicCommand { - private static final Set GLOBAL_ACTIONS = Set.of("accept", "deny", "leave", "list"); - private static final Set TEAM_ACTIONS = Set.of("invite", "add", "kick", "setrole", "request", "info", "deny-request"); + /** + * @param name subcommand literal, also the config key under {@code team.commands..enabled} + * @param usage argument hint shown after the action name in /team's help output + * @param permission permission node required to even attempt this action + * @param requiresPlayer true if only a real player (not console) may run this + * @param teamScoped true if this action takes a team id as its first argument + * @param teamRequired only meaningful if teamScoped: false = team id may be omitted (self-service actions + * fall back to the player's own single membership) + * @param adminGated true if, beyond the permission node, the sender must also pass + * {@link TeamManager#isTeamAdmin} for the given team + * @param hasPlayerArg true if a target player name follows the team id + * @param hasRoleArg true if a role literal follows the player name (setrole only) + * @param description one-line summary shown in the help listing + */ + private record ActionSpec(String name, String usage, String permission, boolean requiresPlayer, + boolean teamScoped, boolean teamRequired, boolean adminGated, + boolean hasPlayerArg, boolean hasRoleArg, String description) { + } + + private static final Map ACTIONS = new LinkedHashMap<>(); + + private static void register(String name, String usage, String permission, boolean requiresPlayer, + boolean teamScoped, boolean teamRequired, boolean adminGated, + boolean hasPlayerArg, boolean hasRoleArg, String description) { + ACTIONS.put(name, new ActionSpec(name, usage, permission, requiresPlayer, teamScoped, teamRequired, + adminGated, hasPlayerArg, hasRoleArg, description)); + } + + static { + register("list", "", "plainbase.team.list", false, false, false, false, false, false, + "List all configured teams."); + register("info", "[team]", "plainbase.team.info", false, true, false, false, false, false, + "Show your teams, or a specific team's members."); + register("invites", "", "plainbase.team.invites", true, false, false, false, false, false, + "List your own pending invites."); + register("accept", "[team]", "plainbase.team.accept", true, true, false, false, false, false, + "Accept a pending invite."); + register("deny", "[team]", "plainbase.team.deny", true, true, false, false, false, false, + "Decline a pending invite."); + register("leave", "[team]", "plainbase.team.leave", true, true, false, false, false, false, + "Leave a team."); + register("request", "", "plainbase.team.request", true, true, true, false, false, false, + "Ask to join a team."); + register("requests", "", "plainbase.team.requests", false, true, true, true, false, false, + "List a team's pending join requests."); + register("invite", " ", "plainbase.team.invite", false, true, true, true, true, false, + "Invite a player to a team."); + register("add", " ", "plainbase.team.add", false, true, true, true, true, false, + "Add a player directly, no confirmation needed."); + register("kick", " ", "plainbase.team.kick", false, true, true, true, true, false, + "Remove a player from a team."); + register("reject", " ", "plainbase.team.reject", false, true, true, true, true, false, + "Reject a pending join request."); + register("setrole", " ", "plainbase.team.setrole", false, true, true, true, true, true, + "Change a player's role in a team."); + } private final PlainBase plugin; @@ -36,175 +96,152 @@ public void execute(@NotNull CommandSourceStack stack, @NotNull String @NotNull TeamManager teams = plugin.getTeamManager(); if (!plugin.getConfig().getBoolean("modules.team", true)) { - sender.sendMessage(plugin.getMiniMessage().deserialize("This module is currently disabled.")); + sender.sendMessage(mm("This module is currently disabled.")); return; } if (!plugin.getTeamConfig().getBoolean("team.enabled", true) || teams == null) { - sender.sendMessage(plugin.getMiniMessage().deserialize("The team system has been disabled.")); + sender.sendMessage(mm("The team system has been disabled.")); return; } if (!plugin.getTeamConfig().getBoolean("team.commands.team.enabled", true)) { - sender.sendMessage(plugin.getMiniMessage().deserialize("This command has been disabled.")); + sender.sendMessage(mm("This command has been disabled.")); return; } if (args.length == 0) { - sendUsage(sender); + sendHelp(sender); return; } - String first = args[0].toLowerCase(); - - if (GLOBAL_ACTIONS.contains(first)) { - handleGlobalAction(sender, teams, first, args); + String actionName = args[0].toLowerCase(Locale.ROOT); + ActionSpec spec = ACTIONS.get(actionName); + if (spec == null) { + sender.sendMessage(mm("Unknown /team subcommand: " + args[0] + ". Run /team for a list.")); return; } - - // Otherwise args[0] must be a team id. - if (!teams.teamExists(first)) { - sender.sendMessage(plugin.getMiniMessage().deserialize("Unknown team or subcommand: " + args[0])); + if (!checkPermission(sender, spec.permission())) return; + if (!plugin.getTeamConfig().getBoolean("team.commands." + spec.name() + ".enabled", true)) { + sender.sendMessage(mm("This command has been disabled.")); return; } - if (args.length < 2) { - sendUsage(sender); + if (spec.requiresPlayer() && !(sender instanceof Player)) { + sender.sendMessage(mm("This command can only be executed by players.")); return; } - handleTeamAction(sender, teams, first, args[1].toLowerCase(), args); - } - private void handleGlobalAction(CommandSender sender, TeamManager teams, String action, String[] args) { - switch (action) { - case "list" -> { - if (!checkPermission(sender, "plainbase.team.list")) return; - teams.listTeams(sender); - } - case "accept", "deny", "leave" -> { - if (!checkPermission(sender, "plainbase.team." + action)) return; - if (!(sender instanceof Player player)) { - sender.sendMessage(plugin.getMiniMessage().deserialize("This command can only be executed by players.")); + int idx = 1; + String teamId = null; + if (spec.teamScoped()) { + if (args.length > idx) { + String candidate = args[idx].toLowerCase(Locale.ROOT); + if (!teams.teamExists(candidate)) { + sender.sendMessage(mm("Unknown team: " + args[idx])); return; } - String teamArg = args.length > 1 ? args[1].toLowerCase() : null; - switch (action) { - case "accept" -> teams.accept(player, teamArg); - case "deny" -> teams.deny(player, teamArg); - case "leave" -> teams.leave(player, teamArg); - } + teamId = candidate; + idx++; + } else if (spec.teamRequired()) { + usage(sender, spec); + return; } } - } + if (spec.adminGated() && !teams.isTeamAdmin(sender, teamId)) { + sender.sendMessage(mm("You must be a team admin of " + teamId + " to do this.")); + return; + } - private void handleTeamAction(CommandSender sender, TeamManager teams, String teamId, String action, String[] args) { - if (!TEAM_ACTIONS.contains(action)) { - sendUsage(sender); + String playerArg = spec.hasPlayerArg() && args.length > idx ? args[idx++] : null; + String roleArg = spec.hasRoleArg() && args.length > idx ? args[idx++] : null; + if ((spec.hasPlayerArg() && playerArg == null) || (spec.hasRoleArg() && roleArg == null)) { + usage(sender, spec); return; } - if (!checkPermission(sender, "plainbase.team." + (action.equals("deny-request") ? "deny" : action))) return; - switch (action) { - case "info" -> teams.info(sender, teamId); - case "request" -> { - if (!(sender instanceof Player player)) { - sender.sendMessage(plugin.getMiniMessage().deserialize("This command can only be executed by players.")); - return; - } - teams.request(player, teamId); - } - case "invite", "add", "kick", "setrole", "deny-request" -> { - if (!teams.isTeamAdmin(sender, teamId)) { - sender.sendMessage(plugin.getMiniMessage().deserialize("You must be a team admin of " + teamId + " to do this.")); - return; - } - handleAdminAction(sender, teams, teamId, action, args); + Player player = sender instanceof Player p ? p : null; + switch (actionName) { + case "list" -> teams.listTeams(sender); + case "info" -> { + if (teamId != null) teams.info(sender, teamId); + else if (player != null) teams.infoSelf(player); + else usage(sender, spec); } + case "invites" -> teams.listInvites(player); + case "accept" -> teams.accept(player, teamId); + case "deny" -> teams.deny(player, teamId); + case "leave" -> teams.leave(player, teamId); + case "request" -> teams.request(player, teamId); + case "requests" -> teams.listRequests(sender, teamId); + case "invite" -> teams.invite(sender, teamId, playerArg); + case "add" -> teams.add(sender, teamId, playerArg); + case "kick" -> teams.kick(sender, teamId, playerArg); + case "reject" -> teams.denyRequest(sender, teamId, playerArg); + case "setrole" -> teams.setRole(sender, teamId, playerArg, roleArg); } } - private void handleAdminAction(CommandSender sender, TeamManager teams, String teamId, String action, String[] args) { - switch (action) { - case "invite" -> { - if (args.length < 3) { usageTeam(sender, teamId, "invite "); return; } - teams.invite(sender, teamId, args[2]); - } - case "add" -> { - if (args.length < 3) { usageTeam(sender, teamId, "add "); return; } - teams.add(sender, teamId, args[2]); - } - case "kick" -> { - if (args.length < 3) { usageTeam(sender, teamId, "kick "); return; } - teams.kick(sender, teamId, args[2]); - } - case "deny-request" -> { - if (args.length < 3) { usageTeam(sender, teamId, "deny-request "); return; } - teams.denyRequest(sender, teamId, args[2]); - } - case "setrole" -> { - if (args.length < 4) { usageTeam(sender, teamId, "setrole "); return; } - teams.setRole(sender, teamId, args[2], args[3]); - } + private void sendHelp(CommandSender sender) { + sender.sendMessage(mm("--- /team ---")); + for (ActionSpec spec : ACTIONS.values()) { + if (!hasPermission(sender, spec.permission())) continue; + String line = "/team " + spec.name() + (spec.usage().isEmpty() ? "" : " " + spec.usage()) + + " - " + spec.description(); + sender.sendMessage(mm(line)); } } - private void sendUsage(CommandSender sender) { - sender.sendMessage(plugin.getMiniMessage().deserialize( - "Usage: /team [team] or /team ..." - )); + private void usage(CommandSender sender, ActionSpec spec) { + sender.sendMessage(mm("Usage: /team " + spec.name() + (spec.usage().isEmpty() ? "" : " " + spec.usage()))); } - private void usageTeam(CommandSender sender, String teamId, String rest) { - sender.sendMessage(plugin.getMiniMessage().deserialize("Usage: /team " + teamId + " " + rest)); + private boolean hasPermission(CommandSender sender, String permission) { + return sender.hasPermission("plainbase.admin") + || sender.hasPermission("plainbase.team.admin") + || sender.hasPermission(permission); } private boolean checkPermission(CommandSender sender, String permission) { - if (!sender.hasPermission("plainbase.admin") - && !sender.hasPermission("plainbase.team.admin") - && !sender.hasPermission(permission)) { - sender.sendMessage(plugin.getMiniMessage().deserialize("No permission!")); + if (!hasPermission(sender, permission)) { + sender.sendMessage(mm("No permission!")); return false; } return true; } + private net.kyori.adventure.text.Component mm(String s) { + return plugin.getMiniMessage().deserialize(s); + } + @Override public @NotNull List suggest(@NotNull CommandSourceStack stack, @NotNull String @NotNull [] args) { TeamManager teams = plugin.getTeamManager(); if (teams == null) return List.of(); + CommandSender sender = stack.getSender(); if (args.length <= 1) { - String input = args.length == 1 ? args[0].toLowerCase() : ""; - List options = new ArrayList<>(GLOBAL_ACTIONS); - for (TeamManager.TeamDefinition def : teams.getTeams()) options.add(def.id()); - return options.stream().filter(s -> s.startsWith(input)).toList(); + String input = args.length == 1 ? args[0].toLowerCase(Locale.ROOT) : ""; + return ACTIONS.values().stream() + .filter(spec -> hasPermission(sender, spec.permission())) + .map(ActionSpec::name) + .filter(name -> name.startsWith(input)) + .toList(); } - String first = args[0].toLowerCase(); + ActionSpec spec = ACTIONS.get(args[0].toLowerCase(Locale.ROOT)); + if (spec == null || !spec.teamScoped()) return List.of(); - if (first.equals("accept") || first.equals("deny") || first.equals("leave")) { - if (args.length == 2) { - String input = args[1].toLowerCase(); - return teams.getTeams().stream().map(TeamManager.TeamDefinition::id) - .filter(id -> id.startsWith(input)).toList(); - } - return List.of(); + if (args.length == 2) { + String input = args[1].toLowerCase(Locale.ROOT); + return teams.getTeams().stream().map(TeamManager.TeamDefinition::id) + .filter(id -> id.startsWith(input)).toList(); } - - if (teams.teamExists(first)) { - if (args.length == 2) { - String input = args[1].toLowerCase(); - return TEAM_ACTIONS.stream().filter(s -> s.startsWith(input)).toList(); - } - if (args.length == 3) { - String action = args[1].toLowerCase(); - if (Set.of("invite", "add", "kick", "setrole", "deny-request").contains(action)) { - String input = args[2].toLowerCase(); - return Bukkit.getOnlinePlayers().stream().map(Player::getName) - .filter(n -> n.toLowerCase().startsWith(input)).toList(); - } - } - if (args.length == 4 && args[1].equalsIgnoreCase("setrole")) { - String input = args[3].toLowerCase(); - return Stream.of("member", "admin").filter(s -> s.startsWith(input)).toList(); - } + if (args.length == 3 && spec.hasPlayerArg()) { + String input = args[2].toLowerCase(Locale.ROOT); + return Bukkit.getOnlinePlayers().stream().map(Player::getName) + .filter(n -> n.toLowerCase(Locale.ROOT).startsWith(input)).toList(); + } + if (args.length == 4 && spec.hasRoleArg()) { + String input = args[3].toLowerCase(Locale.ROOT); + return Stream.of("member", "admin").filter(s -> s.startsWith(input)).toList(); } return List.of(); } diff --git a/src/main/resources/modules/team.yml b/src/main/resources/modules/team.yml index 8465373..96f2575 100644 --- a/src/main/resources/modules/team.yml +++ b/src/main/resources/modules/team.yml @@ -4,13 +4,42 @@ team: # How many teams a single player can be a member of at the same time. max-teams-per-player: 1 + # Every /team subcommand can be individually enabled/disabled here, in + # addition to needing its own permission node (see wiki: Module-Team). + # "team" is the master switch for the whole /team command tree. commands: team: enabled: true + list: + enabled: true + info: + enabled: true + invites: + enabled: true + accept: + enabled: true + deny: + enabled: true + leave: + enabled: true + request: + enabled: true + requests: + enabled: true + invite: + enabled: true + add: + enabled: true + kick: + enabled: true + reject: + enabled: true + setrole: + enabled: true # Teams can ONLY be created/removed here — membership, roles, invites and # join requests are all managed at runtime via /team and are stored per -# player (by UUID) in plugins/PlainBase/data/playerdata/.yml, not here. +# player (by UUID) in plugins/PlainBase/data/teams/, not here. # # "color" is a MiniMessage string used for the team's display name wherever # PlainBase shows it (chat, /team list|info, PAPI placeholders) — full @@ -24,7 +53,8 @@ team: # Vanilla scoreboard teams only support ONE flat color (no gradients/rainbow) # for things like glow/name-tint — PlainBase picks the closest vanilla color # to your "color" automatically. This does NOT affect the selector working, -# only the small vanilla color accent. +# only the small vanilla color accent. Not available on Folia (its scoreboard +# API is currently unsupported there) — everything else still works. teams: red: display-name: "Red Team" @@ -61,7 +91,7 @@ messages: leave-usage-multiple: "You're in multiple teams — specify one: /team leave " request-sent: "Join request for %team% sent to its admins." - request-received: "%player% wants to join %team%! Use /team %team% add %player% to accept or /team %team% deny %player% to reject." + request-received: "%player% wants to join %team%! Use /team add %team% %player% to accept or /team reject %team% %player% to reject." request-not-found: "No pending join request from %player% for %team%." request-already-pending: "You already have a pending join request for %team%." @@ -74,29 +104,17 @@ messages: info-member: "- %player% (%role%)" info-empty: "This team has no members yet." -commands: - team: - enabled: true - invite: - enabled: true - add: - enabled: true - kick: - enabled: true - leave: - enabled: true - request: - enabled: true - accept: - enabled: true - deny: - enabled: true - setrole: - enabled: true - list: - enabled: true - info: - enabled: true + requests-header: "--- Pending requests: %team% ---" + requests-empty: "No pending join requests for %team%." + requests-entry: "- %player%" + + invites-header: "--- Your pending invites ---" + invites-empty: "You have no pending invites." + invites-entry: "- %team%" + + your-teams-header: "--- Your teams ---" + your-teams-empty: "You are not in any team." + your-teams-entry: "- %team% (%role%)" # DO NOT EDIT BELOW UNLESS YOU KNOW WHAT YOU ARE DOING -version: 1.0 +version: 1.1 From 2a6af1d8835f3762805ff7a6a29acbf39272f12b Mon Sep 17 00:00:00 2001 From: j-gaertig-co-dev Date: Thu, 13 Aug 2026 20:02:39 +0200 Subject: [PATCH 5/8] feat(team): back the new list commands; fix blocking player resolve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getPendingRequests()/getPendingInvites()/listRequests()/listInvites()/ infoSelf() back the new /team requests, /team invites and no-arg /team info commands from the schema redesign. - Independent full-project code review (delegated, fresh context) flagged that TeamManager#resolveTarget() called the deprecated Bukkit#getOfflinePlayer(String) SYNCHRONOUSLY on the command-dispatch thread for invite/add/kick/reject/setrole — that call can block on a Mojang lookup (main thread on Paper, the sender's region thread on Folia). The moderation module already has the correct pattern for this (ModerationCommandBase#resolveTarget: online/cached lookups resolve instantly, only a true cache-miss falls back to the async scheduler with the callback dispatched back onto the main/region thread) — teams now uses the exact same pattern instead of its own naive synchronous version. Verified on Paper + Folia after the refactor: full invite/add/setrole/ reject/kick cycle including the async cache-miss fallback path (queried a never-seen name), no regressions. --- .../jgaertig/plainBase/team/TeamManager.java | 261 ++++++++++++------ 1 file changed, 171 insertions(+), 90 deletions(-) diff --git a/src/main/java/de/jgaertig/plainBase/team/TeamManager.java b/src/main/java/de/jgaertig/plainBase/team/TeamManager.java index 05bd012..f6c6bd4 100644 --- a/src/main/java/de/jgaertig/plainBase/team/TeamManager.java +++ b/src/main/java/de/jgaertig/plainBase/team/TeamManager.java @@ -18,6 +18,7 @@ import java.io.IOException; import java.util.*; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; /** * Core logic for the Team module: team definitions (config-only), runtime @@ -186,37 +187,47 @@ public boolean isInvited(UUID uuid, String teamId) { return invites.getOrDefault(uuid, Set.of()).contains(teamId.toLowerCase()); } + /** Snapshot of a team's pending join requests (admin-facing). */ + public Set getPendingRequests(String teamId) { + return Set.copyOf(requests.getOrDefault(teamId.toLowerCase(), Set.of())); + } + + /** Snapshot of a player's own pending invites, across every team. */ + public Set getPendingInvites(UUID uuid) { + return Set.copyOf(invites.getOrDefault(uuid, Set.of())); + } + // --------------------------------------------------------------- // Actions (send their own feedback messages, matching TPAManager/VanishManager style) // --------------------------------------------------------------- public void invite(CommandSender staff, String teamId, String targetName) { String id = teamId.toLowerCase(); - OfflinePlayer target = resolveTarget(staff, targetName); - if (target == null) return; - UUID uuid = target.getUniqueId(); + resolveTarget(staff, targetName, target -> { + UUID uuid = target.getUniqueId(); - if (isMember(uuid, id)) { - staff.sendMessage(msg("already-in-team", "player", targetName, "team", id)); - return; - } - if (invites.getOrDefault(uuid, Set.of()).contains(id)) { - staff.sendMessage(msg("invite-already-pending", "player", targetName, "team", id)); - return; - } - if (getPlayerTeams(uuid).size() >= getMaxTeamsPerPlayer()) { - staff.sendMessage(msg("max-teams-reached", "player", targetName, "max", String.valueOf(getMaxTeamsPerPlayer()))); - return; - } + if (isMember(uuid, id)) { + staff.sendMessage(msg("already-in-team", "player", targetName, "team", id)); + return; + } + if (invites.getOrDefault(uuid, Set.of()).contains(id)) { + staff.sendMessage(msg("invite-already-pending", "player", targetName, "team", id)); + return; + } + if (getPlayerTeams(uuid).size() >= getMaxTeamsPerPlayer()) { + staff.sendMessage(msg("max-teams-reached", "player", targetName, "max", String.valueOf(getMaxTeamsPerPlayer()))); + return; + } - invites.computeIfAbsent(uuid, k -> ConcurrentHashMap.newKeySet()).add(id); - saveInvites(); - staff.sendMessage(msg("invite-sent", "player", targetName, "team", id)); + invites.computeIfAbsent(uuid, k -> ConcurrentHashMap.newKeySet()).add(id); + saveInvites(); + staff.sendMessage(msg("invite-sent", "player", targetName, "team", id)); - Player online = Bukkit.getPlayer(uuid); - if (online != null) { - online.sendMessage(msg("invite-received", "team", id)); - } + Player online = Bukkit.getPlayer(uuid); + if (online != null) { + online.sendMessage(msg("invite-received", "team", id)); + } + }); } public void accept(Player player, String teamIdOrNull) { @@ -249,52 +260,52 @@ public void deny(Player player, String teamIdOrNull) { public void add(CommandSender staff, String teamId, String targetName) { String id = teamId.toLowerCase(); - OfflinePlayer target = resolveTarget(staff, targetName); - if (target == null) return; - UUID uuid = target.getUniqueId(); + resolveTarget(staff, targetName, target -> { + UUID uuid = target.getUniqueId(); - if (isMember(uuid, id)) { - staff.sendMessage(msg("already-in-team", "player", targetName, "team", id)); - return; - } - if (getPlayerTeams(uuid).size() >= getMaxTeamsPerPlayer()) { - staff.sendMessage(msg("max-teams-reached", "player", targetName, "max", String.valueOf(getMaxTeamsPerPlayer()))); - return; - } - - // Adding directly also clears any pending invite/request for this team. - // (getOrDefault falls back to the immutable Set.of() when there's no - // entry yet — remove() on that throws, so only touch a real set.) - Set pendingInvites = invites.get(uuid); - if (pendingInvites != null) pendingInvites.remove(id); - Set pendingRequests = requests.get(id); - if (pendingRequests != null) pendingRequests.remove(uuid); - saveInvites(); - saveRequests(); - - setMember(uuid, id, Role.MEMBER); - staff.sendMessage(msg("add-success", "player", targetName, "team", id)); + if (isMember(uuid, id)) { + staff.sendMessage(msg("already-in-team", "player", targetName, "team", id)); + return; + } + if (getPlayerTeams(uuid).size() >= getMaxTeamsPerPlayer()) { + staff.sendMessage(msg("max-teams-reached", "player", targetName, "max", String.valueOf(getMaxTeamsPerPlayer()))); + return; + } - Player online = Bukkit.getPlayer(uuid); - if (online != null) online.sendMessage(msg("add-success", "player", online.getName(), "team", id)); + // Adding directly also clears any pending invite/request for this team. + // (getOrDefault falls back to the immutable Set.of() when there's no + // entry yet — remove() on that throws, so only touch a real set.) + Set pendingInvites = invites.get(uuid); + if (pendingInvites != null) pendingInvites.remove(id); + Set pendingRequests = requests.get(id); + if (pendingRequests != null) pendingRequests.remove(uuid); + saveInvites(); + saveRequests(); + + setMember(uuid, id, Role.MEMBER); + staff.sendMessage(msg("add-success", "player", targetName, "team", id)); + + Player online = Bukkit.getPlayer(uuid); + if (online != null) online.sendMessage(msg("add-success", "player", online.getName(), "team", id)); + }); } public void kick(CommandSender staff, String teamId, String targetName) { String id = teamId.toLowerCase(); - OfflinePlayer target = resolveTarget(staff, targetName); - if (target == null) return; - UUID uuid = target.getUniqueId(); + resolveTarget(staff, targetName, target -> { + UUID uuid = target.getUniqueId(); - if (!isMember(uuid, id)) { - staff.sendMessage(msg("not-in-team", "team", id)); - return; - } + if (!isMember(uuid, id)) { + staff.sendMessage(msg("not-in-team", "team", id)); + return; + } - removeMember(uuid, id); - staff.sendMessage(msg("kick-success", "player", targetName, "team", id)); + removeMember(uuid, id); + staff.sendMessage(msg("kick-success", "player", targetName, "team", id)); - Player online = Bukkit.getPlayer(uuid); - if (online != null) online.sendMessage(msg("kick-success", "player", online.getName(), "team", id)); + Player online = Bukkit.getPlayer(uuid); + if (online != null) online.sendMessage(msg("kick-success", "player", online.getName(), "team", id)); + }); } public void leave(Player player, String teamIdOrNull) { @@ -352,18 +363,18 @@ public void request(Player player, String teamId) { public void denyRequest(CommandSender staff, String teamId, String targetName) { String id = teamId.toLowerCase(); - OfflinePlayer target = resolveTarget(staff, targetName); - if (target == null) return; - UUID uuid = target.getUniqueId(); + resolveTarget(staff, targetName, target -> { + UUID uuid = target.getUniqueId(); - Set pending = requests.getOrDefault(id, Set.of()); - if (!pending.contains(uuid)) { - staff.sendMessage(msg("request-not-found", "player", targetName, "team", id)); - return; - } - pending.remove(uuid); - saveRequests(); - staff.sendMessage(msg("kick-success", "player", targetName, "team", id)); // reuse: "removed/rejected" + Set pending = requests.getOrDefault(id, Set.of()); + if (!pending.contains(uuid)) { + staff.sendMessage(msg("request-not-found", "player", targetName, "team", id)); + return; + } + pending.remove(uuid); + saveRequests(); + staff.sendMessage(msg("kick-success", "player", targetName, "team", id)); // reuse: "removed/rejected" + }); } public void setRole(CommandSender staff, String teamId, String targetName, String roleStr) { @@ -376,22 +387,22 @@ public void setRole(CommandSender staff, String teamId, String targetName, Strin return; } - OfflinePlayer target = resolveTarget(staff, targetName); - if (target == null) return; - UUID uuid = target.getUniqueId(); + resolveTarget(staff, targetName, target -> { + UUID uuid = target.getUniqueId(); - if (!isMember(uuid, id)) { - staff.sendMessage(msg("not-in-team", "team", id)); - return; - } + if (!isMember(uuid, id)) { + staff.sendMessage(msg("not-in-team", "team", id)); + return; + } - setMember(uuid, id, role); - staff.sendMessage(msg("setrole-success", "player", targetName, "team", id, "role", role.name().toLowerCase())); + setMember(uuid, id, role); + staff.sendMessage(msg("setrole-success", "player", targetName, "team", id, "role", role.name().toLowerCase())); - Player online = Bukkit.getPlayer(uuid); - if (online != null) { - online.sendMessage(msg("setrole-success", "player", online.getName(), "team", id, "role", role.name().toLowerCase())); - } + Player online = Bukkit.getPlayer(uuid); + if (online != null) { + online.sendMessage(msg("setrole-success", "player", online.getName(), "team", id, "role", role.name().toLowerCase())); + } + }); } public void listTeams(CommandSender sender) { @@ -424,6 +435,51 @@ public void info(CommandSender sender, String teamId) { } } + /** + * Admin-facing: list a team's pending join requests (people who ran + * /team <team> request and are waiting on an admin to /team add them). + */ + public void listRequests(CommandSender sender, String teamId) { + String id = teamId.toLowerCase(); + Set pending = getPendingRequests(id); + sender.sendMessage(msg("requests-header", "team", id)); + if (pending.isEmpty()) { + sender.sendMessage(msg("requests-empty", "team", id)); + return; + } + for (UUID uuid : pending) { + String name = Optional.ofNullable(Bukkit.getOfflinePlayer(uuid).getName()).orElse(uuid.toString()); + sender.sendMessage(msg("requests-entry", "player", name)); + } + } + + /** Self-facing: list the invites a player is currently sitting on. */ + public void listInvites(Player player) { + Set pending = getPendingInvites(player.getUniqueId()); + player.sendMessage(msg("invites-header")); + if (pending.isEmpty()) { + player.sendMessage(msg("invites-empty")); + return; + } + for (String teamId : pending) { + player.sendMessage(msg("invites-entry", "team", teamId)); + } + } + + /** Self-facing summary used by "/team info" with no team argument. */ + public void infoSelf(Player player) { + Set memberOf = getPlayerTeams(player.getUniqueId()); + player.sendMessage(msg("your-teams-header")); + if (memberOf.isEmpty()) { + player.sendMessage(msg("your-teams-empty")); + return; + } + for (String teamId : memberOf) { + Role role = getRole(player.getUniqueId(), teamId); + player.sendMessage(msg("your-teams-entry", "team", teamId, "role", role.name().toLowerCase())); + } + } + /** * Called on player join: delivers reminders for any pending invites and * re-syncs this player's scoreboard entry under their current name. @@ -472,14 +528,39 @@ private String resolveSingle(Player player, Set pending, String teamIdOr return null; } - private OfflinePlayer resolveTarget(CommandSender staff, String name) { - @SuppressWarnings("deprecation") - OfflinePlayer target = Bukkit.getOfflinePlayer(name); - if (target.getName() == null && !target.hasPlayedBefore()) { - staff.sendMessage(msg("player-not-found", "player", name)); - return null; + /** + * Resolves a target by name without ever blocking the calling thread: + * online players and Paper's cached offline-player lookup resolve + * instantly; only an uncached, never-joined name falls back to the + * deprecated Bukkit#getOfflinePlayer(String), which can block on a + * Mojang lookup — so that call always runs on the async scheduler, with + * the callback dispatched back onto the main/region thread afterwards + * (same pattern as ModerationCommandBase#resolveTarget). + */ + private void resolveTarget(CommandSender staff, String name, Consumer callback) { + Player online = Bukkit.getPlayer(name); + if (online != null) { + callback.accept(online); + return; } - return target; + + OfflinePlayer cached = Bukkit.getOfflinePlayerIfCached(name); + if (cached != null) { + callback.accept(cached); + return; + } + + Bukkit.getAsyncScheduler().runNow(plugin, task -> { + @SuppressWarnings("deprecation") + OfflinePlayer resolved = Bukkit.getOfflinePlayer(name); + Bukkit.getGlobalRegionScheduler().run(plugin, t -> { + if (resolved.getName() == null && !resolved.hasPlayedBefore()) { + staff.sendMessage(msg("player-not-found", "player", name)); + return; + } + callback.accept(resolved); + }); + }); } private void setMember(UUID uuid, String teamId, Role role) { From 2c413609dbfbbea87a19ea7c235e4aeb75d3cabe Mon Sep 17 00:00:00 2001 From: j-gaertig-co-dev Date: Thu, 13 Aug 2026 20:19:30 +0200 Subject: [PATCH 6/8] fix(config): bump latestVersions[config.yml] to match shipped 1.7 default The team module commit bumped config.yml's shipped default to version: 1.7 (new modules.team key) but never bumped the corresponding entry in latestVersions, so upgraders on 1.6 never saw the outdated-config warning. Verified: forcing an on-disk config.yml back to 1.6 now logs '!!! OUTDATED CONFIG: config.yml !!!' on both Paper and Folia. --- src/main/java/de/jgaertig/plainBase/PlainBase.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/de/jgaertig/plainBase/PlainBase.java b/src/main/java/de/jgaertig/plainBase/PlainBase.java index ba363ee..f7c8841 100644 --- a/src/main/java/de/jgaertig/plainBase/PlainBase.java +++ b/src/main/java/de/jgaertig/plainBase/PlainBase.java @@ -63,7 +63,7 @@ public void onEnable() { saveDefaultConfig(); - latestVersions.put("config.yml", 1.6); + latestVersions.put("config.yml", 1.7); latestVersions.put("spawn.yml", 1.2); latestVersions.put("joinitems.yml", 1.2); latestVersions.put("messages.yml", 1.1); From 41a7c63e509575c64fbacc94c6fe8d8b58a66629 Mon Sep 17 00:00:00 2001 From: j-gaertig-co-dev Date: Thu, 13 Aug 2026 20:19:30 +0200 Subject: [PATCH 7/8] fix(moderation): close cache-refresh race in BanManager refreshCacheBlocking() cleared and repopulated the ban/kick caches without holding mutationLock, so a ban/unban committing to the DB between the refresh's read and its cache swap could have its update clobbered by a stale snapshot. The periodic refresh now swaps the caches under the same lock the mutation methods already use. --- .../plainBase/moderation/BanManager.java | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/main/java/de/jgaertig/plainBase/moderation/BanManager.java b/src/main/java/de/jgaertig/plainBase/moderation/BanManager.java index a85cda8..2980bf9 100644 --- a/src/main/java/de/jgaertig/plainBase/moderation/BanManager.java +++ b/src/main/java/de/jgaertig/plainBase/moderation/BanManager.java @@ -104,16 +104,22 @@ private void refreshCacheBlocking() { newKicksByUuid.computeIfAbsent(record.uuid(), k -> new CopyOnWriteArrayList<>()).add(record); } - bansCache.clear(); - bansCache.addAll(bans); - kicksCache.clear(); - kicksCache.addAll(kicks); - ipBansCache.clear(); - ipBansCache.addAll(ipBans); - bansByUuid.clear(); - bansByUuid.putAll(newBansByUuid); - kicksByUuid.clear(); - kicksByUuid.putAll(newKicksByUuid); + // Swap the cache under the same lock the mutation methods use, so a + // ban/unban that commits to the DB between this method's reads and + // its cache swap can't have its cache update clobbered by a stale + // snapshot read just before it. + synchronized (mutationLock) { + bansCache.clear(); + bansCache.addAll(bans); + kicksCache.clear(); + kicksCache.addAll(kicks); + ipBansCache.clear(); + ipBansCache.addAll(ipBans); + bansByUuid.clear(); + bansByUuid.putAll(newBansByUuid); + kicksByUuid.clear(); + kicksByUuid.putAll(newKicksByUuid); + } } catch (SQLException e) { plugin.getLogger().severe("Could not refresh moderation cache: " + e.getMessage()); } From 65bb430d22d2e8d7b4e12401e690f2078a46e4eb Mon Sep 17 00:00:00 2001 From: j-gaertig-co-dev Date: Thu, 13 Aug 2026 20:19:30 +0200 Subject: [PATCH 8/8] fix(team): serialize per-file writes to members/invites/requests.yml Each save*() scheduled an independent async task; two near-simultaneous mutations to the same team could produce two tasks racing to write the same YAML file concurrently, risking an interleaved/corrupted write -- the same bug class already fixed once for the moderation module's ban/kick files. Added one lock per file so concurrent saves to the same file serialize, while members/invites/requests still don't block each other. --- .../jgaertig/plainBase/team/TeamManager.java | 46 +++++++++++++------ 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/src/main/java/de/jgaertig/plainBase/team/TeamManager.java b/src/main/java/de/jgaertig/plainBase/team/TeamManager.java index f6c6bd4..17a9717 100644 --- a/src/main/java/de/jgaertig/plainBase/team/TeamManager.java +++ b/src/main/java/de/jgaertig/plainBase/team/TeamManager.java @@ -704,38 +704,54 @@ private void loadState() { } } + // Each save*() schedules an independent async task; without a per-file + // lock, two near-simultaneous mutations to the same team could produce + // two tasks racing to write the same YAML file concurrently, risking an + // interleaved/corrupted write. Serializing per file (not one lock for + // all three) keeps members/invites/requests writes from blocking each + // other while still ruling out that race for each file individually. + private final Object membersFileLock = new Object(); + private final Object invitesFileLock = new Object(); + private final Object requestsFileLock = new Object(); + private void saveMemberships() { Bukkit.getAsyncScheduler().runNow(plugin, task -> { - YamlConfiguration config = new YamlConfiguration(); - for (Map.Entry> teamEntry : memberships.entrySet()) { - for (Map.Entry memberEntry : teamEntry.getValue().entrySet()) { - config.set(teamEntry.getKey() + "." + memberEntry.getKey(), memberEntry.getValue().name()); + synchronized (membersFileLock) { + YamlConfiguration config = new YamlConfiguration(); + for (Map.Entry> teamEntry : memberships.entrySet()) { + for (Map.Entry memberEntry : teamEntry.getValue().entrySet()) { + config.set(teamEntry.getKey() + "." + memberEntry.getKey(), memberEntry.getValue().name()); + } } + saveQuietly(config, "members.yml"); } - saveQuietly(config, "members.yml"); }); } private void saveInvites() { Bukkit.getAsyncScheduler().runNow(plugin, task -> { - YamlConfiguration config = new YamlConfiguration(); - for (Map.Entry> entry : invites.entrySet()) { - if (!entry.getValue().isEmpty()) config.set(entry.getKey().toString(), new ArrayList<>(entry.getValue())); + synchronized (invitesFileLock) { + YamlConfiguration config = new YamlConfiguration(); + for (Map.Entry> entry : invites.entrySet()) { + if (!entry.getValue().isEmpty()) config.set(entry.getKey().toString(), new ArrayList<>(entry.getValue())); + } + saveQuietly(config, "invites.yml"); } - saveQuietly(config, "invites.yml"); }); } private void saveRequests() { Bukkit.getAsyncScheduler().runNow(plugin, task -> { - YamlConfiguration config = new YamlConfiguration(); - for (Map.Entry> entry : requests.entrySet()) { - if (!entry.getValue().isEmpty()) { - List uuids = entry.getValue().stream().map(UUID::toString).toList(); - config.set(entry.getKey(), uuids); + synchronized (requestsFileLock) { + YamlConfiguration config = new YamlConfiguration(); + for (Map.Entry> entry : requests.entrySet()) { + if (!entry.getValue().isEmpty()) { + List uuids = entry.getValue().stream().map(UUID::toString).toList(); + config.set(entry.getKey(), uuids); + } } + saveQuietly(config, "requests.yml"); } - saveQuietly(config, "requests.yml"); }); }