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;
|
||||
|
||||
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}.
|
||||
*
|
||||
* <p>{@link Milestones} already announces thresholds ("passou de 100 km"). This
|
||||
* covers the other half — combinations and ratios that say something about
|
||||
* <em>how</em> someone plays: dying more than they mine, walking a marathon
|
||||
* without ever touching the Nether, killing a thousand mobs.
|
||||
* <p>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 <em>are</em>.
|
||||
*
|
||||
* <p>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 <em>are</em>.
|
||||
*
|
||||
* <p>The reward is chat only — a name and a line. Nothing here touches
|
||||
* gameplay, in keeping with the rest of the plugin.
|
||||
* <p>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<String> 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<String, Long> stats);
|
||||
}
|
||||
/** The whole catalogue, replaced wholesale on load/reload. */
|
||||
private static volatile List<Achievement> 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<String, Long> stats) {
|
||||
return stats != null && condition.met(stats);
|
||||
/** True when this player's raw statistics satisfy the condition. */
|
||||
boolean met(Map<String, Long> 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<String, Long> 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<Achievement> achievements) {
|
||||
catalog = List.copyOf(achievements);
|
||||
}
|
||||
|
||||
/** Distance walked in kilometres; the raw statistic is in centimetres. */
|
||||
private static long km(Map<String, Long> 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<Achievement> earned(Map<String, Long> stats) {
|
||||
/** Every achievement whose condition the raw statistics satisfy. */
|
||||
static List<Achievement> earned(Map<String, Long> rawStats) {
|
||||
List<Achievement> out = new ArrayList<>();
|
||||
for (Achievement achievement : values()) {
|
||||
if (achievement.met(stats)) {
|
||||
if (rawStats == null) {
|
||||
return out;
|
||||
}
|
||||
Map<String, Long> 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<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.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.
|
||||
*
|
||||
* <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. */
|
||||
void check() {
|
||||
if (!plugin.settings().moduleEnabled(Module.CONQUISTAS)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 <nome> <on|off>", "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 <m>", "entrada | intervalo | ambos | manual");
|
||||
commands.put("/curiosidade intervalo <min>", "intervalo do modo temporizado");
|
||||
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 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<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 File file;
|
||||
private final YamlConfiguration data;
|
||||
private List<Track> 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<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. */
|
||||
void check() {
|
||||
for (Player player : Bukkit.getOnlinePlayers()) {
|
||||
for (Track track : TRACKS) {
|
||||
for (Track track : tracks) {
|
||||
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;
|
||||
|
||||
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<String, Long> stats() {
|
||||
/** Raw stats (cm, ticks, counts) with everything zeroed. */
|
||||
private static Map<String, Long> raw() {
|
||||
Map<String, Long> 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<String> 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<String, Long> s = raw();
|
||||
s.put("mineracao", 9_999L);
|
||||
assertFalse(a.met(s));
|
||||
s.put("mineracao", 10_000L);
|
||||
assertTrue(a.met(s));
|
||||
}
|
||||
|
||||
@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");
|
||||
}
|
||||
void distanceIsKilometresAndTimeIsHours() {
|
||||
Achievement maratona = Achievement.parse("m", "M", "d", List.of("distancia >= 42"));
|
||||
Map<String, Long> 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<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
|
||||
void byKeyFindsOrReturnsNull() {
|
||||
assertEquals(Achievement.VETERANO, Achievement.byKey("veterano"));
|
||||
assertEquals(Achievement.VETERANO, Achievement.byKey(" VETERANO "));
|
||||
assertNull(Achievement.byKey("nao-existe"));
|
||||
assertNull(Achievement.byKey(null));
|
||||
void allClausesMustHold() {
|
||||
Achievement turista = Achievement.parse("t", "T", "d",
|
||||
List.of("tempo >= 100", "mineracao < 5000"));
|
||||
Map<String, Long> 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 nothingIsEarnedWithZeroedStats() {
|
||||
// A brand-new player must not be handed anything on their first check.
|
||||
assertTrue(Achievement.earned(stats()).isEmpty());
|
||||
void ratioTargetDividesAMetric() {
|
||||
Achievement imortal = Achievement.parse("i", "I", "d",
|
||||
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
|
||||
void metIsFalseForNullStats() {
|
||||
for (Achievement achievement : Achievement.values()) {
|
||||
assertFalse(achievement.met(null), achievement.key() + " must handle null");
|
||||
}
|
||||
}
|
||||
|
||||
// --- mining -------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
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));
|
||||
void metricComparedToMetric() {
|
||||
Achievement kamikaze = Achievement.parse("k", "K", "d",
|
||||
List.of("combate >= 100", "mortes > combate"));
|
||||
Map<String, Long> s = raw();
|
||||
s.put("combate", 100L);
|
||||
s.put("mortes", 101L);
|
||||
assertTrue(kamikaze.met(s));
|
||||
s.put("mortes", 100L);
|
||||
assertFalse(kamikaze.met(s));
|
||||
}
|
||||
|
||||
@Test
|
||||
void escavadeiraNeedsAHundredThousand() {
|
||||
Map<String, Long> stats = stats();
|
||||
stats.put("mineracao", 99_999L);
|
||||
assertFalse(Achievement.ESCAVADEIRA.met(stats));
|
||||
stats.put("mineracao", 100_000L);
|
||||
assertTrue(Achievement.ESCAVADEIRA.met(stats));
|
||||
void nullStatsAreNeverMet() {
|
||||
assertFalse(Achievement.parse("x", "X", "d", List.of("mineracao >= 1")).met(null));
|
||||
}
|
||||
|
||||
// --- travel and time ----------------------------------------------------
|
||||
// --- the parser rejects garbage ----------------------------------------
|
||||
|
||||
@Test
|
||||
void maratonistaConvertsCentimetresToKilometres() {
|
||||
Map<String, Long> 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));
|
||||
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 veteranoConvertsTicksToHours() {
|
||||
Map<String, Long> 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));
|
||||
void defaultCatalogueLoadsAndIsHygienic() {
|
||||
List<Achievement> catalogue = loadDefault();
|
||||
assertTrue(catalogue.size() >= 25, "expected a healthy catalogue, got " + catalogue.size());
|
||||
Set<String> 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"));
|
||||
}
|
||||
|
||||
// --- the ratio ones -----------------------------------------------------
|
||||
|
||||
@Test
|
||||
void imortalAsAvessasNeedsBothTheRatioAndRealMining() {
|
||||
Map<String, Long> 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<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());
|
||||
static List<Achievement> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Achievement> 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<Achievement> 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<Achievement> earned = List.of(Achievement.PEDREIRO);
|
||||
// A real achievement, but not one this player has: cannot be worn.
|
||||
List<Achievement> 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<String, Long> 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")));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user