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. 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..f7c8841 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; @@ -59,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); @@ -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.1); 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,50 @@ 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", 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) + ); + 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) + ); + 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) + ); } public void reloadModules() { @@ -257,6 +307,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 +338,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 +513,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 +574,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/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()); } diff --git a/src/main/java/de/jgaertig/plainBase/placeholder/PlainBaseExpansion.java b/src/main/java/de/jgaertig/plainBase/placeholder/PlainBaseExpansion.java index fdab20d..3b02697 100644 --- a/src/main/java/de/jgaertig/plainBase/placeholder/PlainBaseExpansion.java +++ b/src/main/java/de/jgaertig/plainBase/placeholder/PlainBaseExpansion.java @@ -89,7 +89,38 @@ 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(plugin.getTeamManager().getPendingInvites(player.getUniqueId()).size()) : "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; + } } \ 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..17a9717 --- /dev/null +++ b/src/main/java/de/jgaertig/plainBase/team/TeamManager.java @@ -0,0 +1,765 @@ +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; +import java.util.function.Consumer; + +/** + * 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(); + + // 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; + } + } + + // --------------------------------------------------------------- + // 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()); + } + + /** 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(); + 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; + } + + 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(); + 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)); + + 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(); + resolveTarget(staff, targetName, target -> { + 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(); + 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" + }); + } + + 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; + } + + resolveTarget(staff, targetName, target -> { + 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())); + } + } + + /** + * 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. + */ + 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; + } + + /** + * 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; + } + + 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) { + 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); + } + } + + // 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 -> { + 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"); + } + }); + } + + private void saveInvites() { + Bukkit.getAsyncScheduler().runNow(plugin, task -> { + 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"); + } + }); + } + + private void saveRequests() { + Bukkit.getAsyncScheduler().runNow(plugin, task -> { + 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"); + } + }); + } + + 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..beda272 --- /dev/null +++ b/src/main/java/de/jgaertig/plainBase/team/commands/TeamCommand.java @@ -0,0 +1,248 @@ +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.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.stream.Stream; + +/** + * 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 { + + /** + * @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; + + 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(mm("This module is currently disabled.")); + return; + } + if (!plugin.getTeamConfig().getBoolean("team.enabled", true) || teams == null) { + sender.sendMessage(mm("The team system has been disabled.")); + return; + } + if (!plugin.getTeamConfig().getBoolean("team.commands.team.enabled", true)) { + sender.sendMessage(mm("This command has been disabled.")); + return; + } + + if (args.length == 0) { + sendHelp(sender); + return; + } + + 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; + } + 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 (spec.requiresPlayer() && !(sender instanceof Player)) { + sender.sendMessage(mm("This command can only be executed by players.")); + return; + } + + 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; + } + 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; + } + + 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; + } + + 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 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 usage(CommandSender sender, ActionSpec spec) { + sender.sendMessage(mm("Usage: /team " + spec.name() + (spec.usage().isEmpty() ? "" : " " + spec.usage()))); + } + + 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 (!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(Locale.ROOT) : ""; + return ACTIONS.values().stream() + .filter(spec -> hasPermission(sender, spec.permission())) + .map(ActionSpec::name) + .filter(name -> name.startsWith(input)) + .toList(); + } + + ActionSpec spec = ACTIONS.get(args[0].toLowerCase(Locale.ROOT)); + if (spec == null || !spec.teamScoped()) 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 (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/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..96f2575 --- /dev/null +++ b/src/main/resources/modules/team.yml @@ -0,0 +1,120 @@ +team: + enabled: true + + # 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/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 +# 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. Not available on Folia (its scoreboard +# API is currently unsupported there) — everything else still works. +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 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%." + + 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." + + 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.1