diff --git a/.gitignore b/.gitignore index 4956f61..e7ee7be 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ .gradle/ build/ +# run-paper test server +run/ + # Gradle wrapper jar must stay versioned !gradle/wrapper/gradle-wrapper.jar diff --git a/README.md b/README.md index a4f00c3..7609cd4 100644 --- a/README.md +++ b/README.md @@ -5,20 +5,30 @@ A quality-of-life SMP plugin for Paper/Folia. ## Features - **Join messages** — a "Welcome to the SMP" title on every join, friendly welcome messages (including a first-join message that lists the server's plugins and a warning that `/nopvp` abuse is bannable and that combat logging is a bad idea), and a special announcement when the owner logs in. -- **`/nopvp`** — toggle no-PvP mode. While active you cannot damage other players and other players cannot damage you (direct hits, projectiles, pets, lingering potions, and player-deployed explosives such as TNT, TNT minecarts, end crystals and bed bombs). While enabled you also glow so everyone can see you're protected. To stop the immunity being used offensively, protected players (and anyone within **25 blocks** of one) are also blocked from placing, priming or detonating PvP explosives — including redstone-primed TNT whose placer is remembered, and dispensers near a protected player firing TNT/lava/fire charges. No-PvP also can't be switched on while you're in combat. Turning no-PvP *off* starts a **1-hour cooldown** before you can enable it again, and the state persists across restarts so relogging can't bypass it. +- **`/nopvp`** — toggle no-PvP mode. While active you cannot damage other players and other players cannot damage you (direct hits, projectiles, pets, lingering potions, and player-deployed explosives such as TNT, TNT minecarts, end crystals and bed bombs). While enabled a floating **"Pacifist"** label appears above you and, if you're in a team with glow on, you glow in the team's colour (visible to everyone). To stop the immunity being used offensively, protected players (and anyone within **25 blocks** of one) are also blocked from placing, priming or detonating PvP explosives — including redstone-primed TNT whose placer is remembered, and dispensers near a protected player firing TNT/lava/fire charges. No-PvP also can't be switched on while you're in combat. Turning no-PvP *off* starts a **1-hour cooldown** before you can enable it again, and the state persists across restarts so relogging can't bypass it. - **Anti-combat-logging** — whenever you hit another player or get hit by one, you're tagged as being in combat for **15 seconds** with a chat warning. Logging off while tagged kills you on the spot and announces it to the server. +- **Cosmetic teams** — `/team create/delete/invite/leave/join/color/admin/glow/chat`. Teams are purely cosmetic (friendly fire stays on). The owner runs the show and can promote **admins** who may also invite players and change the colour; membership is request-based rather than open. A team's colour becomes the glow colour of any member who has no-PvP enabled (toggleable with `/team glow`, on by default), and `/team chat` gives the team a private chat channel. Stored in `plugins/qol-tweaks/teams.yml`. ## Commands -| Command | Description | Permission | -|----------|--------------------------------------------------------------------------|-----------------------| -| `/nopvp` | Toggle no-PvP mode (damage immunity vs. players + player glow). | `qoltweaks.nopvp` | +| Command | Description | Permission | +|--------------------------------|----------------------------------------------------------------------|-------------------| +| `/nopvp` | Toggle no-PvP mode ("Pacifist" label + optional team-coloured glow). | `qoltweaks.nopvp` | +| `/team create ` | Create a team (you become its owner). | `qoltweaks.team` | +| `/team invite

` | Invite an online player, or accept their join request (owner/admin). | `qoltweaks.team` | +| `/team join ` | Accept an invite, or send a join request to the team. | `qoltweaks.team` | +| `/team color ` | Set the team glow colour, or `none` to clear (owner/admin). | `qoltweaks.team` | +| `/team admin

`| Promote/demote a member to admin (owner only). | `qoltweaks.team` | +| `/team glow` | Toggle the whole team's no-PvP glow (owner/admin). | `qoltweaks.team` | +| `/team chat [message]` | Send a team-only message, or toggle team chat mode. | `qoltweaks.team` | +| `/team leave` | Leave your team (owners must delete instead). | `qoltweaks.team` | +| `/team delete` | Disband the whole team (owner only). | `qoltweaks.team` | -All permissions default to `true`. +All permissions default to `true`. Note `/team` takes over the vanilla scoreboard-team command. ## Data -Player no-PvP state and cooldown timestamps are stored in `plugins/qol-tweaks/nopvp.yml` so they survive restarts and reloads. +Player no-PvP state and cooldown timestamps are stored in `plugins/qol-tweaks/nopvp.yml` so they survive restarts and reloads. Teams, ownership, admins, members, colours and the glow toggle are stored in `plugins/qol-tweaks/teams.yml`. ## Compatibility diff --git a/src/main/java/com/dvorakmortonxd/qoltweaks/QoLtweaks.java b/src/main/java/com/dvorakmortonxd/qoltweaks/QoLtweaks.java index ddac367..70314d3 100644 --- a/src/main/java/com/dvorakmortonxd/qoltweaks/QoLtweaks.java +++ b/src/main/java/com/dvorakmortonxd/qoltweaks/QoLtweaks.java @@ -7,6 +7,9 @@ import com.dvorakmortonxd.qoltweaks.nopvp.NoPvPCommand; import com.dvorakmortonxd.qoltweaks.nopvp.NoPvPListener; import com.dvorakmortonxd.qoltweaks.nopvp.NoPvPManager; import com.dvorakmortonxd.qoltweaks.nopvp.NoPvPProtectionListener; +import com.dvorakmortonxd.qoltweaks.teams.TeamCommand; +import com.dvorakmortonxd.qoltweaks.teams.TeamListener; +import com.dvorakmortonxd.qoltweaks.teams.TeamManager; import org.bukkit.command.PluginCommand; import org.bukkit.plugin.java.JavaPlugin; @@ -14,14 +17,17 @@ public final class QoLtweaks extends JavaPlugin { private NoPvPManager noPvPManager; private CombatLogManager combatLogManager; + private TeamManager teamManager; @Override public void onEnable() { // Plugin startup logic - noPvPManager = new NoPvPManager(this); + teamManager = new TeamManager(this); + noPvPManager = new NoPvPManager(this, teamManager); combatLogManager = new CombatLogManager(this); getServer().getPluginManager().registerEvents(new JoinListener(), this); + getServer().getPluginManager().registerEvents(new TeamListener(teamManager), this); getServer().getPluginManager().registerEvents(new NoPvPListener(noPvPManager), this); getServer().getPluginManager().registerEvents(new NoPvPProtectionListener(noPvPManager), this); getServer().getPluginManager().registerEvents(new CombatLogListener(this, combatLogManager), this); @@ -30,14 +36,25 @@ public final class QoLtweaks extends JavaPlugin { if (noPvPCommand != null) { noPvPCommand.setExecutor(new NoPvPCommand(noPvPManager, combatLogManager)); } + + PluginCommand teamCommand = getCommand("team"); + if (teamCommand != null) { + TeamCommand executor = new TeamCommand(teamManager, noPvPManager); + teamCommand.setExecutor(executor); + teamCommand.setTabCompleter(executor); + } } @Override public void onDisable() { // Plugin shutdown logic if (noPvPManager != null) { + noPvPManager.removeAllVisuals(); noPvPManager.save(); } + if (teamManager != null) { + teamManager.saveNow(); + } if (combatLogManager != null) { combatLogManager.cancelNotices(); } diff --git a/src/main/java/com/dvorakmortonxd/qoltweaks/listeners/JoinListener.java b/src/main/java/com/dvorakmortonxd/qoltweaks/listeners/JoinListener.java index 8b40b2d..8878b05 100644 --- a/src/main/java/com/dvorakmortonxd/qoltweaks/listeners/JoinListener.java +++ b/src/main/java/com/dvorakmortonxd/qoltweaks/listeners/JoinListener.java @@ -21,6 +21,7 @@ public class JoinListener implements Listener { player.sendMessage("§7- tradecycle, which lets you cycle thru villagers trades by pressing [F] in any inventory slot while trading"); player.sendMessage("§7- ClickVillagers, which lets you pick up villagers into your inventory by shift-right-clicking"); player.sendMessage("§7- a /nopvp command to toggle no-pvp mode if you want to build in peace"); + player.sendMessage("§7- cosmetic /team commands to make a team with friends and pick your glow colour"); player.sendMessage("§cAbuse of /nopvp is bannable. Also, combat logging is NOT a good idea."); player.sendMessage("§aThats about it, enjoy your game! :3"); event.setJoinMessage("§e" + player.getName() + " has joined the SMP for the first time!"); diff --git a/src/main/java/com/dvorakmortonxd/qoltweaks/nopvp/NoPvPListener.java b/src/main/java/com/dvorakmortonxd/qoltweaks/nopvp/NoPvPListener.java index 2b96c00..2ff51f6 100644 --- a/src/main/java/com/dvorakmortonxd/qoltweaks/nopvp/NoPvPListener.java +++ b/src/main/java/com/dvorakmortonxd/qoltweaks/nopvp/NoPvPListener.java @@ -16,7 +16,10 @@ import org.bukkit.event.Listener; import org.bukkit.event.entity.EntityDamageByEntityEvent; import org.bukkit.event.entity.EntityDamageEvent; import org.bukkit.event.entity.EntityDamageEvent.DamageCause; +import org.bukkit.event.entity.PlayerDeathEvent; +import org.bukkit.event.player.PlayerChangedWorldEvent; import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.event.player.PlayerRespawnEvent; public final class NoPvPListener implements Listener { @@ -29,12 +32,27 @@ public final class NoPvPListener implements Listener { @EventHandler(priority = EventPriority.MONITOR) public void onPlayerJoin(PlayerJoinEvent event) { - manager.applyGlow(event.getPlayer()); + manager.applyState(event.getPlayer()); } @EventHandler(priority = EventPriority.MONITOR) public void onPlayerRespawn(PlayerRespawnEvent event) { - manager.applyGlow(event.getPlayer()); + manager.applyState(event.getPlayer()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onPlayerChangedWorld(PlayerChangedWorldEvent event) { + manager.applyState(event.getPlayer()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onPlayerQuit(PlayerQuitEvent event) { + manager.clearVisuals(event.getPlayer()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onPlayerDeath(PlayerDeathEvent event) { + manager.clearVisuals(event.getEntity()); } /** diff --git a/src/main/java/com/dvorakmortonxd/qoltweaks/nopvp/NoPvPManager.java b/src/main/java/com/dvorakmortonxd/qoltweaks/nopvp/NoPvPManager.java index 0010c95..c02ff65 100644 --- a/src/main/java/com/dvorakmortonxd/qoltweaks/nopvp/NoPvPManager.java +++ b/src/main/java/com/dvorakmortonxd/qoltweaks/nopvp/NoPvPManager.java @@ -1,5 +1,6 @@ package com.dvorakmortonxd.qoltweaks.nopvp; +import com.dvorakmortonxd.qoltweaks.teams.TeamManager; import org.bukkit.Location; import org.bukkit.World; import org.bukkit.configuration.ConfigurationSection; @@ -21,13 +22,17 @@ public final class NoPvPManager { public static final double PROTECTION_RADIUS = 25.0; private final JavaPlugin plugin; + private final TeamManager teamManager; private final File dataFile; + private final PacifistHologram hologram; private final Map enabled = new ConcurrentHashMap<>(); private final Map cooldownUntil = new ConcurrentHashMap<>(); - public NoPvPManager(JavaPlugin plugin) { + public NoPvPManager(JavaPlugin plugin, TeamManager teamManager) { this.plugin = plugin; + this.teamManager = teamManager; this.dataFile = new File(plugin.getDataFolder(), "nopvp.yml"); + this.hologram = new PacifistHologram(plugin); load(); } @@ -56,7 +61,7 @@ public final class NoPvPManager { UUID uuid = player.getUniqueId(); enabled.put(uuid, true); cooldownUntil.put(uuid, 0L); - player.setGlowing(true); + applyState(player); scheduleSave(); } @@ -64,16 +69,39 @@ public final class NoPvPManager { UUID uuid = player.getUniqueId(); enabled.put(uuid, false); cooldownUntil.put(uuid, System.currentTimeMillis() + COOLDOWN_MILLIS); - player.setGlowing(false); + applyState(player); scheduleSave(); } - public void applyGlow(Player player) { + /** + * Applies the no-PvP visuals for a player: the Pacifist hologram plus, if + * they are in a team with glow enabled, the team-coloured glow. + */ + public void applyState(Player player) { if (isEnabled(player)) { - player.setGlowing(true); + hologram.show(player); + player.setGlowing(teamManager != null && teamManager.shouldGlow(player)); + } else { + hologram.hide(player); + player.setGlowing(false); } } + /** Removes the per-player visuals, e.g. on quit or death. */ + public void clearVisuals(Player player) { + hologram.hide(player); + player.setGlowing(false); + } + + /** Re-applies visuals on the player's own region thread (Folia safe). */ + public void refreshLater(Player player) { + player.getScheduler().run(plugin, task -> applyState(player), null); + } + + public void removeAllVisuals() { + hologram.removeAll(); + } + private void load() { if (!dataFile.exists()) { return; diff --git a/src/main/java/com/dvorakmortonxd/qoltweaks/nopvp/PacifistHologram.java b/src/main/java/com/dvorakmortonxd/qoltweaks/nopvp/PacifistHologram.java new file mode 100644 index 0000000..5b23aab --- /dev/null +++ b/src/main/java/com/dvorakmortonxd/qoltweaks/nopvp/PacifistHologram.java @@ -0,0 +1,128 @@ +package com.dvorakmortonxd.qoltweaks.nopvp; + +import io.papermc.paper.threadedregions.scheduler.ScheduledTask; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.Color; +import org.bukkit.Location; +import org.bukkit.entity.Display; +import org.bukkit.entity.Player; +import org.bukkit.entity.TextDisplay; +import org.bukkit.plugin.java.JavaPlugin; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Shows a floating "Pacifist" label above a no-PvP player. The label is a + * non-persistent {@link TextDisplay} that follows its player via that player's + * Folia entity scheduler, so it stays on the owning region thread. + */ +public final class PacifistHologram { + + private static final String TAG = "qol_pacifist"; + /** Blocks above the player's feet; ~1 block above the head, one line above the name tag. */ + private static final double HEIGHT_OFFSET = 2.8; + + private final JavaPlugin plugin; + private final Map displays = new ConcurrentHashMap<>(); + private final Map tasks = new ConcurrentHashMap<>(); + + public PacifistHologram(JavaPlugin plugin) { + this.plugin = plugin; + } + + public void show(Player player) { + UUID uuid = player.getUniqueId(); + TextDisplay current = displays.get(uuid); + if (current != null && current.isValid() && current.getWorld().equals(player.getWorld())) { + current.teleport(positionOf(player)); + return; + } + hide(player); + spawn(player); + } + + public void hide(Player player) { + UUID uuid = player.getUniqueId(); + ScheduledTask task = tasks.remove(uuid); + if (task != null) { + task.cancel(); + } + removeEntity(displays.remove(uuid)); + } + + public void removeAll() { + for (UUID uuid : displays.keySet().toArray(new UUID[0])) { + ScheduledTask task = tasks.remove(uuid); + if (task != null) { + task.cancel(); + } + removeEntity(displays.remove(uuid)); + } + tasks.clear(); + } + + private void spawn(Player player) { + UUID uuid = player.getUniqueId(); + Location location = positionOf(player); + TextDisplay display = location.getWorld().spawn(location, TextDisplay.class, entity -> { + entity.text(Component.text("Pacifist", NamedTextColor.GOLD)); + entity.setBillboard(Display.Billboard.CENTER); + entity.setSeeThrough(false); + entity.setShadowed(true); + entity.setDefaultBackground(false); + entity.setBackgroundColor(Color.fromARGB(0, 0, 0, 0)); + entity.setTeleportDuration(2); + entity.setPersistent(false); + entity.addScoreboardTag(TAG); + }); + displays.put(uuid, display); + + ScheduledTask task = player.getScheduler().runAtFixedRate(plugin, + scheduled -> tick(player, display, scheduled), null, 1L, 2L); + if (task != null) { + tasks.put(uuid, task); + } + } + + private void tick(Player player, TextDisplay display, ScheduledTask task) { + UUID uuid = player.getUniqueId(); + if (displays.get(uuid) != display) { + task.cancel(); + return; + } + if (!display.isValid() || !player.isOnline()) { + displays.remove(uuid, display); + tasks.remove(uuid); + task.cancel(); + removeEntity(display); + return; + } + if (!display.getWorld().equals(player.getWorld())) { + displays.remove(uuid, display); + tasks.remove(uuid); + task.cancel(); + removeEntity(display); + spawn(player); + return; + } + display.teleport(positionOf(player)); + } + + private void removeEntity(TextDisplay display) { + if (display == null || !display.isValid()) { + return; + } + try { + display.getScheduler().run(plugin, scheduled -> display.remove(), null); + } catch (RuntimeException ignored) { + // Plugin is shutting down / scheduler unavailable; entity is non-persistent. + } + } + + private Location positionOf(Player player) { + return player.getLocation().add(0, HEIGHT_OFFSET, 0); + } +} diff --git a/src/main/java/com/dvorakmortonxd/qoltweaks/teams/Team.java b/src/main/java/com/dvorakmortonxd/qoltweaks/teams/Team.java new file mode 100644 index 0000000..e17cfbf --- /dev/null +++ b/src/main/java/com/dvorakmortonxd/qoltweaks/teams/Team.java @@ -0,0 +1,114 @@ +package com.dvorakmortonxd.qoltweaks.teams; + +import net.kyori.adventure.text.format.NamedTextColor; + +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +/** + * A purely cosmetic player team. Membership is tracked by UUID, the colour is + * applied to every member's no-PvP glow, and the glow can be switched off for + * the whole team. + */ +public final class Team { + + private final UUID id; + private String name; + private UUID owner; + private final Set members = new LinkedHashSet<>(); + private final Set admins = new LinkedHashSet<>(); + private NamedTextColor color; + private boolean glowEnabled = true; + + public Team(UUID id, String name, UUID owner) { + this.id = id; + this.name = name; + this.owner = owner; + this.members.add(owner); + } + + public UUID getId() { + return id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public UUID getOwner() { + return owner; + } + + public void setOwner(UUID owner) { + this.owner = owner; + } + + public Set getMembers() { + return members; + } + + public Set getAdmins() { + return admins; + } + + public boolean isMember(UUID uuid) { + return members.contains(uuid); + } + + public boolean isOwner(UUID uuid) { + return owner != null && owner.equals(uuid); + } + + public boolean isAdmin(UUID uuid) { + return isOwner(uuid) || admins.contains(uuid); + } + + public void addAdmin(UUID uuid) { + admins.add(uuid); + } + + public void removeAdmin(UUID uuid) { + admins.remove(uuid); + } + + public NamedTextColor getColor() { + return color; + } + + public void setColor(NamedTextColor color) { + this.color = color; + } + + public boolean isGlowEnabled() { + return glowEnabled; + } + + public void setGlowEnabled(boolean glowEnabled) { + this.glowEnabled = glowEnabled; + } + + public void addMember(UUID uuid) { + members.add(uuid); + } + + public void removeMember(UUID uuid) { + members.remove(uuid); + admins.remove(uuid); + } + + @Override + public boolean equals(Object other) { + return other instanceof Team team && id.equals(team.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } +} diff --git a/src/main/java/com/dvorakmortonxd/qoltweaks/teams/TeamCommand.java b/src/main/java/com/dvorakmortonxd/qoltweaks/teams/TeamCommand.java new file mode 100644 index 0000000..bb3763d --- /dev/null +++ b/src/main/java/com/dvorakmortonxd/qoltweaks/teams/TeamCommand.java @@ -0,0 +1,442 @@ +package com.dvorakmortonxd.qoltweaks.teams; + +import com.dvorakmortonxd.qoltweaks.nopvp.NoPvPManager; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.Bukkit; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; +import org.bukkit.entity.Player; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.UUID; + +public final class TeamCommand implements CommandExecutor, TabCompleter { + + private static final List SUBCOMMANDS = List.of( + "create", "delete", "invite", "leave", "join", "color", "admin", "glow", "chat"); + + private final TeamManager manager; + private final NoPvPManager noPvPManager; + + public TeamCommand(TeamManager manager, NoPvPManager noPvPManager) { + this.manager = manager; + this.noPvPManager = noPvPManager; + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (!(sender instanceof Player player)) { + sender.sendMessage("§cOnly players can use this command."); + return true; + } + if (!player.hasPermission("qoltweaks.team")) { + player.sendMessage("§cYou don't have permission to use this command."); + return true; + } + if (args.length == 0) { + sendUsage(player); + return true; + } + + switch (args[0].toLowerCase(Locale.ROOT)) { + case "create" -> create(player, args); + case "delete" -> delete(player); + case "invite" -> invite(player, args); + case "leave" -> leave(player); + case "join" -> join(player, args); + case "color" -> color(player, args); + case "admin" -> admin(player, args); + case "glow" -> glow(player); + case "chat" -> chat(player, args); + default -> sendUsage(player); + } + return true; + } + + private void create(Player player, String[] args) { + if (args.length < 2) { + player.sendMessage("§cUsage: /team create "); + return; + } + if (manager.teamOf(player) != null) { + player.sendMessage("§cYou're already in a team. Leave or delete it first."); + return; + } + String name = args[1]; + if (!TeamManager.isValidName(name)) { + player.sendMessage("§cTeam names must be 2-16 letters, numbers or underscores."); + return; + } + if (manager.nameTaken(name)) { + player.sendMessage("§cA team called §e" + name + "§c already exists."); + return; + } + manager.create(player, name); + player.sendMessage("§aTeam §e" + name + "§a created! Invite players with §e/team invite §a."); + } + + private void delete(Player player) { + Team team = manager.teamOf(player); + if (team == null) { + player.sendMessage("§cYou're not in a team."); + return; + } + if (!team.isOwner(player.getUniqueId())) { + player.sendMessage("§cOnly the team owner can delete the team."); + return; + } + List members = new ArrayList<>(team.getMembers()); + String name = team.getName(); + manager.delete(team); + player.sendMessage("§cTeam §e" + name + "§c has been deleted."); + for (UUID member : members) { + Player online = Bukkit.getPlayer(member); + if (online != null) { + noPvPManager.refreshLater(online); + if (!online.equals(player)) { + online.sendMessage("§cYour team §e" + name + "§c was deleted by the owner."); + } + } + } + } + + private void invite(Player player, String[] args) { + if (args.length < 2) { + player.sendMessage("§cUsage: /team invite "); + return; + } + Team team = manager.teamOf(player); + if (team == null) { + player.sendMessage("§cYou're not in a team."); + return; + } + if (!team.isAdmin(player.getUniqueId())) { + player.sendMessage("§cOnly the team owner or an admin can invite players."); + return; + } + Player target = Bukkit.getPlayerExact(args[1]); + if (target == null) { + player.sendMessage("§cThat player is not online."); + return; + } + if (target.getUniqueId().equals(player.getUniqueId())) { + player.sendMessage("§cYou can't invite yourself."); + return; + } + if (manager.teamOf(target) != null) { + player.sendMessage("§c" + target.getName() + " is already in a team."); + return; + } + + if (manager.hasJoinRequest(team, target.getUniqueId())) { + manager.addMember(team, target); + noPvPManager.refreshLater(target); + player.sendMessage("§a" + target.getName() + " joined the team!"); + target.sendMessage("§aYou joined team §e" + team.getName() + "§a!"); + return; + } + + manager.addInvite(team, target.getUniqueId()); + player.sendMessage("§aInvited §e" + target.getName() + "§a to the team."); + target.sendMessage("§e" + player.getName() + "§a invited you to team §e" + team.getName() + + "§a. Use §e/team join " + team.getName() + "§a to accept."); + } + + private void leave(Player player) { + Team team = manager.teamOf(player); + if (team == null) { + player.sendMessage("§cYou're not in a team."); + return; + } + if (team.isOwner(player.getUniqueId())) { + player.sendMessage("§cYou own this team. Use §e/team delete§c to disband it."); + return; + } + List members = new ArrayList<>(team.getMembers()); + String name = team.getName(); + manager.removeMember(team, player); + noPvPManager.refreshLater(player); + player.sendMessage("§aYou left team §e" + name + "§a."); + for (UUID member : members) { + Player online = Bukkit.getPlayer(member); + if (online != null && !online.equals(player)) { + online.sendMessage("§e" + player.getName() + "§c left team §e" + name + "§c."); + } + } + } + + private void join(Player player, String[] args) { + if (args.length < 2) { + player.sendMessage("§cUsage: /team join "); + return; + } + if (manager.teamOf(player) != null) { + player.sendMessage("§cYou're already in a team. Leave it first."); + return; + } + Team team = manager.teamByName(args[1]); + if (team == null) { + player.sendMessage("§cNo team called §e" + args[1] + "§c exists."); + return; + } + + if (manager.hasInvite(team, player.getUniqueId())) { + manager.addMember(team, player); + noPvPManager.refreshLater(player); + player.sendMessage("§aYou joined team §e" + team.getName() + "§a!"); + notifyMembers(team, player, "§e" + player.getName() + "§a joined the team!"); + return; + } + + manager.addJoinRequest(team, player.getUniqueId()); + player.sendMessage("§aJoin request sent to team §e" + team.getName() + "§a."); + notifyAdmins(team, "§e" + player.getName() + "§a wants to join your team. Use §e/team invite " + + player.getName() + "§a to accept."); + } + + private void color(Player player, String[] args) { + if (args.length < 2) { + player.sendMessage("§cUsage: /team color "); + player.sendMessage("§7Colors: " + colorList()); + return; + } + Team team = manager.teamOf(player); + if (team == null) { + player.sendMessage("§cYou're not in a team."); + return; + } + if (!team.isAdmin(player.getUniqueId())) { + player.sendMessage("§cOnly the team owner or an admin can change the team colour."); + return; + } + + String input = args[1].toLowerCase(Locale.ROOT); + if (input.equals("none") || input.equals("reset") || input.equals("clear")) { + manager.setColor(team, null); + player.sendMessage("§aTeam colour cleared."); + return; + } + + NamedTextColor color = TeamManager.parseColor(input); + if (color == null) { + player.sendMessage("§cUnknown colour. Options: " + colorList()); + return; + } + manager.setColor(team, color); + player.sendMessage("§aTeam colour set to §e" + color); + } + + private void admin(Player player, String[] args) { + if (args.length < 2) { + player.sendMessage("§cUsage: /team admin "); + return; + } + Team team = manager.teamOf(player); + if (team == null) { + player.sendMessage("§cYou're not in a team."); + return; + } + if (!team.isOwner(player.getUniqueId())) { + player.sendMessage("§cOnly the team owner can manage admins."); + return; + } + String action = args[1].toLowerCase(Locale.ROOT); + if (!action.equals("add") && !action.equals("remove")) { + player.sendMessage("§cUsage: /team admin "); + return; + } + if (args.length < 3) { + player.sendMessage("§cUsage: /team admin " + action + " "); + return; + } + Player target = Bukkit.getPlayerExact(args[2]); + if (target == null) { + player.sendMessage("§cThat player is not online."); + return; + } + if (!team.isMember(target.getUniqueId())) { + player.sendMessage("§c" + target.getName() + " is not in your team."); + return; + } + if (team.isOwner(target.getUniqueId())) { + player.sendMessage("§cThe owner is already an admin."); + return; + } + + if (action.equals("add")) { + manager.addAdmin(team, target.getUniqueId()); + player.sendMessage("§a" + target.getName() + " is now a team admin."); + target.sendMessage("§aYou are now an admin of team §e" + team.getName() + "§a."); + } else { + manager.removeAdmin(team, target.getUniqueId()); + player.sendMessage("§a" + target.getName() + " is no longer a team admin."); + target.sendMessage("§cYou are no longer an admin of team §e" + team.getName() + "§c."); + } + } + + private void glow(Player player) { + Team team = manager.teamOf(player); + if (team == null) { + player.sendMessage("§cYou're not in a team."); + return; + } + if (!team.isAdmin(player.getUniqueId())) { + player.sendMessage("§cOnly the team owner or an admin can toggle the team glow."); + return; + } + boolean enabled = manager.toggleGlow(team); + for (UUID member : new ArrayList<>(team.getMembers())) { + Player online = Bukkit.getPlayer(member); + if (online != null) { + noPvPManager.refreshLater(online); + online.sendMessage(enabled + ? "§aYour team's no-PvP glow was turned on." + : "§cYour team's no-PvP glow was turned off."); + } + } + } + + private void chat(Player player, String[] args) { + Team team = manager.teamOf(player); + if (team == null) { + player.sendMessage("§cYou're not in a team."); + return; + } + if (args.length == 1) { + boolean toggled = manager.toggleTeamChat(player.getUniqueId()); + player.sendMessage(toggled + ? "§aTeam chat §lON§a. Your messages now go to the team only." + : "§cTeam chat §lOFF§c."); + return; + } + + String text = String.join(" ", java.util.Arrays.copyOfRange(args, 1, args.length)); + NamedTextColor color = team.getColor() != null ? team.getColor() : NamedTextColor.AQUA; + Component message = Component.text("[", NamedTextColor.DARK_GRAY) + .append(Component.text(team.getName(), color)) + .append(Component.text("] ", NamedTextColor.DARK_GRAY)) + .append(Component.text(player.getName() + ": ", NamedTextColor.WHITE)) + .append(Component.text(text, NamedTextColor.WHITE)); + for (UUID member : new ArrayList<>(team.getMembers())) { + Player online = Bukkit.getPlayer(member); + if (online != null) { + online.sendMessage(message); + } + } + } + + private void notifyMembers(Team team, Player except, String message) { + for (UUID member : new ArrayList<>(team.getMembers())) { + Player online = Bukkit.getPlayer(member); + if (online != null && !online.equals(except)) { + online.sendMessage(message); + } + } + } + + private void notifyAdmins(Team team, String message) { + for (UUID admin : new ArrayList<>(team.getAdmins())) { + Player online = Bukkit.getPlayer(admin); + if (online != null) { + online.sendMessage(message); + } + } + Player owner = Bukkit.getPlayer(team.getOwner()); + if (owner != null && !team.getAdmins().contains(team.getOwner())) { + owner.sendMessage(message); + } + } + + private void sendUsage(Player player) { + player.sendMessage("§6§lTeam commands"); + player.sendMessage("§e/team create §7- make a team"); + player.sendMessage("§e/team invite §7- invite someone"); + player.sendMessage("§e/team join §7- request or accept an invite"); + player.sendMessage("§e/team color §7- set the team's glow colour"); + player.sendMessage("§e/team admin §7- manage admins"); + player.sendMessage("§e/team glow §7- toggle the team glow"); + player.sendMessage("§e/team chat [message] §7- team chat / toggle it"); + player.sendMessage("§e/team leave §7- leave your team"); + player.sendMessage("§e/team delete §7- disband your team (owner)"); + } + + private String colorList() { + List names = new ArrayList<>(); + for (NamedTextColor color : TeamManager.colors()) { + names.add(color.toString()); + } + return String.join("§7, §e", names); + } + + @Override + public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { + if (!(sender instanceof Player player)) { + return List.of(); + } + if (args.length == 1) { + return filter(SUBCOMMANDS, args[0]); + } + if (args.length == 2) { + switch (args[0].toLowerCase(Locale.ROOT)) { + case "invite" -> { + return filter(onlineNames(online -> !online.equals(player) && manager.teamOf(online) == null), args[1]); + } + case "join" -> { + List names = new ArrayList<>(); + for (Team team : manager.teams()) { + names.add(team.getName()); + } + return filter(names, args[1]); + } + case "color" -> { + List names = new ArrayList<>(); + for (NamedTextColor color : TeamManager.colors()) { + names.add(color.toString()); + } + names.add("none"); + return filter(names, args[1]); + } + case "admin" -> { + return filter(List.of("add", "remove"), args[1]); + } + default -> { + return List.of(); + } + } + } + if (args.length == 3 && args[0].equalsIgnoreCase("admin")) { + Team team = manager.teamOf(player); + if (team == null) { + return List.of(); + } + return filter(onlineNames(online -> team.isMember(online.getUniqueId())), args[2]); + } + return List.of(); + } + + private List onlineNames(java.util.function.Predicate predicate) { + List names = new ArrayList<>(); + for (Player online : Bukkit.getOnlinePlayers()) { + if (predicate.test(online)) { + names.add(online.getName()); + } + } + return names; + } + + private List filter(List options, String prefix) { + String lower = prefix.toLowerCase(Locale.ROOT); + List result = new ArrayList<>(); + for (String option : options) { + if (option.toLowerCase(Locale.ROOT).startsWith(lower)) { + result.add(option); + } + } + return result; + } +} diff --git a/src/main/java/com/dvorakmortonxd/qoltweaks/teams/TeamListener.java b/src/main/java/com/dvorakmortonxd/qoltweaks/teams/TeamListener.java new file mode 100644 index 0000000..5a016d4 --- /dev/null +++ b/src/main/java/com/dvorakmortonxd/qoltweaks/teams/TeamListener.java @@ -0,0 +1,75 @@ +package com.dvorakmortonxd.qoltweaks.teams; + +import io.papermc.paper.event.player.AsyncChatEvent; +import net.kyori.adventure.audience.Audience; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; + +import java.util.List; +import java.util.Set; +import java.util.UUID; + +public final class TeamListener implements Listener { + + private final TeamManager manager; + + public TeamListener(TeamManager manager) { + this.manager = manager; + } + + /** + * Runs at NORMAL so the scoreboard team (and thus glow colour) is applied + * before {@code NoPvPListener} turns the glow on at MONITOR. + */ + @EventHandler(priority = EventPriority.NORMAL) + public void onPlayerJoin(PlayerJoinEvent event) { + Player player = event.getPlayer(); + manager.syncPlayer(player); + + Team team = manager.teamOf(player); + if (team != null && team.isAdmin(player.getUniqueId())) { + List pending = manager.pendingRequestNames(team); + if (!pending.isEmpty()) { + player.sendMessage("§ePlayers wanting to join your team: §a" + String.join("§7, §a", pending) + + "§e. Use §a/team invite §e to accept."); + } + } + } + + /** Redirects chat into the team channel when a player has team chat toggled on. */ + @EventHandler(priority = EventPriority.LOWEST) + public void onChat(AsyncChatEvent event) { + Player player = event.getPlayer(); + if (!manager.isTeamChat(player.getUniqueId())) { + return; + } + Team team = manager.teamOf(player); + if (team == null) { + manager.clearTeamChat(player.getUniqueId()); + return; + } + + Set viewers = event.viewers(); + viewers.clear(); + for (UUID member : team.getMembers()) { + Player online = Bukkit.getPlayer(member); + if (online != null) { + viewers.add(online); + } + } + + NamedTextColor color = team.getColor() != null ? team.getColor() : NamedTextColor.AQUA; + event.renderer((source, sourceDisplayName, message, viewer) -> + Component.text("[", NamedTextColor.DARK_GRAY) + .append(Component.text(team.getName(), color)) + .append(Component.text("] ", NamedTextColor.DARK_GRAY)) + .append(Component.text(source.getName() + ": ", NamedTextColor.WHITE)) + .append(message.colorIfAbsent(NamedTextColor.WHITE))); + } +} diff --git a/src/main/java/com/dvorakmortonxd/qoltweaks/teams/TeamManager.java b/src/main/java/com/dvorakmortonxd/qoltweaks/teams/TeamManager.java new file mode 100644 index 0000000..adba3b5 --- /dev/null +++ b/src/main/java/com/dvorakmortonxd/qoltweaks/teams/TeamManager.java @@ -0,0 +1,418 @@ +package com.dvorakmortonxd.qoltweaks.teams; + +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.Bukkit; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scoreboard.Scoreboard; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Stores cosmetic teams, their members and colours, and mirrors them onto the + * main scoreboard so the no-PvP glow picks up the team colour. + */ +public final class TeamManager { + + private static final List COLORS = List.of( + NamedTextColor.BLACK, NamedTextColor.DARK_BLUE, NamedTextColor.DARK_GREEN, + NamedTextColor.DARK_AQUA, NamedTextColor.DARK_RED, NamedTextColor.DARK_PURPLE, + NamedTextColor.GOLD, NamedTextColor.GRAY, NamedTextColor.DARK_GRAY, + NamedTextColor.BLUE, NamedTextColor.GREEN, NamedTextColor.AQUA, + NamedTextColor.RED, NamedTextColor.LIGHT_PURPLE, NamedTextColor.YELLOW, + NamedTextColor.WHITE); + + private final JavaPlugin plugin; + private final File dataFile; + private final Scoreboard scoreboard; + + private final Map teams = new LinkedHashMap<>(); // team id -> team + private final Map teamByMember = new LinkedHashMap<>(); // member -> team id + private final Map teamByName = new LinkedHashMap<>(); // lowercase name -> team id + private final Map joinRequests = new ConcurrentHashMap<>(); // player -> team id + private final Map> invites = new ConcurrentHashMap<>(); // team id -> invited players + private final Set teamChat = ConcurrentHashMap.newKeySet(); // players with team chat toggled on + + public TeamManager(JavaPlugin plugin) { + this.plugin = plugin; + this.dataFile = new File(plugin.getDataFolder(), "teams.yml"); + this.scoreboard = Bukkit.getScoreboardManager().getMainScoreboard(); + synchronized (this) { + load(); + } + refreshAllScoreboards(); + } + + public static boolean isValidName(String name) { + return name != null && name.matches("[A-Za-z0-9_]{2,16}"); + } + + public static List colors() { + return COLORS; + } + + public static NamedTextColor parseColor(String input) { + if (input == null) { + return null; + } + String normalized = input.toLowerCase(Locale.ROOT).replace(" ", "").replace("-", ""); + for (NamedTextColor color : COLORS) { + String name = color.toString(); + if (name.equals(normalized) || name.replace("_", "").equals(normalized)) { + return color; + } + } + return null; + } + + public synchronized Team teamOf(Player player) { + UUID teamId = teamByMember.get(player.getUniqueId()); + return teamId == null ? null : teams.get(teamId); + } + + public synchronized Team teamByName(String name) { + UUID teamId = teamByName.get(name.toLowerCase(Locale.ROOT)); + return teamId == null ? null : teams.get(teamId); + } + + public synchronized boolean nameTaken(String name) { + return teamByName.containsKey(name.toLowerCase(Locale.ROOT)); + } + + public synchronized Collection teams() { + return new ArrayList<>(teams.values()); + } + + public Team create(Player owner, String name) { + Team team; + synchronized (this) { + team = new Team(UUID.randomUUID(), name, owner.getUniqueId()); + index(team); + scheduleSave(); + } + refreshScoreboard(team.getId()); + return team; + } + + public void delete(Team team) { + synchronized (this) { + teams.remove(team.getId()); + teamByName.remove(team.getName().toLowerCase(Locale.ROOT)); + for (UUID member : team.getMembers()) { + teamByMember.remove(member); + joinRequests.remove(member); + teamChat.remove(member); + } + invites.remove(team.getId()); + joinRequests.entrySet().removeIf(entry -> entry.getValue().equals(team.getId())); + scheduleSave(); + } + plugin.getServer().getGlobalRegionScheduler().run(plugin, task -> { + org.bukkit.scoreboard.Team board = scoreboard.getTeam(scoreboardTeamName(team.getId())); + if (board != null) { + board.unregister(); + } + }); + } + + public void addMember(Team team, Player player) { + synchronized (this) { + if (teams.get(team.getId()) == null) { + return; + } + team.addMember(player.getUniqueId()); + teamByMember.put(player.getUniqueId(), team.getId()); + joinRequests.remove(player.getUniqueId()); + invites.values().forEach(pending -> pending.remove(player.getUniqueId())); + scheduleSave(); + } + refreshScoreboard(team.getId()); + } + + public void removeMember(Team team, Player player) { + synchronized (this) { + team.removeMember(player.getUniqueId()); + teamByMember.remove(player.getUniqueId()); + teamChat.remove(player.getUniqueId()); + scheduleSave(); + } + refreshScoreboard(team.getId()); + } + + public void setColor(Team team, NamedTextColor color) { + synchronized (this) { + team.setColor(color); + scheduleSave(); + } + refreshScoreboard(team.getId()); + } + + public void addAdmin(Team team, UUID player) { + synchronized (this) { + team.addAdmin(player); + scheduleSave(); + } + } + + public void removeAdmin(Team team, UUID player) { + synchronized (this) { + team.removeAdmin(player); + scheduleSave(); + } + } + + public boolean toggleGlow(Team team) { + synchronized (this) { + boolean enabled = !team.isGlowEnabled(); + team.setGlowEnabled(enabled); + scheduleSave(); + return enabled; + } + } + + public synchronized boolean shouldGlow(Player player) { + UUID teamId = teamByMember.get(player.getUniqueId()); + if (teamId == null) { + return false; + } + Team team = teams.get(teamId); + return team != null && team.isGlowEnabled(); + } + + public boolean isTeamChat(UUID player) { + return teamChat.contains(player); + } + + /** Toggles team chat mode and returns the new state. */ + public boolean toggleTeamChat(UUID player) { + if (teamChat.remove(player)) { + return false; + } + teamChat.add(player); + return true; + } + + public void clearTeamChat(UUID player) { + teamChat.remove(player); + } + + public void addInvite(Team team, UUID player) { + invites.computeIfAbsent(team.getId(), id -> ConcurrentHashMap.newKeySet()).add(player); + } + + public boolean hasInvite(Team team, UUID player) { + Set pending = invites.get(team.getId()); + return pending != null && pending.contains(player); + } + + public void addJoinRequest(Team team, UUID player) { + joinRequests.put(player, team.getId()); + } + + public boolean hasJoinRequest(Team team, UUID player) { + return team.getId().equals(joinRequests.get(player)); + } + + public List pendingRequestNames(Team team) { + List names = new ArrayList<>(); + for (Map.Entry entry : joinRequests.entrySet()) { + if (entry.getValue().equals(team.getId())) { + Player online = Bukkit.getPlayer(entry.getKey()); + if (online != null) { + names.add(online.getName()); + } + } + } + return names; + } + + /** Adds a joining player's name to their team's scoreboard team (also picks up renames). */ + public void syncPlayer(Player player) { + UUID teamId; + synchronized (this) { + teamId = teamByMember.get(player.getUniqueId()); + } + if (teamId != null) { + refreshScoreboard(teamId); + } + } + + public void refreshAllScoreboards() { + List ids; + synchronized (this) { + ids = new ArrayList<>(teams.keySet()); + } + plugin.getServer().getGlobalRegionScheduler().run(plugin, task -> { + for (UUID id : ids) { + refreshScoreboardNow(id); + } + }); + } + + private void index(Team team) { + teams.put(team.getId(), team); + teamByName.put(team.getName().toLowerCase(Locale.ROOT), team.getId()); + for (UUID member : team.getMembers()) { + teamByMember.put(member, team.getId()); + } + } + + private void refreshScoreboard(UUID teamId) { + plugin.getServer().getGlobalRegionScheduler().run(plugin, task -> refreshScoreboardNow(teamId)); + } + + private void refreshScoreboardNow(UUID teamId) { + NamedTextColor color; + List names = new ArrayList<>(); + synchronized (this) { + Team team = teams.get(teamId); + if (team == null) { + return; + } + color = team.getColor(); + for (UUID member : team.getMembers()) { + Player online = Bukkit.getPlayer(member); + if (online != null) { + names.add(online.getName()); + } + } + } + + String boardName = scoreboardTeamName(teamId); + org.bukkit.scoreboard.Team board = scoreboard.getTeam(boardName); + if (board == null) { + board = scoreboard.registerNewTeam(boardName); + } + board.setAllowFriendlyFire(true); + board.setCanSeeFriendlyInvisibles(true); + board.color(color); + + Set wanted = Set.copyOf(names); + for (String entry : new ArrayList<>(board.getEntries())) { + if (!wanted.contains(entry)) { + board.removeEntry(entry); + } + } + for (String name : names) { + if (!board.hasEntry(name)) { + board.addEntry(name); + } + } + } + + private String scoreboardTeamName(UUID teamId) { + return "qt_" + teamId.toString().replace("-", "").substring(0, 13); + } + + private void load() { + if (!dataFile.exists()) { + return; + } + YamlConfiguration config = YamlConfiguration.loadConfiguration(dataFile); + ConfigurationSection root = config.getConfigurationSection("teams"); + if (root == null) { + return; + } + for (String key : root.getKeys(false)) { + ConfigurationSection section = root.getConfigurationSection(key); + if (section == null) { + continue; + } + UUID id = parseUuid(key); + UUID owner = parseUuid(section.getString("owner")); + String name = section.getString("name"); + if (id == null || owner == null || name == null) { + continue; + } + Team team = new Team(id, name, owner); + for (String member : section.getStringList("members")) { + UUID uuid = parseUuid(member); + if (uuid != null) { + team.addMember(uuid); + } + } + team.addMember(owner); + for (String admin : section.getStringList("admins")) { + UUID uuid = parseUuid(admin); + if (uuid != null) { + team.addAdmin(uuid); + } + } + team.setGlowEnabled(section.getBoolean("glow", true)); + NamedTextColor color = parseColor(section.getString("color")); + if (color != null) { + team.setColor(color); + } + index(team); + } + } + + private void scheduleSave() { + YamlConfiguration config = buildConfig(); + plugin.getServer().getGlobalRegionScheduler().run(plugin, task -> writeConfig(config)); + } + + public void saveNow() { + writeConfig(buildConfig()); + } + + private YamlConfiguration buildConfig() { + YamlConfiguration config = new YamlConfiguration(); + ConfigurationSection root = config.createSection("teams"); + for (Team team : teams.values()) { + String path = team.getId().toString(); + root.set(path + ".name", team.getName()); + root.set(path + ".owner", team.getOwner().toString()); + if (team.getColor() != null) { + root.set(path + ".color", team.getColor().toString()); + } + List members = new ArrayList<>(); + for (UUID member : team.getMembers()) { + members.add(member.toString()); + } + root.set(path + ".members", members); + List admins = new ArrayList<>(); + for (UUID admin : team.getAdmins()) { + admins.add(admin.toString()); + } + root.set(path + ".admins", admins); + root.set(path + ".glow", team.isGlowEnabled()); + } + return config; + } + + private void writeConfig(YamlConfiguration config) { + if (!dataFile.getParentFile().exists() && !dataFile.getParentFile().mkdirs()) { + plugin.getLogger().severe("Could not create data folder for teams.yml"); + return; + } + try { + config.save(dataFile); + } catch (IOException e) { + plugin.getLogger().severe("Could not save teams.yml: " + e.getMessage()); + } + } + + private UUID parseUuid(String value) { + if (value == null) { + return null; + } + try { + return UUID.fromString(value); + } catch (IllegalArgumentException e) { + return null; + } + } +} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 5dd6c2f..b6c31a5 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -14,8 +14,14 @@ commands: nopvp: description: Toggle no-PvP mode so other players cannot damage you and you cannot damage them. usage: /nopvp + team: + description: Create and manage a cosmetic team whose colour is used for the no-PvP glow. + usage: /team permissions: qoltweaks.nopvp: description: Allows use of /nopvp default: true + qoltweaks.team: + description: Allows use of /team + default: true