diff --git a/src/main/java/dev/marcospaulo/canalhandia/Achievement.java b/src/main/java/dev/marcospaulo/canalhandia/Achievement.java index f04f253..8c6f00f 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Achievement.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Achievement.java @@ -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. + * + *

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 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 statRefs; - private Achievement(String key, String title, String description, Condition condition) { + private Achievement(String key, String title, String description, Condition condition, + TextColor color, Set 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 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 referencedStats() { + Set 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 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 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 clauses = new ArrayList<>(); + Set 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 normalise(Map raw) { - Map out = new HashMap<>(); + Map 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); diff --git a/src/main/java/dev/marcospaulo/canalhandia/Achievements.java b/src/main/java/dev/marcospaulo/canalhandia/Achievements.java index bfdb593..f3004a4 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Achievements.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Achievements.java @@ -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 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 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. - * - *

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 snapshot(Player player) { - Map 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); diff --git a/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java index c5a0fbb..cabe0d9 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java +++ b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java @@ -1298,7 +1298,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))); } diff --git a/src/main/java/dev/marcospaulo/canalhandia/OfflineStats.java b/src/main/java/dev/marcospaulo/canalhandia/OfflineStats.java index 8f87d5f..9749e3b 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/OfflineStats.java +++ b/src/main/java/dev/marcospaulo/canalhandia/OfflineStats.java @@ -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 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. */ diff --git a/src/main/java/dev/marcospaulo/canalhandia/StatRef.java b/src/main/java/dev/marcospaulo/canalhandia/StatRef.java new file mode 100644 index 0000000..447e430 --- /dev/null +++ b/src/main/java/dev/marcospaulo/canalhandia/StatRef.java @@ -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}. + * + *

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:} inside it. + * + *

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 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); + } +} diff --git a/src/main/java/dev/marcospaulo/canalhandia/TitleChatListener.java b/src/main/java/dev/marcospaulo/canalhandia/TitleChatListener.java index 144fb22..af1842c 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/TitleChatListener.java +++ b/src/main/java/dev/marcospaulo/canalhandia/TitleChatListener.java @@ -44,10 +44,11 @@ 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. */ static Component tag(Achievement achievement) { return Component.text("[", NamedTextColor.DARK_GRAY) - .append(Component.text(achievement.title(), NamedTextColor.AQUA)) + .append(Component.text(achievement.title(), achievement.color())) .append(Component.text("] ", NamedTextColor.DARK_GRAY)) .decoration(TextDecoration.BOLD, false); } diff --git a/src/main/resources/conquistas-catalogo.yml b/src/main/resources/conquistas-catalogo.yml index 9a2167b..81b27f1 100644 --- a/src/main/resources/conquistas-catalogo.yml +++ b/src/main/resources/conquistas-catalogo.yml @@ -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 diff --git a/src/test/java/dev/marcospaulo/canalhandia/AchievementTest.java b/src/test/java/dev/marcospaulo/canalhandia/AchievementTest.java index e40864f..8fabfd3 100644 --- a/src/test/java/dev/marcospaulo/canalhandia/AchievementTest.java +++ b/src/test/java/dev/marcospaulo/canalhandia/AchievementTest.java @@ -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 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