From 29000c208b16064158efdb4dd87ff1f900cefa2d Mon Sep 17 00:00:00 2001 From: Marcos Paulo Date: Tue, 11 Aug 2026 12:17:24 -0300 Subject: [PATCH] feat(conquistas): view any player's titles, /perfil card, wearable title tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the achievements module from self-only to a full RPG-status layer, all still chat-only and derived from vanilla stats. - /conquistas [jogador]: look up anyone's earned titles, offline included, computed from their stats on disk via the new OfflineStats.achievementStats (same keys the online snapshot uses, so identical Achievement conditions). - /perfil [jogador]: a status card — headline stats, titles earned, worn title. - /titulo [nome|limpar]: pick which earned title to wear; only earned ones are accepted and tab-completed. Stored per player in titulos.yml (Titles). - TitleChatListener: an AsyncChat renderer that prefixes the chosen "[Título]" chip, for Java and Bedrock alike; gated on the conquistas module. Tests: TitlesTest covers title selection and the offline stat-key contract. Co-Authored-By: Claude Opus 4.8 --- .../marcospaulo/canalhandia/Canalhandia.java | 11 +- .../canalhandia/CanalhandiaCommand.java | 175 +++++++++++++++++- .../marcospaulo/canalhandia/OfflineStats.java | 26 +++ .../canalhandia/TitleChatListener.java | 54 ++++++ .../dev/marcospaulo/canalhandia/Titles.java | 59 ++++++ src/main/resources/plugin.yml | 12 +- .../marcospaulo/canalhandia/TitlesTest.java | 49 +++++ 7 files changed, 375 insertions(+), 11 deletions(-) create mode 100644 src/main/java/dev/marcospaulo/canalhandia/TitleChatListener.java create mode 100644 src/main/java/dev/marcospaulo/canalhandia/Titles.java create mode 100644 src/test/java/dev/marcospaulo/canalhandia/TitlesTest.java 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 ."); + return true; + } + renderCatalogue(sender, plugin.achievements().earnedBy(player), + Platform.isBedrock(player), player.getName()); return true; } - List earned = plugin.achievements().earnedBy(player); - Msg.header(sender, "Conquistas (" + earned.size() + "/" + Achievement.values().length + ")"); + String wanted = String.join(" ", args); + OfflineStats.Known who = plugin.offlineStats().resolve(wanted); + if (who == null) { + Msg.error(sender, "Não conheço ninguém chamado \"" + wanted + "\"."); + return true; + } + Map stats = plugin.offlineStats().achievementStats(UUID.fromString(who.uuid())); + if (stats == null) { + Msg.error(sender, "Ainda não tenho estatísticas de " + who.name() + "."); + return true; + } + boolean bedrock = sender instanceof Player viewer && Platform.isBedrock(viewer); + renderCatalogue(sender, Achievement.earned(stats), bedrock, who.name()); + return true; + } + + /** The catalogue with earned entries ticked — shared by self and lookup. */ + private void renderCatalogue(CommandSender sender, java.util.Collection earned, + boolean bedrock, String who) { + Msg.header(sender, "Conquistas de " + who + " (" + + earned.size() + "/" + Achievement.values().length + ")"); // Bedrock renders "✔" as a tofu box, so it gets an ASCII marker — the // same rule the reaction labels follow. - boolean bedrock = Platform.isBedrock(player); String tick = bedrock ? " [x] " : " ✔ "; String blank = bedrock ? " [ ] " : " · "; for (Achievement achievement : Achievement.values()) { @@ -1268,9 +1299,121 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { .append(Component.text(" — " + achievement.description(), NamedTextColor.DARK_GRAY))); } + } + + /** + * {@code /perfil [jogador]} — a status card: headline stats, how many titles + * earned and which are worn. Works for anyone the server has seen, online or + * not, reading the same numbers the rankings do. + */ + private boolean perfil(CommandSender sender, String[] args) { + String uuid; + String name; + if (args.length == 0) { + if (!(sender instanceof Player player)) { + Msg.error(sender, "Diga de quem: /perfil ."); + return true; + } + uuid = player.getUniqueId().toString(); + name = player.getName(); + } else { + String wanted = String.join(" ", args); + OfflineStats.Known who = plugin.offlineStats().resolve(wanted); + if (who == null) { + Msg.error(sender, "Não conheço ninguém chamado \"" + wanted + "\"."); + return true; + } + uuid = who.uuid(); + name = who.name(); + } + UUID id = UUID.fromString(uuid); + Msg.header(sender, "Perfil de " + name); + + String summary = plugin.offlineStats().summary(id); + if (summary == null) { + Msg.line(sender, "Estatísticas", "sem dados ainda"); + } else { + // summary() prefixes the name; the header already has it, so drop it. + int colon = summary.indexOf(": "); + sender.sendMessage(Component.text(" " + + (colon >= 0 ? summary.substring(colon + 2) : summary), NamedTextColor.GRAY)); + } + + Map stats = plugin.offlineStats().achievementStats(id); + List earned = stats == null ? List.of() : Achievement.earned(stats); + Msg.line(sender, "Conquistas", earned.size() + "/" + Achievement.values().length + + (earned.isEmpty() ? "" : " (" + titlesList(earned) + ")")); + + Achievement worn = plugin.titles().chosenAchievement(id); + Msg.line(sender, "Título", worn == null ? "nenhum" : worn.title()); return true; } + /** + * {@code /titulo [nome|limpar]} — choose which earned title to wear in chat, + * or clear it. Only titles you have actually earned can be worn; the list + * with no argument shows exactly those, so nobody has to guess the spelling. + */ + private boolean titulo(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 usam títulos."); + return true; + } + List earned = plugin.achievements().earnedBy(player); + if (args.length == 0) { + Achievement worn = plugin.titles().chosenAchievement(player.getUniqueId()); + Msg.line(sender, "Título atual", worn == null ? "nenhum" : worn.title()); + if (earned.isEmpty()) { + Msg.error(sender, "Você ainda não desbloqueou nenhum título. Veja /conquistas."); + } else { + Msg.line(sender, "Disponíveis", titlesList(earned)); + sender.sendMessage(Component.text(" Use /titulo para usar, " + + "ou /titulo limpar para tirar.", NamedTextColor.DARK_GRAY)); + } + return true; + } + String arg = String.join(" ", args); + if (arg.equalsIgnoreCase("limpar") || arg.equalsIgnoreCase("nenhum")) { + plugin.titles().clear(player.getUniqueId()); + Msg.ok(player, "Título removido."); + return true; + } + Achievement chosen = matchEarned(arg, earned); + if (chosen == null) { + Msg.error(sender, "Você não tem o título \"" + arg + "\". Veja /titulo para a lista."); + return true; + } + plugin.titles().set(player.getUniqueId(), chosen); + Msg.ok(player, "Título definido: " + chosen.title() + "."); + return true; + } + + /** Matches typed text to an earned achievement by key or (case-insensitive) title. */ + static Achievement matchEarned(String text, List earned) { + Achievement byKey = Achievement.byKey(text); + if (byKey != null && earned.contains(byKey)) { + return byKey; + } + for (Achievement achievement : earned) { + if (achievement.title().equalsIgnoreCase(text.trim())) { + return achievement; + } + } + return null; + } + + private static String titlesList(List earned) { + List names = new ArrayList<>(); + for (Achievement achievement : earned) { + names.add(achievement.title()); + } + return String.join(", ", names); + } + private void notaAdd(CommandSender sender, String[] args, Note.Scope scope) { if (!(sender instanceof Player player)) { Msg.error(sender, "Só jogadores podem anotar (a anotação guarda onde você está)."); @@ -1547,6 +1690,22 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { if (name.equals("save")) { return args.length == 1 ? filter(List.of("coords"), args[0]) : List.of(); } + if ((name.equals("perfil") || name.equals("conquistas")) && args.length == 1) { + // Any player the server has seen — these commands read off disk. + return filter(plugin.offlineStats().knownNames(), args[0]); + } + if (name.equals("titulo") && args.length == 1) { + // Only the titles this player has actually earned, plus the clear + // word — completion should never suggest a title you cannot wear. + List options = new ArrayList<>(); + options.add("limpar"); + if (sender instanceof Player player) { + for (Achievement achievement : plugin.achievements().earnedBy(player)) { + options.add(achievement.title()); + } + } + return filter(options, args[0]); + } if (name.equals("ia") || name.equals("iap")) { // Only the tuning subcommands are suggested — the rest of /ia is // free text, and completing a question would be noise. diff --git a/src/main/java/dev/marcospaulo/canalhandia/OfflineStats.java b/src/main/java/dev/marcospaulo/canalhandia/OfflineStats.java index 87830d9..8f87d5f 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/OfflineStats.java +++ b/src/main/java/dev/marcospaulo/canalhandia/OfflineStats.java @@ -123,6 +123,32 @@ final class OfflineStats { + RankingMetric.COMBATE.format(mobKills) + " derrotados."; } + /** + * The full stat map an {@link Achievement} reads, for a player who may be + * offline. Keyed by {@link RankingMetric#commandKey()} — the same names the + * online {@link Achievements#snapshot} produces — so the identical pure + * conditions in {@link Achievement} evaluate the same whether the player is + * on- or offline. + * + * @return null when there is no stats file for this player (never played, or + * the directory is missing), which the caller shows as "sem dados". + */ + Map achievementStats(UUID uuid) { + File dir = statsDirectory(); + if (dir == null) { + return null; + } + File file = new File(dir, uuid + ".json"); + if (!file.isFile()) { + return null; + } + Map stats = new HashMap<>(); + for (RankingMetric metric : RankingMetric.values()) { + stats.put(metric.commandKey(), read(file, metric)); + } + return stats; + } + private long read(File file, RankingMetric metric) { try (Reader reader = new FileReader(file)) { JsonElement root = JsonParser.parseReader(reader); diff --git a/src/main/java/dev/marcospaulo/canalhandia/TitleChatListener.java b/src/main/java/dev/marcospaulo/canalhandia/TitleChatListener.java new file mode 100644 index 0000000..144fb22 --- /dev/null +++ b/src/main/java/dev/marcospaulo/canalhandia/TitleChatListener.java @@ -0,0 +1,54 @@ +package dev.marcospaulo.canalhandia; + +import io.papermc.paper.chat.ChatRenderer; +import io.papermc.paper.event.player.AsyncChatEvent; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; + +/** + * Prefixes a player's chosen title to their chat line, when they wear one. + * + *

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 earned = List.of(Achievement.PEDREIRO, Achievement.VETERANO); + // by key + assertSame(Achievement.PEDREIRO, CanalhandiaCommand.matchEarned("pedreiro", earned)); + // by display name, case-insensitively + assertSame(Achievement.VETERANO, CanalhandiaCommand.matchEarned("veterano", earned)); + assertSame(Achievement.PEDREIRO, CanalhandiaCommand.matchEarned("Pedreiro", earned)); + } + + @Test + void refusesTitlesNotYetEarned() { + List earned = List.of(Achievement.PEDREIRO); + // A real achievement, but not one this player has: cannot be worn. + assertNull(CanalhandiaCommand.matchEarned("veterano", earned)); + assertNull(CanalhandiaCommand.matchEarned("Casca Grossa", earned)); + // Not an achievement at all. + assertNull(CanalhandiaCommand.matchEarned("rei do mundo", earned)); + } + + @Test + void offlineStatKeysFeedAchievementConditions() { + // OfflineStats.achievementStats keys its map by RankingMetric.commandKey(); + // Achievement conditions read the same names. If those two drift apart, a + // lookup by name silently awards nothing — so pin the contract here. + Map stats = new HashMap<>(); + stats.put(RankingMetric.MINERACAO.commandKey(), 10_000L); + assertTrue(Achievement.earned(stats).contains(Achievement.PEDREIRO)); + + stats.put(RankingMetric.MINERACAO.commandKey(), 9_999L); + assertFalse(Achievement.earned(stats).contains(Achievement.PEDREIRO)); + } +}