etc etc etc - all vibecoded - wood stonecutter feature

This commit is contained in:
Martin Dvořák 2026-09-28 13:18:00 +00:00
commit e2b7f31caf
11 changed files with 407 additions and 107 deletions

View file

@ -5,9 +5,10 @@ A quality-of-life SMP plugin for Paper/Folia.
## Features ## 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. - **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. - **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 ## Commands
@ -19,7 +20,7 @@ A quality-of-life SMP plugin for Paper/Folia.
| `/team join <team>` | Accept an invite, or send a join request to the team. | `qoltweaks.team` | | `/team join <team>` | Accept an invite, or send a join request to the team. | `qoltweaks.team` |
| `/team color <clr>` | Set the team glow colour, or `none` to clear (owner/admin). | `qoltweaks.team` | | `/team color <clr>` | Set the team glow colour, or `none` to clear (owner/admin). | `qoltweaks.team` |
| `/team admin <add\|remove> <p>`| Promote/demote a member to admin (owner only). | `qoltweaks.team` | | `/team admin <add\|remove> <p>`| 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 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 leave` | Leave your team (owners must delete instead). | `qoltweaks.team` |
| `/team delete` | Disband the whole team (owner only). | `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 ## 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 ## Compatibility

View file

@ -10,6 +10,7 @@ import com.dvorakmortonxd.qoltweaks.nopvp.NoPvPProtectionListener;
import com.dvorakmortonxd.qoltweaks.teams.TeamCommand; import com.dvorakmortonxd.qoltweaks.teams.TeamCommand;
import com.dvorakmortonxd.qoltweaks.teams.TeamListener; import com.dvorakmortonxd.qoltweaks.teams.TeamListener;
import com.dvorakmortonxd.qoltweaks.teams.TeamManager; import com.dvorakmortonxd.qoltweaks.teams.TeamManager;
import com.dvorakmortonxd.qoltweaks.woodcutter.WoodcutterRecipes;
import org.bukkit.command.PluginCommand; import org.bukkit.command.PluginCommand;
import org.bukkit.plugin.java.JavaPlugin; 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 NoPvPProtectionListener(noPvPManager), this);
getServer().getPluginManager().registerEvents(new CombatLogListener(this, combatLogManager), this); getServer().getPluginManager().registerEvents(new CombatLogListener(this, combatLogManager), this);
WoodcutterRecipes.register(this);
PluginCommand noPvPCommand = getCommand("nopvp"); PluginCommand noPvPCommand = getCommand("nopvp");
if (noPvPCommand != null) { if (noPvPCommand != null) {
noPvPCommand.setExecutor(new NoPvPCommand(noPvPManager, combatLogManager)); noPvPCommand.setExecutor(new NoPvPCommand(noPvPManager, combatLogManager));
@ -43,6 +46,8 @@ public final class QoLtweaks extends JavaPlugin {
teamCommand.setExecutor(executor); teamCommand.setExecutor(executor);
teamCommand.setTabCompleter(executor); teamCommand.setTabCompleter(executor);
} }
noPvPManager.applyToAllOnline();
} }
@Override @Override

View file

@ -30,7 +30,7 @@ public final class NoPvPCommand implements CommandExecutor {
if (manager.isEnabled(player)) { if (manager.isEnabled(player)) {
manager.disable(player); manager.disable(player);
player.sendMessage("§cYou turned off no-PvP. You can now be attacked by other players again."); 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; return true;
} }

View file

@ -16,7 +16,7 @@ import java.util.concurrent.ConcurrentHashMap;
public final class NoPvPManager { 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. */ /** 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; public static final double PROTECTION_RADIUS = 25.0;
@ -52,6 +52,19 @@ public final class NoPvPManager {
return !world.getNearbyPlayers(location, radius, this::isEnabled).isEmpty(); 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) { public long getRemainingCooldownMillis(Player player) {
long until = cooldownUntil.getOrDefault(player.getUniqueId(), 0L); long until = cooldownUntil.getOrDefault(player.getUniqueId(), 0L);
return Math.max(0L, until - System.currentTimeMillis()); 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 * Applies the no-PvP visuals for a player: the Pacifist hologram while
* they are in a team with glow enabled, the team-coloured glow. * 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) { public void applyState(Player player) {
if (isEnabled(player)) { if (isEnabled(player)) {
hologram.show(player); hologram.show(player);
player.setGlowing(teamManager != null && teamManager.shouldGlow(player));
} else { } else {
hologram.hide(player); 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);
} }
} }

View file

@ -20,6 +20,7 @@ import org.bukkit.event.block.Action;
import org.bukkit.event.block.BlockBreakEvent; import org.bukkit.event.block.BlockBreakEvent;
import org.bukkit.event.block.BlockDispenseEvent; import org.bukkit.event.block.BlockDispenseEvent;
import org.bukkit.event.block.BlockExplodeEvent; import org.bukkit.event.block.BlockExplodeEvent;
import org.bukkit.event.block.BlockIgniteEvent;
import org.bukkit.event.block.BlockPlaceEvent; import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.event.block.TNTPrimeEvent; import org.bukkit.event.block.TNTPrimeEvent;
import org.bukkit.event.entity.EntityDamageByEntityEvent; import org.bukkit.event.entity.EntityDamageByEntityEvent;
@ -154,6 +155,29 @@ public final class NoPvPProtectionListener implements Listener {
} }
} }
/**
* Flint &amp; 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) @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onEntityExplode(EntityExplodeEvent event) { public void onEntityExplode(EntityExplodeEvent event) {
Entity explosive = event.getEntity(); Entity explosive = event.getEntity();

View file

@ -3,6 +3,7 @@ package com.dvorakmortonxd.qoltweaks.nopvp;
import io.papermc.paper.threadedregions.scheduler.ScheduledTask; import io.papermc.paper.threadedregions.scheduler.ScheduledTask;
import net.kyori.adventure.text.Component; import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.Color; import org.bukkit.Color;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.entity.Display; import org.bukkit.entity.Display;
@ -15,29 +16,35 @@ import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
/** /**
* Shows a floating "Pacifist" label above a no-PvP player. The label is a * Shows a floating "Pacifist" label above a no-PvP player.
* non-persistent {@link TextDisplay} that follows its player via that player's * <p>
* Folia entity scheduler, so it stays on the owning region thread. * 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 { public final class PacifistHologram {
private static final String TAG = "qol_pacifist"; private static final String TAG = "qol_pacifist";
/** Blocks above the player's feet; ~1 block above the head, one line above the name tag. */ /** Height of the label's centre, in blocks above the player's feet. */
private static final double HEIGHT_OFFSET = 2.8; private static final double TARGET_HEIGHT = 2.5;
private final JavaPlugin plugin; private final JavaPlugin plugin;
private final Map<UUID, TextDisplay> displays = new ConcurrentHashMap<>(); private final Map<UUID, TextDisplay> displays = new ConcurrentHashMap<>();
private final Map<UUID, ScheduledTask> tasks = new ConcurrentHashMap<>(); private final ScheduledTask followTask;
public PacifistHologram(JavaPlugin plugin) { public PacifistHologram(JavaPlugin plugin) {
this.plugin = plugin; this.plugin = plugin;
this.followTask = plugin.getServer().getGlobalRegionScheduler()
.runAtFixedRate(plugin, task -> followAll(), 1L, 2L);
} }
public void show(Player player) { public void show(Player player) {
UUID uuid = player.getUniqueId(); UUID uuid = player.getUniqueId();
TextDisplay current = displays.get(uuid); TextDisplay current = displays.get(uuid);
if (current != null && current.isValid() && current.getWorld().equals(player.getWorld())) { if (current != null && current.isValid() && current.getWorld().equals(player.getWorld())) {
current.teleport(positionOf(player)); current.teleport(targetOf(player));
return; return;
} }
hide(player); hide(player);
@ -45,31 +52,22 @@ public final class PacifistHologram {
} }
public void hide(Player player) { public void hide(Player player) {
UUID uuid = player.getUniqueId(); removeEntity(displays.remove(player.getUniqueId()));
ScheduledTask task = tasks.remove(uuid);
if (task != null) {
task.cancel();
}
removeEntity(displays.remove(uuid));
} }
public void removeAll() { public void removeAll() {
if (followTask != null) {
followTask.cancel();
}
for (UUID uuid : displays.keySet().toArray(new UUID[0])) { for (UUID uuid : displays.keySet().toArray(new UUID[0])) {
ScheduledTask task = tasks.remove(uuid);
if (task != null) {
task.cancel();
}
removeEntity(displays.remove(uuid)); removeEntity(displays.remove(uuid));
} }
tasks.clear();
} }
private void spawn(Player player) { private void spawn(Player player) {
UUID uuid = player.getUniqueId(); TextDisplay display = player.getWorld().spawn(targetOf(player), TextDisplay.class, entity -> {
Location location = positionOf(player);
TextDisplay display = location.getWorld().spawn(location, TextDisplay.class, entity -> {
entity.text(Component.text("Pacifist", NamedTextColor.GOLD)); entity.text(Component.text("Pacifist", NamedTextColor.GOLD));
entity.setBillboard(Display.Billboard.CENTER); entity.setBillboard(Display.Billboard.VERTICAL);
entity.setSeeThrough(false); entity.setSeeThrough(false);
entity.setShadowed(true); entity.setShadowed(true);
entity.setDefaultBackground(false); entity.setDefaultBackground(false);
@ -78,37 +76,33 @@ public final class PacifistHologram {
entity.setPersistent(false); entity.setPersistent(false);
entity.addScoreboardTag(TAG); entity.addScoreboardTag(TAG);
}); });
displays.put(uuid, display); displays.put(player.getUniqueId(), display);
}
ScheduledTask task = player.getScheduler().runAtFixedRate(plugin, private void followAll() {
scheduled -> tick(player, display, scheduled), null, 1L, 2L); if (displays.isEmpty()) {
if (task != null) { return;
tasks.put(uuid, task); }
for (Map.Entry<UUID, TextDisplay> 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) { private Location targetOf(Player player) {
UUID uuid = player.getUniqueId(); return player.getLocation().add(0, TARGET_HEIGHT, 0);
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) { private void removeEntity(TextDisplay display) {
@ -116,13 +110,9 @@ public final class PacifistHologram {
return; return;
} }
try { try {
display.getScheduler().run(plugin, scheduled -> display.remove(), null); display.getScheduler().run(plugin, task -> display.remove(), null);
} catch (RuntimeException ignored) { } 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);
}
} }

View file

@ -8,9 +8,9 @@ import java.util.Set;
import java.util.UUID; import java.util.UUID;
/** /**
* A purely cosmetic player team. Membership is tracked by UUID, the colour is * A purely cosmetic player team. Membership is tracked by UUID and the team's
* applied to every member's no-PvP glow, and the glow can be switched off for * colour is applied to each member's no-PvP glow (which each member can toggle
* the whole team. * off for themselves).
*/ */
public final class Team { public final class Team {
@ -20,7 +20,6 @@ public final class Team {
private final Set<UUID> members = new LinkedHashSet<>(); private final Set<UUID> members = new LinkedHashSet<>();
private final Set<UUID> admins = new LinkedHashSet<>(); private final Set<UUID> admins = new LinkedHashSet<>();
private NamedTextColor color; private NamedTextColor color;
private boolean glowEnabled = true;
public Team(UUID id, String name, UUID owner) { public Team(UUID id, String name, UUID owner) {
this.id = id; this.id = id;
@ -85,14 +84,6 @@ public final class Team {
this.color = color; this.color = color;
} }
public boolean isGlowEnabled() {
return glowEnabled;
}
public void setGlowEnabled(boolean glowEnabled) {
this.glowEnabled = glowEnabled;
}
public void addMember(UUID uuid) { public void addMember(UUID uuid) {
members.add(uuid); members.add(uuid);
} }

View file

@ -77,6 +77,7 @@ public final class TeamCommand implements CommandExecutor, TabCompleter {
return; return;
} }
manager.create(player, name); manager.create(player, name);
noPvPManager.refreshLater(player);
player.sendMessage("§aTeam §e" + name + "§a created! Invite players with §e/team invite <player>§a."); player.sendMessage("§aTeam §e" + name + "§a created! Invite players with §e/team invite <player>§a.");
} }
@ -285,20 +286,11 @@ public final class TeamCommand implements CommandExecutor, TabCompleter {
player.sendMessage("§cYou're not in a team."); player.sendMessage("§cYou're not in a team.");
return; return;
} }
if (!team.isAdmin(player.getUniqueId())) { boolean enabled = manager.toggleGlow(player.getUniqueId());
player.sendMessage("§cOnly the team owner or an admin can toggle the team glow."); noPvPManager.refreshLater(player);
return; player.sendMessage(enabled
} ? "§aYour no-PvP glow was turned on."
boolean enabled = manager.toggleGlow(team); : "§cYour no-PvP glow was turned off.");
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) { private void chat(Player player, String[] args) {
@ -359,7 +351,7 @@ public final class TeamCommand implements CommandExecutor, TabCompleter {
player.sendMessage("§e/team join <team> §7- request or accept an invite"); player.sendMessage("§e/team join <team> §7- request or accept an invite");
player.sendMessage("§e/team color <color> §7- set the team's glow colour"); player.sendMessage("§e/team color <color> §7- set the team's glow colour");
player.sendMessage("§e/team admin <add|remove> <player> §7- manage admins"); player.sendMessage("§e/team admin <add|remove> <player> §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 chat [message] §7- team chat / toggle it");
player.sendMessage("§e/team leave §7- leave your team"); player.sendMessage("§e/team leave §7- leave your team");
player.sendMessage("§e/team delete §7- disband your team (owner)"); player.sendMessage("§e/team delete §7- disband your team (owner)");

View file

@ -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) @EventHandler(priority = EventPriority.LOWEST)
public void onChat(AsyncChatEvent event) { public void onChat(AsyncChatEvent event) {
Player player = event.getPlayer(); Player player = event.getPlayer();
if (!manager.isTeamChat(player.getUniqueId())) {
return;
}
Team team = manager.teamOf(player); 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; 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<Audience> viewers = event.viewers(); Set<Audience> viewers = event.viewers();
viewers.clear(); viewers.clear();
for (UUID member : team.getMembers()) { for (UUID member : team.getMembers()) {

View file

@ -1,5 +1,6 @@
package com.dvorakmortonxd.qoltweaks.teams; package com.dvorakmortonxd.qoltweaks.teams;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.ConfigurationSection;
@ -44,6 +45,7 @@ public final class TeamManager {
private final Map<UUID, UUID> joinRequests = new ConcurrentHashMap<>(); // player -> team id private final Map<UUID, UUID> joinRequests = new ConcurrentHashMap<>(); // player -> team id
private final Map<UUID, Set<UUID>> invites = new ConcurrentHashMap<>(); // team id -> invited players private final Map<UUID, Set<UUID>> invites = new ConcurrentHashMap<>(); // team id -> invited players
private final Set<UUID> teamChat = ConcurrentHashMap.newKeySet(); // players with team chat toggled on private final Set<UUID> teamChat = ConcurrentHashMap.newKeySet(); // players with team chat toggled on
private final Set<UUID> glowOff = ConcurrentHashMap.newKeySet(); // players who turned their own glow off
public TeamManager(JavaPlugin plugin) { public TeamManager(JavaPlugin plugin) {
this.plugin = plugin; this.plugin = plugin;
@ -77,6 +79,14 @@ public final class TeamManager {
return null; 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) { public synchronized Team teamOf(Player player) {
UUID teamId = teamByMember.get(player.getUniqueId()); UUID teamId = teamByMember.get(player.getUniqueId());
return teamId == null ? null : teams.get(teamId); 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) { synchronized (this) {
boolean enabled = !team.isGlowEnabled(); if (glowOff.remove(player)) {
team.setGlowEnabled(enabled); scheduleSave();
return true;
}
glowOff.add(player);
scheduleSave(); 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) { public synchronized boolean shouldGlow(Player player) {
UUID teamId = teamByMember.get(player.getUniqueId()); UUID uuid = player.getUniqueId();
if (teamId == null) { return teamByMember.containsKey(uuid) && !glowOff.contains(uuid);
return false;
}
Team team = teams.get(teamId);
return team != null && team.isGlowEnabled();
} }
public boolean isTeamChat(UUID player) { public boolean isTeamChat(UUID player) {
@ -275,6 +289,7 @@ public final class TeamManager {
private void refreshScoreboardNow(UUID teamId) { private void refreshScoreboardNow(UUID teamId) {
NamedTextColor color; NamedTextColor color;
String teamName;
List<String> names = new ArrayList<>(); List<String> names = new ArrayList<>();
synchronized (this) { synchronized (this) {
Team team = teams.get(teamId); Team team = teams.get(teamId);
@ -282,6 +297,7 @@ public final class TeamManager {
return; return;
} }
color = team.getColor(); color = team.getColor();
teamName = team.getName();
for (UUID member : team.getMembers()) { for (UUID member : team.getMembers()) {
Player online = Bukkit.getPlayer(member); Player online = Bukkit.getPlayer(member);
if (online != null) { if (online != null) {
@ -298,6 +314,9 @@ public final class TeamManager {
board.setAllowFriendlyFire(true); board.setAllowFriendlyFire(true);
board.setCanSeeFriendlyInvisibles(true); board.setCanSeeFriendlyInvisibles(true);
board.color(color); 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<String> wanted = Set.copyOf(names); Set<String> wanted = Set.copyOf(names);
for (String entry : new ArrayList<>(board.getEntries())) { for (String entry : new ArrayList<>(board.getEntries())) {
@ -321,6 +340,12 @@ public final class TeamManager {
return; return;
} }
YamlConfiguration config = YamlConfiguration.loadConfiguration(dataFile); 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"); ConfigurationSection root = config.getConfigurationSection("teams");
if (root == null) { if (root == null) {
return; return;
@ -350,7 +375,6 @@ public final class TeamManager {
team.addAdmin(uuid); team.addAdmin(uuid);
} }
} }
team.setGlowEnabled(section.getBoolean("glow", true));
NamedTextColor color = parseColor(section.getString("color")); NamedTextColor color = parseColor(section.getString("color"));
if (color != null) { if (color != null) {
team.setColor(color); team.setColor(color);
@ -388,8 +412,12 @@ public final class TeamManager {
admins.add(admin.toString()); admins.add(admin.toString());
} }
root.set(path + ".admins", admins); root.set(path + ".admins", admins);
root.set(path + ".glow", team.isGlowEnabled());
} }
List<String> glowOffList = new ArrayList<>();
for (UUID uuid : glowOff) {
glowOffList.add(uuid.toString());
}
config.set("glow-off", glowOffList);
return config; return config;
} }

View file

@ -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.
* <p>
* 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.
* <p>
* 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 <prefix>_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<String> 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<Shape> 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<Shape> COSTLY = List.of(
new Shape("DOOR", 2),
new Shape("TRAPDOOR", 1),
new Shape("PRESSURE_PLATE", 2));
private static final List<Family> 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<Strip> 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<Material> 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<Strip> buildStrips() {
List<Strip> 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<String> standardLogs(String prefix) {
return List.of(prefix + "_LOG", prefix + "_WOOD",
"STRIPPED_" + prefix + "_LOG", "STRIPPED_" + prefix + "_WOOD");
}
private static List<String> stemLogs(String prefix) {
return List.of(prefix + "_STEM", prefix + "_HYPHAE",
"STRIPPED_" + prefix + "_STEM", "STRIPPED_" + prefix + "_HYPHAE");
}
private static List<String> withExtra(List<String> base, String... extra) {
List<String> all = new ArrayList<>(base);
all.addAll(List.of(extra));
return List.copyOf(all);
}
private static List<Material> resolve(Logger log, List<String> names) {
List<Material> 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);
}
}