i18n: per-player EN/PT via Adventure GlobalTranslator #1
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <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)) {
|
||||
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 <jogador>.");
|
||||
return true;
|
||||
}
|
||||
renderCatalogue(sender, plugin.achievements().earnedBy(player),
|
||||
Platform.isBedrock(player), player.getName());
|
||||
return true;
|
||||
}
|
||||
List<Achievement> 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<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
|
||||
// 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 <jogador>.");
|
||||
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) {
|
||||
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<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")) {
|
||||
// Only the tuning subcommands are suggested — the rest of /ia is
|
||||
// free text, and completing a question would be noise.
|
||||
|
||||
@@ -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<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) {
|
||||
try (Reader reader = new FileReader(file)) {
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user