feat(conquistas): config-driven catalogue with /canalhandia reload
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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,102 +1,45 @@
|
|||||||
package dev.marcospaulo.canalhandia;
|
package dev.marcospaulo.canalhandia;
|
||||||
|
|
||||||
|
import org.bukkit.configuration.ConfigurationSection;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Named achievements: the things worth telling the room about that a round
|
* A named achievement, loaded from {@code conquistas-catalogo.yml}.
|
||||||
* number cannot express.
|
|
||||||
*
|
*
|
||||||
* <p>{@link Milestones} already announces thresholds ("passou de 100 km"). This
|
* <p>This used to be a hardcoded enum. Now every entry — key, title, description
|
||||||
* covers the other half — combinations and ratios that say something about
|
* and the condition that unlocks it — comes from config, so operators add or
|
||||||
* <em>how</em> someone plays: dying more than they mine, walking a marathon
|
* retune titles by editing one file and running {@code /canalhandia reload}, the
|
||||||
* without ever touching the Nether, killing a thousand mobs.
|
* same shape as the whitelist. {@link Achievements} still owns the "announce
|
||||||
|
* once" bookkeeping; this owns what the achievements <em>are</em>.
|
||||||
*
|
*
|
||||||
* <p>Every condition is a pure function of a stat map, so the whole catalogue is
|
* <p>Conditions are a tiny grammar rather than code: each is one or more clauses
|
||||||
* testable without a server. {@link Achievements} owns the "announce once"
|
* (all must hold) of the form {@code metrica operador alvo}, where the target is
|
||||||
* bookkeeping; this owns what the achievements <em>are</em>.
|
* a number, another metric, or {@code metrica/numero}. Metrics are written in
|
||||||
*
|
* friendly units — distance in kilometres, time in hours — normalised from the
|
||||||
* <p>The reward is chat only — a name and a line. Nothing here touches
|
* raw statistics before evaluation, so the file reads the way a person thinks.
|
||||||
* gameplay, in keeping with the rest of the plugin.
|
|
||||||
*/
|
*/
|
||||||
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<String> METRICS = List.of(
|
||||||
|
"mineracao", "combate", "mortes", "pesca", "pulos", "distancia", "tempo");
|
||||||
|
|
||||||
PEDREIRO("pedreiro", "Pedreiro",
|
/** The whole catalogue, replaced wholesale on load/reload. */
|
||||||
"minerou 10.000 blocos",
|
private static volatile List<Achievement> catalog = List.of();
|
||||||
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<String, Long> stats);
|
|
||||||
}
|
|
||||||
|
|
||||||
private final String key;
|
private final String key;
|
||||||
private final String title;
|
private final String title;
|
||||||
private final String description;
|
private final String description;
|
||||||
private final Condition condition;
|
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.key = key;
|
||||||
this.title = title;
|
this.title = title;
|
||||||
this.description = description;
|
this.description = description;
|
||||||
@@ -115,22 +58,21 @@ enum Achievement {
|
|||||||
return description;
|
return description;
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean met(Map<String, Long> stats) {
|
/** True when this player's raw statistics satisfy the condition. */
|
||||||
return stats != null && condition.met(stats);
|
boolean met(Map<String, Long> rawStats) {
|
||||||
|
return rawStats != null && condition.met(normalise(rawStats));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// --- the live catalogue -------------------------------------------------
|
||||||
* 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
|
/** Replaces the live catalogue (called on enable and on reload). */
|
||||||
* made in one place.
|
static void load(List<Achievement> achievements) {
|
||||||
*/
|
catalog = List.copyOf(achievements);
|
||||||
private static long hours(Map<String, Long> stats) {
|
|
||||||
return stats.getOrDefault("tempo", 0L) / 20L / 3600L;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Distance walked in kilometres; the raw statistic is in centimetres. */
|
/** The current catalogue as an array, so callers can use {@code .length}. */
|
||||||
private static long km(Map<String, Long> stats) {
|
static Achievement[] values() {
|
||||||
return stats.getOrDefault("distancia", 0L) / 100_000L;
|
return catalog.toArray(new Achievement[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Achievement byKey(String key) {
|
static Achievement byKey(String key) {
|
||||||
@@ -138,7 +80,7 @@ enum Achievement {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
String wanted = key.trim().toLowerCase(Locale.ROOT);
|
String wanted = key.trim().toLowerCase(Locale.ROOT);
|
||||||
for (Achievement achievement : values()) {
|
for (Achievement achievement : catalog) {
|
||||||
if (achievement.key.equals(wanted)) {
|
if (achievement.key.equals(wanted)) {
|
||||||
return achievement;
|
return achievement;
|
||||||
}
|
}
|
||||||
@@ -146,14 +88,165 @@ enum Achievement {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Every achievement whose condition the stats satisfy. */
|
/** Every achievement whose condition the raw statistics satisfy. */
|
||||||
static List<Achievement> earned(Map<String, Long> stats) {
|
static List<Achievement> earned(Map<String, Long> rawStats) {
|
||||||
List<Achievement> out = new ArrayList<>();
|
List<Achievement> out = new ArrayList<>();
|
||||||
for (Achievement achievement : values()) {
|
if (rawStats == null) {
|
||||||
if (achievement.met(stats)) {
|
return out;
|
||||||
|
}
|
||||||
|
Map<String, Long> stats = normalise(rawStats);
|
||||||
|
for (Achievement achievement : catalog) {
|
||||||
|
if (achievement.condition.met(stats)) {
|
||||||
out.add(achievement);
|
out.add(achievement);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out;
|
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<Achievement> loadFrom(ConfigurationSection section, Logger log) {
|
||||||
|
List<Achievement> 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<String> 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<Clause> 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<String, Long> normalise(Map<String, Long> raw) {
|
||||||
|
Map<String, Long> 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<String, Long> 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<String, Long> stats) {
|
||||||
|
long left = stats.getOrDefault(metric, 0L);
|
||||||
|
long right = rhsMetric == null ? rhsConst : stats.getOrDefault(rhsMetric, 0L) / divisor;
|
||||||
|
return op.test(left, right);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,11 @@ import java.io.File;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Awards {@link Achievement}s once and remembers that it did.
|
* Awards {@link Achievement}s once and remembers that it did.
|
||||||
@@ -36,6 +39,64 @@ final class Achievements {
|
|||||||
this.data = YamlConfiguration.loadConfiguration(file);
|
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.
|
||||||
|
*
|
||||||
|
* <p>The per-player first-sight rule keeps a brand-new player quiet; this is
|
||||||
|
* its counterpart for a brand-new <em>achievement</em>. 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.
|
||||||
|
*
|
||||||
|
* <p>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
|
||||||
|
* <em>after</em> introduction announce. Idempotent — re-running with no new
|
||||||
|
* keys does nothing.
|
||||||
|
*/
|
||||||
|
void syncCatalogue() {
|
||||||
|
Set<String> known = new HashSet<>(data.getStringList(CATALOGUE));
|
||||||
|
List<String> current = new ArrayList<>();
|
||||||
|
for (Achievement achievement : Achievement.values()) {
|
||||||
|
current.add(achievement.key());
|
||||||
|
}
|
||||||
|
List<Achievement> 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<String, Long> 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. */
|
/** Checks every online player and announces anything newly earned. */
|
||||||
void check() {
|
void check() {
|
||||||
if (!plugin.settings().moduleEnabled(Module.CONQUISTAS)) {
|
if (!plugin.settings().moduleEnabled(Module.CONQUISTAS)) {
|
||||||
|
|||||||
@@ -102,6 +102,9 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
|||||||
@Override
|
@Override
|
||||||
public void onEnable() {
|
public void onEnable() {
|
||||||
saveDefaultConfig();
|
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);
|
settings = new Settings(this);
|
||||||
notes = new Notes(new java.io.File(getDataFolder(), "notas.yml"));
|
notes = new Notes(new java.io.File(getDataFolder(), "notas.yml"));
|
||||||
mail = new Mail(new java.io.File(getDataFolder(), "recados.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);
|
milestones = new Milestones(this);
|
||||||
achievements = new Achievements(this);
|
achievements = new Achievements(this);
|
||||||
titles = new Titles(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"));
|
weeklyStats = new WeeklyStats(new java.io.File(getDataFolder(), "semana.yml"));
|
||||||
aiBudget = new Budget(settings.aiSpontaneousPerDay(),
|
aiBudget = new Budget(settings.aiSpontaneousPerDay(),
|
||||||
settings.aiSpontaneousGapMinutes() * 60_000L,
|
settings.aiSpontaneousGapMinutes() * 60_000L,
|
||||||
@@ -211,6 +220,35 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
|||||||
return titles;
|
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. */
|
/** The weekly ranking baseline. Never null. */
|
||||||
WeeklyStats weeklyStats() {
|
WeeklyStats weeklyStats() {
|
||||||
return weeklyStats;
|
return weeklyStats;
|
||||||
|
|||||||
@@ -106,9 +106,11 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
|||||||
case "reload" -> {
|
case "reload" -> {
|
||||||
if (admin(sender)) {
|
if (admin(sender)) {
|
||||||
plugin.reloadConfig();
|
plugin.reloadConfig();
|
||||||
|
plugin.reloadCatalogo();
|
||||||
plugin.rescheduleTimer();
|
plugin.rescheduleTimer();
|
||||||
plugin.rescheduleMilestones();
|
plugin.rescheduleMilestones();
|
||||||
Msg.ok(sender, "Configuração recarregada.");
|
Msg.ok(sender, "Recarregado: " + Achievement.values().length
|
||||||
|
+ " conquistas e " + plugin.milestones().trackCount() + " marcos.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
default -> help(sender);
|
default -> help(sender);
|
||||||
@@ -859,7 +861,8 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
|||||||
commands.put("/canalhandia modulo <nome> <on|off>", "liga/desliga um módulo");
|
commands.put("/canalhandia modulo <nome> <on|off>", "liga/desliga um módulo");
|
||||||
commands.put("/canalhandia marcos", "força uma verificação de marcos");
|
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 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 <m>", "entrada | intervalo | ambos | manual");
|
commands.put("/curiosidade modo <m>", "entrada | intervalo | ambos | manual");
|
||||||
commands.put("/curiosidade intervalo <min>", "intervalo do modo temporizado");
|
commands.put("/curiosidade intervalo <min>", "intervalo do modo temporizado");
|
||||||
commands.put("/curiosidade atraso <seg>", "espera após o jogador entrar");
|
commands.put("/curiosidade atraso <seg>", "espera após o jogador entrar");
|
||||||
|
|||||||
@@ -5,12 +5,19 @@ import net.kyori.adventure.text.format.NamedTextColor;
|
|||||||
import net.kyori.adventure.text.format.TextDecoration;
|
import net.kyori.adventure.text.format.TextDecoration;
|
||||||
import org.bukkit.Bukkit;
|
import org.bukkit.Bukkit;
|
||||||
import org.bukkit.Statistic;
|
import org.bukkit.Statistic;
|
||||||
|
import org.bukkit.configuration.ConfigurationSection;
|
||||||
import org.bukkit.configuration.file.YamlConfiguration;
|
import org.bukkit.configuration.file.YamlConfiguration;
|
||||||
import org.bukkit.entity.Player;
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
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
|
* 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 enum Unit { COUNT, HOURS, KILOMETRES }
|
||||||
|
|
||||||
private static final List<Track> 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 Canalhandia plugin;
|
||||||
private final File file;
|
private final File file;
|
||||||
private final YamlConfiguration data;
|
private final YamlConfiguration data;
|
||||||
|
private List<Track> tracks;
|
||||||
|
|
||||||
Milestones(Canalhandia plugin) {
|
Milestones(Canalhandia plugin) {
|
||||||
this.plugin = plugin;
|
this.plugin = plugin;
|
||||||
this.file = new File(plugin.getDataFolder(), "marcos.yml");
|
this.file = new File(plugin.getDataFolder(), "marcos.yml");
|
||||||
this.data = YamlConfiguration.loadConfiguration(file);
|
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<Track> loadTracks(ConfigurationSection section, Logger log) {
|
||||||
|
List<Track> 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<Long> 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.
|
||||||
|
*
|
||||||
|
* <p>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<String, Long> 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. */
|
/** Checks every online player and announces any newly crossed threshold. */
|
||||||
void check() {
|
void check() {
|
||||||
for (Player player : Bukkit.getOnlinePlayers()) {
|
for (Player player : Bukkit.getOnlinePlayers()) {
|
||||||
for (Track track : TRACKS) {
|
for (Track track : tracks) {
|
||||||
check(player, track);
|
check(player, track);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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]
|
||||||
@@ -1,25 +1,31 @@
|
|||||||
package dev.marcospaulo.canalhandia;
|
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.assertFalse;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
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 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.HashMap;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.Locale;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
|
import org.bukkit.configuration.file.YamlConfiguration;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
/** The config-driven catalogue: the condition grammar, and the shipped defaults. */
|
||||||
class AchievementTest {
|
class AchievementTest {
|
||||||
|
|
||||||
private static final long TICKS_PER_HOUR = 20L * 3600L;
|
private static final long TICKS_PER_HOUR = 20L * 3600L;
|
||||||
private static final long CM_PER_KM = 100_000L;
|
private static final long CM_PER_KM = 100_000L;
|
||||||
|
|
||||||
/** A stat map with everything at zero, so each test sets only what it means. */
|
/** Raw stats (cm, ticks, counts) with everything zeroed. */
|
||||||
private static Map<String, Long> stats() {
|
private static Map<String, Long> raw() {
|
||||||
Map<String, Long> stats = new HashMap<>();
|
Map<String, Long> stats = new HashMap<>();
|
||||||
for (String key : new String[]{"mineracao", "tempo", "distancia", "mortes",
|
for (String key : new String[]{"mineracao", "tempo", "distancia", "mortes",
|
||||||
"combate", "pesca", "pulos"}) {
|
"combate", "pesca", "pulos"}) {
|
||||||
@@ -28,171 +34,123 @@ class AchievementTest {
|
|||||||
return stats;
|
return stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- catalogue hygiene --------------------------------------------------
|
// --- the condition grammar ---------------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void keysAreUniqueLowercaseAscii() {
|
void simpleThreshold() {
|
||||||
Set<String> seen = new HashSet<>();
|
Achievement a = Achievement.parse("pedreiro", "Pedreiro", "d", List.of("mineracao >= 10000"));
|
||||||
for (Achievement achievement : Achievement.values()) {
|
Map<String, Long> s = raw();
|
||||||
String key = achievement.key();
|
s.put("mineracao", 9_999L);
|
||||||
assertTrue(seen.add(key), "duplicate key: " + key);
|
assertFalse(a.met(s));
|
||||||
assertEquals(key.toLowerCase(Locale.ROOT), key);
|
s.put("mineracao", 10_000L);
|
||||||
// Keys go into YAML paths and are typed by operators; keep them
|
assertTrue(a.met(s));
|
||||||
// plain, with no accents.
|
|
||||||
assertTrue(key.matches("[a-z-]+"), "key must be plain ascii: " + key);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void everyAchievementHasATitleAndDescription() {
|
void distanceIsKilometresAndTimeIsHours() {
|
||||||
for (Achievement achievement : Achievement.values()) {
|
Achievement maratona = Achievement.parse("m", "M", "d", List.of("distancia >= 42"));
|
||||||
assertFalse(achievement.title().isBlank(), achievement.key() + " needs a title");
|
Map<String, Long> s = raw();
|
||||||
assertFalse(achievement.description().isBlank(),
|
s.put("distancia", 41 * CM_PER_KM);
|
||||||
achievement.key() + " needs a description");
|
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<String, Long> 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
|
@Test
|
||||||
void byKeyFindsOrReturnsNull() {
|
void allClausesMustHold() {
|
||||||
assertEquals(Achievement.VETERANO, Achievement.byKey("veterano"));
|
Achievement turista = Achievement.parse("t", "T", "d",
|
||||||
assertEquals(Achievement.VETERANO, Achievement.byKey(" VETERANO "));
|
List.of("tempo >= 100", "mineracao < 5000"));
|
||||||
assertNull(Achievement.byKey("nao-existe"));
|
Map<String, Long> s = raw();
|
||||||
assertNull(Achievement.byKey(null));
|
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
|
@Test
|
||||||
void nothingIsEarnedWithZeroedStats() {
|
void ratioTargetDividesAMetric() {
|
||||||
// A brand-new player must not be handed anything on their first check.
|
Achievement imortal = Achievement.parse("i", "I", "d",
|
||||||
assertTrue(Achievement.earned(stats()).isEmpty());
|
List.of("mineracao >= 2000", "mortes > mineracao/100"));
|
||||||
|
Map<String, Long> 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
|
@Test
|
||||||
void metIsFalseForNullStats() {
|
void metricComparedToMetric() {
|
||||||
for (Achievement achievement : Achievement.values()) {
|
Achievement kamikaze = Achievement.parse("k", "K", "d",
|
||||||
assertFalse(achievement.met(null), achievement.key() + " must handle null");
|
List.of("combate >= 100", "mortes > combate"));
|
||||||
}
|
Map<String, Long> s = raw();
|
||||||
}
|
s.put("combate", 100L);
|
||||||
|
s.put("mortes", 101L);
|
||||||
// --- mining -------------------------------------------------------------
|
assertTrue(kamikaze.met(s));
|
||||||
|
s.put("mortes", 100L);
|
||||||
@Test
|
assertFalse(kamikaze.met(s));
|
||||||
void pedreiroNeedsTenThousandBlocks() {
|
|
||||||
Map<String, Long> stats = stats();
|
|
||||||
stats.put("mineracao", 9_999L);
|
|
||||||
assertFalse(Achievement.PEDREIRO.met(stats));
|
|
||||||
stats.put("mineracao", 10_000L);
|
|
||||||
assertTrue(Achievement.PEDREIRO.met(stats));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void escavadeiraNeedsAHundredThousand() {
|
void nullStatsAreNeverMet() {
|
||||||
Map<String, Long> stats = stats();
|
assertFalse(Achievement.parse("x", "X", "d", List.of("mineracao >= 1")).met(null));
|
||||||
stats.put("mineracao", 99_999L);
|
|
||||||
assertFalse(Achievement.ESCAVADEIRA.met(stats));
|
|
||||||
stats.put("mineracao", 100_000L);
|
|
||||||
assertTrue(Achievement.ESCAVADEIRA.met(stats));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- travel and time ----------------------------------------------------
|
// --- the parser rejects garbage ----------------------------------------
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void maratonistaConvertsCentimetresToKilometres() {
|
void rejectsBadDefinitions() {
|
||||||
Map<String, Long> stats = stats();
|
assertThrows(IllegalArgumentException.class,
|
||||||
stats.put("distancia", 41 * CM_PER_KM);
|
() -> Achievement.parse("chave_ruim", "T", "d", List.of("mineracao >= 1")));
|
||||||
assertFalse(Achievement.MARATONISTA.met(stats));
|
assertThrows(IllegalArgumentException.class,
|
||||||
stats.put("distancia", 42 * CM_PER_KM);
|
() -> Achievement.parse("k", "T", "d", List.of("naoexiste >= 1")));
|
||||||
assertTrue(Achievement.MARATONISTA.met(stats));
|
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
|
@Test
|
||||||
void veteranoConvertsTicksToHours() {
|
void defaultCatalogueLoadsAndIsHygienic() {
|
||||||
Map<String, Long> stats = stats();
|
List<Achievement> catalogue = loadDefault();
|
||||||
stats.put("tempo", 199 * TICKS_PER_HOUR);
|
assertTrue(catalogue.size() >= 25, "expected a healthy catalogue, got " + catalogue.size());
|
||||||
assertFalse(Achievement.VETERANO.met(stats));
|
Set<String> keys = new HashSet<>();
|
||||||
stats.put("tempo", 200 * TICKS_PER_HOUR);
|
for (Achievement achievement : catalogue) {
|
||||||
assertTrue(Achievement.VETERANO.met(stats));
|
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"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- the ratio ones -----------------------------------------------------
|
static List<Achievement> loadDefault() {
|
||||||
|
try (InputStream in = AchievementTest.class.getResourceAsStream("/conquistas-catalogo.yml")) {
|
||||||
@Test
|
assertNotNull(in, "conquistas-catalogo.yml must be on the classpath");
|
||||||
void imortalAsAvessasNeedsBothTheRatioAndRealMining() {
|
YamlConfiguration yaml = YamlConfiguration.loadConfiguration(
|
||||||
Map<String, Long> stats = stats();
|
new InputStreamReader(in, StandardCharsets.UTF_8));
|
||||||
// A brand-new player with 2 deaths and almost no mining satisfies the
|
return Achievement.loadFrom(yaml.getConfigurationSection("conquistas"),
|
||||||
// ratio but must NOT get a joke achievement on their second death.
|
Logger.getAnonymousLogger());
|
||||||
stats.put("mineracao", 100L);
|
} catch (Exception e) {
|
||||||
stats.put("mortes", 50L);
|
throw new AssertionError(e);
|
||||||
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<String, Long> 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<String, Long> 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<String, Long> 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<String, Long> 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());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +1,57 @@
|
|||||||
package dev.marcospaulo.canalhandia;
|
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.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
import org.junit.jupiter.api.Test;
|
||||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
||||||
|
|
||||||
/** 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 {
|
class TitlesTest {
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void loadCatalogue() {
|
||||||
|
Achievement.load(AchievementTest.loadDefault());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void matchesEarnedTitleByKeyAndByName() {
|
void matchesEarnedTitleByKeyAndByName() {
|
||||||
List<Achievement> earned = List.of(Achievement.PEDREIRO, Achievement.VETERANO);
|
Achievement pedreiro = Achievement.byKey("pedreiro");
|
||||||
// by key
|
Achievement veterano = Achievement.byKey("veterano");
|
||||||
assertSame(Achievement.PEDREIRO, CanalhandiaCommand.matchEarned("pedreiro", earned));
|
List<Achievement> earned = List.of(pedreiro, veterano);
|
||||||
// by display name, case-insensitively
|
assertSame(pedreiro, CanalhandiaCommand.matchEarned("pedreiro", earned));
|
||||||
assertSame(Achievement.VETERANO, CanalhandiaCommand.matchEarned("veterano", earned));
|
assertSame(veterano, CanalhandiaCommand.matchEarned("Veterano", earned)); // display name, ci
|
||||||
assertSame(Achievement.PEDREIRO, CanalhandiaCommand.matchEarned("Pedreiro", earned));
|
assertSame(pedreiro, CanalhandiaCommand.matchEarned("Pedreiro", earned));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void refusesTitlesNotYetEarned() {
|
void refusesTitlesNotYetEarned() {
|
||||||
List<Achievement> earned = List.of(Achievement.PEDREIRO);
|
List<Achievement> earned = List.of(Achievement.byKey("pedreiro"));
|
||||||
// A real achievement, but not one this player has: cannot be worn.
|
|
||||||
assertNull(CanalhandiaCommand.matchEarned("veterano", earned));
|
assertNull(CanalhandiaCommand.matchEarned("veterano", earned));
|
||||||
assertNull(CanalhandiaCommand.matchEarned("Casca Grossa", earned));
|
assertNull(CanalhandiaCommand.matchEarned("Casca Grossa", earned));
|
||||||
// Not an achievement at all.
|
|
||||||
assertNull(CanalhandiaCommand.matchEarned("rei do mundo", earned));
|
assertNull(CanalhandiaCommand.matchEarned("rei do mundo", earned));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void offlineStatKeysFeedAchievementConditions() {
|
void offlineStatKeysFeedAchievementConditions() {
|
||||||
// OfflineStats.achievementStats keys its map by RankingMetric.commandKey();
|
// OfflineStats.achievementStats keys its map by RankingMetric.commandKey();
|
||||||
// Achievement conditions read the same names. If those two drift apart, a
|
// the achievement conditions read the same names. Pin that contract.
|
||||||
// lookup by name silently awards nothing — so pin the contract here.
|
|
||||||
Map<String, Long> stats = new HashMap<>();
|
Map<String, Long> stats = new HashMap<>();
|
||||||
stats.put(RankingMetric.MINERACAO.commandKey(), 10_000L);
|
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);
|
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")));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user