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