diff --git a/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java b/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java index 79fcfec..adffbbd 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java @@ -97,6 +97,7 @@ public final class Canalhandia extends JavaPlugin implements Listener { private Reactions liveReactions; private GuessRound guessRound; private Poll poll; + private Titles titles; @Override public void onEnable() { @@ -108,6 +109,7 @@ public final class Canalhandia extends JavaPlugin implements Listener { offlineStats = new OfflineStats(this); milestones = new Milestones(this); achievements = new Achievements(this); + titles = new Titles(this); weeklyStats = new WeeklyStats(new java.io.File(getDataFolder(), "semana.yml")); aiBudget = new Budget(settings.aiSpontaneousPerDay(), settings.aiSpontaneousGapMinutes() * 60_000L, @@ -127,11 +129,13 @@ public final class Canalhandia extends JavaPlugin implements Listener { CanalhandiaCommand root = new CanalhandiaCommand(this); for (String name : List.of("canalhandia", "curiosidade", "adivinha", "enquete", "ranking", "reagir", "reacoes", "palpite", "votar", "legal", "wow", "top", "f", "ia", "iap", - "errado", "nota", "save", "recado", "recados", "mortes", "conquistas")) { + "errado", "nota", "save", "recado", "recados", "mortes", "conquistas", + "perfil", "titulo")) { register(name, root); } getServer().getPluginManager().registerEvents(this, this); + getServer().getPluginManager().registerEvents(new TitleChatListener(this), this); rescheduleTimer(); rescheduleMilestones(); @@ -202,6 +206,11 @@ public final class Canalhandia extends JavaPlugin implements Listener { return achievements; } + /** The title each player has chosen to wear in chat. Never null. */ + Titles titles() { + return titles; + } + /** The weekly ranking baseline. Never null. */ WeeklyStats weeklyStats() { return weeklyStats; diff --git a/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java index a4405e4..946824d 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java +++ b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java @@ -17,6 +17,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.UUID; /** * Every command the plugin owns. @@ -58,7 +59,9 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { case "recado" -> recado(sender, args); case "recados" -> recados(sender); case "mortes" -> mortes(sender); - case "conquistas" -> conquistas(sender); + case "conquistas" -> conquistas(sender, args); + case "perfil" -> perfil(sender, args); + case "titulo" -> titulo(sender, args); // Typed twin of the [F] mourning button — "f" is not a configured // reaction (the mourning set is hardcoded), so it can't be found via // reactionForCommand; route it directly. Acts on the latest message, @@ -1239,24 +1242,52 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { } /** - * {@code /conquistas} — the full catalogue, with the ones you have earned + * {@code /conquistas [jogador]} — the full catalogue, with the ones earned * marked. Showing the locked ones too is the point: an achievement nobody * can see is one nobody chases. + * + *
With no name it is your own list, from the recorded flags (what was
+ * actually announced). With a name it is that player's, computed from their
+ * current stats on disk, so it works for anyone the server has seen — online
+ * or not.
*/
- private boolean conquistas(CommandSender sender) {
+ private boolean conquistas(CommandSender sender, String[] args) {
if (!plugin.settings().moduleEnabled(Module.CONQUISTAS)) {
Msg.error(sender, "O módulo de conquistas está desligado.");
return true;
}
- if (!(sender instanceof Player player)) {
- Msg.error(sender, "Só jogadores têm conquistas.");
+ if (args.length == 0) {
+ if (!(sender instanceof Player player)) {
+ Msg.error(sender, "Só jogadores têm conquistas. Use /conquistas Chat-only, like the rest of the plugin: this changes how a message renders,
+ * never the player. It wraps the existing {@link ChatRenderer} instead of
+ * rewriting the message, so it composes with anything else that touches chat,
+ * and every viewer — Java and Bedrock alike, since Geyser/Floodgate deliver
+ * Bedrock chat through this same event — sees one "[Título] Nome: mensagem".
+ *
+ * Gated on the {@code conquistas} module: switching achievements off also
+ * stops the titles they feed, in one place.
+ */
+final class TitleChatListener implements Listener {
+
+ private final Canalhandia plugin;
+
+ TitleChatListener(Canalhandia plugin) {
+ this.plugin = plugin;
+ }
+
+ @EventHandler(priority = EventPriority.NORMAL)
+ void onChat(AsyncChatEvent event) {
+ if (!plugin.settings().moduleEnabled(Module.CONQUISTAS)) {
+ return;
+ }
+ Achievement worn = plugin.titles().chosenAchievement(event.getPlayer().getUniqueId());
+ if (worn == null) {
+ return;
+ }
+ Component tag = tag(worn);
+ ChatRenderer previous = event.renderer();
+ event.renderer((source, sourceDisplayName, message, viewer) ->
+ tag.append(previous.render(source, sourceDisplayName, message, viewer)));
+ }
+
+ /** The bracketed title chip that sits before the name. Pure, so it is testable. */
+ static Component tag(Achievement achievement) {
+ return Component.text("[", NamedTextColor.DARK_GRAY)
+ .append(Component.text(achievement.title(), NamedTextColor.AQUA))
+ .append(Component.text("] ", NamedTextColor.DARK_GRAY))
+ .decoration(TextDecoration.BOLD, false);
+ }
+}
diff --git a/src/main/java/dev/marcospaulo/canalhandia/Titles.java b/src/main/java/dev/marcospaulo/canalhandia/Titles.java
new file mode 100644
index 0000000..505a3ea
--- /dev/null
+++ b/src/main/java/dev/marcospaulo/canalhandia/Titles.java
@@ -0,0 +1,59 @@
+package dev.marcospaulo.canalhandia;
+
+import org.bukkit.configuration.file.YamlConfiguration;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.UUID;
+
+/**
+ * The one title a player has chosen to wear in chat.
+ *
+ * {@link Achievements} decides what a player has earned; this only
+ * remembers which of those they picked to show as a chat tag — one per player,
+ * stored by UUID in {@code titulos.yml}. Earning is not wearing: a player can
+ * hold ten titles and display none, or swap between them at will.
+ *
+ * The stored value is the achievement key, not its display text, so
+ * a title's wording can change in code without rewriting everyone's file. A key
+ * that no longer resolves (an achievement removed from the enum) simply reads
+ * back as no title via {@link Achievement#byKey}, which is the safe direction to
+ * fail.
+ */
+final class Titles {
+
+ private final Canalhandia plugin;
+ private final File file;
+ private final YamlConfiguration data;
+
+ Titles(Canalhandia plugin) {
+ this.plugin = plugin;
+ this.file = new File(plugin.getDataFolder(), "titulos.yml");
+ this.data = YamlConfiguration.loadConfiguration(file);
+ }
+
+ /** The achievement this player wears, or null if none / unknown key. */
+ Achievement chosenAchievement(UUID player) {
+ return Achievement.byKey(data.getString(player.toString(), null));
+ }
+
+ /** Sets the worn title to an achievement's key and persists. */
+ void set(UUID player, Achievement achievement) {
+ data.set(player.toString(), achievement.key());
+ save();
+ }
+
+ /** Clears the worn title and persists. */
+ void clear(UUID player) {
+ data.set(player.toString(), null);
+ save();
+ }
+
+ private void save() {
+ try {
+ data.save(file);
+ } catch (IOException e) {
+ plugin.getLogger().warning("Não consegui salvar titulos.yml: " + e.getMessage());
+ }
+ }
+}
diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml
index 9874ae5..6ac7262 100644
--- a/src/main/resources/plugin.yml
+++ b/src/main/resources/plugin.yml
@@ -87,9 +87,17 @@ commands:
usage: /mortes
aliases: [minhasmortes]
conquistas:
- description: Lista as conquistas e marca as que você já desbloqueou.
- usage: /conquistas
+ description: Lista as conquistas e marca as que você (ou outro jogador) já desbloqueou.
+ usage: /conquistas [jogador]
aliases: [conquista]
+ perfil:
+ description: Mostra o perfil de um jogador — estatísticas, conquistas e título.
+ usage: /perfil [jogador]
+ aliases: [status]
+ titulo:
+ description: Escolhe qual conquista você exibe como título no chat.
+ usage: /titulo [nome|limpar]
+ aliases: [titulos, title]
permissions:
# Declared explicitly: an undeclared Bukkit permission falls back to op-only,
diff --git a/src/test/java/dev/marcospaulo/canalhandia/TitlesTest.java b/src/test/java/dev/marcospaulo/canalhandia/TitlesTest.java
new file mode 100644
index 0000000..09d51ce
--- /dev/null
+++ b/src/test/java/dev/marcospaulo/canalhandia/TitlesTest.java
@@ -0,0 +1,49 @@
+package dev.marcospaulo.canalhandia;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/** The pure parts of the titles feature: selection and the offline stat contract. */
+class TitlesTest {
+
+ @Test
+ void matchesEarnedTitleByKeyAndByName() {
+ List