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