diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/silenceNametag/ActionBarNametagFeedback.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/silenceNametag/ActionBarNametagFeedback.java new file mode 100644 index 0000000..e196287 --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/silenceNametag/ActionBarNametagFeedback.java @@ -0,0 +1,46 @@ +package eu.mhsl.craftattack.spawn.craftattack.appliances.gameplay.silenceNametag; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.JoinConfiguration; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.entity.Player; + +import java.util.ArrayList; +import java.util.List; + +final class ActionBarNametagFeedback implements NametagFeedback { + @Override + public void send(Player player, NametagFeedbackUpdate update) { + player.sendActionBar(this.render(update)); + } + + Component render(NametagFeedbackUpdate update) { + if(update.summary()) { + var counts = update.counts(); + List parts = new ArrayList<>(); + addCount(parts, counts.silenced(), "verstummt", NamedTextColor.GREEN); + addCount(parts, counts.unsilenced(), "entstummt", NamedTextColor.GREEN); + addCount(parts, counts.alreadySilent(), "bereits stumm", NamedTextColor.YELLOW); + addCount(parts, counts.alreadyAudible(), "bereits hörbar", NamedTextColor.YELLOW); + addCount(parts, counts.unsupported(), "nicht unterstützt", NamedTextColor.RED); + return Component.join(JoinConfiguration.separator(Component.text(" · ", NamedTextColor.GRAY)), parts); + } + + boolean silence = update.action() == NametagAction.SILENCE; + String suffix = switch(update.result()) { + case CHANGED -> silence ? " ist jetzt stumm." : " ist jetzt wieder hörbar."; + case UNCHANGED -> silence ? " ist bereits stumm." : " ist bereits hörbar."; + case UNSUPPORTED -> " unterstützt dieses Namensschild nicht."; + }; + NamedTextColor color = switch(update.result()) { + case CHANGED -> NamedTextColor.GREEN; + case UNCHANGED -> NamedTextColor.YELLOW; + case UNSUPPORTED -> NamedTextColor.RED; + }; + return Component.empty().color(color).append(update.targetName()).append(Component.text(suffix)); + } + + private static void addCount(List parts, int count, String label, NamedTextColor color) { + if(count > 0) parts.add(Component.text(count + " " + label, color)); + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/silenceNametag/NametagAction.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/silenceNametag/NametagAction.java new file mode 100644 index 0000000..1e317fd --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/silenceNametag/NametagAction.java @@ -0,0 +1,52 @@ +package eu.mhsl.craftattack.spawn.craftattack.appliances.gameplay.silenceNametag; + +import net.kyori.adventure.text.Component; +import org.jetbrains.annotations.Nullable; + +import java.util.Locale; +import java.util.Set; + +public enum NametagAction { + SILENCE(0, Set.of("@verstummen", "@stumm", "@mute"), "Dieses Namensschild muted Mobs.", true), + UNSILENCE(1, Set.of("@entstummen", "@laut", "@unmute"), "Dieses Namensschild unmuted Mobs.", false); + + private final int persistentId; + private final Set renameTexts; + private final Component lore; + private final boolean silent; + + NametagAction(int persistentId, Set renameTexts, String lore, boolean silent) { + this.persistentId = persistentId; + this.renameTexts = Set.copyOf(renameTexts); + this.lore = Component.text(lore); + this.silent = silent; + } + + public int persistentId() { + return this.persistentId; + } + + public Component lore() { + return this.lore; + } + + public boolean silent() { + return this.silent; + } + + public static @Nullable NametagAction fromPersistentId(int id) { + for(NametagAction action : values()) { + if(action.persistentId == id) return action; + } + return null; + } + + public static @Nullable NametagAction fromRenameText(@Nullable String text) { + if(text == null) return null; + String normalizedText = text.strip().toLowerCase(Locale.ROOT); + for(NametagAction action : values()) { + if(action.renameTexts.contains(normalizedText)) return action; + } + return null; + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/silenceNametag/NametagAnvilListener.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/silenceNametag/NametagAnvilListener.java new file mode 100644 index 0000000..244d883 --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/silenceNametag/NametagAnvilListener.java @@ -0,0 +1,25 @@ +package eu.mhsl.craftattack.spawn.craftattack.appliances.gameplay.silenceNametag; + +import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener; +import org.bukkit.Material; +import org.bukkit.event.EventHandler; +import org.bukkit.event.inventory.PrepareAnvilEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.view.AnvilView; + +public class NametagAnvilListener extends ApplianceListener { + @EventHandler + public void onAnvilRename(PrepareAnvilEvent event) { + ItemStack resultItem = event.getResult(); + if(resultItem == null || resultItem.getType() != Material.NAME_TAG) return; + + AnvilView anvilView = event.getView(); + ItemStack inputItem = anvilView.getItem(0); + if(inputItem == null || inputItem.getType() != Material.NAME_TAG) return; + + NametagAction action = NametagAction.fromRenameText(anvilView.getRenameText()); + if(action == null) return; + + event.setResult(SilenceNametagItem.create(resultItem, action)); + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/silenceNametag/NametagEntityListener.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/silenceNametag/NametagEntityListener.java new file mode 100644 index 0000000..e82f897 --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/silenceNametag/NametagEntityListener.java @@ -0,0 +1,46 @@ +package eu.mhsl.craftattack.spawn.craftattack.appliances.gameplay.silenceNametag; + +import eu.mhsl.craftattack.spawn.core.appliance.ApplianceListener; +import org.bukkit.GameMode; +import org.bukkit.Material; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.player.PlayerInteractEntityEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.ItemStack; + +public class NametagEntityListener extends ApplianceListener { + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onNametagUse(PlayerInteractEntityEvent event) { + ItemStack item; + if(event.getHand() == EquipmentSlot.HAND) { + item = event.getPlayer().getInventory().getItemInMainHand(); + } else { + item = event.getPlayer().getInventory().getItemInOffHand(); + } + + if(item.getType() != Material.NAME_TAG) return; + + NametagAction action = SilenceNametagItem.readAction(item); + if(action == null) return; + + SilenceNametag.ActionResult result = this.getAppliance().applyNametagAction(event.getRightClicked(), action); + this.getAppliance().feedback().send(event.getPlayer(), event.getRightClicked(), action, result); + if(result == SilenceNametag.ActionResult.UNSUPPORTED) return; + + // Named tags retain vanilla naming and consumption, even if the mob is already in the desired state. + if(item.getItemMeta().customName() != null) return; + + // Unnamed special tags are handled entirely here, including no-op interactions. + event.setCancelled(true); + if(result == SilenceNametag.ActionResult.CHANGED && event.getPlayer().getGameMode() != GameMode.CREATIVE) { + item.setAmount(item.getAmount() - 1); + } + } + + @EventHandler + public void onQuit(PlayerQuitEvent event) { + this.getAppliance().feedback().forget(event.getPlayer().getUniqueId()); + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/silenceNametag/NametagFeedback.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/silenceNametag/NametagFeedback.java new file mode 100644 index 0000000..a7b9e92 --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/silenceNametag/NametagFeedback.java @@ -0,0 +1,7 @@ +package eu.mhsl.craftattack.spawn.craftattack.appliances.gameplay.silenceNametag; + +import org.bukkit.entity.Player; + +interface NametagFeedback { + void send(Player player, NametagFeedbackUpdate update); +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/silenceNametag/NametagFeedbackService.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/silenceNametag/NametagFeedbackService.java new file mode 100644 index 0000000..289f771 --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/silenceNametag/NametagFeedbackService.java @@ -0,0 +1,82 @@ +package eu.mhsl.craftattack.spawn.craftattack.appliances.gameplay.silenceNametag; + +import net.kyori.adventure.text.Component; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import java.util.function.LongSupplier; + +final class NametagFeedbackService { + private static final long SERIES_TIMEOUT = TimeUnit.SECONDS.toNanos(1); + private final LongSupplier clock; + private final List outputs; + private final Map seriesByPlayer = new HashMap<>(); + + NametagFeedbackService(LongSupplier clock, List outputs) { + this.clock = clock; + this.outputs = List.copyOf(outputs); + } + + void send(Player player, Entity target, NametagAction action, SilenceNametag.ActionResult result) { + Component name = target.customName(); + if(name == null) name = Component.translatable(target.getType().translationKey()); + NametagFeedbackUpdate update = this.recordInteraction(player.getUniqueId(), target.getUniqueId(), name, action, result); + this.outputs.forEach(output -> output.send(player, update)); + } + + // Called synchronously by the entity listener. Series retain no Bukkit objects or display names. + NametagFeedbackUpdate recordInteraction(UUID playerId, UUID targetId, Component name, + NametagAction action, SilenceNametag.ActionResult result) { + long now = this.clock.getAsLong(); + this.seriesByPlayer.values().removeIf(series -> now - series.lastInteraction >= SERIES_TIMEOUT); + boolean newSeries = !this.seriesByPlayer.containsKey(playerId); + Series series = this.seriesByPlayer.computeIfAbsent(playerId, ignored -> new Series()); + series.lastInteraction = now; + if(result == SilenceNametag.ActionResult.UNSUPPORTED) { + series.unsupported.add(targetId); + } else { + series.results.merge(new TargetAction(targetId, action), result, (previous, current) -> + previous == SilenceNametag.ActionResult.CHANGED ? previous : current); + } + return new NametagFeedbackUpdate(targetId, name, action, result, newSeries, !newSeries, series.counts()); + } + + void forget(UUID playerId) { + this.seriesByPlayer.remove(playerId); + } + + void clear() { + this.seriesByPlayer.clear(); + } + + private record TargetAction(UUID targetId, NametagAction action) { + } + + private static final class Series { + private long lastInteraction; + private final Map results = new HashMap<>(); + private final Set unsupported = new HashSet<>(); + + private NametagFeedbackUpdate.Counts counts() { + int silenced = 0, unsilenced = 0, alreadySilent = 0, alreadyAudible = 0; + for(var entry : this.results.entrySet()) { + boolean changed = entry.getValue() == SilenceNametag.ActionResult.CHANGED; + if(entry.getKey().action() == NametagAction.SILENCE) { + if(changed) silenced++; + else alreadySilent++; + } else { + if(changed) unsilenced++; + else alreadyAudible++; + } + } + return new NametagFeedbackUpdate.Counts(silenced, unsilenced, alreadySilent, alreadyAudible, this.unsupported.size()); + } + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/silenceNametag/NametagFeedbackUpdate.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/silenceNametag/NametagFeedbackUpdate.java new file mode 100644 index 0000000..5e9a9bc --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/silenceNametag/NametagFeedbackUpdate.java @@ -0,0 +1,18 @@ +package eu.mhsl.craftattack.spawn.craftattack.appliances.gameplay.silenceNametag; + +import net.kyori.adventure.text.Component; + +import java.util.UUID; + +record NametagFeedbackUpdate( + UUID targetId, + Component targetName, + NametagAction action, + SilenceNametag.ActionResult result, + boolean newSeries, + boolean summary, + Counts counts +) { + record Counts(int silenced, int unsilenced, int alreadySilent, int alreadyAudible, int unsupported) { + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/silenceNametag/SilenceNametag.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/silenceNametag/SilenceNametag.java new file mode 100644 index 0000000..c4691cb --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/silenceNametag/SilenceNametag.java @@ -0,0 +1,44 @@ +package eu.mhsl.craftattack.spawn.craftattack.appliances.gameplay.silenceNametag; + +import eu.mhsl.craftattack.spawn.core.appliance.Appliance; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Mob; +import org.bukkit.event.Listener; +import org.jetbrains.annotations.NotNull; + +import java.util.List; +import java.util.Objects; + +public class SilenceNametag extends Appliance { + private final NametagFeedbackService feedback = new NametagFeedbackService( + System::nanoTime, List.of(new ActionBarNametagFeedback())); + + NametagFeedbackService feedback() { + return this.feedback; + } + + @Override + public void onDisable() { + this.feedback.clear(); + } + + public enum ActionResult { + CHANGED, + UNCHANGED, + UNSUPPORTED + } + + public ActionResult applyNametagAction(Entity entity, NametagAction action) { + Objects.requireNonNull(action, "Action must not be null."); + if(!(entity instanceof Mob)) return ActionResult.UNSUPPORTED; + if(entity.isSilent() == action.silent()) return ActionResult.UNCHANGED; + + entity.setSilent(action.silent()); + return ActionResult.CHANGED; + } + + @Override + protected @NotNull List listeners() { + return List.of(new NametagAnvilListener(), new NametagEntityListener()); + } +} diff --git a/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/silenceNametag/SilenceNametagItem.java b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/silenceNametag/SilenceNametagItem.java new file mode 100644 index 0000000..512328c --- /dev/null +++ b/craftattack/src/main/java/eu/mhsl/craftattack/spawn/craftattack/appliances/gameplay/silenceNametag/SilenceNametagItem.java @@ -0,0 +1,50 @@ +package eu.mhsl.craftattack.spawn.craftattack.appliances.gameplay.silenceNametag; + +import net.kyori.adventure.text.Component; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +final class SilenceNametagItem { + // Keep the key and IDs compatible with existing nametags. + private static final NamespacedKey ACTION_KEY = new NamespacedKey("silence-nametag", "nametag-action"); + + private SilenceNametagItem() { + } + + static @Nullable NametagAction readAction(ItemStack item) { + if(item.getType() != Material.NAME_TAG) return null; + var data = item.getPersistentDataContainer(); + if(!data.has(ACTION_KEY, PersistentDataType.INTEGER)) return null; + + Integer id = data.get(ACTION_KEY, PersistentDataType.INTEGER); + return id == null ? null : NametagAction.fromPersistentId(id); + } + + static ItemStack create(ItemStack source, NametagAction action) { + Objects.requireNonNull(action, "Action must not be null."); + if(source.getType() != Material.NAME_TAG) { + throw new IllegalArgumentException("Item must be a nametag."); + } + + ItemStack result = source.clone(); + result.editMeta(meta -> { + meta.getPersistentDataContainer().set(ACTION_KEY, PersistentDataType.INTEGER, action.persistentId()); + List existingLore = meta.lore(); + List lore = existingLore == null ? new ArrayList<>() : new ArrayList<>(existingLore); + for(NametagAction knownAction : NametagAction.values()) { + lore.removeIf(knownAction.lore()::equals); + } + lore.add(action.lore()); + meta.lore(lore); + meta.customName(null); + }); + return result; + } +}