i18n: per-player EN/PT via Adventure GlobalTranslator #1

Merged
masi merged 20 commits from feat/ia-grounding into main 2026-08-12 15:53:28 +00:00
9 changed files with 783 additions and 299 deletions
Showing only changes of commit 6dda1e33d3 - Show all commits
@@ -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);
}
}
+157
View File
@@ -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"]
+45
View File
@@ -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")));
}
}