diff --git a/README.md b/README.md index 7609cd4..a1b9ebd 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,10 @@ 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 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. +- **`/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. Team members glow in their team's colour whenever the team glow is on (independent of no-PvP). 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. Flint & steel is restricted by proximity too: a protected player can't ignite anything within 25 blocks of another player, and a non-protected player can't ignite anything within 25 blocks of a protected player (lighting portals while alone still works). No-PvP also can't be switched on while you're in combat. Turning no-PvP *off* starts a **5-minute 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`. +- **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 is applied as the glow colour of all its members whenever their personal glow is on — each member can toggle their own with `/team glow` (on by default and remembered across restarts, independent of no-PvP). Members show a `[Team]` tag next to their name in the tab list and above their head, and in normal chat, all coloured with the team's colour; `/team chat` gives the team a private chat channel. Stored in `plugins/qol-tweaks/teams.yml`. +- **Woodcutting** — wood works in the stonecutter. A log/wood block (including stripped and stem/hyphae variants) cuts into **4 planks**; planks cut into stairs, slabs, fences, fence gates, buttons, signs and hanging signs; and — because a stonecutter can only ever consume one item — the pricier blocks are cut from a whole log instead: 1 log → 2 doors or 2 pressure plates (2 planks each), 1 trapdoor (~4 planks) and 1 boat (bamboo gives a raft; crimson/warped have none). The cutter also strips bark one-for-one: log/wood/stem/hyphae (and bamboo block) → its stripped form. Covers every wood family. No resource pack needed. ## Commands @@ -19,7 +20,7 @@ A quality-of-life SMP plugin for Paper/Folia. | `/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 glow` | Toggle your **own** no-PvP glow (default on, remembered). | `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` | @@ -28,7 +29,7 @@ All permissions default to `true`. Note `/team` takes over the vanilla scoreboar ## Data -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`. +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 each player's glow preference 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 70314d3..9999bb8 100644 --- a/src/main/java/com/dvorakmortonxd/qoltweaks/QoLtweaks.java +++ b/src/main/java/com/dvorakmortonxd/qoltweaks/QoLtweaks.java @@ -10,6 +10,7 @@ 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 com.dvorakmortonxd.qoltweaks.woodcutter.WoodcutterRecipes; import org.bukkit.command.PluginCommand; import org.bukkit.plugin.java.JavaPlugin; @@ -32,6 +33,8 @@ public final class QoLtweaks extends JavaPlugin { getServer().getPluginManager().registerEvents(new NoPvPProtectionListener(noPvPManager), this); getServer().getPluginManager().registerEvents(new CombatLogListener(this, combatLogManager), this); + WoodcutterRecipes.register(this); + PluginCommand noPvPCommand = getCommand("nopvp"); if (noPvPCommand != null) { noPvPCommand.setExecutor(new NoPvPCommand(noPvPManager, combatLogManager)); @@ -43,6 +46,8 @@ public final class QoLtweaks extends JavaPlugin { teamCommand.setExecutor(executor); teamCommand.setTabCompleter(executor); } + + noPvPManager.applyToAllOnline(); } @Override diff --git a/src/main/java/com/dvorakmortonxd/qoltweaks/nopvp/NoPvPCommand.java b/src/main/java/com/dvorakmortonxd/qoltweaks/nopvp/NoPvPCommand.java index 5e30b9b..f47abfc 100644 --- a/src/main/java/com/dvorakmortonxd/qoltweaks/nopvp/NoPvPCommand.java +++ b/src/main/java/com/dvorakmortonxd/qoltweaks/nopvp/NoPvPCommand.java @@ -30,7 +30,7 @@ public final class NoPvPCommand implements CommandExecutor { if (manager.isEnabled(player)) { manager.disable(player); player.sendMessage("§cYou turned off no-PvP. You can now be attacked by other players again."); - player.sendMessage("§cYou must wait 1 hour before you can turn no-PvP back on."); + player.sendMessage("§cYou must wait 5 minutes before you can turn no-PvP back on."); return true; } diff --git a/src/main/java/com/dvorakmortonxd/qoltweaks/nopvp/NoPvPManager.java b/src/main/java/com/dvorakmortonxd/qoltweaks/nopvp/NoPvPManager.java index c02ff65..439a22e 100644 --- a/src/main/java/com/dvorakmortonxd/qoltweaks/nopvp/NoPvPManager.java +++ b/src/main/java/com/dvorakmortonxd/qoltweaks/nopvp/NoPvPManager.java @@ -16,7 +16,7 @@ import java.util.concurrent.ConcurrentHashMap; public final class NoPvPManager { - public static final long COOLDOWN_MILLIS = 60 * 60 * 1000L; // 1 hour + public static final long COOLDOWN_MILLIS = 5 * 60 * 1000L; // 5 minutes /** How close a no-PvP player has to be before PvP gear can't be placed or detonated. */ public static final double PROTECTION_RADIUS = 25.0; @@ -52,6 +52,19 @@ public final class NoPvPManager { return !world.getNearbyPlayers(location, radius, this::isEnabled).isEmpty(); } + /** + * Whether any player other than {@code exclude} is within {@code radius} of + * the location. + */ + public boolean isPlayerWithin(Location location, double radius, UUID exclude) { + World world = location.getWorld(); + if (world == null) { + return false; + } + return !world.getNearbyPlayers(location, radius, + player -> !player.getUniqueId().equals(exclude)).isEmpty(); + } + public long getRemainingCooldownMillis(Player player) { long until = cooldownUntil.getOrDefault(player.getUniqueId(), 0L); return Math.max(0L, until - System.currentTimeMillis()); @@ -74,16 +87,26 @@ public final class NoPvPManager { } /** - * 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. + * Applies the no-PvP visuals for a player: the Pacifist hologram while + * no-PvP is on, plus the team-coloured glow whenever they are in a team and + * haven't turned their own glow off (independent of no-PvP). */ public void applyState(Player player) { if (isEnabled(player)) { hologram.show(player); - player.setGlowing(teamManager != null && teamManager.shouldGlow(player)); } else { hologram.hide(player); - player.setGlowing(false); + } + player.setGlowing(teamManager != null && teamManager.shouldGlow(player)); + } + + /** + * Re-applies visuals to every online player on their own region thread. + * Used on startup/reload when there is no join event. + */ + public void applyToAllOnline() { + for (Player player : plugin.getServer().getOnlinePlayers()) { + refreshLater(player); } } diff --git a/src/main/java/com/dvorakmortonxd/qoltweaks/nopvp/NoPvPProtectionListener.java b/src/main/java/com/dvorakmortonxd/qoltweaks/nopvp/NoPvPProtectionListener.java index f8f4bb9..258bc2f 100644 --- a/src/main/java/com/dvorakmortonxd/qoltweaks/nopvp/NoPvPProtectionListener.java +++ b/src/main/java/com/dvorakmortonxd/qoltweaks/nopvp/NoPvPProtectionListener.java @@ -20,6 +20,7 @@ import org.bukkit.event.block.Action; import org.bukkit.event.block.BlockBreakEvent; import org.bukkit.event.block.BlockDispenseEvent; import org.bukkit.event.block.BlockExplodeEvent; +import org.bukkit.event.block.BlockIgniteEvent; import org.bukkit.event.block.BlockPlaceEvent; import org.bukkit.event.block.TNTPrimeEvent; import org.bukkit.event.entity.EntityDamageByEntityEvent; @@ -154,6 +155,29 @@ public final class NoPvPProtectionListener implements Listener { } } + /** + * Flint & steel: a no-PvP player can't ignite anything within 25 blocks of + * another player, and a non-protected player can't ignite anything within 25 + * blocks of a protected player. Lighting portals etc. while alone still works. + */ + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onBlockIgnite(BlockIgniteEvent event) { + Player player = event.getPlayer(); + if (player == null || event.getCause() != BlockIgniteEvent.IgniteCause.FLINT_AND_STEEL) { + return; + } + Location location = event.getBlock().getLocation(); + if (manager.isEnabled(player)) { + if (manager.isPlayerWithin(location, NoPvPManager.PROTECTION_RADIUS, player.getUniqueId())) { + event.setCancelled(true); + player.sendMessage("§cYou can't use flint and steel near other players while no-PvP is on."); + } + } else if (manager.isProtectedWithin(location, NoPvPManager.PROTECTION_RADIUS)) { + event.setCancelled(true); + player.sendMessage("§cYou can't use flint and steel near a no-PvP player."); + } + } + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) public void onEntityExplode(EntityExplodeEvent event) { Entity explosive = event.getEntity(); diff --git a/src/main/java/com/dvorakmortonxd/qoltweaks/nopvp/PacifistHologram.java b/src/main/java/com/dvorakmortonxd/qoltweaks/nopvp/PacifistHologram.java index 5b23aab..3e84cbc 100644 --- a/src/main/java/com/dvorakmortonxd/qoltweaks/nopvp/PacifistHologram.java +++ b/src/main/java/com/dvorakmortonxd/qoltweaks/nopvp/PacifistHologram.java @@ -3,6 +3,7 @@ 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.Bukkit; import org.bukkit.Color; import org.bukkit.Location; import org.bukkit.entity.Display; @@ -15,29 +16,35 @@ 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. + * Shows a floating "Pacifist" label above a no-PvP player. + *

+ * The label is a plain (non-persistent) {@link TextDisplay} that is teleported + * to an absolute height above the player by a global follow task, with the + * actual teleport dispatched on the region that owns the label. Using an + * absolute height (rather than a passenger mount) keeps the label clear of the + * player's head and name tag. */ 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; + /** Height of the label's centre, in blocks above the player's feet. */ + private static final double TARGET_HEIGHT = 2.5; private final JavaPlugin plugin; private final Map displays = new ConcurrentHashMap<>(); - private final Map tasks = new ConcurrentHashMap<>(); + private final ScheduledTask followTask; public PacifistHologram(JavaPlugin plugin) { this.plugin = plugin; + this.followTask = plugin.getServer().getGlobalRegionScheduler() + .runAtFixedRate(plugin, task -> followAll(), 1L, 2L); } 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)); + current.teleport(targetOf(player)); return; } hide(player); @@ -45,31 +52,22 @@ public final class PacifistHologram { } public void hide(Player player) { - UUID uuid = player.getUniqueId(); - ScheduledTask task = tasks.remove(uuid); - if (task != null) { - task.cancel(); - } - removeEntity(displays.remove(uuid)); + removeEntity(displays.remove(player.getUniqueId())); } public void removeAll() { + if (followTask != null) { + followTask.cancel(); + } 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 -> { + TextDisplay display = player.getWorld().spawn(targetOf(player), TextDisplay.class, entity -> { entity.text(Component.text("Pacifist", NamedTextColor.GOLD)); - entity.setBillboard(Display.Billboard.CENTER); + entity.setBillboard(Display.Billboard.VERTICAL); entity.setSeeThrough(false); entity.setShadowed(true); entity.setDefaultBackground(false); @@ -78,37 +76,33 @@ public final class PacifistHologram { entity.setPersistent(false); entity.addScoreboardTag(TAG); }); - displays.put(uuid, display); + displays.put(player.getUniqueId(), display); + } - ScheduledTask task = player.getScheduler().runAtFixedRate(plugin, - scheduled -> tick(player, display, scheduled), null, 1L, 2L); - if (task != null) { - tasks.put(uuid, task); + private void followAll() { + if (displays.isEmpty()) { + return; + } + for (Map.Entry entry : displays.entrySet()) { + Player player = Bukkit.getPlayer(entry.getKey()); + TextDisplay display = entry.getValue(); + if (player == null || !player.isOnline() || !display.isValid()) { + continue; + } + if (!display.getWorld().equals(player.getWorld())) { + continue; + } + Location target = targetOf(player); + Bukkit.getRegionScheduler().run(plugin, target, task -> { + if (display.isValid()) { + display.teleport(target); + } + }); } } - 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 Location targetOf(Player player) { + return player.getLocation().add(0, TARGET_HEIGHT, 0); } private void removeEntity(TextDisplay display) { @@ -116,13 +110,9 @@ public final class PacifistHologram { return; } try { - display.getScheduler().run(plugin, scheduled -> display.remove(), null); + display.getScheduler().run(plugin, task -> display.remove(), null); } catch (RuntimeException ignored) { - // Plugin is shutting down / scheduler unavailable; entity is non-persistent. + // Plugin shutting down / scheduler unavailable; the 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 index e17cfbf..74e56f1 100644 --- a/src/main/java/com/dvorakmortonxd/qoltweaks/teams/Team.java +++ b/src/main/java/com/dvorakmortonxd/qoltweaks/teams/Team.java @@ -8,9 +8,9 @@ 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. + * A purely cosmetic player team. Membership is tracked by UUID and the team's + * colour is applied to each member's no-PvP glow (which each member can toggle + * off for themselves). */ public final class Team { @@ -20,7 +20,6 @@ public final class Team { 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; @@ -85,14 +84,6 @@ public final class Team { this.color = color; } - public boolean isGlowEnabled() { - return glowEnabled; - } - - public void setGlowEnabled(boolean glowEnabled) { - this.glowEnabled = glowEnabled; - } - public void addMember(UUID uuid) { members.add(uuid); } diff --git a/src/main/java/com/dvorakmortonxd/qoltweaks/teams/TeamCommand.java b/src/main/java/com/dvorakmortonxd/qoltweaks/teams/TeamCommand.java index bb3763d..357460a 100644 --- a/src/main/java/com/dvorakmortonxd/qoltweaks/teams/TeamCommand.java +++ b/src/main/java/com/dvorakmortonxd/qoltweaks/teams/TeamCommand.java @@ -77,6 +77,7 @@ public final class TeamCommand implements CommandExecutor, TabCompleter { return; } manager.create(player, name); + noPvPManager.refreshLater(player); player.sendMessage("§aTeam §e" + name + "§a created! Invite players with §e/team invite §a."); } @@ -285,20 +286,11 @@ public final class TeamCommand implements CommandExecutor, TabCompleter { 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."); - } - } + boolean enabled = manager.toggleGlow(player.getUniqueId()); + noPvPManager.refreshLater(player); + player.sendMessage(enabled + ? "§aYour no-PvP glow was turned on." + : "§cYour no-PvP glow was turned off."); } private void chat(Player player, String[] args) { @@ -359,7 +351,7 @@ public final class TeamCommand implements CommandExecutor, TabCompleter { 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 glow §7- toggle your own 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)"); diff --git a/src/main/java/com/dvorakmortonxd/qoltweaks/teams/TeamListener.java b/src/main/java/com/dvorakmortonxd/qoltweaks/teams/TeamListener.java index 5a016d4..a079d1b 100644 --- a/src/main/java/com/dvorakmortonxd/qoltweaks/teams/TeamListener.java +++ b/src/main/java/com/dvorakmortonxd/qoltweaks/teams/TeamListener.java @@ -42,19 +42,39 @@ public final class TeamListener implements Listener { } } - /** Redirects chat into the team channel when a player has team chat toggled on. */ + /** + * Redirects chat into the team channel when a player has team chat toggled + * on, and otherwise adds the team's tag in front of normal chat messages. + */ @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()); + + if (manager.isTeamChat(player.getUniqueId())) { + if (team == null) { + manager.clearTeamChat(player.getUniqueId()); + return; + } + redirectToTeam(event, team); return; } + if (team == null) { + return; + } + + NamedTextColor color = team.getColor() != null ? team.getColor() : NamedTextColor.AQUA; + Component tag = TeamManager.tag(team.getName(), color); + Component name = Component.text(player.getName(), color); + event.renderer((source, sourceDisplayName, message, viewer) -> + tag.append(Component.text("<", NamedTextColor.WHITE)) + .append(name) + .append(Component.text("> ", NamedTextColor.WHITE)) + .append(message.colorIfAbsent(NamedTextColor.WHITE))); + } + + private void redirectToTeam(AsyncChatEvent event, Team team) { Set viewers = event.viewers(); viewers.clear(); for (UUID member : team.getMembers()) { diff --git a/src/main/java/com/dvorakmortonxd/qoltweaks/teams/TeamManager.java b/src/main/java/com/dvorakmortonxd/qoltweaks/teams/TeamManager.java index adba3b5..1bfc158 100644 --- a/src/main/java/com/dvorakmortonxd/qoltweaks/teams/TeamManager.java +++ b/src/main/java/com/dvorakmortonxd/qoltweaks/teams/TeamManager.java @@ -1,5 +1,6 @@ package com.dvorakmortonxd.qoltweaks.teams; +import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Bukkit; import org.bukkit.configuration.ConfigurationSection; @@ -44,6 +45,7 @@ public final class TeamManager { 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 + private final Set glowOff = ConcurrentHashMap.newKeySet(); // players who turned their own glow off public TeamManager(JavaPlugin plugin) { this.plugin = plugin; @@ -77,6 +79,14 @@ public final class TeamManager { return null; } + /** The {@code [Team]} tag shown in chat and next to the name in the tab list. */ + public static Component tag(String teamName, NamedTextColor color) { + NamedTextColor tagColor = color != null ? color : NamedTextColor.AQUA; + return Component.text("[", NamedTextColor.DARK_GRAY) + .append(Component.text(teamName, tagColor)) + .append(Component.text("] ", NamedTextColor.DARK_GRAY)); + } + public synchronized Team teamOf(Player player) { UUID teamId = teamByMember.get(player.getUniqueId()); return teamId == null ? null : teams.get(teamId); @@ -173,22 +183,26 @@ public final class TeamManager { } } - public boolean toggleGlow(Team team) { + /** + * Toggles this player's own glow preference and returns the new state + * ({@code true} = glowing). + */ + public boolean toggleGlow(UUID player) { synchronized (this) { - boolean enabled = !team.isGlowEnabled(); - team.setGlowEnabled(enabled); + if (glowOff.remove(player)) { + scheduleSave(); + return true; + } + glowOff.add(player); scheduleSave(); - return enabled; + return false; } } + /** Whether the player should glow: they must be in a team and not have opted out. */ 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(); + UUID uuid = player.getUniqueId(); + return teamByMember.containsKey(uuid) && !glowOff.contains(uuid); } public boolean isTeamChat(UUID player) { @@ -275,6 +289,7 @@ public final class TeamManager { private void refreshScoreboardNow(UUID teamId) { NamedTextColor color; + String teamName; List names = new ArrayList<>(); synchronized (this) { Team team = teams.get(teamId); @@ -282,6 +297,7 @@ public final class TeamManager { return; } color = team.getColor(); + teamName = team.getName(); for (UUID member : team.getMembers()) { Player online = Bukkit.getPlayer(member); if (online != null) { @@ -298,6 +314,9 @@ public final class TeamManager { board.setAllowFriendlyFire(true); board.setCanSeeFriendlyInvisibles(true); board.color(color); + // The prefix is what the client shows next to the name in the tab list + // (and above the player's head). + board.prefix(tag(teamName, color)); Set wanted = Set.copyOf(names); for (String entry : new ArrayList<>(board.getEntries())) { @@ -321,6 +340,12 @@ public final class TeamManager { return; } YamlConfiguration config = YamlConfiguration.loadConfiguration(dataFile); + for (String entry : config.getStringList("glow-off")) { + UUID uuid = parseUuid(entry); + if (uuid != null) { + glowOff.add(uuid); + } + } ConfigurationSection root = config.getConfigurationSection("teams"); if (root == null) { return; @@ -350,7 +375,6 @@ public final class TeamManager { team.addAdmin(uuid); } } - team.setGlowEnabled(section.getBoolean("glow", true)); NamedTextColor color = parseColor(section.getString("color")); if (color != null) { team.setColor(color); @@ -388,8 +412,12 @@ public final class TeamManager { admins.add(admin.toString()); } root.set(path + ".admins", admins); - root.set(path + ".glow", team.isGlowEnabled()); } + List glowOffList = new ArrayList<>(); + for (UUID uuid : glowOff) { + glowOffList.add(uuid.toString()); + } + config.set("glow-off", glowOffList); return config; } diff --git a/src/main/java/com/dvorakmortonxd/qoltweaks/woodcutter/WoodcutterRecipes.java b/src/main/java/com/dvorakmortonxd/qoltweaks/woodcutter/WoodcutterRecipes.java new file mode 100644 index 0000000..1f310a8 --- /dev/null +++ b/src/main/java/com/dvorakmortonxd/qoltweaks/woodcutter/WoodcutterRecipes.java @@ -0,0 +1,226 @@ +package com.dvorakmortonxd.qoltweaks.woodcutter; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.logging.Logger; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.RecipeChoice; +import org.bukkit.inventory.StonecuttingRecipe; +import org.bukkit.plugin.Plugin; + +/** + * Makes wood usable in the stonecutter. + *

+ * The vanilla stonecutter only accepts an item when at least one stonecutting + * recipe matches it, so registering wood recipes both lists them in the UI and + * makes the whole wood family valid input. Recipes are added to the server's + * recipe manager at runtime; the server rebuilds its cached stonecutter recipe + * set and resyncs clients automatically, so this works on a running vanilla + * client with no resource pack. + *

+ * Yields mirror the stonecutter's usual "one input, N output" style: a log is + * worth 4 planks (as when crafting), and a plank cuts into one shaped block + * (two for slabs). Doors, trapdoors and pressure plates are cut from a whole log + * rather than a single plank, so they cost about a log's worth of planks instead + * of one. The cutter can also strip bark (log to stripped log, and so on) + * one-for-one. + */ +public final class WoodcutterRecipes { + + private WoodcutterRecipes() { + } + + /** An output block type, named by the suffix appended to the wood prefix. */ + private record Shape(String suffix, int amount) { + } + + /** + * A wood family: the {@code _PLANKS} block, the blocks treated as + * logs, and the matching boat material ({@code null} for families without + * one, i.e. crimson and warped). + */ + private record Family(String prefix, List logMaterials, String boat) { + } + + /** A block that can be stripped into another by the stonecutter. */ + private record Strip(String input, String output) { + } + + /** + * Blocks cut straight from a single plank. Doors, trapdoors and pressure + * plates are deliberately absent: they are cut from a whole log instead (see + * {@link #COSTLY}) so they cost a log's worth of planks rather than one. + */ + private static final List SHAPES = List.of( + new Shape("STAIRS", 1), + new Shape("SLAB", 2), + new Shape("FENCE", 1), + new Shape("FENCE_GATE", 1), + new Shape("BUTTON", 1), + new Shape("SIGN", 1), + new Shape("HANGING_SIGN", 1)); + + /** + * The "expensive" blocks, cut from a whole log (worth 4 planks) so the cost + * is closer to crafting: 1 log gives 2 doors or 2 pressure plates (2 planks + * each) and 1 trapdoor (~4 planks). A boat is handled separately. + */ + private static final List COSTLY = List.of( + new Shape("DOOR", 2), + new Shape("TRAPDOOR", 1), + new Shape("PRESSURE_PLATE", 2)); + + private static final List FAMILIES = List.of( + new Family("OAK", standardLogs("OAK"), "OAK_BOAT"), + new Family("SPRUCE", standardLogs("SPRUCE"), "SPRUCE_BOAT"), + new Family("BIRCH", standardLogs("BIRCH"), "BIRCH_BOAT"), + new Family("JUNGLE", standardLogs("JUNGLE"), "JUNGLE_BOAT"), + new Family("ACACIA", standardLogs("ACACIA"), "ACACIA_BOAT"), + new Family("DARK_OAK", standardLogs("DARK_OAK"), "DARK_OAK_BOAT"), + new Family("MANGROVE", withExtra(standardLogs("MANGROVE"), + "MANGROVE_ROOTS", "MUDDY_MANGROVE_ROOTS"), "MANGROVE_BOAT"), + new Family("CHERRY", standardLogs("CHERRY"), "CHERRY_BOAT"), + new Family("PALE_OAK", standardLogs("PALE_OAK"), "PALE_OAK_BOAT"), + new Family("BAMBOO", List.of("BAMBOO_BLOCK", "STRIPPED_BAMBOO_BLOCK"), "BAMBOO_RAFT"), + new Family("CRIMSON", stemLogs("CRIMSON"), null), + new Family("WARPED", stemLogs("WARPED"), null)); + + /** Bark-stripping recipes: barked block in, stripped block out. */ + private static final List STRIPS = buildStrips(); + + /** + * Registers every woodcutting recipe with the server. Safe to call on every + * enable: recipes are keyed, so re-registering simply replaces them. + */ + public static void register(Plugin plugin) { + Logger log = plugin.getLogger(); + int added = 0; + for (Family family : FAMILIES) { + Material planks = match(family.prefix() + "_PLANKS"); + if (planks == null) { + log.warning("Woodcutter: unknown planks material for " + family.prefix()); + continue; + } + + List logs = resolve(log, family.logMaterials()); + if (!logs.isEmpty()) { + if (add(plugin, key(family, "planks_from_logs"), new ItemStack(planks, 4), + new RecipeChoice.MaterialChoice(logs))) { + added++; + } + + for (Shape shape : COSTLY) { + Material output = match(family.prefix() + "_" + shape.suffix()); + if (output == null) { + continue; + } + if (add(plugin, key(family, shape.suffix().toLowerCase(Locale.ROOT) + "_from_logs"), + new ItemStack(output, shape.amount()), + new RecipeChoice.MaterialChoice(logs))) { + added++; + } + } + + if (family.boat() != null) { + Material boat = match(family.boat()); + if (boat != null && add(plugin, key(family, "boat_from_logs"), + new ItemStack(boat), new RecipeChoice.MaterialChoice(logs))) { + added++; + } + } + } + + for (Shape shape : SHAPES) { + Material output = match(family.prefix() + "_" + shape.suffix()); + if (output == null) { + continue; + } + if (add(plugin, key(family, shape.suffix().toLowerCase(Locale.ROOT) + "_from_planks"), + new ItemStack(output, shape.amount()), + new RecipeChoice.MaterialChoice(planks))) { + added++; + } + } + } + for (Strip strip : STRIPS) { + Material input = match(strip.input()); + Material output = match(strip.output()); + if (input == null || output == null) { + log.warning("Woodcutter: unknown stripping material " + + strip.input() + " -> " + strip.output()); + continue; + } + String key = strip.output().toLowerCase(Locale.ROOT) + + "_from_" + strip.input().toLowerCase(Locale.ROOT); + if (add(plugin, key, new ItemStack(output), new RecipeChoice.MaterialChoice(input))) { + added++; + } + } + log.info("Registered " + added + " woodcutting recipes."); + } + + private static List buildStrips() { + List strips = new ArrayList<>(); + for (String prefix : List.of("OAK", "SPRUCE", "BIRCH", "JUNGLE", "ACACIA", + "DARK_OAK", "MANGROVE", "CHERRY", "PALE_OAK")) { + strips.add(new Strip(prefix + "_LOG", "STRIPPED_" + prefix + "_LOG")); + strips.add(new Strip(prefix + "_WOOD", "STRIPPED_" + prefix + "_WOOD")); + } + strips.add(new Strip("BAMBOO_BLOCK", "STRIPPED_BAMBOO_BLOCK")); + for (String prefix : List.of("CRIMSON", "WARPED")) { + strips.add(new Strip(prefix + "_STEM", "STRIPPED_" + prefix + "_STEM")); + strips.add(new Strip(prefix + "_HYPHAE", "STRIPPED_" + prefix + "_HYPHAE")); + } + return List.copyOf(strips); + } + + private static boolean add(Plugin plugin, String key, ItemStack result, RecipeChoice input) { + StonecuttingRecipe recipe = new StonecuttingRecipe(new NamespacedKey(plugin, key), result, input); + if (!Bukkit.addRecipe(recipe, true)) { + plugin.getLogger().warning("Woodcutter: could not register recipe " + key); + return false; + } + return true; + } + + private static String key(Family family, String suffix) { + return family.prefix().toLowerCase(Locale.ROOT) + "_" + suffix; + } + + private static List standardLogs(String prefix) { + return List.of(prefix + "_LOG", prefix + "_WOOD", + "STRIPPED_" + prefix + "_LOG", "STRIPPED_" + prefix + "_WOOD"); + } + + private static List stemLogs(String prefix) { + return List.of(prefix + "_STEM", prefix + "_HYPHAE", + "STRIPPED_" + prefix + "_STEM", "STRIPPED_" + prefix + "_HYPHAE"); + } + + private static List withExtra(List base, String... extra) { + List all = new ArrayList<>(base); + all.addAll(List.of(extra)); + return List.copyOf(all); + } + + private static List resolve(Logger log, List names) { + List materials = new ArrayList<>(names.size()); + for (String name : names) { + Material material = match(name); + if (material == null) { + log.warning("Woodcutter: unknown log material " + name); + } else { + materials.add(material); + } + } + return materials; + } + + private static Material match(String name) { + return Material.matchMaterial(name); + } +}