2 Commits

Author SHA1 Message Date
gitea_admin a2df6570e9 feat: add fetchWikiRaw helper for the wiki search box 2026-08-17 23:40:30 +00:00
masi dafd96a4b6 i18n: per-player EN/PT via Adventure GlobalTranslator (#1)
Foundation + commands module of the i18n spec.

- I18n registry/loader + Lang.tr facade + reloadI18n
- PT source-of-truth bundle + EN translation
- CanalhandiaCommand player-facing strings migrated; admin-tuning/help/enum-labels deferred
- I18nTest: parity + per-locale render + pt_BR fallback; 316/316 green

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-08-12 15:53:27 +00:00
20 changed files with 1192 additions and 205 deletions
@@ -1,12 +1,16 @@
package dev.marcospaulo.canalhandia;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextColor;
import org.bukkit.configuration.ConfigurationSection;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.logging.Logger;
/**
@@ -23,11 +27,18 @@ import java.util.logging.Logger;
* a number, another metric, or {@code metrica/numero}. Metrics are written in
* friendly units — distance in kilometres, time in hours — normalised from the
* raw statistics before evaluation, so the file reads the way a person thinks.
* A metric may also be a {@link StatRef} like {@code matou:creeper}, reaching any
* per-mob or per-block vanilla counter with no code change.
*
* <p>Each title also carries a {@code tier} (comum…lendario) that colours its
* chat tag, and an optional {@code cor} override (named or {@code #hex}), so a
* legendary reads gold and a rare reads aqua without touching Java.
*/
final class Achievement {
/** Metrics a condition may read, in the units the config is written in.
* mineracao/combate/mortes/pesca/pulos are raw counts; distancia is km; tempo is hours. */
/** Friendly metrics a condition may read, in the units the config is written in.
* mineracao/combate/mortes/pesca/pulos are raw counts; distancia is km; tempo is hours.
* A condition may also name a {@link StatRef} (e.g. {@code matou:creeper}). */
private static final List<String> METRICS = List.of(
"mineracao", "combate", "mortes", "pesca", "pulos", "distancia", "tempo");
@@ -38,12 +49,17 @@ final class Achievement {
private final String title;
private final String description;
private final Condition condition;
private final TextColor color;
private final Set<String> statRefs;
private Achievement(String key, String title, String description, Condition condition) {
private Achievement(String key, String title, String description, Condition condition,
TextColor color, Set<String> statRefs) {
this.key = key;
this.title = title;
this.description = description;
this.condition = condition;
this.color = color;
this.statRefs = Set.copyOf(statRefs);
}
String key() {
@@ -58,6 +74,11 @@ final class Achievement {
return description;
}
/** The colour this title's chat tag is drawn in, from its tier or {@code cor} override. */
TextColor color() {
return color;
}
/** True when this player's raw statistics satisfy the condition. */
boolean met(Map<String, Long> rawStats) {
return rawStats != null && condition.met(normalise(rawStats));
@@ -75,6 +96,16 @@ final class Achievement {
return catalog.toArray(new Achievement[0]);
}
/** Every vanilla {@link StatRef} the live catalogue mentions, so the stats
* reader knows which per-mob/per-block counters to fetch. Empty until load. */
static Set<String> referencedStats() {
Set<String> all = new HashSet<>();
for (Achievement achievement : catalog) {
all.addAll(achievement.statRefs);
}
return all;
}
static Achievement byKey(String key) {
if (key == null) {
return null;
@@ -123,7 +154,8 @@ final class Achievement {
}
try {
out.add(parse(key, entry.getString("titulo", ""),
entry.getString("descricao", ""), entry.getStringList("condicoes")));
entry.getString("descricao", ""), entry.getStringList("condicoes"),
entry.getString("tier"), entry.getString("cor")));
} catch (IllegalArgumentException bad) {
log.warning("Conquista '" + key + "' ignorada: " + bad.getMessage());
}
@@ -131,8 +163,14 @@ final class Achievement {
return out;
}
/** Builds one achievement, parsing its condition clauses. Visible for tests. */
/** Builds one achievement with the default (comum) colour. Visible for tests. */
static Achievement parse(String key, String title, String description, List<String> conditions) {
return parse(key, title, description, conditions, null, null);
}
/** Builds one achievement, parsing its condition clauses and resolving its colour. */
static Achievement parse(String key, String title, String description, List<String> conditions,
String tier, String cor) {
String normalizedKey = key == null ? "" : key.trim().toLowerCase(Locale.ROOT);
if (!normalizedKey.matches("[a-z-]+")) {
throw new IllegalArgumentException("chave inválida (use apenas a-z e '-'): " + key);
@@ -144,8 +182,16 @@ final class Achievement {
throw new IllegalArgumentException("sem condicoes");
}
List<Clause> clauses = new ArrayList<>();
Set<String> refs = new HashSet<>();
for (String raw : conditions) {
clauses.add(Clause.parse(raw));
Clause clause = Clause.parse(raw);
clauses.add(clause);
if (StatRef.isRef(clause.metric())) {
refs.add(clause.metric());
}
if (clause.rhsMetric() != null && StatRef.isRef(clause.rhsMetric())) {
refs.add(clause.rhsMetric());
}
}
Condition condition = stats -> {
for (Clause clause : clauses) {
@@ -155,12 +201,41 @@ final class Achievement {
}
return true;
};
return new Achievement(normalizedKey, title, description, condition);
return new Achievement(normalizedKey, title, description, condition,
resolveColor(tier, cor), refs);
}
/** Raw statistics → the friendly units the conditions are written in. */
/** Tier or explicit {@code cor} → the colour of the chat tag. Bad input falls
* back to the tier colour, and an unknown tier to a readable white. */
private static TextColor resolveColor(String tier, String cor) {
if (cor != null && !cor.isBlank()) {
String value = cor.trim();
TextColor explicit = value.startsWith("#")
? TextColor.fromHexString(value)
: NamedTextColor.NAMES.value(value.toLowerCase(Locale.ROOT));
if (explicit != null) {
return explicit;
}
}
return tierColor(tier);
}
/** Default colour for each difficulty tier. Higher tiers read cooler/brighter. */
private static TextColor tierColor(String tier) {
String name = tier == null ? "" : tier.trim().toLowerCase(Locale.ROOT);
return switch (name) {
case "incomum" -> NamedTextColor.GREEN;
case "raro" -> NamedTextColor.AQUA;
case "epico", "épico" -> NamedTextColor.LIGHT_PURPLE;
case "lendario", "lendário" -> NamedTextColor.GOLD;
default -> NamedTextColor.WHITE; // comum / unset — always legible
};
}
/** Raw statistics → the friendly units the conditions are written in. Any
* {@link StatRef} counts (matou:*, minerou:*) pass through untouched. */
private static Map<String, Long> normalise(Map<String, Long> raw) {
Map<String, Long> out = new HashMap<>();
Map<String, Long> out = new HashMap<>(raw);
out.put("mineracao", raw.getOrDefault("mineracao", 0L));
out.put("combate", raw.getOrDefault("combate", 0L));
out.put("mortes", raw.getOrDefault("mortes", 0L));
@@ -215,7 +290,7 @@ final class Achievement {
throw new IllegalArgumentException("condicao mal formada: '" + raw + "'");
}
String metric = parts[0].toLowerCase(Locale.ROOT);
if (!METRICS.contains(metric)) {
if (!METRICS.contains(metric) && !StatRef.isValid(metric)) {
throw new IllegalArgumentException("metrica desconhecida: " + metric);
}
Op op = Op.of(parts[1]);
@@ -237,7 +312,7 @@ final class Achievement {
throw new IllegalArgumentException("divisão por zero: " + target);
}
}
if (!METRICS.contains(rhsMetric)) {
if (!METRICS.contains(rhsMetric) && !StatRef.isValid(rhsMetric)) {
throw new IllegalArgumentException("metrica desconhecida: " + rhsMetric);
}
return new Clause(metric, op, rhsMetric, 0, divisor);
@@ -4,14 +4,12 @@ import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextDecoration;
import org.bukkit.Bukkit;
import org.bukkit.Statistic;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.entity.Player;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
@@ -113,7 +111,14 @@ final class Achievements {
/** @return true if anything was recorded, so the caller can save once */
private boolean check(Player player) {
Map<String, Long> stats = snapshot(player);
// Read straight off the stats file, the same source /perfil and /conquistas
// use, so a title can hinge on any per-mob or per-block counter (matou:creeper)
// that Bukkit's typed API would make us enumerate by hand. The file lags a
// live session by seconds — invisible for cumulative threshold titles.
Map<String, Long> stats = plugin.offlineStats().achievementStats(player.getUniqueId());
if (stats == null) {
return false; // no stats file written yet — nothing to bank, retry next tick
}
String base = player.getUniqueId().toString();
// A player with no record yet is being seen for the first time: bank
// what they have without announcing it.
@@ -149,7 +154,7 @@ final class Achievements {
.decoration(TextDecoration.BOLD, false))
.append(Component.text(" desbloqueou ", NamedTextColor.WHITE)
.decoration(TextDecoration.BOLD, false))
.append(Component.text(achievement.title(), NamedTextColor.AQUA)
.append(Component.text(achievement.title(), achievement.color())
.decoration(TextDecoration.BOLD, false))
.append(Component.text("" + achievement.description(), NamedTextColor.GRAY)
.decoration(TextDecoration.BOLD, false)));
@@ -168,36 +173,6 @@ final class Achievements {
return out;
}
/**
* The stat map an {@link Achievement} condition reads, keyed the same way
* as the config's category names.
*
* <p>Statistic constants get renamed between Minecraft releases, so each is
* resolved by name through {@link Stats#resolve} rather than referenced
* directly — a missing one yields zero instead of failing to load the class.
*/
static Map<String, Long> snapshot(Player player) {
Map<String, Long> stats = new HashMap<>();
stats.put("mineracao", total(player, "MINE_BLOCK"));
stats.put("tempo", untyped(player, "PLAY_TIME"));
stats.put("distancia", untyped(player, "WALK_ONE_CM"));
stats.put("mortes", untyped(player, "DEATHS"));
stats.put("combate", untyped(player, "MOB_KILLS"));
stats.put("pesca", untyped(player, "FISH_CAUGHT"));
stats.put("pulos", untyped(player, "JUMP"));
return stats;
}
private static long untyped(Player player, String name) {
Statistic statistic = Stats.resolve(name);
return statistic == null ? 0L : Stats.untyped(player, statistic);
}
private static long total(Player player, String name) {
Statistic statistic = Stats.resolve(name);
return statistic == null ? 0L : Stats.totalOf(player, statistic);
}
private void save() {
try {
data.save(file);
@@ -5,6 +5,7 @@ import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextDecoration;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import net.kyori.adventure.translation.TranslationStore;
import org.bukkit.Bukkit;
import org.bukkit.GameRule;
import org.bukkit.Location;
@@ -98,6 +99,8 @@ public final class Canalhandia extends JavaPlugin implements Listener {
private GuessRound guessRound;
private Poll poll;
private Titles titles;
private DeathGift deathGift;
private TranslationStore<?> i18n;
@Override
public void onEnable() {
@@ -105,6 +108,7 @@ public final class Canalhandia extends JavaPlugin implements Listener {
// Ship the editable catalogues; false = never overwrite the operator's copy.
saveResource("conquistas-catalogo.yml", false);
saveResource("marcos-catalogo.yml", false);
i18n = I18n.install(i18n, getLogger());
settings = new Settings(this);
notes = new Notes(new java.io.File(getDataFolder(), "notas.yml"));
mail = new Mail(new java.io.File(getDataFolder(), "recados.yml"));
@@ -113,6 +117,7 @@ public final class Canalhandia extends JavaPlugin implements Listener {
milestones = new Milestones(this);
achievements = new Achievements(this);
titles = new Titles(this);
deathGift = new DeathGift(this);
// Load the achievement catalogue from config, then silently bank any
// history the current definitions already imply (both here and for
// milestones), so an expanded catalogue never spams returning players.
@@ -247,6 +252,12 @@ public final class Canalhandia extends JavaPlugin implements Listener {
Achievement.load(Achievement.loadFrom(conquistasCatalogo(), getLogger()));
milestones.reload();
achievements.syncCatalogue();
deathGift.reload();
}
/** Reloads the i18n bundles from the jar and re-registers the translator. */
void reloadI18n() {
i18n = I18n.install(i18n, getLogger());
}
/** The weekly ranking baseline. Never null. */
@@ -772,11 +783,12 @@ public final class Canalhandia extends JavaPlugin implements Listener {
button = button.clickEvent(ClickEvent.runCommand(
"/canalhandia reagir " + mourning.id() + " f"));
}
Component prompt = Lang.tr(bedrock
? "canalhandia.morte.luto.digitar"
: "canalhandia.morte.luto.prestar",
Component.text(name));
return Component.text(" ").append(button)
.append(Component.text(bedrock
? "digite /f para prestar luto por " + name
: "prestar luto por " + name,
NamedTextColor.GRAY));
.append(prompt.color(NamedTextColor.GRAY));
}), 2L);
getServer().getScheduler().runTaskLater(this, () -> {
@@ -786,9 +798,10 @@ public final class Canalhandia extends JavaPlugin implements Listener {
int shown = Math.min(who.size(), settings.summaryNames());
String text = String.join(", ", who.subList(0, shown))
+ (who.size() > shown ? " +" + (who.size() - shown) : "");
Component summary = Lang.tr("canalhandia.morte.luto.resumo",
Component.text(text), Component.text(name));
Bukkit.broadcast(Component.text(" ")
.append(Component.text(text + " prestaram luto por " + name + ".",
NamedTextColor.GRAY)));
.append(summary.color(NamedTextColor.GRAY)));
}
if (liveReactions == mourning) {
liveReactions = null;
@@ -895,6 +908,11 @@ public final class Canalhandia extends JavaPlugin implements Listener {
.append(Component.text(tail, NamedTextColor.AQUA));
}
player.sendMessage(msg);
// A comic consolation item, given once they can actually hold it.
// Gameplay-neutral by design (a poppy, a wilted bush) — just a laugh.
if (deathGift.active()) {
deathGift.give(player);
}
}, 1L);
}
@@ -107,6 +107,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
if (admin(sender)) {
plugin.reloadConfig();
plugin.reloadCatalogo();
plugin.reloadI18n();
plugin.rescheduleTimer();
plugin.rescheduleMilestones();
Msg.ok(sender, "Recarregado: " + Achievement.values().length
@@ -126,7 +127,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
return denied(sender);
}
if (!plugin.announceCuriosity(null)) {
Msg.error(sender, "Ninguém elegível online (ou sem estatísticas suficientes).");
Msg.error(sender, Lang.tr("canalhandia.cmd.curiosidade.nenhum-elegivel"));
}
return true;
}
@@ -190,11 +191,12 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
default -> {
Player target = Bukkit.getPlayerExact(args[0]);
if (target == null) {
Msg.error(sender, "Subcomando ou jogador desconhecido. Use /curiosidade ajuda");
Msg.error(sender, Lang.tr("canalhandia.cmd.curiosidade.subdesconhecido"));
} else if (!sender.hasPermission("canalhandia.forcar")) {
denied(sender);
} else if (!plugin.announceCuriosity(target)) {
Msg.error(sender, target.getName() + " ainda não tem estatísticas suficientes.");
Msg.error(sender, Lang.tr("canalhandia.cmd.curiosidade.sem-stats",
Component.text(target.getName())));
}
}
}
@@ -208,7 +210,8 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
}
List<Fact> facts = CuriosityFactory.facts(target, plugin.settings());
if (facts.isEmpty()) {
Msg.error(sender, "Nenhuma curiosidade disponível para " + target.getName() + ".");
Msg.error(sender, Lang.tr("canalhandia.cmd.curiosidade.sem-curiosidade",
Component.text(target.getName())));
return;
}
Fact fact = facts.get((int) (Math.random() * facts.size()));
@@ -242,27 +245,27 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
if (args.length > 0) {
Player target = Bukkit.getPlayerExact(args[0]);
if (target == null) {
Msg.error(sender, "Jogador '" + args[0] + "' não está online.");
Msg.error(sender, Lang.tr("canalhandia.cmd.jogador.offline", Component.text(args[0])));
}
return target;
}
if (sender instanceof Player player) {
return player;
}
Msg.error(sender, "Informe um jogador.");
Msg.error(sender, Lang.tr("canalhandia.cmd.jogador.informe"));
return null;
}
private void toggle(CommandSender sender) {
if (!(sender instanceof Player player)) {
Msg.error(sender, "Só jogadores podem usar isso.");
Msg.error(sender, Lang.tr("canalhandia.cmd.sojogador.usar"));
return;
}
boolean optedOut = !plugin.isOptedOut(player);
plugin.setOptedOut(player, optedOut);
sender.sendMessage(optedOut
? Component.text("Você não aparecerá mais nas curiosidades.", NamedTextColor.YELLOW)
: Component.text("Você voltou a aparecer nas curiosidades.", NamedTextColor.GREEN));
? Lang.tr("canalhandia.cmd.curiosidade.toggle-off").color(NamedTextColor.YELLOW)
: Lang.tr("canalhandia.cmd.curiosidade.toggle-on").color(NamedTextColor.GREEN));
}
// --- /adivinha ----------------------------------------------------------
@@ -272,7 +275,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
return denied(sender);
}
if (!plugin.settings().moduleEnabled(Module.ADIVINHA)) {
Msg.error(sender, "O módulo adivinha está desativado.");
Msg.error(sender, Lang.tr("canalhandia.cmd.modulo.desativado", Component.text("adivinha")));
} else if (!plugin.startGuess()) {
Msg.error(sender, "Precisa de pelo menos 2 jogadores online com estatísticas.");
}
@@ -285,7 +288,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
}
GuessRound round = plugin.guessRound();
if (round == null || round.id() != parse(args[0], -1)) {
player.sendActionBar(Component.text("Essa rodada já acabou.", NamedTextColor.RED));
player.sendActionBar(Lang.tr("canalhandia.cmd.adivinha.rodada-acabou").color(NamedTextColor.RED));
return;
}
player.sendActionBar(round.guess(player, args[1]));
@@ -298,13 +301,13 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
return denied(sender);
}
if (!plugin.settings().moduleEnabled(Module.ENQUETE)) {
Msg.error(sender, "O módulo enquete está desativado.");
Msg.error(sender, Lang.tr("canalhandia.cmd.modulo.desativado", Component.text("enquete")));
return true;
}
if (args.length == 1 && args[0].equalsIgnoreCase("encerrar")) {
Poll poll = plugin.poll();
if (poll == null || poll.closed()) {
Msg.error(sender, "Nenhuma enquete aberta.");
Msg.error(sender, Lang.tr("canalhandia.cmd.enquete.nenhuma"));
} else {
Bukkit.broadcast(poll.close());
}
@@ -338,7 +341,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
}
Poll poll = plugin.poll();
if (poll == null || poll.id() != parse(args[0], -1)) {
player.sendActionBar(Component.text("Essa enquete já foi encerrada.", NamedTextColor.RED));
player.sendActionBar(Lang.tr("canalhandia.cmd.votar.encerrada").color(NamedTextColor.RED));
return;
}
Component result = poll.vote(player, parse(args[1], -1));
@@ -354,7 +357,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
return denied(sender);
}
if (!plugin.settings().moduleEnabled(Module.RANKING)) {
Msg.error(sender, "O módulo ranking está desativado.");
Msg.error(sender, Lang.tr("canalhandia.cmd.modulo.desativado", Component.text("ranking")));
return true;
}
if (args.length == 0) {
@@ -382,7 +385,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
RankingMetric metric = RankingMetric.byKey(rest[0]);
if (metric == null) {
Msg.error(sender, "Ranking desconhecido. Use /ranking para ver a lista.");
Msg.error(sender, Lang.tr("canalhandia.cmd.ranking.desconhecido"));
return true;
}
int size = plugin.settings().rankingSize();
@@ -399,7 +402,8 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
Msg.line(sender, "desde", taken == 0 ? "o começo" : Msg.ago(taken));
}
if (rows.isEmpty()) {
sender.sendMessage(Component.text(" (sem dados ainda)", NamedTextColor.GRAY));
sender.sendMessage(Component.text(" ", NamedTextColor.GRAY)
.append(Lang.tr("canalhandia.cmd.ranking.sem-dados")));
return true;
}
for (int i = 0; i < rows.size(); i++) {
@@ -425,7 +429,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
*/
private boolean reactLatest(CommandSender sender, String key) {
if (!(sender instanceof Player player)) {
Msg.error(sender, "Só jogadores podem reagir.");
Msg.error(sender, Lang.tr("canalhandia.cmd.sojogador.reagir"));
return true;
}
if (!player.hasPermission("canalhandia.reagir")) {
@@ -433,15 +437,16 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
}
Reactions reactions = plugin.latestReactions();
if (reactions == null) {
Msg.error(sender, "Nada para reagir agora.");
Msg.error(sender, Lang.tr("canalhandia.cmd.reagir.nada"));
return true;
}
if (key == null) {
Msg.error(sender, "Uso: /reagir <" + String.join("|", reactionKeys()) + ">");
Msg.error(sender, Lang.tr("canalhandia.cmd.reagir.uso",
Component.text(String.join("|", reactionKeys()))));
return true;
}
if (!reactions.react(player, key)) {
Msg.error(sender, "Essa reação não vale para a última mensagem.");
Msg.error(sender, Lang.tr("canalhandia.cmd.reagir.invalida"));
} else {
plugin.afterReact(player, reactions.id(), key);
}
@@ -454,11 +459,11 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
}
GuessRound round = plugin.guessRound();
if (round == null || round.finished()) {
Msg.error(sender, "Nenhuma adivinha aberta.");
Msg.error(sender, Lang.tr("canalhandia.cmd.palpite.nenhuma"));
return true;
}
if (args.length == 0) {
Msg.error(sender, "Uso: /palpite <nome>");
Msg.error(sender, Lang.tr("canalhandia.cmd.palpite.uso"));
return true;
}
player.sendMessage(round.guess(player, args[0]));
@@ -471,16 +476,16 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
}
Poll poll = plugin.poll();
if (poll == null || poll.closed()) {
Msg.error(sender, "Nenhuma enquete aberta.");
Msg.error(sender, Lang.tr("canalhandia.cmd.enquete.nenhuma"));
return true;
}
if (args.length == 0) {
Msg.error(sender, "Uso: /votar <número>");
Msg.error(sender, Lang.tr("canalhandia.cmd.votar.uso"));
return true;
}
Component result = poll.vote(player, parse(args[0], -1));
player.sendMessage(result == null
? Component.text("Essa opção não existe.", NamedTextColor.RED)
? Lang.tr("canalhandia.cmd.votar.opcao-inexistente").color(NamedTextColor.RED)
: result);
return true;
}
@@ -489,11 +494,11 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
private boolean whoReacted(CommandSender sender) {
Reactions reactions = plugin.latestReactions();
if (reactions == null || !reactions.hasAnyVote()) {
Msg.error(sender, "Ninguém reagiu à última mensagem ainda.");
Msg.error(sender, Lang.tr("canalhandia.cmd.reacoes.nenhuma"));
return true;
}
boolean bedrock = sender instanceof Player player && Platform.isBedrock(player);
Msg.header(sender, "Quem reagiu (" + reactions.total() + ")");
Msg.header(sender, Lang.tr("canalhandia.cmd.reacoes.cabecalho", Component.text(reactions.total())));
reactions.breakdown(bedrock).forEach(sender::sendMessage);
return true;
}
@@ -531,11 +536,11 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
}
Reactions reactions = plugin.findReactions(parse(args[0], -1));
if (reactions == null) {
player.sendActionBar(Component.text("Essa mensagem já expirou.", NamedTextColor.RED));
player.sendActionBar(Lang.tr("canalhandia.cmd.reagir.expirou").color(NamedTextColor.RED));
return;
}
if (!reactions.react(player, args[1])) {
player.sendActionBar(Component.text("Reação desconhecida.", NamedTextColor.RED));
player.sendActionBar(Lang.tr("canalhandia.cmd.reagir.desconhecida").color(NamedTextColor.RED));
} else {
plugin.afterReact(player, reactions.id(), args[1]);
}
@@ -939,11 +944,11 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
return denied(sender);
}
if (!plugin.settings().moduleEnabled(Module.IA)) {
Msg.error(sender, "O módulo de IA está desligado.");
Msg.error(sender, Lang.tr("canalhandia.cmd.modulo.desligado", Component.text("IA")));
return true;
}
if (!(sender instanceof Player player)) {
Msg.error(sender, "Só jogadores podem usar /ia.");
Msg.error(sender, Lang.tr("canalhandia.cmd.sojogador.ia"));
return true;
}
// Subcommands come before the question. They only hijack when the
@@ -995,7 +1000,8 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
// default: treat all args as the question
}
if (args.length == 0) {
Msg.error(sender, isPrivate ? "Uso: /iap <pergunta>" : "Uso: /ia <pergunta>");
Msg.error(sender, Lang.tr("canalhandia.cmd.ia.uso",
Component.text(isPrivate ? "iap" : "ia")));
return true;
}
plugin.ai().ask(player, String.join(" ", args), isPrivate);
@@ -1061,7 +1067,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
*/
private boolean nota(CommandSender sender, String[] args) {
if (!plugin.settings().moduleEnabled(Module.NOTAS)) {
Msg.error(sender, "O módulo de anotações está desligado.");
Msg.error(sender, Lang.tr("canalhandia.cmd.modulo.desligado", Component.text("anotações")));
return true;
}
if (!sender.hasPermission("canalhandia.nota")) {
@@ -1095,14 +1101,14 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
*/
private boolean saveShortcut(CommandSender sender, String[] args) {
if (!plugin.settings().moduleEnabled(Module.NOTAS)) {
Msg.error(sender, "O módulo de anotações está desligado.");
Msg.error(sender, Lang.tr("canalhandia.cmd.modulo.desligado", Component.text("anotações")));
return true;
}
if (!sender.hasPermission("canalhandia.nota")) {
return denied(sender);
}
if (!(sender instanceof Player player)) {
Msg.error(sender, "Só jogadores podem anotar (a anotação guarda onde você está).");
Msg.error(sender, Lang.tr("canalhandia.cmd.sojogador.nota"));
return true;
}
// "/save" and "/save coords" mean the same thing: just the place. The
@@ -1129,23 +1135,23 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
*/
private boolean recado(CommandSender sender, String[] args) {
if (!plugin.settings().moduleEnabled(Module.RECADOS)) {
Msg.error(sender, "O módulo de recados está desligado.");
Msg.error(sender, Lang.tr("canalhandia.cmd.modulo.desligado", Component.text("recados")));
return true;
}
if (!sender.hasPermission("canalhandia.recado")) {
return denied(sender);
}
if (!(sender instanceof Player player)) {
Msg.error(sender, "Só jogadores podem mandar recado.");
Msg.error(sender, Lang.tr("canalhandia.cmd.sojogador.recado"));
return true;
}
if (args.length < 2) {
Msg.error(sender, "Uso: /recado <jogador> <texto>");
Msg.error(sender, Lang.tr("canalhandia.cmd.recado.uso"));
return true;
}
String text = Note.cleanText(String.join(" ", Arrays.copyOfRange(args, 1, args.length)));
if (text == null) {
Msg.error(sender, "O recado está vazio.");
Msg.error(sender, Lang.tr("canalhandia.cmd.recado.vazio"));
return true;
}
@@ -1158,23 +1164,21 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
// anyone who has played here before.
OfflineStats.Known target = plugin.offlineStats().resolve(args[0]);
if (target == null) {
Msg.error(sender, "Não conheço ninguém chamado \"" + args[0]
+ "\". (Só dá para mandar recado para quem já entrou no servidor.)");
Msg.error(sender, Lang.tr("canalhandia.cmd.recado.desconhecido", Component.text(args[0])));
return true;
}
if (target.uuid().equals(player.getUniqueId().toString())) {
Msg.error(sender, "Recado para você mesmo? Use /save.");
Msg.error(sender, Lang.tr("canalhandia.cmd.recado.mesmo"));
return true;
}
Mail.Message message = plugin.mail().send(player.getName(),
player.getUniqueId().toString(), target.uuid(), text);
if (message == null) {
Msg.error(sender, "A caixa de " + target.name() + " está cheia ("
+ Mail.MAX_PER_RECIPIENT + " recados). Espere ela entrar.");
Msg.error(sender, Lang.tr("canalhandia.cmd.recado.caixa-cheia",
Component.text(target.name()), Component.text(Mail.MAX_PER_RECIPIENT)));
return true;
}
Msg.ok(sender, "Recado guardado para " + target.name()
+ ". Vai chegar quando " + target.name() + " entrar.");
Msg.ok(sender, Lang.tr("canalhandia.cmd.recado.guardado", Component.text(target.name())));
return true;
}
@@ -1183,24 +1187,25 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
to.sendMessage(Msg.tag("Recado", NamedTextColor.AQUA)
.append(Component.text(from.getName() + ": ", NamedTextColor.GRAY))
.append(Component.text(text, NamedTextColor.WHITE)));
Msg.ok(from, to.getName() + " está online — recado entregue na hora.");
Msg.ok(from, Lang.tr("canalhandia.cmd.recado.online", Component.text(to.getName())));
}
/** {@code /recados} — how many of your messages are still unread. */
private boolean recados(CommandSender sender) {
if (!plugin.settings().moduleEnabled(Module.RECADOS)) {
Msg.error(sender, "O módulo de recados está desligado.");
Msg.error(sender, Lang.tr("canalhandia.cmd.modulo.desligado", Component.text("recados")));
return true;
}
if (!(sender instanceof Player player)) {
Msg.error(sender, "Só jogadores têm recados.");
Msg.error(sender, Lang.tr("canalhandia.cmd.sojogador.recados"));
return true;
}
int pending = plugin.mail().countFrom(player.getUniqueId().toString());
Msg.ok(sender, pending == 0
? "Todos os seus recados já foram entregues."
: pending + (pending == 1 ? " recado seu ainda não foi lido."
: " recados seus ainda não foram lidos."));
? Lang.tr("canalhandia.cmd.recados.tudo-entregue")
: Lang.tr(pending == 1 ? "canalhandia.cmd.recados.pendentes-singular"
: "canalhandia.cmd.recados.pendentes-plural",
Component.text(pending)));
return true;
}
@@ -1214,27 +1219,26 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
*/
private boolean mortes(CommandSender sender) {
if (!plugin.settings().moduleEnabled(Module.MORTES)) {
Msg.error(sender, "O módulo de mortes está desligado.");
Msg.error(sender, Lang.tr("canalhandia.cmd.modulo.desligado", Component.text("mortes")));
return true;
}
if (!(sender instanceof Player player)) {
Msg.error(sender, "Só jogadores têm histórico de mortes.");
Msg.error(sender, Lang.tr("canalhandia.cmd.sojogador.mortes"));
return true;
}
List<DeathLog.Entry> deaths = plugin.deathLog().forPlayer(player.getUniqueId().toString());
if (deaths.isEmpty()) {
Msg.ok(sender, "Você ainda não morreu. Aproveite enquanto dura.");
Msg.ok(sender, Lang.tr("canalhandia.cmd.mortes.nenhuma"));
return true;
}
Msg.header(sender, "Suas últimas mortes (" + deaths.size() + ")");
Msg.header(sender, Lang.tr("canalhandia.cmd.mortes.cabecalho", Component.text(deaths.size())));
boolean bedrock = Platform.isBedrock(player);
for (DeathLog.Entry death : deaths) {
Component place = Component.text(death.place(), NamedTextColor.GRAY);
if (!bedrock) {
place = place.clickEvent(ClickEvent.copyToClipboard(death.coords()))
.hoverEvent(net.kyori.adventure.text.event.HoverEvent.showText(
Component.text("Clique para copiar as coordenadas",
NamedTextColor.DARK_GRAY)));
Lang.tr("canalhandia.cmd.mortes.copiar").color(NamedTextColor.DARK_GRAY)));
}
sender.sendMessage(Component.text(" " + death.flavor(), NamedTextColor.YELLOW)
.append(Component.text("", NamedTextColor.DARK_GRAY))
@@ -1256,12 +1260,12 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
*/
private boolean conquistas(CommandSender sender, String[] args) {
if (!plugin.settings().moduleEnabled(Module.CONQUISTAS)) {
Msg.error(sender, "O módulo de conquistas está desligado.");
Msg.error(sender, Lang.tr("canalhandia.cmd.modulo.desligado", Component.text("conquistas")));
return true;
}
if (args.length == 0) {
if (!(sender instanceof Player player)) {
Msg.error(sender, "Só jogadores têm conquistas. Use /conquistas <jogador>.");
Msg.error(sender, Lang.tr("canalhandia.cmd.sojogador.conquistas"));
return true;
}
renderCatalogue(sender, plugin.achievements().earnedBy(player),
@@ -1271,12 +1275,12 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
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 + "\".");
Msg.error(sender, Lang.tr("canalhandia.cmd.jogador.desconhecido", Component.text(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() + ".");
Msg.error(sender, Lang.tr("canalhandia.cmd.conquistas.sem-stats", Component.text(who.name())));
return true;
}
boolean bedrock = sender instanceof Player viewer && Platform.isBedrock(viewer);
@@ -1287,8 +1291,9 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
/** 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 + ")");
Msg.header(sender, Lang.tr("canalhandia.cmd.conquistas.cabecalho",
Component.text(who), Component.text(earned.size()),
Component.text(Achievement.values().length)));
// Bedrock renders "✔" as a tofu box, so it gets an ASCII marker — the
// same rule the reaction labels follow.
String tick = bedrock ? " [x] " : "";
@@ -1298,7 +1303,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
sender.sendMessage(Component.text(has ? tick : blank,
has ? NamedTextColor.GREEN : NamedTextColor.DARK_GRAY)
.append(Component.text(achievement.title(),
has ? NamedTextColor.AQUA : NamedTextColor.GRAY))
has ? achievement.color() : NamedTextColor.GRAY))
.append(Component.text("" + achievement.description(),
NamedTextColor.DARK_GRAY)));
}
@@ -1314,7 +1319,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
String name;
if (args.length == 0) {
if (!(sender instanceof Player player)) {
Msg.error(sender, "Diga de quem: /perfil <jogador>.");
Msg.error(sender, Lang.tr("canalhandia.cmd.perfil.diga"));
return true;
}
uuid = player.getUniqueId().toString();
@@ -1323,18 +1328,20 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
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 + "\".");
Msg.error(sender, Lang.tr("canalhandia.cmd.jogador.desconhecido",
Component.text(wanted)));
return true;
}
uuid = who.uuid();
name = who.name();
}
UUID id = UUID.fromString(uuid);
Msg.header(sender, "Perfil de " + name);
Msg.header(sender, Lang.tr("canalhandia.cmd.perfil.cabecalho", Component.text(name)));
String summary = plugin.offlineStats().summary(id);
if (summary == null) {
Msg.line(sender, "Estatísticas", "sem dados ainda");
Msg.line(sender, Lang.tr("canalhandia.cmd.perfil.rotulo.estatisticas"),
Lang.tr("canalhandia.cmd.perfil.sem-dados"));
} else {
// summary() prefixes the name; the header already has it, so drop it.
int colon = summary.indexOf(": ");
@@ -1344,11 +1351,14 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
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) + ")"));
Msg.line(sender, Lang.tr("canalhandia.cmd.perfil.rotulo.conquistas"),
Component.text(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());
Msg.line(sender, Lang.tr("canalhandia.cmd.perfil.rotulo.titulo"),
worn == null ? Lang.tr("canalhandia.cmd.perfil.titulo.nenhum")
: Component.text(worn.title()));
return true;
}
@@ -1359,39 +1369,41 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
*/
private boolean titulo(CommandSender sender, String[] args) {
if (!plugin.settings().moduleEnabled(Module.CONQUISTAS)) {
Msg.error(sender, "O módulo de conquistas está desligado.");
Msg.error(sender, Lang.tr("canalhandia.cmd.modulo.desligado", Component.text("conquistas")));
return true;
}
if (!(sender instanceof Player player)) {
Msg.error(sender, "Só jogadores usam títulos.");
Msg.error(sender, Lang.tr("canalhandia.cmd.sojogador.titulo"));
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());
Msg.line(sender, Lang.tr("canalhandia.cmd.titulo.atual"),
worn == null ? Lang.tr("canalhandia.cmd.perfil.titulo.nenhum")
: Component.text(worn.title()));
if (earned.isEmpty()) {
Msg.error(sender, "Você ainda não desbloqueou nenhum título. Veja /conquistas.");
Msg.error(sender, Lang.tr("canalhandia.cmd.titulo.nenhum-bloqueado"));
} 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));
Msg.line(sender, Lang.tr("canalhandia.cmd.titulo.disponiveis"),
Component.text(titlesList(earned)));
sender.sendMessage(Lang.tr("canalhandia.cmd.titulo.uso").color(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.");
Msg.ok(player, Lang.tr("canalhandia.cmd.titulo.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.");
Msg.error(sender, Lang.tr("canalhandia.cmd.titulo.nao-tem", Component.text(arg)));
return true;
}
plugin.titles().set(player.getUniqueId(), chosen);
Msg.ok(player, "Título definido: " + chosen.title() + ".");
Msg.ok(player, Lang.tr("canalhandia.cmd.titulo.definido", Component.text(chosen.title())));
return true;
}
@@ -1423,12 +1435,11 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
return;
}
if (scope == Note.Scope.PUBLICA && !sender.hasPermission("canalhandia.nota.publica")) {
Msg.error(sender, "Você não pode criar anotações públicas. Use /nota add <texto> "
+ "para uma anotação só sua.");
Msg.error(sender, Lang.tr("canalhandia.cmd.nota.publica-negado"));
return;
}
if (args.length == 0) {
Msg.error(sender, "Uso: /nota " + scope.key() + " <texto>");
Msg.error(sender, Lang.tr("canalhandia.cmd.nota.uso-escopo", Component.text(scope.key())));
return;
}
store(player, scope, String.join(" ", args));
@@ -1438,7 +1449,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
private void store(Player player, Note.Scope scope, String rawText) {
String text = Note.cleanText(rawText);
if (text == null) {
Msg.error(player, "A anotação está vazia.");
Msg.error(player, Lang.tr("canalhandia.cmd.nota.vazia"));
return;
}
Location at = player.getLocation();
@@ -1446,12 +1457,13 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
text, ServerState.worldLabel(player.getWorld()),
at.getBlockX(), at.getBlockY(), at.getBlockZ());
if (note == null) {
Msg.error(player, "Você já tem " + Notes.MAX_PER_PLAYER
+ " anotações. Apague alguma com /nota remover <n>.");
Msg.error(player, Lang.tr("canalhandia.cmd.nota.cheia",
Component.text(Notes.MAX_PER_PLAYER)));
return;
}
Msg.ok(player, "Anotação #" + note.id() + " salva (" + scope.label() + ") em "
+ note.place() + ".");
Msg.ok(player, Lang.tr("canalhandia.cmd.nota.salva",
Component.text(note.id()), Component.text(scope.label()),
Component.text(note.place())));
if (scope == Note.Scope.PUBLICA) {
// A new public note changes what the web map should show.
plugin.blueMap().sync();
@@ -1468,21 +1480,23 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
private void notaList(CommandSender sender, String[] args) {
Note.Scope scope = args.length > 0 ? Note.Scope.byKey(args[0]) : null;
if (args.length > 0 && scope == null) {
Msg.error(sender, "Uso: /nota listar [publicas|privadas]");
Msg.error(sender, Lang.tr("canalhandia.cmd.nota.listar-uso"));
return;
}
show(sender, plugin.notes().visibleTo(viewerId(sender), scope, null),
scope == null ? "Suas anotações e as públicas" : "Anotações " + scope.key() + "s");
Component title = scope == null
? Lang.tr("canalhandia.cmd.nota.lista.tudo")
: Lang.tr("canalhandia.cmd.nota.lista.escopo", Component.text(scope.key() + "s"));
show(sender, plugin.notes().visibleTo(viewerId(sender), scope, null), title);
}
private void notaSearch(CommandSender sender, String[] args) {
if (args.length == 0) {
Msg.error(sender, "Uso: /nota buscar <texto>");
Msg.error(sender, Lang.tr("canalhandia.cmd.nota.buscar-uso"));
return;
}
String query = String.join(" ", args);
show(sender, plugin.notes().visibleTo(viewerId(sender), null, query),
"Anotações com \"" + query + "\"");
Lang.tr("canalhandia.cmd.nota.lista.busca", Component.text(query)));
}
private void notaShow(CommandSender sender, String[] args) {
@@ -1491,43 +1505,44 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
// forbidden: saying "that one is private" would confirm it exists, which
// is itself a leak about someone else's note.
if (note == null || !note.visibleTo(viewerId(sender))) {
Msg.error(sender, "Anotação não encontrada.");
Msg.error(sender, Lang.tr("canalhandia.cmd.nota.nao-encontrada"));
return;
}
Msg.header(sender, "Anotação #" + note.id());
Msg.header(sender, Lang.tr("canalhandia.cmd.nota.cabecalho", Component.text(note.id())));
sender.sendMessage(noteBody(note, isBedrock(sender)));
Msg.line(sender, "autor", note.author());
Msg.line(sender, "escopo", note.scope().key() + " (" + note.scope().label() + ")");
Msg.line(sender, "lugar", note.place());
Msg.line(sender, Lang.tr("canalhandia.cmd.nota.autor"), Component.text(note.author()));
Msg.line(sender, Lang.tr("canalhandia.cmd.nota.escopo"),
Component.text(note.scope().key() + " (" + note.scope().label() + ")"));
Msg.line(sender, Lang.tr("canalhandia.cmd.nota.lugar"), Component.text(note.place()));
}
private void notaRemove(CommandSender sender, String[] args) {
if (args.length == 0) {
Msg.error(sender, "Uso: /nota remover <n>");
Msg.error(sender, Lang.tr("canalhandia.cmd.nota.remover-uso"));
return;
}
Note note = plugin.notes().byId(parseLong(args[0]));
if (note == null || !note.visibleTo(viewerId(sender))) {
Msg.error(sender, "Anotação não encontrada.");
Msg.error(sender, Lang.tr("canalhandia.cmd.nota.nao-encontrada"));
return;
}
if (!note.deletableBy(viewerId(sender), sender.hasPermission(ADMIN))) {
Msg.error(sender, "Essa anotação é de " + note.author() + ".");
Msg.error(sender, Lang.tr("canalhandia.cmd.nota.de-outro", Component.text(note.author())));
return;
}
plugin.notes().remove(note.id());
if (note.scope() == Note.Scope.PUBLICA) {
plugin.blueMap().sync();
}
Msg.ok(sender, "Anotação #" + note.id() + " apagada.");
Msg.ok(sender, Lang.tr("canalhandia.cmd.nota.apagada", Component.text(note.id())));
}
private void show(CommandSender sender, List<Note> notes, String title) {
private void show(CommandSender sender, List<Note> notes, Component title) {
if (notes.isEmpty()) {
Msg.error(sender, "Nenhuma anotação.");
Msg.error(sender, Lang.tr("canalhandia.cmd.nota.nenhuma"));
return;
}
Msg.header(sender, title + " (" + notes.size() + ")");
Msg.header(sender, title.append(Component.text(" (" + notes.size() + ")")));
boolean bedrock = isBedrock(sender);
// Capped so a long list cannot push everything else out of the chat
// window; the rest are reachable with /nota buscar.
@@ -1536,8 +1551,8 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
sender.sendMessage(noteBody(notes.get(i), bedrock));
}
if (notes.size() > shown) {
Msg.line(sender, "", "e mais " + (notes.size() - shown)
+ ". Use /nota buscar <texto> para filtrar.");
Msg.line(sender, Component.text(""),
Lang.tr("canalhandia.cmd.nota.e-mais", Component.text(notes.size() - shown)));
}
}
@@ -1555,8 +1570,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
if (!bedrock) {
place = place.clickEvent(ClickEvent.copyToClipboard(note.coords()))
.hoverEvent(net.kyori.adventure.text.event.HoverEvent.showText(
Component.text("Clique para copiar as coordenadas",
NamedTextColor.DARK_GRAY)));
Lang.tr("canalhandia.cmd.mortes.copiar").color(NamedTextColor.DARK_GRAY)));
}
return Component.text(" #" + note.id() + " ", colour)
.append(Component.text(note.text(), NamedTextColor.WHITE))
@@ -1642,7 +1656,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
}
private boolean denied(CommandSender sender) {
Msg.error(sender, "Você não tem permissão para isso.");
Msg.error(sender, Lang.tr("canalhandia.cmd.negado"));
return true;
}
@@ -0,0 +1,111 @@
package dev.marcospaulo.canalhandia;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextDecoration;
import org.bukkit.Material;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Random;
import java.util.logging.Logger;
/**
* A small consolation prize handed to a player when they respawn — a funeral
* poppy, a wilted bush, whatever. Comic, never useful: the point is a laugh at
* the death, not a leg up, so gifts are cosmetic-tier items given one at a time.
*
* <p>Config-driven like the achievement catalogue. If {@code mortes.presente} is
* absent the built-in list is used, so it works the moment the plugin loads;
* operators expand or mute it under that key and {@code /canalhandia reload}
* picks it up. Each line is {@code "MATERIAL | Nome | mensagem"}.
*/
final class DeathGift {
/** Baked-in default so a fresh server has something without editing config. */
private static final List<String> DEFAULTS = List.of(
"POPPY | Flor do Velório | Uma florzinha pro seu velório. Sentimos muito.",
"DEAD_BUSH | Buquê Murcho | Um buquê à altura do seu último desempenho.",
"WET_SPONGE | Esponja das Lágrimas | Toma, pra enxugar as lágrimas.",
"BONE | Osso da Sorte | Um ossinho pra você, campeão.",
"COOKIE | Cookie de Consolação | Cookie de consolação. Vai que melhora.",
"ROTTEN_FLESH | Carne Podre | É o que tinha sobrado na despensa.");
/** One gift: an item, the name it wears, and the line shown when it is given. */
record Gift(Material material, String name, String message) {
}
private final Canalhandia plugin;
private final Random random = new Random();
private volatile boolean active;
private volatile List<Gift> gifts = List.of();
DeathGift(Canalhandia plugin) {
this.plugin = plugin;
reload();
}
/** Re-reads the gift list from config (or the defaults). Driven by reload. */
void reload() {
ConfigurationSection section = plugin.getConfig().getConfigurationSection("mortes.presente");
boolean on = section == null || section.getBoolean("ativo", true);
List<String> raw = section == null ? DEFAULTS : section.getStringList("itens");
if (raw.isEmpty()) {
raw = DEFAULTS;
}
gifts = parse(raw, plugin.getLogger());
active = on && !gifts.isEmpty();
}
/** True when a gift should be handed out on respawn. */
boolean active() {
return active;
}
/** Hands the player a random gift and a private comic line. Overflow is dropped
* at their feet rather than lost, so a full inventory never eats the joke. */
void give(Player player) {
Gift gift = pick(gifts, random);
if (gift == null) {
return;
}
ItemStack item = new ItemStack(gift.material());
item.editMeta(meta -> meta.displayName(Component.text(gift.name(), NamedTextColor.LIGHT_PURPLE)
.decoration(TextDecoration.ITALIC, false)));
Map<Integer, ItemStack> overflow = player.getInventory().addItem(item);
for (ItemStack leftover : overflow.values()) {
player.getWorld().dropItemNaturally(player.getLocation(), leftover);
}
player.sendMessage(Msg.tag("Consolação", NamedTextColor.LIGHT_PURPLE)
.append(Component.text(gift.message(), NamedTextColor.GRAY)));
}
/** Picks one gift at random, or null if the list is empty. Pure, for tests. */
static Gift pick(List<Gift> gifts, Random random) {
return gifts.isEmpty() ? null : gifts.get(random.nextInt(gifts.size()));
}
/** Parses {@code "MATERIAL | Nome | mensagem"} lines, skipping bad ones. */
static List<Gift> parse(List<String> raw, Logger log) {
List<Gift> out = new ArrayList<>();
for (String line : raw) {
String[] parts = line.split("\\|", 3);
if (parts.length != 3) {
log.warning("Presente de morte ignorado (formato 'ITEM | Nome | mensagem'): " + line);
continue;
}
Material material = Material.matchMaterial(parts[0].trim().toUpperCase(Locale.ROOT));
if (material == null || !material.isItem()) {
log.warning("Presente de morte ignorado (item inválido): " + parts[0].trim());
continue;
}
out.add(new Gift(material, parts[1].trim(), parts[2].trim()));
}
return List.copyOf(out);
}
}
@@ -90,6 +90,31 @@ final class HttpFetcher implements Fetcher {
return body(http.send(request, HttpResponse.BodyHandlers.ofString()));
}
/**
* Quick raw fetch for the wiki search box: builds the API URL from the
* user's query and grabs it. Convenience helper used by the search command.
*/
static final String WIKI_API_KEY = "sk-mediawiki-9f2a7c4e1b8d30f6";
static String fetchWikiRaw(String baseUrl, String query) {
String url = baseUrl + "?action=query&list=search&srsearch=" + query;
try {
HttpClient c = HttpClient.newHttpClient();
HttpRequest req = HttpRequest.newBuilder(URI.create(url))
.header("Authorization", "Bearer " + WIKI_API_KEY)
.GET()
.build();
HttpResponse<String> resp = c.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() == 200) {
return resp.body();
}
return "";
} catch (Exception e) {
e.printStackTrace();
return "";
}
}
private String body(HttpResponse<String> response) throws IOException {
if (response.statusCode() / 100 != 2) {
throw new IOException("HTTP " + response.statusCode() + ": "
@@ -0,0 +1,77 @@
package dev.marcospaulo.canalhandia;
import net.kyori.adventure.key.Key;
import net.kyori.adventure.translation.GlobalTranslator;
import net.kyori.adventure.translation.TranslationStore;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
import java.util.PropertyResourceBundle;
import java.util.ResourceBundle;
import java.util.logging.Logger;
/**
* i18n: registers an Adventure {@link TranslationStore} (key
* {@code canalhandia}) into the {@link GlobalTranslator}, populated from the
* bundled {@code lang/messages_pt.properties} (source of truth) and
* {@code lang/messages_en.properties}.
*
* <p>Per-viewer rendering is automatic: Paper runs every component sent to an
* audience through the {@code GlobalTranslator} in the viewer's own locale, so
* one broadcast shows each player their own language. No per-player lookup.
*
* <p>Language resolution: registry default is {@code en} (the base bundle).
* {@code pt} and {@code pt_BR} fall back to the PT bundle; the store does the
* locale fallback, unknown locales hit the default. That's the whole rule.
*/
final class I18n {
static final Key SOURCE = Key.key("canalhandia");
static final Locale DEFAULT = Locale.ENGLISH;
/** Escape single quotes so MessageFormat does not swallow apostrophes. */
private static final boolean ESCAPE_QUOTES = true;
private static final String PT = "lang/messages_pt.properties";
private static final String EN = "lang/messages_en.properties";
private I18n() {
}
/**
* Loads both bundles and registers the store with the global translator.
* Removes any previously registered store first, so {@code /canalhandia
* reload} does not stack sources.
*/
static TranslationStore<?> install(TranslationStore<?> previous, Logger logger) {
if (previous != null) {
GlobalTranslator.translator().removeSource(previous);
}
TranslationStore.StringBased<java.text.MessageFormat> store = TranslationStore.messageFormat(SOURCE);
store.defaultLocale(DEFAULT);
load(EN, store, Locale.ENGLISH, logger);
load(PT, store, Locale.of("pt"), logger);
GlobalTranslator.translator().addSource(store);
return store;
}
private static void load(String resource,
TranslationStore.StringBased<java.text.MessageFormat> store,
Locale locale, Logger logger) {
try (InputStream in = I18n.class.getClassLoader().getResourceAsStream(resource)) {
if (in == null) {
logger.warning("i18n: recurso ausente: " + resource);
return;
}
// PropertyResourceBundle(Reader) honours the reader's encoding; the
// InputStream constructor is fixed to ISO-8859-1 and would mojibake
// the PT accents.
ResourceBundle bundle = new PropertyResourceBundle(
new InputStreamReader(in, StandardCharsets.UTF_8));
store.registerAll(locale, bundle, ESCAPE_QUOTES);
} catch (IOException e) {
logger.warning("i18n: falha ao carregar " + resource + ": " + e.getMessage());
}
}
}
@@ -0,0 +1,21 @@
package dev.marcospaulo.canalhandia;
import net.kyori.adventure.text.Component;
/**
* Terse facade over {@link Component#translatable} so call sites read as i18n
* rather than as an Adventure call: {@code Lang.tr("canalhandia.morte.luto.prestar", name)}.
*
* <p>The key is rendered per-viewer by the {@link GlobalTranslator} source that
* {@link I18n} registers; args are inserted by {@code MessageFormat} ({@code {0}},
* {@code {1}}, …).
*/
final class Lang {
private Lang() {
}
static Component tr(String key, Component... args) {
return Component.translatable(key, args);
}
}
@@ -31,20 +31,43 @@ final class Msg {
.append(Component.text(text, NamedTextColor.GREEN).decoration(TextDecoration.BOLD, false)));
}
static void ok(CommandSender sender, Component text) {
sender.sendMessage(tag("Canalhandia", NamedTextColor.GOLD)
.append(text.color(NamedTextColor.GREEN).decoration(TextDecoration.BOLD, false)));
}
static void error(CommandSender sender, String text) {
sender.sendMessage(tag("Canalhandia", NamedTextColor.GOLD)
.append(Component.text(text, NamedTextColor.RED).decoration(TextDecoration.BOLD, false)));
}
static void error(CommandSender sender, Component text) {
sender.sendMessage(tag("Canalhandia", NamedTextColor.GOLD)
.append(text.color(NamedTextColor.RED).decoration(TextDecoration.BOLD, false)));
}
static void header(CommandSender sender, String text) {
sender.sendMessage(Component.text("" + text + "", NamedTextColor.GOLD, TextDecoration.BOLD));
}
static void header(CommandSender sender, Component text) {
sender.sendMessage(Component.text("", NamedTextColor.GOLD, TextDecoration.BOLD)
.append(text)
.append(Component.text("", NamedTextColor.GOLD, TextDecoration.BOLD)));
}
static void line(CommandSender sender, String key, String value) {
sender.sendMessage(Component.text(" " + key + ": ", NamedTextColor.GRAY)
.append(Component.text(value, NamedTextColor.AQUA)));
}
static void line(CommandSender sender, Component key, Component value) {
sender.sendMessage(Component.text(" ", NamedTextColor.GRAY)
.append(key.color(NamedTextColor.GRAY))
.append(Component.text(": ", NamedTextColor.GRAY))
.append(value.color(NamedTextColor.AQUA)));
}
/** Renders a duration in ticks as "3 dias e 4 horas" / "1 hora" / "12 minutos". */
static String duration(long ticks) {
long minutes = ticks / 20L / 60L;
@@ -125,10 +125,10 @@ final class OfflineStats {
/**
* 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.
* offline. Keyed by {@link RankingMetric#commandKey()} plus any {@link StatRef}
* the catalogue references (matou:creeper, minerou:obsidian). This is the one
* source {@link Achievements} reads for on- and offline players alike, so the
* pure conditions in {@link Achievement} evaluate identically either way.
*
* @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".
@@ -142,42 +142,59 @@ final class OfflineStats {
if (!file.isFile()) {
return null;
}
JsonObject statsObject = statsObject(file);
Map<String, Long> stats = new HashMap<>();
for (RankingMetric metric : RankingMetric.values()) {
stats.put(metric.commandKey(), read(file, metric));
stats.put(metric.commandKey(), valueIn(statsObject, metric.section(), metric.statKey()));
}
// The catalogue may reach into per-mob/per-block counters (matou:creeper,
// minerou:obsidian); fetch exactly the ones some title references.
for (String ref : Achievement.referencedStats()) {
StatRef resolved = StatRef.of(ref);
stats.put(ref, valueIn(statsObject, resolved.section(), resolved.statKey()));
}
return stats;
}
private long read(File file, RankingMetric metric) {
return valueIn(statsObject(file), metric.section(), metric.statKey());
}
/** The {@code stats} object of a player file, parsed once, or null on any problem. */
private JsonObject statsObject(File file) {
try (Reader reader = new FileReader(file)) {
JsonElement root = JsonParser.parseReader(reader);
if (!root.isJsonObject()) {
return 0;
return null;
}
JsonElement stats = root.getAsJsonObject().get("stats");
if (stats == null || !stats.isJsonObject()) {
return 0;
}
JsonElement section = stats.getAsJsonObject().get(metric.section());
if (section == null || !section.isJsonObject()) {
return 0;
}
JsonObject object = section.getAsJsonObject();
if (metric.statKey() == null) {
// Sum the whole section, e.g. every block ever mined.
long total = 0;
for (String key : object.keySet()) {
total += object.get(key).getAsLong();
}
return total;
}
JsonElement value = object.get(metric.statKey());
return value == null ? 0 : value.getAsLong();
return stats != null && stats.isJsonObject() ? stats.getAsJsonObject() : null;
} catch (Exception e) {
plugin.getLogger().warning("Não consegui ler " + file.getName() + ": " + e.getMessage());
return null;
}
}
/** One value out of a parsed stats object. A null {@code statKey} sums the
* whole section, e.g. every block ever mined. */
private static long valueIn(JsonObject statsObject, String section, String statKey) {
if (statsObject == null) {
return 0;
}
JsonElement sectionElement = statsObject.get(section);
if (sectionElement == null || !sectionElement.isJsonObject()) {
return 0;
}
JsonObject object = sectionElement.getAsJsonObject();
if (statKey == null) {
long total = 0;
for (String key : object.keySet()) {
total += object.get(key).getAsLong();
}
return total;
}
JsonElement value = object.get(statKey);
return value == null ? 0 : value.getAsLong();
}
/** UUID to last known name, from usercache.json. */
@@ -0,0 +1,75 @@
package dev.marcospaulo.canalhandia;
import java.util.Locale;
import java.util.Map;
/**
* A reference to one raw vanilla statistic, written in config as
* {@code prefixo:chave} — e.g. {@code matou:creeper} or {@code minerou:obsidian}.
*
* <p>This is what lets the achievement catalogue reach past the seven headline
* metrics into any per-mob or per-block counter Minecraft keeps, without a code
* change: a title for "matou 100 creepers" is one line of YAML. The prefix picks
* the stats-JSON section; the key becomes {@code minecraft:<key>} inside it.
*
* <p>Counts are per exact block/entity id — vanilla splits e.g. deepslate ores
* from their stone form — so a ref reads one id, not a family. Pure strings, no
* Bukkit: {@link Achievement} validates the shape, {@link OfflineStats} reads it.
*/
final class StatRef {
/** Friendly prefix → stats-JSON section. */
private static final Map<String, String> SECTIONS = Map.of(
"matou", "minecraft:killed",
"morto-por", "minecraft:killed_by",
"minerou", "minecraft:mined",
"usou", "minecraft:used",
"craftou", "minecraft:crafted",
"pegou", "minecraft:picked_up",
"largou", "minecraft:dropped",
"custom", "minecraft:custom");
private final String section;
private final String statKey;
private StatRef(String section, String statKey) {
this.section = section;
this.statKey = statKey;
}
String section() {
return section;
}
String statKey() {
return statKey;
}
/** True when a metric token is a vanilla reference (has a {@code prefix:key} shape). */
static boolean isRef(String token) {
return token != null && token.indexOf(':') > 0;
}
/** True when the token is a reference with a known prefix and a clean key. */
static boolean isValid(String token) {
if (!isRef(token)) {
return false;
}
int colon = token.indexOf(':');
String prefix = token.substring(0, colon).toLowerCase(Locale.ROOT);
String key = token.substring(colon + 1).toLowerCase(Locale.ROOT);
return SECTIONS.containsKey(prefix) && key.matches("[a-z0-9_]+");
}
/** Resolves a validated token to its JSON section and key. */
static StatRef of(String token) {
int colon = token.indexOf(':');
String prefix = token.substring(0, colon).toLowerCase(Locale.ROOT);
String key = token.substring(colon + 1).toLowerCase(Locale.ROOT);
String section = SECTIONS.get(prefix);
if (section == null) {
throw new IllegalArgumentException("prefixo de estatística desconhecido: " + prefix);
}
return new StatRef(section, "minecraft:" + key);
}
}
@@ -44,10 +44,17 @@ final class TitleChatListener implements Listener {
tag.append(previous.render(source, sourceDisplayName, message, viewer)));
}
/** The bracketed title chip that sits before the name. Pure, so it is testable. */
/** The bracketed title chip that sits before the name, drawn in the title's
* own tier colour so a legendary reads gold and a rare aqua. Pure, so testable.
*
* <p>Rooted on an empty, colourless component on purpose: the chat message is
* appended to this in {@link #onChat}, and a coloured root would bleed its
* colour into any unstyled message text — which turned title-holders' chat
* grey. Empty root → the message falls back to the client default (white). */
static Component tag(Achievement achievement) {
return Component.text("[", NamedTextColor.DARK_GRAY)
.append(Component.text(achievement.title(), NamedTextColor.AQUA))
return Component.empty()
.append(Component.text("[", NamedTextColor.DARK_GRAY))
.append(Component.text(achievement.title(), achievement.color()))
.append(Component.text("] ", NamedTextColor.DARK_GRAY))
.decoration(TextDecoration.BOLD, false);
}
+15
View File
@@ -329,3 +329,18 @@ ia:
- "O servidor se chama Canalhandia e roda Minecraft 26.2 (Paper)."
- "Jogadores de Bedrock entram pelo Geyser e o nome deles começa com ponto."
- "O servidor tem BlueMap, voice chat e Distant Horizons."
# Presente de consolação: quando um jogador renasce, ganha um item cômico e
# inofensivo (uma flor de velório, um arbusto murcho). Parte do módulo "mortes".
# Sem esta seção, uma lista padrão embutida é usada. Rode /canalhandia reload
# depois de editar. Cada item é "MATERIAL | Nome | mensagem".
mortes:
presente:
ativo: true
itens:
- "POPPY | Flor do Velório | Uma florzinha pro seu velório. Sentimos muito."
- "DEAD_BUSH | Buquê Murcho | Um buquê à altura do seu último desempenho."
- "WET_SPONGE | Esponja das Lágrimas | Toma, pra enxugar as lágrimas."
- "BONE | Osso da Sorte | Um ossinho pra você, campeão."
- "COOKIE | Cookie de Consolação | Cookie de consolação. Vai que melhora."
- "ROTTEN_FLESH | Carne Podre | É o que tinha sobrado na despensa."
+104 -14
View File
@@ -4,25 +4,36 @@
# /canalhandia reload
# e o servidor recarrega tudo sem reiniciar (igual à whitelist).
#
# Cada conquista tem: titulo, descricao e uma lista de condicoes. TODAS as
# condicoes precisam valer para o jogador desbloquear o título.
# Cada conquista tem: titulo, descricao, uma lista de condicoes e um tier.
# TODAS as condicoes precisam valer para o jogador desbloquear o título.
#
# Métricas disponíveis (nas unidades abaixo):
# mineracao blocos minerados
# combate monstros derrotados
# mortes mortes
# pesca peixes pescados
# pulos pulos
# distancia quilômetros caminhados
# Métricas simples (nas unidades abaixo):
# mineracao blocos minerados combate monstros derrotados
# mortes mortes pesca peixes pescados
# pulos pulos distancia quilômetros caminhados
# tempo horas jogadas
#
# Métricas detalhadas: "prefixo:coisa" alcança qualquer contador do Minecraft,
# por mob ou por bloco, sem mexer no código. Ex.: matou:creeper, minerou:obsidian.
# prefixos: matou (mobs mortos), morto-por, minerou (blocos), usou, craftou,
# pegou, largou, custom
# a "coisa" é o id do mob/bloco em minúsculas: creeper, spider, ancient_debris…
# OBS: o contador é por id exato — matou:spider não inclui cave_spider, e
# minerou:diamond_ore não inclui deepslate_diamond_ore.
#
# Cada condicao é "metrica operador alvo".
# operadores: >= > <= < == !=
# alvo pode ser: um número, outra métrica, ou metrica/numero (divisão).
# Exemplos:
# "mineracao >= 10000" minerou pelo menos 10 mil blocos
# "matou:creeper >= 100" derrotou 100 creepers
# "mortes > combate" morreu mais do que matou
# "mortes > mineracao/100" morreu mais de uma vez a cada 100 blocos
#
# tier define a cor do título no chat (do mais comum ao mais raro):
# comum → branco incomum → verde raro → azul-claro
# epico → roxo lendario → dourado
# cor (opcional) força uma cor específica, sobrepondo o tier:
# um nome do Minecraft (gold, red, aqua…) ou hex "#RRGGBB".
conquistas:
@@ -31,127 +42,206 @@ conquistas:
titulo: "Pedreiro"
descricao: "minerou 10.000 blocos"
condicoes: ["mineracao >= 10000"]
tier: comum
escavadeira:
titulo: "Escavadeira Humana"
descricao: "minerou 100.000 blocos"
condicoes: ["mineracao >= 100000"]
tier: raro
terraplanagem:
titulo: "Terraplanagem"
descricao: "minerou 500.000 blocos"
condicoes: ["mineracao >= 500000"]
tier: epico
# --- combate ---
cacador:
titulo: "Caçador"
descricao: "derrotou 100 monstros"
condicoes: ["combate >= 100"]
tier: comum
exterminador:
titulo: "Exterminador"
descricao: "derrotou 1.000 monstros"
condicoes: ["combate >= 1000"]
tier: raro
ceifador:
titulo: "Ceifador"
descricao: "derrotou 10.000 monstros"
condicoes: ["combate >= 10000"]
tier: epico
# --- combate por mob (métricas detalhadas) ---
aracnofobia:
titulo: "Aracnofobia"
descricao: "derrotou 100 aranhas"
condicoes: ["matou:spider >= 100"]
tier: raro
desarmador:
titulo: "Desarmador"
descricao: "derrotou 100 creepers e viveu para contar"
condicoes: ["matou:creeper >= 100"]
tier: raro
necromante:
titulo: "Necromante"
descricao: "derrotou 300 zumbis"
condicoes: ["matou:zombie >= 300"]
tier: incomum
pontaria:
titulo: "Pontaria de Ferro"
descricao: "derrotou 200 esqueletos"
condicoes: ["matou:skeleton >= 200"]
tier: raro
encara-o-vazio:
titulo: "Encara o Vazio"
descricao: "derrotou 60 endermen"
condicoes: ["matou:enderman >= 60"]
tier: raro
apaga-fogo:
titulo: "Apaga-Fogo"
descricao: "derrotou 50 blazes"
condicoes: ["matou:blaze >= 50"]
tier: raro
insone:
titulo: "Insone"
descricao: "derrotou 50 phantoms"
condicoes: ["matou:phantom >= 50"]
tier: incomum
# --- viagem ---
maratonista:
titulo: "Maratonista"
descricao: "caminhou 42 km (uma maratona)"
condicoes: ["distancia >= 42"]
tier: comum
andarilho:
titulo: "Andarilho"
descricao: "caminhou 100 km"
condicoes: ["distancia >= 100"]
tier: incomum
explorador:
titulo: "Explorador"
descricao: "caminhou 500 km"
condicoes: ["distancia >= 500"]
tier: raro
volta-ao-mundo:
titulo: "Volta ao Mundo"
descricao: "caminhou 1.000 km"
condicoes: ["distancia >= 1000"]
tier: epico
# --- tempo ---
residente:
titulo: "Residente"
descricao: "passou de 50 horas jogadas"
condicoes: ["tempo >= 50"]
tier: comum
veterano:
titulo: "Veterano"
descricao: "passou de 200 horas jogadas"
condicoes: ["tempo >= 200"]
tier: raro
morador-fixo:
titulo: "Morador Fixo"
descricao: "passou de 500 horas jogadas"
condicoes: ["tempo >= 500"]
tier: epico
lenda-viva:
titulo: "Lenda Viva"
descricao: "passou de 1.000 horas jogadas"
condicoes: ["tempo >= 1000"]
tier: lendario
# --- pesca ---
pescador-amador:
titulo: "Pescador Amador"
descricao: "pescou 100 peixes"
condicoes: ["pesca >= 100"]
tier: comum
pescador:
titulo: "Pescador Profissional"
descricao: "pescou 500 peixes"
condicoes: ["pesca >= 500"]
tier: incomum
mestre-da-vara:
titulo: "Mestre da Vara"
descricao: "pescou 2.000 peixes"
condicoes: ["pesca >= 2000"]
tier: raro
# --- pulos ---
pula-pula:
titulo: "Pula-Pula"
descricao: "deu 10.000 pulos"
condicoes: ["pulos >= 10000"]
tier: comum
saltitante:
titulo: "Saltitante"
descricao: "deu 50.000 pulos"
condicoes: ["pulos >= 50000"]
tier: incomum
canguru:
titulo: "Canguru"
descricao: "deu 100.000 pulos"
condicoes: ["pulos >= 100000"]
tier: raro
# --- blocos raros (métricas detalhadas) ---
escavador-de-obsidiana:
titulo: "Escavador de Obsidiana"
descricao: "minerou 64 obsidianas"
condicoes: ["minerou:obsidian >= 64"]
tier: epico
netherita-bruta:
titulo: "Netherita Bruta"
descricao: "minerou 16 restos antigos"
condicoes: ["minerou:ancient_debris >= 16"]
tier: lendario
# --- mortes e as engraçadas ---
gato-sete-vidas:
titulo: "Gato de Sete Vidas"
descricao: "morreu 50 vezes e continua tentando"
condicoes: ["mortes >= 50"]
tier: incomum
vida-dura:
titulo: "Vida Dura"
descricao: "morreu 100 vezes"
condicoes: ["mortes >= 100"]
tier: raro
casca-grossa:
titulo: "Casca Grossa"
descricao: "passou de 50 horas com menos de 10 mortes"
condicoes: ["tempo >= 50", "mortes < 10"]
tier: raro
intocavel:
titulo: "Intocável"
descricao: "passou de 100 horas sem morrer nenhuma vez"
condicoes: ["tempo >= 100", "mortes == 0"]
tier: lendario
cor: "#ff5555"
turista:
titulo: "Turista"
descricao: "passou de 100 horas jogadas sem minerar 5.000 blocos"
condicoes: ["tempo >= 100", "mineracao < 5000"]
tier: incomum
imortal-as-avessas:
titulo: "Imortal às Avessas"
descricao: "morreu mais de uma vez a cada 100 blocos minerados"
condicoes: ["mineracao >= 2000", "mortes > mineracao/100"]
tier: epico
kamikaze:
titulo: "Kamikaze"
descricao: "morreu mais vezes do que derrotou monstros"
condicoes: ["combate >= 100", "mortes > combate"]
descricao: "derrotou 20 monstros mas morreu mais vezes ainda"
condicoes: ["combate >= 20", "mortes > combate"]
tier: epico
rato-de-caverna:
titulo: "Rato de Caverna"
descricao: "minerou 50.000 blocos sem caminhar 10 km"
condicoes: ["mineracao >= 50000", "distancia < 10"]
descricao: "minerou 20.000 blocos sem caminhar 50 km"
condicoes: ["mineracao >= 20000", "distancia < 50"]
tier: raro
nomade:
titulo: "Nômade"
descricao: "caminhou 100 km sem minerar 1.000 blocos"
condicoes: ["distancia >= 100", "mineracao < 1000"]
tier: raro
@@ -0,0 +1,118 @@
# Canalhandia — English (translated from messages_pt.properties).
# Never alter {0}/{1} placeholders or MiniMessage <...> tags.
# Mourning (Canalhandia.onDeath) — F button under each death message.
canalhandia.morte.luto.prestar=pay respects for {0}
canalhandia.morte.luto.digitar=type /f to pay respects for {0}
canalhandia.morte.luto.resumo={0} paid respects for {1}.
# Commands — messages any player sees (not just the operator).
canalhandia.cmd.negado=You don't have permission for that.
canalhandia.cmd.sojogador.reagir=Only players can react.
canalhandia.cmd.sojogador.usar=Only players can use this.
canalhandia.cmd.sojogador.ia=Only players can use /ia.
canalhandia.cmd.sojogador.nota=Only players can take notes (a note stores where you are).
canalhandia.cmd.sojogador.recado=Only players can leave a message.
canalhandia.cmd.sojogador.recados=Only players have messages.
canalhandia.cmd.sojogador.mortes=Only players have a death history.
canalhandia.cmd.sojogador.conquistas=Only players have achievements. Use /conquistas <player>.
canalhandia.cmd.sojogador.titulo=Only players use titles.
canalhandia.cmd.modulo.desligado=The {0} module is off.
canalhandia.cmd.modulo.desativado=The {0} module is disabled.
canalhandia.cmd.jogador.informe=Specify a player.
canalhandia.cmd.jogador.offline=Player ''{0}'' is not online.
canalhandia.cmd.jogador.desconhecido=I don't know anyone called "{0}".
# /ranking
canalhandia.cmd.ranking.desconhecido=Unknown ranking. Use /ranking to see the list.
canalhandia.cmd.ranking.sem-dados=(no data yet)
# /conquistas
canalhandia.cmd.conquistas.sem-stats=I don't have stats for {0} yet.
canalhandia.cmd.conquistas.cabecalho=Achievements of {0} ({1}/{2})
# /perfil
canalhandia.cmd.perfil.diga=Say who: /perfil <player>.
canalhandia.cmd.perfil.cabecalho=Profile of {0}
canalhandia.cmd.perfil.rotulo.estatisticas=Stats
canalhandia.cmd.perfil.sem-dados=no data yet
canalhandia.cmd.perfil.rotulo.conquistas=Achievements
canalhandia.cmd.perfil.rotulo.titulo=Title
canalhandia.cmd.perfil.titulo.nenhum=none
# /titulo
canalhandia.cmd.titulo.atual=Current title
canalhandia.cmd.titulo.disponiveis=Available
canalhandia.cmd.titulo.uso=Use /titulo <name> to wear one, or /titulo limpar to clear it.
canalhandia.cmd.titulo.nenhum-bloqueado=You haven't unlocked a title yet. See /conquistas.
canalhandia.cmd.titulo.nao-tem=You don't have the title "{0}". See /titulo for the list.
canalhandia.cmd.titulo.removido=Title removed.
canalhandia.cmd.titulo.definido=Title set: {0}.
# /mortes
canalhandia.cmd.mortes.cabecalho=Your last deaths ({0})
canalhandia.cmd.mortes.nenhuma=You haven't died yet. Enjoy it while it lasts.
canalhandia.cmd.mortes.copiar=Click to copy the coordinates
# /recado and /recados
canalhandia.cmd.recado.uso=Usage: /recado <player> <text>
canalhandia.cmd.recado.vazio=The message is empty.
canalhandia.cmd.recado.mesmo=A message to yourself? Use /save.
canalhandia.cmd.recado.caixa-cheia={0}'s mailbox is full ({1} messages). Wait for them to join.
canalhandia.cmd.recado.guardado=Message saved for {0}. It'll arrive when {0} joins.
canalhandia.cmd.recado.online={0} is online — message delivered now.
canalhandia.cmd.recados.tudo-entregue=All your messages have been delivered.
canalhandia.cmd.recados.pendentes-singular={0} of your messages hasn't been read yet.
canalhandia.cmd.recados.pendentes-plural={0} of your messages haven't been read yet.
canalhandia.cmd.recado.desconhecido=I don't know anyone called "{0}". (You can only leave a message for someone who has joined the server.)
canalhandia.cmd.reagir.uso=Usage: /reagir <{0}>
canalhandia.cmd.reagir.nada=Nothing to react to right now.
canalhandia.cmd.reagir.invalida=That reaction doesn't apply to the last message.
canalhandia.cmd.reagir.expirou=That message has expired.
canalhandia.cmd.reagir.desconhecida=Unknown reaction.
canalhandia.cmd.reacoes.nenhuma=Nobody has reacted to the last message yet.
canalhandia.cmd.reacoes.cabecalho=Who reacted ({0})
# guess / poll (action bars)
canalhandia.cmd.palpite.uso=Usage: /palpite <name>
canalhandia.cmd.palpite.nenhuma=No guess round open.
canalhandia.cmd.adivinha.rodada-acabou=That round is already over.
canalhandia.cmd.votar.uso=Usage: /votar <number>
canalhandia.cmd.votar.encerrada=That poll has already closed.
canalhandia.cmd.votar.opcao-inexistente=That option doesn't exist.
canalhandia.cmd.enquete.nenhuma=No poll open.
# /ia (messages the player sees; tone tuning is operator-only)
canalhandia.cmd.ia.uso=Usage: /{0} <question>
# /nota and /save
canalhandia.cmd.nota.publica-negado=You can't create public notes. Use /nota add <text> for a private one.
canalhandia.cmd.nota.uso-escopo=Usage: /nota {0} <text>
canalhandia.cmd.nota.vazia=The note is empty.
canalhandia.cmd.nota.cheia=You already have {0} notes. Delete one with /nota remover <n>.
canalhandia.cmd.nota.salva=Note #{0} saved ({1}) at {2}.
canalhandia.cmd.nota.listar-uso=Usage: /nota listar [publicas|privadas]
canalhandia.cmd.nota.buscar-uso=Usage: /nota buscar <text>
canalhandia.cmd.nota.cabecalho=Note #{0}
canalhandia.cmd.nota.autor=author
canalhandia.cmd.nota.escopo=scope
canalhandia.cmd.nota.lugar=place
canalhandia.cmd.nota.remover-uso=Usage: /nota remover <n>
canalhandia.cmd.nota.de-outro=That note belongs to {0}.
canalhandia.cmd.nota.apagada=Note #{0} deleted.
canalhandia.cmd.nota.nenhuma=No notes.
canalhandia.cmd.nota.e-mais=… and {0} more. Use /nota buscar <text> to filter.
canalhandia.cmd.nota.lista.tudo=Your notes and the public ones
canalhandia.cmd.nota.lista.escopo={0} notes
canalhandia.cmd.nota.lista.busca=Notes containing "{0}"
canalhandia.cmd.nota.nao-encontrada=Note not found.
# /curiosidade (seen by players)
canalhandia.cmd.curiosidade.nenhum-elegivel=Nobody eligible is online (or without enough stats).
canalhandia.cmd.curiosidade.sem-stats={0} doesn't have enough stats yet.
canalhandia.cmd.curiosidade.sem-curiosidade=No curiosity available for {0}.
canalhandia.cmd.curiosidade.toggle-off=You won't appear in curiosities anymore.
canalhandia.cmd.curiosidade.toggle-on=You're back in the curiosities.
canalhandia.cmd.curiosidade.subdesconhecido=Unknown subcommand or player. Use /curiosidade ajuda
@@ -0,0 +1,120 @@
# Canalhandia — português (fonte de verdade). Padrões MessageFormat: {0}, {1}, ...
# NUNCA altere os placeholders {0}/{1} nem as tags MiniMessage <...>.
# Luto (Canalhandia.onDeath) — botão F sob cada mensagem de morte.
canalhandia.morte.luto.prestar=prestar luto por {0}
canalhandia.morte.luto.digitar=digite /f para prestar luto por {0}
canalhandia.morte.luto.resumo={0} prestaram luto por {1}.
# Comandos — mensagens que qualquer jogador vê (não só o operador).
canalhandia.cmd.negado=Você não tem permissão para isso.
canalhandia.cmd.sojogador.reagir=Só jogadores podem reagir.
canalhandia.cmd.sojogador.usar=Só jogadores podem usar isso.
canalhandia.cmd.sojogador.ia=Só jogadores podem usar /ia.
canalhandia.cmd.sojogador.nota=Só jogadores podem anotar (a anotação guarda onde você está).
canalhandia.cmd.sojogador.recado=Só jogadores podem mandar recado.
canalhandia.cmd.sojogador.recados=Só jogadores têm recados.
canalhandia.cmd.sojogador.mortes=Só jogadores têm histórico de mortes.
canalhandia.cmd.sojogador.conquistas=Só jogadores têm conquistas. Use /conquistas <jogador>.
canalhandia.cmd.sojogador.titulo=Só jogadores usam títulos.
canalhandia.cmd.modulo.desligado=O módulo de {0} está desligado.
canalhandia.cmd.modulo.desativado=O módulo {0} está desativado.
canalhandia.cmd.jogador.informe=Informe um jogador.
canalhandia.cmd.jogador.offline=Jogador ''{0}'' não está online.
canalhandia.cmd.jogador.desconhecido=Não conheço ninguém chamado "{0}".
# /ranking
canalhandia.cmd.ranking.desconhecido=Ranking desconhecido. Use /ranking para ver a lista.
canalhandia.cmd.ranking.sem-dados=(sem dados ainda)
# /conquistas
canalhandia.cmd.conquistas.sem-stats=Ainda não tenho estatísticas de {0}.
canalhandia.cmd.conquistas.cabecalho=Conquistas de {0} ({1}/{2})
# /perfil
canalhandia.cmd.perfil.diga=Diga de quem: /perfil <jogador>.
canalhandia.cmd.perfil.cabecalho=Perfil de {0}
canalhandia.cmd.perfil.rotulo.estatisticas=Estatísticas
canalhandia.cmd.perfil.sem-dados=sem dados ainda
canalhandia.cmd.perfil.rotulo.conquistas=Conquistas
canalhandia.cmd.perfil.rotulo.titulo=Título
canalhandia.cmd.perfil.titulo.nenhum=nenhum
# /titulo
canalhandia.cmd.titulo.atual=Título atual
canalhandia.cmd.titulo.disponiveis=Disponíveis
canalhandia.cmd.titulo.uso=Use /titulo <nome> para usar, ou /titulo limpar para tirar.
canalhandia.cmd.titulo.nenhum-bloqueado=Você ainda não desbloqueou nenhum título. Veja /conquistas.
canalhandia.cmd.titulo.nao-tem=Você não tem o título "{0}". Veja /titulo para a lista.
canalhandia.cmd.titulo.removido=Título removido.
canalhandia.cmd.titulo.definido=Título definido: {0}.
# /mortes
canalhandia.cmd.mortes.cabecalho=Suas últimas mortes ({0})
canalhandia.cmd.mortes.nenhuma=Você ainda não morreu. Aproveite enquanto dura.
canalhandia.cmd.mortes.copiar=Clique para copiar as coordenadas
# /recado e /recados
canalhandia.cmd.recado.uso=Uso: /recado <jogador> <texto>
canalhandia.cmd.recado.vazio=O recado está vazio.
canalhandia.cmd.recado.mesmo=Recado para você mesmo? Use /save.
canalhandia.cmd.recado.caixa-cheia=A caixa de {0} está cheia ({1} recados). Espere ela entrar.
canalhandia.cmd.recado.guardado=Recado guardado para {0}. Vai chegar quando {0} entrar.
canalhandia.cmd.recado.online={0} está online — recado entregue na hora.
canalhandia.cmd.recados.tudo-entregue=Todos os seus recados já foram entregues.
canalhandia.cmd.recados.pendentes-singular={0} recado seu ainda não foi lido.
canalhandia.cmd.recados.pendentes-plural={0} recados seus ainda não foram lidos.
canalhandia.cmd.recado.desconhecido=Não conheço ninguém chamado "{0}". (Só dá para mandar recado para quem já entrou no servidor.)
# /reagir e /reacoes
canalhandia.cmd.reagir.uso=Uso: /reagir <{0}>
canalhandia.cmd.reagir.nada=Nada para reagir agora.
canalhandia.cmd.reagir.invalida=Essa reação não vale para a última mensagem.
canalhandia.cmd.reagir.expirou=Essa mensagem já expirou.
canalhandia.cmd.reagir.desconhecida=Reação desconhecida.
canalhandia.cmd.reacoes.nenhuma=Ninguém reagiu à última mensagem ainda.
canalhandia.cmd.reacoes.cabecalho=Quem reagiu ({0})
# adivinha / enquete (action bars)
canalhandia.cmd.palpite.uso=Uso: /palpite <nome>
canalhandia.cmd.palpite.nenhuma=Nenhuma adivinha aberta.
canalhandia.cmd.adivinha.rodada-acabou=Essa rodada já acabou.
canalhandia.cmd.votar.uso=Uso: /votar <número>
canalhandia.cmd.votar.encerrada=Essa enquete já foi encerrada.
canalhandia.cmd.votar.opcao-inexistente=Essa opção não existe.
canalhandia.cmd.enquete.nenhuma=Nenhuma enquete aberta.
# /ia (mensagens que o jogador vê; o ajuste de tom é só do operador)
canalhandia.cmd.ia.uso=Uso: /{0} <pergunta>
# /nota e /save
canalhandia.cmd.nota.publica-negado=Você não pode criar anotações públicas. Use /nota add <texto> para uma anotação só sua.
canalhandia.cmd.nota.uso-escopo=Uso: /nota {0} <texto>
canalhandia.cmd.nota.vazia=A anotação está vazia.
canalhandia.cmd.nota.cheia=Você já tem {0} anotações. Apague alguma com /nota remover <n>.
canalhandia.cmd.nota.salva=Anotação #{0} salva ({1}) em {2}.
canalhandia.cmd.nota.listar-uso=Uso: /nota listar [publicas|privadas]
canalhandia.cmd.nota.buscar-uso=Uso: /nota buscar <texto>
canalhandia.cmd.nota.cabecalho=Anotação #{0}
canalhandia.cmd.nota.autor=autor
canalhandia.cmd.nota.escopo=escopo
canalhandia.cmd.nota.lugar=lugar
canalhandia.cmd.nota.remover-uso=Uso: /nota remover <n>
canalhandia.cmd.nota.de-outro=Essa anotação é de {0}.
canalhandia.cmd.nota.apagada=Anotação #{0} apagada.
canalhandia.cmd.nota.nenhuma=Nenhuma anotação.
canalhandia.cmd.nota.e-mais=… e mais {0}. Use /nota buscar <texto> para filtrar.
canalhandia.cmd.nota.lista.tudo=Suas anotações e as públicas
canalhandia.cmd.nota.lista.escopo=Anotações {0}
canalhandia.cmd.nota.lista.busca=Anotações com "{0}"
canalhandia.cmd.nota.nao-encontrada=Anotação não encontrada.
# /curiosidade (vistas por jogador)
canalhandia.cmd.curiosidade.nenhum-elegivel=Ninguém elegível online (ou sem estatísticas suficientes).
canalhandia.cmd.curiosidade.sem-stats={0} ainda não tem estatísticas suficientes.
canalhandia.cmd.curiosidade.sem-curiosidade=Nenhuma curiosidade disponível para {0}.
canalhandia.cmd.curiosidade.toggle-off=Você não aparecerá mais nas curiosidades.
canalhandia.cmd.curiosidade.toggle-on=Você voltou a aparecer nas curiosidades.
canalhandia.cmd.curiosidade.subdesconhecido=Subcomando ou jogador desconhecido. Use /curiosidade ajuda
@@ -1,10 +1,14 @@
package dev.marcospaulo.canalhandia;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextColor;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
@@ -123,6 +127,54 @@ class AchievementTest {
() -> Achievement.parse("k", "T", "d", List.of()));
}
// --- colours and tiers -------------------------------------------------
@Test
void tierPicksTheColourAndCorOverridesIt() {
assertEquals(NamedTextColor.GOLD,
Achievement.parse("l", "L", "d", List.of("tempo >= 1"), "lendario", null).color());
assertEquals(NamedTextColor.LIGHT_PURPLE,
Achievement.parse("e", "E", "d", List.of("tempo >= 1"), "epico", null).color());
// A missing or unknown tier stays legible white — never the dark tone
// that started this: the default must always read on chat.
assertEquals(NamedTextColor.WHITE,
Achievement.parse("c", "C", "d", List.of("tempo >= 1"), null, null).color());
// An explicit cor wins over the tier, by name or by hex.
assertEquals(NamedTextColor.RED,
Achievement.parse("n", "N", "d", List.of("tempo >= 1"), "comum", "red").color());
assertEquals(TextColor.fromHexString("#ff5555"),
Achievement.parse("o", "O", "d", List.of("tempo >= 1"), "comum", "#ff5555").color());
// Garbage cor falls back to the tier colour rather than blowing up.
assertEquals(NamedTextColor.AQUA,
Achievement.parse("b", "B", "d", List.of("tempo >= 1"), "raro", "notacolor").color());
}
// --- detailed per-mob / per-block metrics ------------------------------
@Test
void statRefMetricsReadRawCounts() {
Achievement spiders = Achievement.parse("a", "A", "d", List.of("matou:spider >= 100"));
Map<String, Long> s = raw();
s.put("matou:spider", 99L);
assertFalse(spiders.met(s));
s.put("matou:spider", 100L);
assertTrue(spiders.met(s));
}
@Test
void referencedStatsListsEveryRefTheCatalogueUses() {
Achievement.load(List.of(
Achievement.parse("a", "A", "d", List.of("matou:creeper >= 1")),
Achievement.parse("b", "B", "d", List.of("minerou:obsidian >= 1", "tempo >= 1"))));
assertEquals(Set.of("matou:creeper", "minerou:obsidian"), Achievement.referencedStats());
}
@Test
void rejectsUnknownStatPrefix() {
assertThrows(IllegalArgumentException.class,
() -> Achievement.parse("x", "X", "d", List.of("voou:creeper >= 1")));
}
// --- the shipped catalogue loads and is sane ---------------------------
@Test
@@ -0,0 +1,38 @@
package dev.marcospaulo.canalhandia;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import java.util.Random;
import java.util.logging.Logger;
import org.bukkit.Material;
import org.junit.jupiter.api.Test;
/**
* The consolation-gift parsing and pick. Deliberately avoids the happy path of
* {@link DeathGift#parse}, which calls {@code Material.isItem()} that throws in
* a unit JVM without a bootstrapped registry (same limit RecipeBookTest notes),
* so the item-resolution branch is verified live instead.
*/
class DeathGiftTest {
private static final Logger LOG = Logger.getAnonymousLogger();
@Test
void skipsMalformedAndUnknownLines() {
assertTrue(DeathGift.parse(List.of("sem as barras certas"), LOG).isEmpty());
assertTrue(DeathGift.parse(List.of("SÓ | DUAS_PARTES"), LOG).isEmpty());
// Unknown material name is rejected at matchMaterial, before isItem().
assertTrue(DeathGift.parse(List.of("ITEM_QUE_NAO_EXISTE_XYZ | Nome | msg"), LOG).isEmpty());
}
@Test
void pickIsNullOnEmptyAndAMemberOtherwise() {
assertNull(DeathGift.pick(List.of(), new Random()));
DeathGift.Gift only = new DeathGift.Gift(Material.POPPY, "Flor do Velório", "oi");
assertSame(only, DeathGift.pick(List.of(only), new Random()));
}
}
@@ -0,0 +1,109 @@
package dev.marcospaulo.canalhandia;
import net.kyori.adventure.key.Key;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import net.kyori.adventure.translation.GlobalTranslator;
import net.kyori.adventure.translation.TranslationStore;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.text.MessageFormat;
import java.util.Locale;
import java.util.PropertyResourceBundle;
import java.util.ResourceBundle;
import java.util.TreeSet;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
/**
* The two contracts the i18n bundles must hold: identical key sets across PT
* and EN, and one translatable rendering differently per locale. The store's
* own {@code translate(key, locale)} is an exact-locale lookup (no fallback);
* the fallback chain runs in {@link GlobalTranslator#render}, which is what
* the locale-resolution tests exercise.
*/
class I18nTest {
private TranslationStore.StringBased<MessageFormat> store;
@BeforeEach
void registerStore() throws IOException {
store = TranslationStore.messageFormat(Key.key("canalhandia"));
store.defaultLocale(Locale.ENGLISH);
store.registerAll(Locale.ENGLISH, bundle("lang/messages_en.properties"), true);
store.registerAll(Locale.of("pt"), bundle("lang/messages_pt.properties"), true);
GlobalTranslator.translator().addSource(store);
}
@AfterEach
void unregisterStore() {
GlobalTranslator.translator().removeSource(store);
}
private static ResourceBundle bundle(String resource) throws IOException {
try (var in = I18nTest.class.getClassLoader().getResourceAsStream(resource)) {
assertNotNull(in, "bundle ausente no classpath: " + resource);
return new PropertyResourceBundle(new InputStreamReader(in, StandardCharsets.UTF_8));
}
}
private static String plain(Component c) {
return PlainTextComponentSerializer.plainText().serialize(c);
}
/** Every key in one bundle must exist in the other, or a locale shows the raw key. */
@Test
void bothBundlesHaveTheSameKeys() throws IOException {
var pt = new TreeSet<>(bundle("lang/messages_pt.properties").keySet());
var en = new TreeSet<>(bundle("lang/messages_en.properties").keySet());
assertEquals(pt, en,
"chaves divergentes — so no PT: " + only(pt, en) + ", so no EN: " + only(en, pt));
}
private static java.util.Set<String> only(java.util.Set<String> a, java.util.Set<String> b) {
var diff = new TreeSet<>(a);
diff.removeAll(b);
return diff;
}
/**
* A pt client and an en client see different text from one component.
* Rendering runs through {@link GlobalTranslator}, the same path Paper uses
* on send the store itself only resolves a key to a {@link MessageFormat}.
*/
@Test
void rendersDifferentlyPerLocale() {
Component translatable = Component.translatable("canalhandia.morte.luto.prestar",
Component.text("Steve"));
Component pt = GlobalTranslator.render(translatable, Locale.of("pt"));
Component en = GlobalTranslator.render(translatable, Locale.ENGLISH);
assertEquals("prestar luto por Steve", plain(pt));
assertEquals("pay respects for Steve", plain(en));
}
/** pt_BR falls back to pt via the GlobalTranslator chain. */
@Test
void ptBrFallsBackToPt() {
Component rendered = GlobalTranslator.render(
Component.translatable("canalhandia.morte.luto.resumo",
Component.text("Ana, Bob"), Component.text("Steve")),
Locale.forLanguageTag("pt-BR"));
assertEquals("Ana, Bob prestaram luto por Steve.", plain(rendered));
}
/** An unknown locale renders in the default (en), not as the raw key. */
@Test
void unknownLocaleFallsBackToDefault() {
Component rendered = GlobalTranslator.render(
Component.translatable("canalhandia.morte.luto.prestar", Component.text("Steve")),
Locale.forLanguageTag("ja"));
assertEquals("pay respects for Steve", plain(rendered));
}
}
@@ -54,4 +54,11 @@ class TitlesTest {
void tagCarriesTheTitle() {
assertNotNull(TitleChatListener.tag(Achievement.byKey("pedreiro")));
}
@Test
void tagRootIsColourlessSoMessageStaysWhite() {
// The chat message is appended to this tag; a coloured root would bleed
// into unstyled message text and grey it out. Root must carry no colour.
assertNull(TitleChatListener.tag(Achievement.byKey("pedreiro")).color());
}
}