feat(conquistas): view any player's titles, /perfil card, wearable title tag

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 <noreply@anthropic.com>
This commit is contained in:
Marcos Paulo
2026-08-11 12:17:24 -03:00
parent c54c6b22b3
commit 29000c208b
7 changed files with 375 additions and 11 deletions
@@ -97,6 +97,7 @@ public final class Canalhandia extends JavaPlugin implements Listener {
private Reactions liveReactions; private Reactions liveReactions;
private GuessRound guessRound; private GuessRound guessRound;
private Poll poll; private Poll poll;
private Titles titles;
@Override @Override
public void onEnable() { public void onEnable() {
@@ -108,6 +109,7 @@ public final class Canalhandia extends JavaPlugin implements Listener {
offlineStats = new OfflineStats(this); offlineStats = new OfflineStats(this);
milestones = new Milestones(this); milestones = new Milestones(this);
achievements = new Achievements(this); achievements = new Achievements(this);
titles = new Titles(this);
weeklyStats = new WeeklyStats(new java.io.File(getDataFolder(), "semana.yml")); weeklyStats = new WeeklyStats(new java.io.File(getDataFolder(), "semana.yml"));
aiBudget = new Budget(settings.aiSpontaneousPerDay(), aiBudget = new Budget(settings.aiSpontaneousPerDay(),
settings.aiSpontaneousGapMinutes() * 60_000L, settings.aiSpontaneousGapMinutes() * 60_000L,
@@ -127,11 +129,13 @@ public final class Canalhandia extends JavaPlugin implements Listener {
CanalhandiaCommand root = new CanalhandiaCommand(this); CanalhandiaCommand root = new CanalhandiaCommand(this);
for (String name : List.of("canalhandia", "curiosidade", "adivinha", "enquete", "ranking", for (String name : List.of("canalhandia", "curiosidade", "adivinha", "enquete", "ranking",
"reagir", "reacoes", "palpite", "votar", "legal", "wow", "top", "f", "ia", "iap", "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); register(name, root);
} }
getServer().getPluginManager().registerEvents(this, this); getServer().getPluginManager().registerEvents(this, this);
getServer().getPluginManager().registerEvents(new TitleChatListener(this), this);
rescheduleTimer(); rescheduleTimer();
rescheduleMilestones(); rescheduleMilestones();
@@ -202,6 +206,11 @@ public final class Canalhandia extends JavaPlugin implements Listener {
return achievements; return achievements;
} }
/** The title each player has chosen to wear in chat. Never null. */
Titles titles() {
return titles;
}
/** The weekly ranking baseline. Never null. */ /** The weekly ranking baseline. Never null. */
WeeklyStats weeklyStats() { WeeklyStats weeklyStats() {
return weeklyStats; return weeklyStats;
@@ -17,6 +17,7 @@ import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.UUID;
/** /**
* Every command the plugin owns. * Every command the plugin owns.
@@ -58,7 +59,9 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
case "recado" -> recado(sender, args); case "recado" -> recado(sender, args);
case "recados" -> recados(sender); case "recados" -> recados(sender);
case "mortes" -> mortes(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 // 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 // reaction (the mourning set is hardcoded), so it can't be found via
// reactionForCommand; route it directly. Acts on the latest message, // 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 * marked. Showing the locked ones too is the point: an achievement nobody
* can see is one nobody chases. * can see is one nobody chases.
*
* <p>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)) { if (!plugin.settings().moduleEnabled(Module.CONQUISTAS)) {
Msg.error(sender, "O módulo de conquistas está desligado."); Msg.error(sender, "O módulo de conquistas está desligado.");
return true; return true;
} }
if (args.length == 0) {
if (!(sender instanceof Player player)) { if (!(sender instanceof Player player)) {
Msg.error(sender, "Só jogadores têm conquistas."); Msg.error(sender, "Só jogadores têm conquistas. Use /conquistas <jogador>.");
return true; return true;
} }
List<Achievement> earned = plugin.achievements().earnedBy(player); renderCatalogue(sender, plugin.achievements().earnedBy(player),
Msg.header(sender, "Conquistas (" + earned.size() + "/" + Achievement.values().length + ")"); Platform.isBedrock(player), player.getName());
return true;
}
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<String, Long> 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<Achievement> 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 // Bedrock renders "✔" as a tofu box, so it gets an ASCII marker — the
// same rule the reaction labels follow. // same rule the reaction labels follow.
boolean bedrock = Platform.isBedrock(player);
String tick = bedrock ? " [x] " : ""; String tick = bedrock ? " [x] " : "";
String blank = bedrock ? " [ ] " : " · "; String blank = bedrock ? " [ ] " : " · ";
for (Achievement achievement : Achievement.values()) { for (Achievement achievement : Achievement.values()) {
@@ -1268,8 +1299,120 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
.append(Component.text("" + achievement.description(), .append(Component.text("" + achievement.description(),
NamedTextColor.DARK_GRAY))); 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 <jogador>.");
return true; 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<String, Long> stats = plugin.offlineStats().achievementStats(id);
List<Achievement> 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<Achievement> 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 <nome> 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<Achievement> 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<Achievement> earned) {
List<String> 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) { private void notaAdd(CommandSender sender, String[] args, Note.Scope scope) {
if (!(sender instanceof Player player)) { if (!(sender instanceof Player player)) {
@@ -1547,6 +1690,22 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
if (name.equals("save")) { if (name.equals("save")) {
return args.length == 1 ? filter(List.of("coords"), args[0]) : List.of(); 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<String> 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")) { if (name.equals("ia") || name.equals("iap")) {
// Only the tuning subcommands are suggested — the rest of /ia is // Only the tuning subcommands are suggested — the rest of /ia is
// free text, and completing a question would be noise. // free text, and completing a question would be noise.
@@ -123,6 +123,32 @@ final class OfflineStats {
+ RankingMetric.COMBATE.format(mobKills) + " derrotados."; + 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<String, Long> achievementStats(UUID uuid) {
File dir = statsDirectory();
if (dir == null) {
return null;
}
File file = new File(dir, uuid + ".json");
if (!file.isFile()) {
return null;
}
Map<String, Long> stats = new HashMap<>();
for (RankingMetric metric : RankingMetric.values()) {
stats.put(metric.commandKey(), read(file, metric));
}
return stats;
}
private long read(File file, RankingMetric metric) { private long read(File file, RankingMetric metric) {
try (Reader reader = new FileReader(file)) { try (Reader reader = new FileReader(file)) {
JsonElement root = JsonParser.parseReader(reader); JsonElement root = JsonParser.parseReader(reader);
@@ -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.
*
* <p>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".
*
* <p>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);
}
}
@@ -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.
*
* <p>{@link Achievements} decides what a player has <em>earned</em>; 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.
*
* <p>The stored value is the achievement <em>key</em>, 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());
}
}
}
+10 -2
View File
@@ -87,9 +87,17 @@ commands:
usage: /mortes usage: /mortes
aliases: [minhasmortes] aliases: [minhasmortes]
conquistas: conquistas:
description: Lista as conquistas e marca as que você já desbloqueou. description: Lista as conquistas e marca as que você (ou outro jogador) já desbloqueou.
usage: /conquistas usage: /conquistas [jogador]
aliases: [conquista] 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: permissions:
# Declared explicitly: an undeclared Bukkit permission falls back to op-only, # Declared explicitly: an undeclared Bukkit permission falls back to op-only,
@@ -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<Achievement> 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<Achievement> 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<String, Long> 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));
}
}