From 6dda1e33d38808d4bcc1fb740131a5b54bff6598 Mon Sep 17 00:00:00 2001 From: Marcos Paulo Date: Tue, 11 Aug 2026 12:35:36 -0300 Subject: [PATCH] feat(conquistas): config-driven catalogue with /canalhandia reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move achievements and milestones out of code and into editable YAML, so operators add or retune titles by editing a file and reloading — no rebuild. - conquistas-catalogo.yml: each title is a key with titulo/descricao and a list of condicoes. Conditions are a tiny grammar ("metrica operador alvo", target may be a number, another metric, or metrica/numero), evaluated on friendly units (km, hours) normalised from the raw stats. Achievement is now a class loaded from this file, not an enum. - marcos-catalogo.yml: milestone tracks (statistica/verbo/unidade/limiares), loaded by Milestones at startup and reload. - /canalhandia reload now reloads both catalogues (plus config.yml) live and reports the counts. - Shipped catalogue expanded 10 -> 29 titles and every milestone track deepened. - Silent backfill on load/reload (Achievements.syncCatalogue, Milestones .resyncSilently, keyed on a stored signature): a grown catalogue banks the history it already implies instead of spamming returning players. Tests: AchievementTest covers the condition grammar and validates the shipped catalogue; TitlesTest loads the real catalogue. Co-Authored-By: Claude Opus 4.8 --- .../marcospaulo/canalhandia/Achievement.java | 287 +++++++++++------ .../marcospaulo/canalhandia/Achievements.java | 61 ++++ .../marcospaulo/canalhandia/Canalhandia.java | 38 +++ .../canalhandia/CanalhandiaCommand.java | 7 +- .../marcospaulo/canalhandia/Milestones.java | 151 ++++++++- src/main/resources/conquistas-catalogo.yml | 157 ++++++++++ src/main/resources/marcos-catalogo.yml | 45 +++ .../canalhandia/AchievementTest.java | 288 ++++++++---------- .../marcospaulo/canalhandia/TitlesTest.java | 48 +-- 9 files changed, 783 insertions(+), 299 deletions(-) create mode 100644 src/main/resources/conquistas-catalogo.yml create mode 100644 src/main/resources/marcos-catalogo.yml diff --git a/src/main/java/dev/marcospaulo/canalhandia/Achievement.java b/src/main/java/dev/marcospaulo/canalhandia/Achievement.java index c81d0cd..f04f253 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Achievement.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Achievement.java @@ -1,102 +1,45 @@ package dev.marcospaulo.canalhandia; +import org.bukkit.configuration.ConfigurationSection; + import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.logging.Logger; /** - * Named achievements: the things worth telling the room about that a round - * number cannot express. + * A named achievement, loaded from {@code conquistas-catalogo.yml}. * - *

{@link Milestones} already announces thresholds ("passou de 100 km"). This - * covers the other half — combinations and ratios that say something about - * how someone plays: dying more than they mine, walking a marathon - * without ever touching the Nether, killing a thousand mobs. + *

This used to be a hardcoded enum. Now every entry — key, title, description + * and the condition that unlocks it — comes from config, so operators add or + * retune titles by editing one file and running {@code /canalhandia reload}, the + * same shape as the whitelist. {@link Achievements} still owns the "announce + * once" bookkeeping; this owns what the achievements are. * - *

Every condition is a pure function of a stat map, so the whole catalogue is - * testable without a server. {@link Achievements} owns the "announce once" - * bookkeeping; this owns what the achievements are. - * - *

The reward is chat only — a name and a line. Nothing here touches - * gameplay, in keeping with the rest of the plugin. + *

Conditions are a tiny grammar rather than code: each is one or more clauses + * (all must hold) of the form {@code metrica operador alvo}, where the target is + * 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. */ -enum Achievement { +final class Achievement { - // --- mining ------------------------------------------------------------- + /** 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. */ + private static final List METRICS = List.of( + "mineracao", "combate", "mortes", "pesca", "pulos", "distancia", "tempo"); - PEDREIRO("pedreiro", "Pedreiro", - "minerou 10.000 blocos", - stats -> stats.getOrDefault("mineracao", 0L) >= 10_000), - - ESCAVADEIRA("escavadeira", "Escavadeira Humana", - "minerou 100.000 blocos", - stats -> stats.getOrDefault("mineracao", 0L) >= 100_000), - - // --- combat ------------------------------------------------------------- - - EXTERMINADOR("exterminador", "Exterminador", - "derrotou 1.000 monstros", - stats -> stats.getOrDefault("combate", 0L) >= 1_000), - - // --- travel ------------------------------------------------------------- - - MARATONISTA("maratonista", "Maratonista", - "caminhou 42 km (uma maratona)", - stats -> km(stats) >= 42), - - // --- the funny ones ----------------------------------------------------- - - /** - * More deaths than a hundredth of the blocks mined — the shape of someone - * who dies constantly relative to how much they actually get done. Gated on - * a real amount of mining so a brand-new player is not immediately handed a - * joke achievement on their second death. - */ - IMORTAL_AS_AVESSAS("imortal-as-avessas", "Imortal às Avessas", - "morreu mais de uma vez a cada 100 blocos minerados", - stats -> stats.getOrDefault("mineracao", 0L) >= 2_000 - && stats.getOrDefault("mortes", 0L) - > stats.getOrDefault("mineracao", 0L) / 100), - - /** - * A hundred hours in and still barely scratched. The counterpart to the one - * above: plays a lot, mines little. - */ - TURISTA("turista", "Turista", - "passou de 100 horas jogadas sem minerar 5.000 blocos", - stats -> hours(stats) >= 100 && stats.getOrDefault("mineracao", 0L) < 5_000), - - /** Long-lived: a lot of playtime with very few deaths. */ - CASCA_GROSSA("casca-grossa", "Casca Grossa", - "passou de 50 horas com menos de 10 mortes", - stats -> hours(stats) >= 50 && stats.getOrDefault("mortes", 0L) < 10), - - /** Pure dedication, no qualifier. */ - VETERANO("veterano", "Veterano", - "passou de 200 horas jogadas", - stats -> hours(stats) >= 200), - - PESCADOR("pescador", "Pescador Profissional", - "pescou 500 peixes", - stats -> stats.getOrDefault("pesca", 0L) >= 500), - - SALTITANTE("saltitante", "Saltitante", - "deu 50.000 pulos", - stats -> stats.getOrDefault("pulos", 0L) >= 50_000); - - /** A condition over the normalised stat map. */ - @FunctionalInterface - interface Condition { - boolean met(Map stats); - } + /** The whole catalogue, replaced wholesale on load/reload. */ + private static volatile List catalog = List.of(); private final String key; private final String title; private final String description; private final Condition condition; - Achievement(String key, String title, String description, Condition condition) { + private Achievement(String key, String title, String description, Condition condition) { this.key = key; this.title = title; this.description = description; @@ -115,22 +58,21 @@ enum Achievement { return description; } - boolean met(Map stats) { - return stats != null && condition.met(stats); + /** True when this player's raw statistics satisfy the condition. */ + boolean met(Map rawStats) { + return rawStats != null && condition.met(normalise(rawStats)); } - /** - * Play time in hours. The raw statistic is in ticks, and the division is - * spelled out here rather than at each use so a unit mistake can only be - * made in one place. - */ - private static long hours(Map stats) { - return stats.getOrDefault("tempo", 0L) / 20L / 3600L; + // --- the live catalogue ------------------------------------------------- + + /** Replaces the live catalogue (called on enable and on reload). */ + static void load(List achievements) { + catalog = List.copyOf(achievements); } - /** Distance walked in kilometres; the raw statistic is in centimetres. */ - private static long km(Map stats) { - return stats.getOrDefault("distancia", 0L) / 100_000L; + /** The current catalogue as an array, so callers can use {@code .length}. */ + static Achievement[] values() { + return catalog.toArray(new Achievement[0]); } static Achievement byKey(String key) { @@ -138,7 +80,7 @@ enum Achievement { return null; } String wanted = key.trim().toLowerCase(Locale.ROOT); - for (Achievement achievement : values()) { + for (Achievement achievement : catalog) { if (achievement.key.equals(wanted)) { return achievement; } @@ -146,14 +88,165 @@ enum Achievement { return null; } - /** Every achievement whose condition the stats satisfy. */ - static List earned(Map stats) { + /** Every achievement whose condition the raw statistics satisfy. */ + static List earned(Map rawStats) { List out = new ArrayList<>(); - for (Achievement achievement : values()) { - if (achievement.met(stats)) { + if (rawStats == null) { + return out; + } + Map stats = normalise(rawStats); + for (Achievement achievement : catalog) { + if (achievement.condition.met(stats)) { out.add(achievement); } } return out; } + + // --- loading ------------------------------------------------------------ + + /** + * Builds a catalogue from a config section. Each child is a key with + * {@code titulo}, {@code descricao} and {@code condicoes} (a list). A badly + * formed entry is logged and skipped rather than failing the whole load — + * one typo must not wipe every title. + */ + static List loadFrom(ConfigurationSection section, Logger log) { + List out = new ArrayList<>(); + if (section == null) { + return out; + } + for (String key : section.getKeys(false)) { + ConfigurationSection entry = section.getConfigurationSection(key); + if (entry == null) { + continue; + } + try { + out.add(parse(key, entry.getString("titulo", ""), + entry.getString("descricao", ""), entry.getStringList("condicoes"))); + } catch (IllegalArgumentException bad) { + log.warning("Conquista '" + key + "' ignorada: " + bad.getMessage()); + } + } + return out; + } + + /** Builds one achievement, parsing its condition clauses. Visible for tests. */ + static Achievement parse(String key, String title, String description, List conditions) { + 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); + } + if (title == null || title.isBlank()) { + throw new IllegalArgumentException("sem titulo"); + } + if (conditions == null || conditions.isEmpty()) { + throw new IllegalArgumentException("sem condicoes"); + } + List clauses = new ArrayList<>(); + for (String raw : conditions) { + clauses.add(Clause.parse(raw)); + } + Condition condition = stats -> { + for (Clause clause : clauses) { + if (!clause.met(stats)) { + return false; + } + } + return true; + }; + return new Achievement(normalizedKey, title, description, condition); + } + + /** Raw statistics → the friendly units the conditions are written in. */ + private static Map normalise(Map raw) { + Map out = new HashMap<>(); + out.put("mineracao", raw.getOrDefault("mineracao", 0L)); + out.put("combate", raw.getOrDefault("combate", 0L)); + out.put("mortes", raw.getOrDefault("mortes", 0L)); + out.put("pesca", raw.getOrDefault("pesca", 0L)); + out.put("pulos", raw.getOrDefault("pulos", 0L)); + out.put("distancia", raw.getOrDefault("distancia", 0L) / 100_000L); // cm → km + out.put("tempo", raw.getOrDefault("tempo", 0L) / 20L / 3600L); // ticks → horas + return out; + } + + @FunctionalInterface + interface Condition { + boolean met(Map stats); + } + + private enum Op { + GE(">="), GT(">"), LE("<="), LT("<"), EQ("=="), NE("!="); + + private final String symbol; + + Op(String symbol) { + this.symbol = symbol; + } + + static Op of(String symbol) { + for (Op op : values()) { + if (op.symbol.equals(symbol)) { + return op; + } + } + throw new IllegalArgumentException("operador desconhecido: " + symbol); + } + + boolean test(long a, long b) { + return switch (this) { + case GE -> a >= b; + case GT -> a > b; + case LE -> a <= b; + case LT -> a < b; + case EQ -> a == b; + case NE -> a != b; + }; + } + } + + /** One "metrica operador alvo" comparison over the normalised stat map. */ + private record Clause(String metric, Op op, String rhsMetric, long rhsConst, long divisor) { + + static Clause parse(String raw) { + String[] parts = raw == null ? new String[0] : raw.trim().split("\\s+"); + if (parts.length != 3) { + throw new IllegalArgumentException("condicao mal formada: '" + raw + "'"); + } + String metric = parts[0].toLowerCase(Locale.ROOT); + if (!METRICS.contains(metric)) { + throw new IllegalArgumentException("metrica desconhecida: " + metric); + } + Op op = Op.of(parts[1]); + String target = parts[2].toLowerCase(Locale.ROOT); + if (target.matches("-?\\d+")) { + return new Clause(metric, op, null, Long.parseLong(target), 1); + } + String rhsMetric = target; + long divisor = 1; + int slash = target.indexOf('/'); + if (slash >= 0) { + rhsMetric = target.substring(0, slash); + String d = target.substring(slash + 1); + if (!d.matches("\\d+")) { + throw new IllegalArgumentException("divisor inválido: " + target); + } + divisor = Long.parseLong(d); + if (divisor == 0) { + throw new IllegalArgumentException("divisão por zero: " + target); + } + } + if (!METRICS.contains(rhsMetric)) { + throw new IllegalArgumentException("metrica desconhecida: " + rhsMetric); + } + return new Clause(metric, op, rhsMetric, 0, divisor); + } + + boolean met(Map stats) { + long left = stats.getOrDefault(metric, 0L); + long right = rhsMetric == null ? rhsConst : stats.getOrDefault(rhsMetric, 0L) / divisor; + return op.test(left, right); + } + } } diff --git a/src/main/java/dev/marcospaulo/canalhandia/Achievements.java b/src/main/java/dev/marcospaulo/canalhandia/Achievements.java index 60e49b6..bfdb593 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Achievements.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Achievements.java @@ -12,8 +12,11 @@ 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; +import java.util.Set; +import java.util.UUID; /** * Awards {@link Achievement}s once and remembers that it did. @@ -36,6 +39,64 @@ final class Achievements { this.data = YamlConfiguration.loadConfiguration(file); } + /** The reserved node in conquistas.yml that records which keys the catalogue + * has already introduced. Not a UUID, so it never collides with a player. */ + private static final String CATALOGUE = "_catalogo"; + + /** + * Silently banks history when the catalogue grows. + * + *

The per-player first-sight rule keeps a brand-new player quiet; this is + * its counterpart for a brand-new achievement. When the enum gains + * entries, every already-known player who already qualifies for them would + * otherwise be announced in a burst the next time they log in — months-old + * history dumped into chat, exactly what the module was careful to avoid. + * + *

So on enable: any achievement not previously in the stored catalogue is + * marked (silently) for every player already on record who currently meets + * it, computed from their stats on disk. Only crossings that happen + * after introduction announce. Idempotent — re-running with no new + * keys does nothing. + */ + void syncCatalogue() { + Set known = new HashSet<>(data.getStringList(CATALOGUE)); + List current = new ArrayList<>(); + for (Achievement achievement : Achievement.values()) { + current.add(achievement.key()); + } + List added = new ArrayList<>(); + for (Achievement achievement : Achievement.values()) { + if (!known.contains(achievement.key())) { + added.add(achievement); + } + } + if (added.isEmpty() && known.equals(new HashSet<>(current))) { + return; + } + for (String base : data.getKeys(false)) { + if (base.equals(CATALOGUE)) { + continue; + } + UUID uuid; + try { + uuid = UUID.fromString(base); + } catch (IllegalArgumentException notAPlayer) { + continue; + } + Map stats = plugin.offlineStats().achievementStats(uuid); + if (stats == null) { + continue; + } + for (Achievement achievement : added) { + if (achievement.met(stats) && !data.getBoolean(base + "." + achievement.key(), false)) { + data.set(base + "." + achievement.key(), true); + } + } + } + data.set(CATALOGUE, current); + save(); + } + /** Checks every online player and announces anything newly earned. */ void check() { if (!plugin.settings().moduleEnabled(Module.CONQUISTAS)) { diff --git a/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java b/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java index adffbbd..48e1a40 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java @@ -102,6 +102,9 @@ public final class Canalhandia extends JavaPlugin implements Listener { @Override public void onEnable() { saveDefaultConfig(); + // Ship the editable catalogues; false = never overwrite the operator's copy. + saveResource("conquistas-catalogo.yml", false); + saveResource("marcos-catalogo.yml", false); settings = new Settings(this); notes = new Notes(new java.io.File(getDataFolder(), "notas.yml")); mail = new Mail(new java.io.File(getDataFolder(), "recados.yml")); @@ -110,6 +113,12 @@ public final class Canalhandia extends JavaPlugin implements Listener { milestones = new Milestones(this); achievements = new Achievements(this); titles = new Titles(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. + Achievement.load(Achievement.loadFrom(conquistasCatalogo(), getLogger())); + achievements.syncCatalogue(); + milestones.resyncSilently(); weeklyStats = new WeeklyStats(new java.io.File(getDataFolder(), "semana.yml")); aiBudget = new Budget(settings.aiSpontaneousPerDay(), settings.aiSpontaneousGapMinutes() * 60_000L, @@ -211,6 +220,35 @@ public final class Canalhandia extends JavaPlugin implements Listener { return titles; } + /** Milestones, exposed for the reload confirmation. Never null. */ + Milestones milestones() { + return milestones; + } + + /** The achievement catalogue section from conquistas-catalogo.yml (may be null if malformed). */ + org.bukkit.configuration.ConfigurationSection conquistasCatalogo() { + return org.bukkit.configuration.file.YamlConfiguration + .loadConfiguration(new java.io.File(getDataFolder(), "conquistas-catalogo.yml")) + .getConfigurationSection("conquistas"); + } + + /** The milestone catalogue section from marcos-catalogo.yml (may be null if malformed). */ + org.bukkit.configuration.ConfigurationSection marcosCatalogo() { + return org.bukkit.configuration.file.YamlConfiguration + .loadConfiguration(new java.io.File(getDataFolder(), "marcos-catalogo.yml")) + .getConfigurationSection("marcos"); + } + + /** + * Reloads the achievement and milestone catalogues from disk and silently + * rebanks any newly implied history. Driven by {@code /canalhandia reload}. + */ + void reloadCatalogo() { + Achievement.load(Achievement.loadFrom(conquistasCatalogo(), getLogger())); + milestones.reload(); + achievements.syncCatalogue(); + } + /** The weekly ranking baseline. Never null. */ WeeklyStats weeklyStats() { return weeklyStats; diff --git a/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java index 946824d..c5a0fbb 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java +++ b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java @@ -106,9 +106,11 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { case "reload" -> { if (admin(sender)) { plugin.reloadConfig(); + plugin.reloadCatalogo(); plugin.rescheduleTimer(); plugin.rescheduleMilestones(); - Msg.ok(sender, "Configuração recarregada."); + Msg.ok(sender, "Recarregado: " + Achievement.values().length + + " conquistas e " + plugin.milestones().trackCount() + " marcos."); } } default -> help(sender); @@ -859,7 +861,8 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { commands.put("/canalhandia modulo ", "liga/desliga um módulo"); commands.put("/canalhandia marcos", "força uma verificação de marcos"); commands.put("/canalhandia limpar [cooldown|historico|tudo]", "zera estado temporário"); - commands.put("/canalhandia reload", "recarrega o config.yml"); + commands.put("/canalhandia reload", + "recarrega config.yml, conquistas-catalogo.yml e marcos-catalogo.yml"); commands.put("/curiosidade modo ", "entrada | intervalo | ambos | manual"); commands.put("/curiosidade intervalo ", "intervalo do modo temporizado"); commands.put("/curiosidade atraso ", "espera após o jogador entrar"); diff --git a/src/main/java/dev/marcospaulo/canalhandia/Milestones.java b/src/main/java/dev/marcospaulo/canalhandia/Milestones.java index 5153693..2122bbc 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Milestones.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Milestones.java @@ -5,12 +5,19 @@ 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.ConfigurationSection; 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.Arrays; import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.logging.Logger; /** * Announces round-number milestones — 100 km walked, 24 hours played — the @@ -29,34 +36,148 @@ final class Milestones { private enum Unit { COUNT, HOURS, KILOMETRES } - private static final List TRACKS = List.of( - new Track("distancia", "WALK_ONE_CM", "caminhados", Unit.KILOMETRES, - new long[]{50, 100, 250, 500, 1000, 2500}), - new Track("tempo", "PLAY_TIME", "jogadas", Unit.HOURS, - new long[]{10, 24, 50, 100, 250, 500, 1000}), - new Track("mortes", "DEATHS", "mortes", Unit.COUNT, - new long[]{10, 25, 50, 100, 250, 500}), - new Track("combate", "MOB_KILLS", "monstros derrotados", Unit.COUNT, - new long[]{100, 500, 1000, 5000, 10000}), - new Track("pulos", "JUMP", "pulos", Unit.COUNT, - new long[]{1000, 5000, 10000, 50000}), - new Track("pesca", "FISH_CAUGHT", "peixes pescados", Unit.COUNT, - new long[]{10, 50, 100, 500})); - private final Canalhandia plugin; private final File file; private final YamlConfiguration data; + private List tracks; Milestones(Canalhandia plugin) { this.plugin = plugin; this.file = new File(plugin.getDataFolder(), "marcos.yml"); this.data = YamlConfiguration.loadConfiguration(file); + this.tracks = loadTracks(plugin.marcosCatalogo(), plugin.getLogger()); + } + + /** Reloads track definitions from disk and silently rebanks any new history. */ + void reload() { + this.tracks = loadTracks(plugin.marcosCatalogo(), plugin.getLogger()); + resyncSilently(); + } + + /** How many tracks are currently loaded, for the reload confirmation. */ + int trackCount() { + return tracks.size(); + } + + /** Parses the track catalogue; a malformed track is logged and skipped. */ + private static List loadTracks(ConfigurationSection section, Logger log) { + List out = new ArrayList<>(); + if (section == null) { + return out; + } + for (String key : section.getKeys(false)) { + ConfigurationSection entry = section.getConfigurationSection(key); + if (entry == null) { + continue; + } + String statistic = entry.getString("statistica"); + String verb = entry.getString("verbo", key); + Unit unit = parseUnit(entry.getString("unidade", "COUNT")); + List values = new ArrayList<>(); + for (Object raw : entry.getList("limiares", List.of())) { + if (raw instanceof Number number) { + values.add(number.longValue()); + } + } + values.sort(Long::compareTo); + if (statistic == null || statistic.isBlank() || unit == null || values.isEmpty()) { + log.warning("Marco '" + key + "' ignorado: statistica/unidade/limiares faltando."); + continue; + } + long[] thresholds = new long[values.size()]; + for (int i = 0; i < thresholds.length; i++) { + thresholds[i] = values.get(i); + } + out.add(new Track(key, statistic, verb, unit, thresholds)); + } + return out; + } + + private static Unit parseUnit(String text) { + if (text == null) { + return null; + } + return switch (text.trim().toUpperCase(Locale.ROOT)) { + case "COUNT", "CONTAGEM" -> Unit.COUNT; + case "HORAS", "HOURS" -> Unit.HOURS; + case "KM", "KILOMETRES", "KILOMETROS", "QUILOMETROS" -> Unit.KILOMETRES; + default -> null; + }; + } + + /** A non-UUID reserved node recording which thresholds were already introduced. */ + private static final String VERSION = "_versao"; + + /** + * Silently banks history when the thresholds change. + * + *

Adding a higher threshold to an existing track would otherwise announce + * it retroactively to everyone already past it — the same burst the + * first-sight rule avoids for new players and new tracks. So when the track + * definitions change, every player already on record has each track set to + * the highest threshold they currently pass, computed from their stats on + * disk, without announcing. Only crossings beyond that announce afterwards. + * Idempotent: unchanged thresholds do nothing. + */ + void resyncSilently() { + String signature = signature(); + if (signature.equals(data.getString(VERSION, ""))) { + return; + } + for (String base : data.getKeys(false)) { + if (base.equals(VERSION)) { + continue; + } + UUID uuid; + try { + uuid = UUID.fromString(base); + } catch (IllegalArgumentException notAPlayer) { + continue; + } + Map raw = plugin.offlineStats().achievementStats(uuid); + if (raw == null) { + continue; + } + for (Track track : tracks) { + long value = inUnit(track, raw.getOrDefault(track.key(), 0L)); + long reached = 0; + for (long threshold : track.thresholds()) { + if (value >= threshold) { + reached = threshold; + } + } + if (reached > data.getLong(base + "." + track.key(), -1)) { + data.set(base + "." + track.key(), reached); + } + } + } + data.set(VERSION, signature); + save(); + } + + /** Converts a raw statistic into the track's unit; shared by check and resync. */ + private static long inUnit(Track track, long raw) { + return switch (track.unit()) { + case COUNT -> raw; + case HOURS -> raw / 20L / 3600L; + case KILOMETRES -> raw / 100_000L; + }; + } + + /** A fingerprint of the current thresholds, so any change triggers a resync. */ + private String signature() { + StringBuilder builder = new StringBuilder(); + for (Track track : tracks) { + builder.append(track.key()).append('=') + .append(Arrays.toString(track.thresholds())).append(';'); + } + return Integer.toHexString(builder.toString().hashCode()); } /** Checks every online player and announces any newly crossed threshold. */ void check() { for (Player player : Bukkit.getOnlinePlayers()) { - for (Track track : TRACKS) { + for (Track track : tracks) { check(player, track); } } diff --git a/src/main/resources/conquistas-catalogo.yml b/src/main/resources/conquistas-catalogo.yml new file mode 100644 index 0000000..9a2167b --- /dev/null +++ b/src/main/resources/conquistas-catalogo.yml @@ -0,0 +1,157 @@ +# Catálogo de conquistas do Canalhandia. +# +# Edite este arquivo para criar, remover ou reajustar títulos, depois rode +# /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. +# +# Métricas disponíveis (nas unidades abaixo): +# mineracao blocos minerados +# combate monstros derrotados +# mortes mortes +# pesca peixes pescados +# pulos pulos +# distancia quilômetros caminhados +# tempo horas jogadas +# +# 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 +# "mortes > combate" morreu mais do que matou +# "mortes > mineracao/100" morreu mais de uma vez a cada 100 blocos + +conquistas: + + # --- mineração --- + pedreiro: + titulo: "Pedreiro" + descricao: "minerou 10.000 blocos" + condicoes: ["mineracao >= 10000"] + escavadeira: + titulo: "Escavadeira Humana" + descricao: "minerou 100.000 blocos" + condicoes: ["mineracao >= 100000"] + terraplanagem: + titulo: "Terraplanagem" + descricao: "minerou 500.000 blocos" + condicoes: ["mineracao >= 500000"] + + # --- combate --- + cacador: + titulo: "Caçador" + descricao: "derrotou 100 monstros" + condicoes: ["combate >= 100"] + exterminador: + titulo: "Exterminador" + descricao: "derrotou 1.000 monstros" + condicoes: ["combate >= 1000"] + ceifador: + titulo: "Ceifador" + descricao: "derrotou 10.000 monstros" + condicoes: ["combate >= 10000"] + + # --- viagem --- + maratonista: + titulo: "Maratonista" + descricao: "caminhou 42 km (uma maratona)" + condicoes: ["distancia >= 42"] + andarilho: + titulo: "Andarilho" + descricao: "caminhou 100 km" + condicoes: ["distancia >= 100"] + explorador: + titulo: "Explorador" + descricao: "caminhou 500 km" + condicoes: ["distancia >= 500"] + volta-ao-mundo: + titulo: "Volta ao Mundo" + descricao: "caminhou 1.000 km" + condicoes: ["distancia >= 1000"] + + # --- tempo --- + residente: + titulo: "Residente" + descricao: "passou de 50 horas jogadas" + condicoes: ["tempo >= 50"] + veterano: + titulo: "Veterano" + descricao: "passou de 200 horas jogadas" + condicoes: ["tempo >= 200"] + morador-fixo: + titulo: "Morador Fixo" + descricao: "passou de 500 horas jogadas" + condicoes: ["tempo >= 500"] + lenda-viva: + titulo: "Lenda Viva" + descricao: "passou de 1.000 horas jogadas" + condicoes: ["tempo >= 1000"] + + # --- pesca --- + pescador-amador: + titulo: "Pescador Amador" + descricao: "pescou 100 peixes" + condicoes: ["pesca >= 100"] + pescador: + titulo: "Pescador Profissional" + descricao: "pescou 500 peixes" + condicoes: ["pesca >= 500"] + mestre-da-vara: + titulo: "Mestre da Vara" + descricao: "pescou 2.000 peixes" + condicoes: ["pesca >= 2000"] + + # --- pulos --- + pula-pula: + titulo: "Pula-Pula" + descricao: "deu 10.000 pulos" + condicoes: ["pulos >= 10000"] + saltitante: + titulo: "Saltitante" + descricao: "deu 50.000 pulos" + condicoes: ["pulos >= 50000"] + canguru: + titulo: "Canguru" + descricao: "deu 100.000 pulos" + condicoes: ["pulos >= 100000"] + + # --- mortes e as engraçadas --- + gato-sete-vidas: + titulo: "Gato de Sete Vidas" + descricao: "morreu 50 vezes e continua tentando" + condicoes: ["mortes >= 50"] + vida-dura: + titulo: "Vida Dura" + descricao: "morreu 100 vezes" + condicoes: ["mortes >= 100"] + casca-grossa: + titulo: "Casca Grossa" + descricao: "passou de 50 horas com menos de 10 mortes" + condicoes: ["tempo >= 50", "mortes < 10"] + intocavel: + titulo: "Intocável" + descricao: "passou de 100 horas sem morrer nenhuma vez" + condicoes: ["tempo >= 100", "mortes == 0"] + turista: + titulo: "Turista" + descricao: "passou de 100 horas jogadas sem minerar 5.000 blocos" + condicoes: ["tempo >= 100", "mineracao < 5000"] + imortal-as-avessas: + titulo: "Imortal às Avessas" + descricao: "morreu mais de uma vez a cada 100 blocos minerados" + condicoes: ["mineracao >= 2000", "mortes > mineracao/100"] + kamikaze: + titulo: "Kamikaze" + descricao: "morreu mais vezes do que derrotou monstros" + condicoes: ["combate >= 100", "mortes > combate"] + rato-de-caverna: + titulo: "Rato de Caverna" + descricao: "minerou 50.000 blocos sem caminhar 10 km" + condicoes: ["mineracao >= 50000", "distancia < 10"] + nomade: + titulo: "Nômade" + descricao: "caminhou 100 km sem minerar 1.000 blocos" + condicoes: ["distancia >= 100", "mineracao < 1000"] diff --git a/src/main/resources/marcos-catalogo.yml b/src/main/resources/marcos-catalogo.yml new file mode 100644 index 0000000..38dc4cb --- /dev/null +++ b/src/main/resources/marcos-catalogo.yml @@ -0,0 +1,45 @@ +# Catálogo de marcos (milestones) do Canalhandia. +# +# Edite e rode /canalhandia reload para aplicar sem reiniciar. +# +# Cada marco anuncia quando um jogador cruza um limiar redondo pela primeira vez. +# Campos por marco: +# statistica nome da estatística do Minecraft (ex.: WALK_ONE_CM, PLAY_TIME) +# verbo texto no anúncio ("acabou de passar de 100 km caminhados") +# unidade COUNT (contagem), HORAS (ticks->horas) ou KM (cm->quilômetros) +# limiares lista de valores, na unidade acima, que valem um anúncio +# +# Passar um limiar já ultrapassado nunca é anunciado de novo: ao adicionar +# limiares maiores, o histórico é registrado em silêncio no próximo reload. + +marcos: + distancia: + statistica: "WALK_ONE_CM" + verbo: "caminhados" + unidade: "KM" + limiares: [50, 100, 250, 500, 1000, 2500, 5000, 10000] + tempo: + statistica: "PLAY_TIME" + verbo: "jogadas" + unidade: "HORAS" + limiares: [10, 24, 50, 100, 250, 500, 1000, 2000, 5000] + mortes: + statistica: "DEATHS" + verbo: "mortes" + unidade: "COUNT" + limiares: [10, 25, 50, 100, 250, 500, 1000, 2500] + combate: + statistica: "MOB_KILLS" + verbo: "monstros derrotados" + unidade: "COUNT" + limiares: [100, 500, 1000, 5000, 10000, 25000, 50000, 100000] + pulos: + statistica: "JUMP" + verbo: "pulos" + unidade: "COUNT" + limiares: [1000, 5000, 10000, 50000, 100000, 500000] + pesca: + statistica: "FISH_CAUGHT" + verbo: "peixes pescados" + unidade: "COUNT" + limiares: [10, 50, 100, 500, 1000, 5000] diff --git a/src/test/java/dev/marcospaulo/canalhandia/AchievementTest.java b/src/test/java/dev/marcospaulo/canalhandia/AchievementTest.java index cd3de2c..e40864f 100644 --- a/src/test/java/dev/marcospaulo/canalhandia/AchievementTest.java +++ b/src/test/java/dev/marcospaulo/canalhandia/AchievementTest.java @@ -1,25 +1,31 @@ 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.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.HashSet; -import java.util.Locale; +import java.util.List; import java.util.Map; import java.util.Set; +import java.util.logging.Logger; + +import org.bukkit.configuration.file.YamlConfiguration; import org.junit.jupiter.api.Test; +/** The config-driven catalogue: the condition grammar, and the shipped defaults. */ class AchievementTest { private static final long TICKS_PER_HOUR = 20L * 3600L; private static final long CM_PER_KM = 100_000L; - /** A stat map with everything at zero, so each test sets only what it means. */ - private static Map stats() { + /** Raw stats (cm, ticks, counts) with everything zeroed. */ + private static Map raw() { Map stats = new HashMap<>(); for (String key : new String[]{"mineracao", "tempo", "distancia", "mortes", "combate", "pesca", "pulos"}) { @@ -28,171 +34,123 @@ class AchievementTest { return stats; } - // --- catalogue hygiene -------------------------------------------------- + // --- the condition grammar --------------------------------------------- @Test - void keysAreUniqueLowercaseAscii() { - Set seen = new HashSet<>(); - for (Achievement achievement : Achievement.values()) { - String key = achievement.key(); - assertTrue(seen.add(key), "duplicate key: " + key); - assertEquals(key.toLowerCase(Locale.ROOT), key); - // Keys go into YAML paths and are typed by operators; keep them - // plain, with no accents. - assertTrue(key.matches("[a-z-]+"), "key must be plain ascii: " + key); + void simpleThreshold() { + Achievement a = Achievement.parse("pedreiro", "Pedreiro", "d", List.of("mineracao >= 10000")); + Map s = raw(); + s.put("mineracao", 9_999L); + assertFalse(a.met(s)); + s.put("mineracao", 10_000L); + assertTrue(a.met(s)); + } + + @Test + void distanceIsKilometresAndTimeIsHours() { + Achievement maratona = Achievement.parse("m", "M", "d", List.of("distancia >= 42")); + Map s = raw(); + s.put("distancia", 41 * CM_PER_KM); + assertFalse(maratona.met(s)); + s.put("distancia", 42 * CM_PER_KM); + assertTrue(maratona.met(s)); + + Achievement veterano = Achievement.parse("v", "V", "d", List.of("tempo >= 200")); + Map t = raw(); + t.put("tempo", 199 * TICKS_PER_HOUR); + assertFalse(veterano.met(t)); + t.put("tempo", 200 * TICKS_PER_HOUR); + assertTrue(veterano.met(t)); + } + + @Test + void allClausesMustHold() { + Achievement turista = Achievement.parse("t", "T", "d", + List.of("tempo >= 100", "mineracao < 5000")); + Map s = raw(); + s.put("tempo", 100 * TICKS_PER_HOUR); + s.put("mineracao", 4_999L); + assertTrue(turista.met(s)); + s.put("mineracao", 5_000L); + assertFalse(turista.met(s)); + } + + @Test + void ratioTargetDividesAMetric() { + Achievement imortal = Achievement.parse("i", "I", "d", + List.of("mineracao >= 2000", "mortes > mineracao/100")); + Map s = raw(); + s.put("mineracao", 2_000L); + s.put("mortes", 21L); + assertTrue(imortal.met(s)); + s.put("mortes", 20L); // exactly at the ratio is not over it + assertFalse(imortal.met(s)); + s.put("mineracao", 100L); + s.put("mortes", 50L); // ratio holds but the mining floor gates it + assertFalse(imortal.met(s)); + } + + @Test + void metricComparedToMetric() { + Achievement kamikaze = Achievement.parse("k", "K", "d", + List.of("combate >= 100", "mortes > combate")); + Map s = raw(); + s.put("combate", 100L); + s.put("mortes", 101L); + assertTrue(kamikaze.met(s)); + s.put("mortes", 100L); + assertFalse(kamikaze.met(s)); + } + + @Test + void nullStatsAreNeverMet() { + assertFalse(Achievement.parse("x", "X", "d", List.of("mineracao >= 1")).met(null)); + } + + // --- the parser rejects garbage ---------------------------------------- + + @Test + void rejectsBadDefinitions() { + assertThrows(IllegalArgumentException.class, + () -> Achievement.parse("chave_ruim", "T", "d", List.of("mineracao >= 1"))); + assertThrows(IllegalArgumentException.class, + () -> Achievement.parse("k", "T", "d", List.of("naoexiste >= 1"))); + assertThrows(IllegalArgumentException.class, + () -> Achievement.parse("k", "T", "d", List.of("mineracao ?? 1"))); + assertThrows(IllegalArgumentException.class, + () -> Achievement.parse("k", "", "d", List.of("mineracao >= 1"))); + assertThrows(IllegalArgumentException.class, + () -> Achievement.parse("k", "T", "d", List.of())); + } + + // --- the shipped catalogue loads and is sane --------------------------- + + @Test + void defaultCatalogueLoadsAndIsHygienic() { + List catalogue = loadDefault(); + assertTrue(catalogue.size() >= 25, "expected a healthy catalogue, got " + catalogue.size()); + Set keys = new HashSet<>(); + for (Achievement achievement : catalogue) { + assertTrue(keys.add(achievement.key()), "duplicate key: " + achievement.key()); + assertTrue(achievement.key().matches("[a-z-]+"), "bad key: " + achievement.key()); + assertFalse(achievement.title().isBlank()); + assertFalse(achievement.description().isBlank()); } + Achievement.load(catalogue); + // A brand-new player must unlock nothing. + assertTrue(Achievement.earned(raw()).isEmpty()); + assertNotNull(Achievement.byKey("pedreiro")); } - @Test - void everyAchievementHasATitleAndDescription() { - for (Achievement achievement : Achievement.values()) { - assertFalse(achievement.title().isBlank(), achievement.key() + " needs a title"); - assertFalse(achievement.description().isBlank(), - achievement.key() + " needs a description"); + static List loadDefault() { + try (InputStream in = AchievementTest.class.getResourceAsStream("/conquistas-catalogo.yml")) { + assertNotNull(in, "conquistas-catalogo.yml must be on the classpath"); + YamlConfiguration yaml = YamlConfiguration.loadConfiguration( + new InputStreamReader(in, StandardCharsets.UTF_8)); + return Achievement.loadFrom(yaml.getConfigurationSection("conquistas"), + Logger.getAnonymousLogger()); + } catch (Exception e) { + throw new AssertionError(e); } } - - @Test - void byKeyFindsOrReturnsNull() { - assertEquals(Achievement.VETERANO, Achievement.byKey("veterano")); - assertEquals(Achievement.VETERANO, Achievement.byKey(" VETERANO ")); - assertNull(Achievement.byKey("nao-existe")); - assertNull(Achievement.byKey(null)); - } - - @Test - void nothingIsEarnedWithZeroedStats() { - // A brand-new player must not be handed anything on their first check. - assertTrue(Achievement.earned(stats()).isEmpty()); - } - - @Test - void metIsFalseForNullStats() { - for (Achievement achievement : Achievement.values()) { - assertFalse(achievement.met(null), achievement.key() + " must handle null"); - } - } - - // --- mining ------------------------------------------------------------- - - @Test - void pedreiroNeedsTenThousandBlocks() { - Map stats = stats(); - stats.put("mineracao", 9_999L); - assertFalse(Achievement.PEDREIRO.met(stats)); - stats.put("mineracao", 10_000L); - assertTrue(Achievement.PEDREIRO.met(stats)); - } - - @Test - void escavadeiraNeedsAHundredThousand() { - Map stats = stats(); - stats.put("mineracao", 99_999L); - assertFalse(Achievement.ESCAVADEIRA.met(stats)); - stats.put("mineracao", 100_000L); - assertTrue(Achievement.ESCAVADEIRA.met(stats)); - } - - // --- travel and time ---------------------------------------------------- - - @Test - void maratonistaConvertsCentimetresToKilometres() { - Map stats = stats(); - stats.put("distancia", 41 * CM_PER_KM); - assertFalse(Achievement.MARATONISTA.met(stats)); - stats.put("distancia", 42 * CM_PER_KM); - assertTrue(Achievement.MARATONISTA.met(stats)); - } - - @Test - void veteranoConvertsTicksToHours() { - Map stats = stats(); - stats.put("tempo", 199 * TICKS_PER_HOUR); - assertFalse(Achievement.VETERANO.met(stats)); - stats.put("tempo", 200 * TICKS_PER_HOUR); - assertTrue(Achievement.VETERANO.met(stats)); - } - - // --- the ratio ones ----------------------------------------------------- - - @Test - void imortalAsAvessasNeedsBothTheRatioAndRealMining() { - Map stats = stats(); - // A brand-new player with 2 deaths and almost no mining satisfies the - // ratio but must NOT get a joke achievement on their second death. - stats.put("mineracao", 100L); - stats.put("mortes", 50L); - assertFalse(Achievement.IMORTAL_AS_AVESSAS.met(stats), - "the mining floor must gate this"); - - // 2000 mined, 21 deaths: over one per hundred blocks. - stats.put("mineracao", 2_000L); - stats.put("mortes", 21L); - assertTrue(Achievement.IMORTAL_AS_AVESSAS.met(stats)); - - // Exactly at the ratio is not over it. - stats.put("mortes", 20L); - assertFalse(Achievement.IMORTAL_AS_AVESSAS.met(stats)); - } - - @Test - void turistaNeedsHoursAndLittleMining() { - Map stats = stats(); - stats.put("tempo", 100 * TICKS_PER_HOUR); - stats.put("mineracao", 4_999L); - assertTrue(Achievement.TURISTA.met(stats)); - - // Mines plenty: not a tourist. - stats.put("mineracao", 5_000L); - assertFalse(Achievement.TURISTA.met(stats)); - - // Not enough hours yet. - stats.put("mineracao", 100L); - stats.put("tempo", 99 * TICKS_PER_HOUR); - assertFalse(Achievement.TURISTA.met(stats)); - } - - @Test - void cascaGrossaNeedsHoursAndFewDeaths() { - Map stats = stats(); - stats.put("tempo", 50 * TICKS_PER_HOUR); - stats.put("mortes", 9L); - assertTrue(Achievement.CASCA_GROSSA.met(stats)); - stats.put("mortes", 10L); - assertFalse(Achievement.CASCA_GROSSA.met(stats)); - } - - @Test - void turistaAndCascaGrossaCanBothApply() { - // They are not mutually exclusive, and nothing in the model pretends - // they are — a long-lived careful player who does not mine gets both. - Map stats = stats(); - stats.put("tempo", 100 * TICKS_PER_HOUR); - stats.put("mineracao", 10L); - stats.put("mortes", 1L); - assertTrue(Achievement.TURISTA.met(stats)); - assertTrue(Achievement.CASCA_GROSSA.met(stats)); - } - - // --- earned ------------------------------------------------------------- - - @Test - void earnedCollectsEverythingThatQualifies() { - Map stats = stats(); - stats.put("mineracao", 100_000L); - stats.put("combate", 1_000L); - var earned = Achievement.earned(stats); - assertTrue(earned.contains(Achievement.PEDREIRO)); - assertTrue(earned.contains(Achievement.ESCAVADEIRA)); - assertTrue(earned.contains(Achievement.EXTERMINADOR)); - assertFalse(earned.contains(Achievement.VETERANO)); - } - - @Test - void earnedHandlesAMapMissingKeys() { - // The snapshot always fills every key, but a condition reading a key - // that is absent must default to zero rather than throw. - assertNotNull(Achievement.earned(new HashMap<>())); - assertTrue(Achievement.earned(new HashMap<>()).isEmpty()); - } } diff --git a/src/test/java/dev/marcospaulo/canalhandia/TitlesTest.java b/src/test/java/dev/marcospaulo/canalhandia/TitlesTest.java index 09d51ce..6b8bfe9 100644 --- a/src/test/java/dev/marcospaulo/canalhandia/TitlesTest.java +++ b/src/test/java/dev/marcospaulo/canalhandia/TitlesTest.java @@ -1,49 +1,57 @@ package dev.marcospaulo.canalhandia; -import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +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.HashMap; import java.util.List; import java.util.Map; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; -/** The pure parts of the titles feature: selection and the offline stat contract. */ +/** Title selection and the offline stat contract, against the shipped catalogue. */ class TitlesTest { + @BeforeAll + static void loadCatalogue() { + Achievement.load(AchievementTest.loadDefault()); + } + @Test void matchesEarnedTitleByKeyAndByName() { - List earned = List.of(Achievement.PEDREIRO, Achievement.VETERANO); - // by key - assertSame(Achievement.PEDREIRO, CanalhandiaCommand.matchEarned("pedreiro", earned)); - // by display name, case-insensitively - assertSame(Achievement.VETERANO, CanalhandiaCommand.matchEarned("veterano", earned)); - assertSame(Achievement.PEDREIRO, CanalhandiaCommand.matchEarned("Pedreiro", earned)); + Achievement pedreiro = Achievement.byKey("pedreiro"); + Achievement veterano = Achievement.byKey("veterano"); + List earned = List.of(pedreiro, veterano); + assertSame(pedreiro, CanalhandiaCommand.matchEarned("pedreiro", earned)); + assertSame(veterano, CanalhandiaCommand.matchEarned("Veterano", earned)); // display name, ci + assertSame(pedreiro, CanalhandiaCommand.matchEarned("Pedreiro", earned)); } @Test void refusesTitlesNotYetEarned() { - List earned = List.of(Achievement.PEDREIRO); - // A real achievement, but not one this player has: cannot be worn. + List earned = List.of(Achievement.byKey("pedreiro")); assertNull(CanalhandiaCommand.matchEarned("veterano", earned)); assertNull(CanalhandiaCommand.matchEarned("Casca Grossa", earned)); - // Not an achievement at all. assertNull(CanalhandiaCommand.matchEarned("rei do mundo", earned)); } @Test void offlineStatKeysFeedAchievementConditions() { // OfflineStats.achievementStats keys its map by RankingMetric.commandKey(); - // Achievement conditions read the same names. If those two drift apart, a - // lookup by name silently awards nothing — so pin the contract here. + // the achievement conditions read the same names. Pin that contract. Map stats = new HashMap<>(); stats.put(RankingMetric.MINERACAO.commandKey(), 10_000L); - assertTrue(Achievement.earned(stats).contains(Achievement.PEDREIRO)); - + assertTrue(Achievement.earned(stats).contains(Achievement.byKey("pedreiro"))); stats.put(RankingMetric.MINERACAO.commandKey(), 9_999L); - assertFalse(Achievement.earned(stats).contains(Achievement.PEDREIRO)); + assertFalse(Achievement.earned(stats).contains(Achievement.byKey("pedreiro"))); + } + + @Test + void tagCarriesTheTitle() { + assertNotNull(TitleChatListener.tag(Achievement.byKey("pedreiro"))); } }