diff --git a/src/main/java/dev/marcospaulo/canalhandia/Achievement.java b/src/main/java/dev/marcospaulo/canalhandia/Achievement.java
new file mode 100644
index 0000000..c81d0cd
--- /dev/null
+++ b/src/main/java/dev/marcospaulo/canalhandia/Achievement.java
@@ -0,0 +1,159 @@
+package dev.marcospaulo.canalhandia;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+/**
+ * Named achievements: the things worth telling the room about that a round
+ * number cannot express.
+ *
+ *
{@link Milestones} already announces thresholds ("passou de 100 km"). This
+ * covers the other half — combinations and ratios that say something about
+ * how someone plays: dying more than they mine, walking a marathon
+ * without ever touching the Nether, killing a thousand mobs.
+ *
+ *
Every condition is a pure function of a stat map, so the whole catalogue is
+ * testable without a server. {@link Achievements} owns the "announce once"
+ * bookkeeping; this owns what the achievements are.
+ *
+ *
The reward is chat only — a name and a line. Nothing here touches
+ * gameplay, in keeping with the rest of the plugin.
+ */
+enum Achievement {
+
+ // --- mining -------------------------------------------------------------
+
+ PEDREIRO("pedreiro", "Pedreiro",
+ "minerou 10.000 blocos",
+ stats -> stats.getOrDefault("mineracao", 0L) >= 10_000),
+
+ ESCAVADEIRA("escavadeira", "Escavadeira Humana",
+ "minerou 100.000 blocos",
+ stats -> stats.getOrDefault("mineracao", 0L) >= 100_000),
+
+ // --- combat -------------------------------------------------------------
+
+ EXTERMINADOR("exterminador", "Exterminador",
+ "derrotou 1.000 monstros",
+ stats -> stats.getOrDefault("combate", 0L) >= 1_000),
+
+ // --- travel -------------------------------------------------------------
+
+ MARATONISTA("maratonista", "Maratonista",
+ "caminhou 42 km (uma maratona)",
+ stats -> km(stats) >= 42),
+
+ // --- the funny ones -----------------------------------------------------
+
+ /**
+ * More deaths than a hundredth of the blocks mined — the shape of someone
+ * who dies constantly relative to how much they actually get done. Gated on
+ * a real amount of mining so a brand-new player is not immediately handed a
+ * joke achievement on their second death.
+ */
+ IMORTAL_AS_AVESSAS("imortal-as-avessas", "Imortal às Avessas",
+ "morreu mais de uma vez a cada 100 blocos minerados",
+ stats -> stats.getOrDefault("mineracao", 0L) >= 2_000
+ && stats.getOrDefault("mortes", 0L)
+ > stats.getOrDefault("mineracao", 0L) / 100),
+
+ /**
+ * A hundred hours in and still barely scratched. The counterpart to the one
+ * above: plays a lot, mines little.
+ */
+ TURISTA("turista", "Turista",
+ "passou de 100 horas jogadas sem minerar 5.000 blocos",
+ stats -> hours(stats) >= 100 && stats.getOrDefault("mineracao", 0L) < 5_000),
+
+ /** Long-lived: a lot of playtime with very few deaths. */
+ CASCA_GROSSA("casca-grossa", "Casca Grossa",
+ "passou de 50 horas com menos de 10 mortes",
+ stats -> hours(stats) >= 50 && stats.getOrDefault("mortes", 0L) < 10),
+
+ /** Pure dedication, no qualifier. */
+ VETERANO("veterano", "Veterano",
+ "passou de 200 horas jogadas",
+ stats -> hours(stats) >= 200),
+
+ PESCADOR("pescador", "Pescador Profissional",
+ "pescou 500 peixes",
+ stats -> stats.getOrDefault("pesca", 0L) >= 500),
+
+ SALTITANTE("saltitante", "Saltitante",
+ "deu 50.000 pulos",
+ stats -> stats.getOrDefault("pulos", 0L) >= 50_000);
+
+ /** A condition over the normalised stat map. */
+ @FunctionalInterface
+ interface Condition {
+ boolean met(Map stats);
+ }
+
+ 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) {
+ this.key = key;
+ this.title = title;
+ this.description = description;
+ this.condition = condition;
+ }
+
+ String key() {
+ return key;
+ }
+
+ String title() {
+ return title;
+ }
+
+ String description() {
+ return description;
+ }
+
+ boolean met(Map stats) {
+ return stats != null && condition.met(stats);
+ }
+
+ /**
+ * Play time in hours. The raw statistic is in ticks, and the division is
+ * spelled out here rather than at each use so a unit mistake can only be
+ * made in one place.
+ */
+ private static long hours(Map stats) {
+ return stats.getOrDefault("tempo", 0L) / 20L / 3600L;
+ }
+
+ /** Distance walked in kilometres; the raw statistic is in centimetres. */
+ private static long km(Map stats) {
+ return stats.getOrDefault("distancia", 0L) / 100_000L;
+ }
+
+ static Achievement byKey(String key) {
+ if (key == null) {
+ return null;
+ }
+ String wanted = key.trim().toLowerCase(Locale.ROOT);
+ for (Achievement achievement : values()) {
+ if (achievement.key.equals(wanted)) {
+ return achievement;
+ }
+ }
+ return null;
+ }
+
+ /** Every achievement whose condition the stats satisfy. */
+ static List earned(Map stats) {
+ List out = new ArrayList<>();
+ for (Achievement achievement : values()) {
+ if (achievement.met(stats)) {
+ out.add(achievement);
+ }
+ }
+ return out;
+ }
+}
diff --git a/src/main/java/dev/marcospaulo/canalhandia/Achievements.java b/src/main/java/dev/marcospaulo/canalhandia/Achievements.java
new file mode 100644
index 0000000..60e49b6
--- /dev/null
+++ b/src/main/java/dev/marcospaulo/canalhandia/Achievements.java
@@ -0,0 +1,147 @@
+package dev.marcospaulo.canalhandia;
+
+import net.kyori.adventure.text.Component;
+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.file.YamlConfiguration;
+import org.bukkit.entity.Player;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Awards {@link Achievement}s once and remembers that it did.
+ *
+ * Runs on the same timer as {@link Milestones} and follows the same
+ * first-sight rule: the first time a player is seen, whatever they have already
+ * earned is recorded silently. Without that, enabling the module would
+ * dump a dozen announcements for history earned months ago, and every existing
+ * player would be spammed at once.
+ */
+final class Achievements {
+
+ private final Canalhandia plugin;
+ private final File file;
+ private final YamlConfiguration data;
+
+ Achievements(Canalhandia plugin) {
+ this.plugin = plugin;
+ this.file = new File(plugin.getDataFolder(), "conquistas.yml");
+ this.data = YamlConfiguration.loadConfiguration(file);
+ }
+
+ /** Checks every online player and announces anything newly earned. */
+ void check() {
+ if (!plugin.settings().moduleEnabled(Module.CONQUISTAS)) {
+ return;
+ }
+ boolean changed = false;
+ for (Player player : Bukkit.getOnlinePlayers()) {
+ changed |= check(player);
+ }
+ if (changed) {
+ save();
+ }
+ }
+
+ /** @return true if anything was recorded, so the caller can save once */
+ private boolean check(Player player) {
+ Map stats = snapshot(player);
+ String base = player.getUniqueId().toString();
+ // A player with no record yet is being seen for the first time: bank
+ // what they have without announcing it.
+ boolean firstSight = !data.contains(base);
+ boolean changed = false;
+
+ for (Achievement achievement : Achievement.values()) {
+ if (!achievement.met(stats)) {
+ continue;
+ }
+ String path = base + "." + achievement.key();
+ if (data.getBoolean(path, false)) {
+ continue;
+ }
+ data.set(path, true);
+ changed = true;
+ if (!firstSight) {
+ announce(player, achievement);
+ }
+ }
+ if (firstSight && !changed) {
+ // Mark the player as seen even when they qualified for nothing, or
+ // every future check would treat them as new and stay silent.
+ data.set(base + ".visto", true);
+ changed = true;
+ }
+ return changed;
+ }
+
+ private void announce(Player player, Achievement achievement) {
+ Bukkit.broadcast(Msg.tag("Conquista", NamedTextColor.GOLD)
+ .append(Component.text(player.getName(), NamedTextColor.GREEN)
+ .decoration(TextDecoration.BOLD, false))
+ .append(Component.text(" desbloqueou ", NamedTextColor.WHITE)
+ .decoration(TextDecoration.BOLD, false))
+ .append(Component.text(achievement.title(), NamedTextColor.AQUA)
+ .decoration(TextDecoration.BOLD, false))
+ .append(Component.text(" — " + achievement.description(), NamedTextColor.GRAY)
+ .decoration(TextDecoration.BOLD, false)));
+ plugin.getLogger().info("[conquistas] " + player.getName() + " → " + achievement.key());
+ }
+
+ /** Which achievements this player has already unlocked. */
+ List earnedBy(Player player) {
+ List out = new ArrayList<>();
+ String base = player.getUniqueId().toString();
+ for (Achievement achievement : Achievement.values()) {
+ if (data.getBoolean(base + "." + achievement.key(), false)) {
+ out.add(achievement);
+ }
+ }
+ return out;
+ }
+
+ /**
+ * The stat map an {@link Achievement} condition reads, keyed the same way
+ * as the config's category names.
+ *
+ * Statistic constants get renamed between Minecraft releases, so each is
+ * resolved by name through {@link Stats#resolve} rather than referenced
+ * directly — a missing one yields zero instead of failing to load the class.
+ */
+ static Map snapshot(Player player) {
+ Map stats = new HashMap<>();
+ stats.put("mineracao", total(player, "MINE_BLOCK"));
+ stats.put("tempo", untyped(player, "PLAY_TIME"));
+ stats.put("distancia", untyped(player, "WALK_ONE_CM"));
+ stats.put("mortes", untyped(player, "DEATHS"));
+ stats.put("combate", untyped(player, "MOB_KILLS"));
+ stats.put("pesca", untyped(player, "FISH_CAUGHT"));
+ stats.put("pulos", untyped(player, "JUMP"));
+ return stats;
+ }
+
+ private static long untyped(Player player, String name) {
+ Statistic statistic = Stats.resolve(name);
+ return statistic == null ? 0L : Stats.untyped(player, statistic);
+ }
+
+ private static long total(Player player, String name) {
+ Statistic statistic = Stats.resolve(name);
+ return statistic == null ? 0L : Stats.totalOf(player, statistic);
+ }
+
+ private void save() {
+ try {
+ data.save(file);
+ } catch (IOException e) {
+ plugin.getLogger().warning("Não consegui salvar conquistas.yml: " + e.getMessage());
+ }
+ }
+}
diff --git a/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java b/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java
index bd20d63..1c956d2 100644
--- a/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java
+++ b/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java
@@ -66,8 +66,11 @@ public final class Canalhandia extends JavaPlugin implements Listener {
private Settings settings;
private Notes notes;
+ private Mail mail;
+ private DeathLog deathLog;
private OfflineStats offlineStats;
private Milestones milestones;
+ private Achievements achievements;
private Ai ai;
private NamespacedKey optOutKey;
private BukkitTask timerTask;
@@ -84,8 +87,11 @@ public final class Canalhandia extends JavaPlugin implements Listener {
saveDefaultConfig();
settings = new Settings(this);
notes = new Notes(new java.io.File(getDataFolder(), "notas.yml"));
+ mail = new Mail(new java.io.File(getDataFolder(), "recados.yml"));
+ deathLog = new DeathLog(new java.io.File(getDataFolder(), "mortes.yml"));
offlineStats = new OfflineStats(this);
milestones = new Milestones(this);
+ achievements = new Achievements(this);
ai = new Ai(this);
// Snapshot the server's recipes on the main thread; RecipeBook.describe
// reads from the async answer path and Bukkit.recipeIterator() is not
@@ -97,7 +103,7 @@ public final class Canalhandia extends JavaPlugin implements Listener {
CanalhandiaCommand root = new CanalhandiaCommand(this);
for (String name : List.of("canalhandia", "curiosidade", "adivinha", "enquete", "ranking",
"reagir", "reacoes", "palpite", "votar", "legal", "wow", "top", "f", "ia", "iap",
- "errado", "nota", "save")) {
+ "errado", "nota", "save", "recado", "recados", "mortes", "conquistas")) {
register(name, root);
}
@@ -157,6 +163,21 @@ public final class Canalhandia extends JavaPlugin implements Listener {
return notes;
}
+ /** Offline messages waiting for delivery. Never null. */
+ Mail mail() {
+ return mail;
+ }
+
+ /** Recent deaths per player, for /mortes. Never null. */
+ DeathLog deathLog() {
+ return deathLog;
+ }
+
+ /** Named achievements. Never null. */
+ Achievements achievements() {
+ return achievements;
+ }
+
// --- scheduling ---------------------------------------------------------
/** Starts, stops or restarts the repeating curiosity task to match the mode. */
@@ -182,7 +203,12 @@ public final class Canalhandia extends JavaPlugin implements Listener {
return;
}
long ticks = 5L * 60L * 20L;
- milestoneTask = getServer().getScheduler().runTaskTimer(this, milestones::check, ticks, ticks);
+ milestoneTask = getServer().getScheduler().runTaskTimer(this, () -> {
+ milestones.check();
+ // Same cadence as milestones: both read statistics for every online
+ // player, so sharing one task keeps that cost to a single sweep.
+ achievements.check();
+ }, ticks, ticks);
}
// --- curiosities --------------------------------------------------------
@@ -494,6 +520,52 @@ public final class Canalhandia extends JavaPlugin implements Listener {
}, settings.joinDelaySeconds() * 20L);
}
+ /**
+ * Delivers any messages waiting for a joining player.
+ *
+ * A handler of its own rather than a branch inside {@link #onJoin},
+ * which returns early when the {@code curiosidades} module is off — mail
+ * must not depend on an unrelated module being enabled.
+ *
+ *
Delayed like the curiosity, so the messages land after the join line
+ * rather than racing it, and re-checked for {@code isOnline} because a
+ * player can leave inside the delay and the mail would then be consumed
+ * without anyone reading it.
+ */
+ @EventHandler
+ public void onJoinMail(PlayerJoinEvent event) {
+ if (!settings.moduleEnabled(Module.RECADOS)) {
+ return;
+ }
+ Player player = event.getPlayer();
+ String id = player.getUniqueId().toString();
+ if (mail.countFor(id) == 0) {
+ return;
+ }
+ getServer().getScheduler().runTaskLater(this, () -> {
+ if (!player.isOnline()) {
+ return;
+ }
+ // takeFor is destructive, so it is called only once we know the
+ // player is still here to read the result.
+ List waiting = mail.takeFor(id);
+ if (waiting.isEmpty()) {
+ return;
+ }
+ player.sendMessage(Msg.tag("Recados", NamedTextColor.AQUA)
+ .append(Component.text(waiting.size() == 1
+ ? "1 recado para você:"
+ : waiting.size() + " recados para você:", NamedTextColor.GRAY)));
+ for (Mail.Message message : waiting) {
+ player.sendMessage(Component.text(" " + message.fromName() + " ",
+ NamedTextColor.AQUA)
+ .append(Component.text("(" + Msg.ago(message.sentAt()) + "): ",
+ NamedTextColor.DARK_GRAY))
+ .append(Component.text(message.text(), NamedTextColor.WHITE)));
+ }
+ }, Math.max(1, settings.joinDelaySeconds()) * 20L);
+ }
+
/**
* Chat gag: a message matching the {@code zoacao} trigger (pattern + match
* mode, default a bare "f") gets replaced with a random line from
@@ -626,6 +698,13 @@ public final class Canalhandia extends JavaPlugin implements Listener {
boolean keepInventory = Boolean.TRUE.equals(
loc.getWorld().getGameRuleValue(GameRule.KEEP_INVENTORY));
pendingDeathCoords.put(player.getUniqueId(), new DeathCoords(coords, keepInventory));
+
+ // Keep the death instead of discarding it once the coords are delivered,
+ // so /mortes can answer "onde eu morri com o pico de diamante?" a day
+ // later. The world label is the pt-BR one, matching how notes read.
+ deathLog.record(player.getUniqueId().toString(), flavor,
+ ServerState.worldLabel(loc.getWorld()),
+ loc.getBlockX(), loc.getBlockY(), loc.getBlockZ());
}
/**
diff --git a/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java
index 2a2ad70..048f1d0 100644
--- a/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java
+++ b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java
@@ -55,6 +55,10 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
// it with a note. Private by default — the safe default for a
// one-word command nobody reads the help for first.
case "save" -> saveShortcut(sender, args);
+ case "recado" -> recado(sender, args);
+ case "recados" -> recados(sender);
+ case "mortes" -> mortes(sender);
+ case "conquistas" -> conquistas(sender);
// Typed twin of the [F] mourning button — "f" is not a configured
// reaction (the mourning set is hardcoded), so it can't be found via
// reactionForCommand; route it directly. Acts on the latest message,
@@ -1056,6 +1060,166 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
return true;
}
+ // --- /recado ------------------------------------------------------------
+
+ /**
+ * {@code /recado } — leave a line for someone who is not
+ * online, delivered on their next join.
+ *
+ * If the recipient is online it is delivered immediately rather
+ * than queued, because queueing it would mean the person standing next to
+ * you reads it only after a relog.
+ */
+ private boolean recado(CommandSender sender, String[] args) {
+ if (!plugin.settings().moduleEnabled(Module.RECADOS)) {
+ Msg.error(sender, "O módulo de recados está desligado.");
+ return true;
+ }
+ if (!sender.hasPermission("canalhandia.recado")) {
+ return denied(sender);
+ }
+ if (!(sender instanceof Player player)) {
+ Msg.error(sender, "Só jogadores podem mandar recado.");
+ return true;
+ }
+ if (args.length < 2) {
+ Msg.error(sender, "Uso: /recado ");
+ return true;
+ }
+ String text = Note.cleanText(String.join(" ", Arrays.copyOfRange(args, 1, args.length)));
+ if (text == null) {
+ Msg.error(sender, "O recado está vazio.");
+ return true;
+ }
+
+ Player online = Bukkit.getPlayerExact(args[0]);
+ if (online != null) {
+ deliverNow(player, online, text);
+ return true;
+ }
+ // Offline: resolve through usercache, so a message can be left for
+ // anyone who has played here before.
+ OfflineStats.Known target = plugin.offlineStats().resolve(args[0]);
+ if (target == null) {
+ Msg.error(sender, "Não conheço ninguém chamado \"" + args[0]
+ + "\". (Só dá para mandar recado para quem já entrou no servidor.)");
+ return true;
+ }
+ if (target.uuid().equals(player.getUniqueId().toString())) {
+ Msg.error(sender, "Recado para você mesmo? Use /save.");
+ return true;
+ }
+ Mail.Message message = plugin.mail().send(player.getName(),
+ player.getUniqueId().toString(), target.uuid(), text);
+ if (message == null) {
+ Msg.error(sender, "A caixa de " + target.name() + " está cheia ("
+ + Mail.MAX_PER_RECIPIENT + " recados). Espere ela entrar.");
+ return true;
+ }
+ Msg.ok(sender, "Recado guardado para " + target.name()
+ + ". Vai chegar quando " + target.name() + " entrar.");
+ return true;
+ }
+
+ /** The recipient is online: say it now, to both sides. */
+ private void deliverNow(Player from, Player to, String text) {
+ to.sendMessage(Msg.tag("Recado", NamedTextColor.AQUA)
+ .append(Component.text(from.getName() + ": ", NamedTextColor.GRAY))
+ .append(Component.text(text, NamedTextColor.WHITE)));
+ Msg.ok(from, to.getName() + " está online — recado entregue na hora.");
+ }
+
+ /** {@code /recados} — how many of your messages are still unread. */
+ private boolean recados(CommandSender sender) {
+ if (!plugin.settings().moduleEnabled(Module.RECADOS)) {
+ Msg.error(sender, "O módulo de recados está desligado.");
+ return true;
+ }
+ if (!(sender instanceof Player player)) {
+ Msg.error(sender, "Só jogadores têm recados.");
+ return true;
+ }
+ int pending = plugin.mail().countFrom(player.getUniqueId().toString());
+ Msg.ok(sender, pending == 0
+ ? "Todos os seus recados já foram entregues."
+ : pending + (pending == 1 ? " recado seu ainda não foi lido."
+ : " recados seus ainda não foram lidos."));
+ return true;
+ }
+
+ /**
+ * {@code /mortes} — your recent deaths, newest first, with the comic cause
+ * and where it happened.
+ *
+ * Your own only. Where someone died is where their stuff is; a public
+ * list of that is a looting guide, which is why the coords are private at
+ * death time too.
+ */
+ private boolean mortes(CommandSender sender) {
+ if (!plugin.settings().moduleEnabled(Module.MORTES)) {
+ Msg.error(sender, "O módulo de mortes está desligado.");
+ return true;
+ }
+ if (!(sender instanceof Player player)) {
+ Msg.error(sender, "Só jogadores têm histórico de mortes.");
+ return true;
+ }
+ List deaths = plugin.deathLog().forPlayer(player.getUniqueId().toString());
+ if (deaths.isEmpty()) {
+ Msg.ok(sender, "Você ainda não morreu. Aproveite enquanto dura.");
+ return true;
+ }
+ Msg.header(sender, "Suas últimas mortes (" + deaths.size() + ")");
+ boolean bedrock = Platform.isBedrock(player);
+ for (DeathLog.Entry death : deaths) {
+ Component place = Component.text(death.place(), NamedTextColor.GRAY);
+ if (!bedrock) {
+ place = place.clickEvent(ClickEvent.copyToClipboard(death.coords()))
+ .hoverEvent(net.kyori.adventure.text.event.HoverEvent.showText(
+ Component.text("Clique para copiar as coordenadas",
+ NamedTextColor.DARK_GRAY)));
+ }
+ sender.sendMessage(Component.text(" " + death.flavor(), NamedTextColor.YELLOW)
+ .append(Component.text(" — ", NamedTextColor.DARK_GRAY))
+ .append(place)
+ .append(Component.text(" " + Msg.ago(death.at()), NamedTextColor.DARK_GRAY)));
+ }
+ return true;
+ }
+
+ /**
+ * {@code /conquistas} — the full catalogue, with the ones you have earned
+ * marked. Showing the locked ones too is the point: an achievement nobody
+ * can see is one nobody chases.
+ */
+ private boolean conquistas(CommandSender sender) {
+ if (!plugin.settings().moduleEnabled(Module.CONQUISTAS)) {
+ Msg.error(sender, "O módulo de conquistas está desligado.");
+ return true;
+ }
+ if (!(sender instanceof Player player)) {
+ Msg.error(sender, "Só jogadores têm conquistas.");
+ return true;
+ }
+ List earned = plugin.achievements().earnedBy(player);
+ Msg.header(sender, "Conquistas (" + earned.size() + "/" + Achievement.values().length + ")");
+ // Bedrock renders "✔" as a tofu box, so it gets an ASCII marker — the
+ // same rule the reaction labels follow.
+ boolean bedrock = Platform.isBedrock(player);
+ String tick = bedrock ? " [x] " : " ✔ ";
+ String blank = bedrock ? " [ ] " : " · ";
+ for (Achievement achievement : Achievement.values()) {
+ boolean has = earned.contains(achievement);
+ sender.sendMessage(Component.text(has ? tick : blank,
+ has ? NamedTextColor.GREEN : NamedTextColor.DARK_GRAY)
+ .append(Component.text(achievement.title(),
+ has ? NamedTextColor.AQUA : NamedTextColor.GRAY))
+ .append(Component.text(" — " + achievement.description(),
+ NamedTextColor.DARK_GRAY)));
+ }
+ return true;
+ }
+
private void notaAdd(CommandSender sender, String[] args, Note.Scope scope) {
if (!(sender instanceof Player player)) {
Msg.error(sender, "Só jogadores podem anotar (a anotação guarda onde você está).");
@@ -1319,6 +1483,11 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
}
return List.of();
}
+ if (name.equals("recado") && args.length == 1) {
+ // Everyone the server has seen, not just who is online — the whole
+ // point is leaving a message for someone who is not here.
+ return filter(plugin.offlineStats().knownNames(), args[0]);
+ }
if (name.equals("save")) {
return args.length == 1 ? filter(List.of("coords"), args[0]) : List.of();
}
diff --git a/src/main/java/dev/marcospaulo/canalhandia/DeathLog.java b/src/main/java/dev/marcospaulo/canalhandia/DeathLog.java
new file mode 100644
index 0000000..a24568b
--- /dev/null
+++ b/src/main/java/dev/marcospaulo/canalhandia/DeathLog.java
@@ -0,0 +1,141 @@
+package dev.marcospaulo.canalhandia;
+
+import org.bukkit.configuration.file.YamlConfiguration;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+
+/**
+ * A short history of where and how each player died.
+ *
+ * The {@code mortes} module already knows all of this at death time and then
+ * throws it away once the coordinates have been delivered on respawn. Keeping it
+ * costs a few lines of YAML and answers the question people actually ask a day
+ * later: "onde foi que eu morri com o pico de diamante?"
+ *
+ *
Bounded per player, oldest dropped first. This is a recent-history feature,
+ * not an archive — on a server where someone dies fifty times a night, an
+ * unbounded log would grow without ever being read.
+ */
+final class DeathLog {
+
+ /** One recorded death. {@code at} is a wall-clock millisecond timestamp. */
+ record Entry(String playerId, String flavor, String world, int x, int y, int z, long at) {
+
+ String coords() {
+ return x + ", " + y + ", " + z;
+ }
+
+ String place() {
+ return coords() + (world == null || world.isBlank() ? "" : " (" + world + ")");
+ }
+ }
+
+ /**
+ * How many deaths are kept per player. Ten covers "where did I die
+ * recently" without turning the file into a diary.
+ */
+ static final int MAX_PER_PLAYER = 10;
+
+ private final File file;
+ private final List entries = new ArrayList<>();
+
+ DeathLog(File file) {
+ this.file = file;
+ load();
+ }
+
+ void load() {
+ YamlConfiguration yaml = file.exists() ? YamlConfiguration.loadConfiguration(file) : null;
+ synchronized (entries) {
+ entries.clear();
+ if (yaml == null) {
+ return;
+ }
+ for (String key : yaml.getKeys(false)) {
+ String playerId = yaml.getString(key + ".jogador-id");
+ if (playerId == null) {
+ continue;
+ }
+ entries.add(new Entry(playerId,
+ yaml.getString(key + ".causa", "bateu as botas"),
+ yaml.getString(key + ".mundo", ""),
+ yaml.getInt(key + ".x"),
+ yaml.getInt(key + ".y"),
+ yaml.getInt(key + ".z"),
+ yaml.getLong(key + ".em", 0)));
+ }
+ }
+ }
+
+ /** Records a death, evicting this player's oldest once past the cap. */
+ void record(String playerId, String flavor, String world, int x, int y, int z) {
+ synchronized (entries) {
+ entries.add(new Entry(playerId, flavor, world, x, y, z, System.currentTimeMillis()));
+ // Evict only this player's oldest. A global cap would let one
+ // player's bad night erase everyone else's history.
+ List mine = forPlayerLocked(playerId);
+ while (mine.size() > MAX_PER_PLAYER) {
+ Entry oldest = mine.remove(mine.size() - 1);
+ entries.remove(oldest);
+ }
+ }
+ save();
+ }
+
+ /** This player's deaths, newest first. */
+ List forPlayer(String playerId) {
+ synchronized (entries) {
+ return forPlayerLocked(playerId);
+ }
+ }
+
+ /** Caller must hold the lock. Newest first. */
+ private List forPlayerLocked(String playerId) {
+ List out = new ArrayList<>();
+ for (Entry entry : entries) {
+ if (entry.playerId().equals(playerId)) {
+ out.add(entry);
+ }
+ }
+ out.sort(Comparator.comparingLong(Entry::at).reversed());
+ return out;
+ }
+
+ int size() {
+ synchronized (entries) {
+ return entries.size();
+ }
+ }
+
+ void clear(String playerId) {
+ synchronized (entries) {
+ entries.removeIf(entry -> entry.playerId().equals(playerId));
+ }
+ save();
+ }
+
+ private void save() {
+ YamlConfiguration yaml = new YamlConfiguration();
+ synchronized (entries) {
+ for (int i = 0; i < entries.size(); i++) {
+ Entry entry = entries.get(i);
+ String key = "d" + i;
+ yaml.set(key + ".jogador-id", entry.playerId());
+ yaml.set(key + ".causa", entry.flavor());
+ yaml.set(key + ".mundo", entry.world());
+ yaml.set(key + ".x", entry.x());
+ yaml.set(key + ".y", entry.y());
+ yaml.set(key + ".z", entry.z());
+ yaml.set(key + ".em", entry.at());
+ }
+ }
+ try {
+ yaml.save(file);
+ } catch (Exception e) {
+ throw new IllegalStateException("não consegui gravar " + file, e);
+ }
+ }
+}
diff --git a/src/main/java/dev/marcospaulo/canalhandia/Mail.java b/src/main/java/dev/marcospaulo/canalhandia/Mail.java
new file mode 100644
index 0000000..dbc00b7
--- /dev/null
+++ b/src/main/java/dev/marcospaulo/canalhandia/Mail.java
@@ -0,0 +1,171 @@
+package dev.marcospaulo.canalhandia;
+
+import org.bukkit.configuration.file.YamlConfiguration;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+
+/**
+ * Offline messages: a line left for a player who is not online, delivered the
+ * next time they join.
+ *
+ * The gap this fills is a small server where people rarely overlap — without
+ * it, "achei diamante em -400 70 200" has to go through Discord or be lost.
+ *
+ *
Storage mirrors {@link Notes}: an in-memory list guarded by its own
+ * monitor, rewritten to YAML on every change. Messages are small and hand-typed,
+ * so a full rewrite stays cheap and cannot leave a half-updated file behind.
+ */
+final class Mail {
+
+ /** One undelivered message. */
+ record Message(long id, String fromName, String fromId, String toId, String text, long sentAt) {
+ }
+
+ /**
+ * A cap per recipient. Without one, a bored player could queue thousands of
+ * lines that all fire at once the moment someone logs in, which is both a
+ * chat flood and a way to make joining unpleasant.
+ */
+ static final int MAX_PER_RECIPIENT = 20;
+
+ /** Longest message kept, matching {@link Note#MAX_TEXT}. */
+ static final int MAX_TEXT = 256;
+
+ private final File file;
+ private final List messages = new ArrayList<>();
+ private long nextId = 1;
+
+ Mail(File file) {
+ this.file = file;
+ load();
+ }
+
+ void load() {
+ YamlConfiguration yaml = file.exists() ? YamlConfiguration.loadConfiguration(file) : null;
+ synchronized (messages) {
+ messages.clear();
+ nextId = 1;
+ if (yaml == null) {
+ return;
+ }
+ for (String key : yaml.getKeys(false)) {
+ String text = yaml.getString(key + ".texto");
+ String toId = yaml.getString(key + ".para-id");
+ if (text == null || toId == null) {
+ continue;
+ }
+ long id = yaml.getLong(key + ".id", 0);
+ messages.add(new Message(id,
+ yaml.getString(key + ".de", "?"),
+ yaml.getString(key + ".de-id", ""),
+ toId,
+ text,
+ yaml.getLong(key + ".em", 0)));
+ nextId = Math.max(nextId, id + 1);
+ }
+ }
+ }
+
+ /**
+ * Queues a message, or returns {@code null} when the recipient's inbox is
+ * full. The caller has already cleaned the text with {@link Note#cleanText}.
+ */
+ Message send(String fromName, String fromId, String toId, String text) {
+ Message message;
+ synchronized (messages) {
+ if (countFor(toId) >= MAX_PER_RECIPIENT) {
+ return null;
+ }
+ message = new Message(nextId++, fromName, fromId, toId, text,
+ System.currentTimeMillis());
+ messages.add(message);
+ }
+ save();
+ return message;
+ }
+
+ /**
+ * Removes and returns everything waiting for this player, oldest first —
+ * reading order for a conversation.
+ *
+ * Delivery is destructive by design: a message that stayed queued would
+ * be re-read on every single join, which turns a helpful note into a
+ * nuisance. {@code /recados} is the way to see them again in the session
+ * they arrived, via the plugin's own in-memory copy.
+ */
+ List takeFor(String playerId) {
+ List out = new ArrayList<>();
+ synchronized (messages) {
+ for (Message message : messages) {
+ if (message.toId().equals(playerId)) {
+ out.add(message);
+ }
+ }
+ messages.removeAll(out);
+ }
+ out.sort(Comparator.comparingLong(Message::id));
+ if (!out.isEmpty()) {
+ save();
+ }
+ return out;
+ }
+
+ /** How many messages are waiting for this player. */
+ int countFor(String playerId) {
+ int count = 0;
+ synchronized (messages) {
+ for (Message message : messages) {
+ if (message.toId().equals(playerId)) {
+ count++;
+ }
+ }
+ }
+ return count;
+ }
+
+ /**
+ * How many undelivered messages this player has sent, so the sender can be
+ * told "3 recados seus ainda não foram lidos".
+ */
+ int countFrom(String senderId) {
+ int count = 0;
+ synchronized (messages) {
+ for (Message message : messages) {
+ if (senderId.equals(message.fromId())) {
+ count++;
+ }
+ }
+ }
+ return count;
+ }
+
+ int size() {
+ synchronized (messages) {
+ return messages.size();
+ }
+ }
+
+ private void save() {
+ YamlConfiguration yaml = new YamlConfiguration();
+ synchronized (messages) {
+ for (int i = 0; i < messages.size(); i++) {
+ Message message = messages.get(i);
+ String key = "m" + i;
+ yaml.set(key + ".id", message.id());
+ yaml.set(key + ".de", message.fromName());
+ yaml.set(key + ".de-id", message.fromId());
+ yaml.set(key + ".para-id", message.toId());
+ yaml.set(key + ".texto", message.text());
+ yaml.set(key + ".em", message.sentAt());
+ }
+ }
+ try {
+ yaml.save(file);
+ } catch (Exception e) {
+ throw new IllegalStateException("não consegui gravar " + file, e);
+ }
+ }
+}
diff --git a/src/main/java/dev/marcospaulo/canalhandia/Module.java b/src/main/java/dev/marcospaulo/canalhandia/Module.java
index a077c7e..6a4d984 100644
--- a/src/main/java/dev/marcospaulo/canalhandia/Module.java
+++ b/src/main/java/dev/marcospaulo/canalhandia/Module.java
@@ -12,6 +12,8 @@ enum Module {
MORTES("mortes", "Mortes com humor e coordenadas"),
ZOACAO("zoacao", "Zoa de quem manda só 'f' no chat"),
NOTAS("notas", "Anotações públicas e privadas no chat"),
+ RECADOS("recados", "Recados entregues quando o jogador entra"),
+ CONQUISTAS("conquistas", "Conquistas com nome, além dos marcos numéricos"),
IA("ia", "Perguntas para a IA");
private final String key;
diff --git a/src/main/java/dev/marcospaulo/canalhandia/Msg.java b/src/main/java/dev/marcospaulo/canalhandia/Msg.java
index 0608b8c..e0ac793 100644
--- a/src/main/java/dev/marcospaulo/canalhandia/Msg.java
+++ b/src/main/java/dev/marcospaulo/canalhandia/Msg.java
@@ -64,4 +64,42 @@ final class Msg {
private static String plural(long value, String singular, String plural) {
return value + " " + (value == 1 ? singular : plural);
}
+
+ /**
+ * How long ago a wall-clock timestamp was, in pt-BR: "agora", "há 5
+ * minutos", "há 2 dias".
+ *
+ * Wall clock, not {@code nanoTime}: these timestamps are persisted to
+ * YAML and compared across restarts, which a monotonic clock cannot do. The
+ * cost is that a clock change can skew the label — bounded here by clamping
+ * a negative difference (a timestamp from the "future") to "agora" rather
+ * than printing a nonsense negative age.
+ */
+ static String ago(long timestamp, long now) {
+ long seconds = Math.max(0, (now - timestamp) / 1000L);
+ if (seconds < 60) {
+ return "agora";
+ }
+ long minutes = seconds / 60;
+ if (minutes < 60) {
+ return "há " + plural(minutes, "minuto", "minutos");
+ }
+ long hours = minutes / 60;
+ if (hours < 24) {
+ return "há " + plural(hours, "hora", "horas");
+ }
+ long days = hours / 24;
+ if (days < 30) {
+ return "há " + plural(days, "dia", "dias");
+ }
+ long months = days / 30;
+ return months < 12
+ ? "há " + plural(months, "mês", "meses")
+ : "há " + plural(months / 12, "ano", "anos");
+ }
+
+ /** {@link #ago(long, long)} against the current clock. */
+ static String ago(long timestamp) {
+ return ago(timestamp, System.currentTimeMillis());
+ }
}
diff --git a/src/main/java/dev/marcospaulo/canalhandia/OfflineStats.java b/src/main/java/dev/marcospaulo/canalhandia/OfflineStats.java
index 029722d..be8f5e8 100644
--- a/src/main/java/dev/marcospaulo/canalhandia/OfflineStats.java
+++ b/src/main/java/dev/marcospaulo/canalhandia/OfflineStats.java
@@ -142,6 +142,40 @@ final class OfflineStats {
}
/** UUID to last known name, from usercache.json. */
+ /**
+ * Resolves a player name to their UUID using {@code usercache.json}, so a
+ * message can be left for someone who is offline.
+ *
+ *
Case-insensitive: nobody types a name with the right capitalisation,
+ * and a message silently addressed to nobody is worse than a typo error.
+ * Returns the cached spelling alongside the id, so the sender is shown the
+ * name as the server knows it and can spot a wrong recipient immediately.
+ *
+ *
Only players who have joined before are in the cache. That is the
+ * right boundary: a message to a name that has never played is a typo, not
+ * a message.
+ */
+ record Known(String uuid, String name) {
+ }
+
+ Known resolve(String name) {
+ if (name == null || name.isBlank()) {
+ return null;
+ }
+ String wanted = name.trim();
+ for (Map.Entry entry : names().entrySet()) {
+ if (entry.getValue().equalsIgnoreCase(wanted)) {
+ return new Known(entry.getKey(), entry.getValue());
+ }
+ }
+ return null;
+ }
+
+ /** Every name the server has seen, for tab completion. */
+ List knownNames() {
+ return new ArrayList<>(names().values());
+ }
+
private Map names() {
Map names = new HashMap<>();
File cache = new File(Bukkit.getWorldContainer(), "usercache.json");
diff --git a/src/main/java/dev/marcospaulo/canalhandia/Stats.java b/src/main/java/dev/marcospaulo/canalhandia/Stats.java
index 4e11aeb..9e20d6a 100644
--- a/src/main/java/dev/marcospaulo/canalhandia/Stats.java
+++ b/src/main/java/dev/marcospaulo/canalhandia/Stats.java
@@ -46,6 +46,39 @@ final class Stats {
}
}
+ /**
+ * Sum of a material-keyed statistic across every material — "how many
+ * blocks have you mined in total", which no single Bukkit call answers.
+ *
+ * Returns a {@code long}: the per-material values are ints, but a
+ * long-running player's total can pass {@link Integer#MAX_VALUE} and an int
+ * accumulator would silently wrap to a negative.
+ */
+ static long totalOf(Player player, Statistic statistic) {
+ if (statistic == null) {
+ return 0L;
+ }
+ boolean block = statistic.getType() == Statistic.Type.BLOCK;
+ if (!block && statistic.getType() != Statistic.Type.ITEM) {
+ return 0L;
+ }
+ long total = 0L;
+ for (Material material : Material.values()) {
+ if (material.isLegacy() || material.isAir()) {
+ continue;
+ }
+ if (block ? !material.isBlock() : !material.isItem()) {
+ continue;
+ }
+ try {
+ total += player.getStatistic(statistic, material);
+ } catch (RuntimeException e) {
+ // Not a valid subject for this statistic on this version.
+ }
+ }
+ return total;
+ }
+
/** A (subject, value) pair for a statistic that is keyed by material or entity. */
record Entry(T subject, int value) {
}
diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml
index eb0a5ef..d80824c 100644
--- a/src/main/resources/config.yml
+++ b/src/main/resources/config.yml
@@ -22,6 +22,13 @@ modulos:
# Anotações no chat: /save e /nota. Privadas (só o autor vê) para todos;
# públicas só para quem tiver canalhandia.nota.publica.
notas: true
+ # /recado — guardado e entregue quando a pessoa entrar.
+ recados: true
+ # Conquistas com nome ("Casca Grossa", "Turista"), além dos marcos numéricos.
+ # Na primeira vez que vê um jogador, o que ele já ganhou é gravado em
+ # silêncio — senão ligar o módulo despejaria um monte de anúncio de história
+ # antiga de uma vez só.
+ conquistas: true
ia: true # /ia — só para quem tem canalhandia.ia
# --- Curiosidades ------------------------------------------------------------
diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml
index 712ece6..9874ae5 100644
--- a/src/main/resources/plugin.yml
+++ b/src/main/resources/plugin.yml
@@ -75,6 +75,21 @@ commands:
description: Atalho para anotar rapidamente onde você está.
usage: /save [coords|]
aliases: [anotar]
+ recado:
+ description: Deixa um recado para alguém, entregue quando a pessoa entrar.
+ usage: /recado
+ aliases: [msg, mensagem]
+ recados:
+ description: Mostra quantos recados seus ainda não foram lidos.
+ usage: /recados
+ mortes:
+ description: Suas últimas mortes, com a causa e onde foi.
+ usage: /mortes
+ aliases: [minhasmortes]
+ conquistas:
+ description: Lista as conquistas e marca as que você já desbloqueou.
+ usage: /conquistas
+ aliases: [conquista]
permissions:
# Declared explicitly: an undeclared Bukkit permission falls back to op-only,
@@ -112,6 +127,9 @@ permissions:
canalhandia.nota.publica:
description: Permite criar anotações públicas, que todos veem. Padrão op; o LuckPerms pode conceder a outros.
default: op
+ canalhandia.recado:
+ description: Permite deixar recados para outros jogadores.
+ default: true
canalhandia.isento:
description: Quem tem isto nunca é sorteado como assunto.
default: false
diff --git a/src/test/java/dev/marcospaulo/canalhandia/AchievementTest.java b/src/test/java/dev/marcospaulo/canalhandia/AchievementTest.java
new file mode 100644
index 0000000..cd3de2c
--- /dev/null
+++ b/src/test/java/dev/marcospaulo/canalhandia/AchievementTest.java
@@ -0,0 +1,198 @@
+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.assertTrue;
+
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import org.junit.jupiter.api.Test;
+
+class AchievementTest {
+
+ private static final long TICKS_PER_HOUR = 20L * 3600L;
+ private static final long CM_PER_KM = 100_000L;
+
+ /** A stat map with everything at zero, so each test sets only what it means. */
+ private static Map stats() {
+ Map stats = new HashMap<>();
+ for (String key : new String[]{"mineracao", "tempo", "distancia", "mortes",
+ "combate", "pesca", "pulos"}) {
+ stats.put(key, 0L);
+ }
+ return stats;
+ }
+
+ // --- catalogue hygiene --------------------------------------------------
+
+ @Test
+ void keysAreUniqueLowercaseAscii() {
+ Set seen = new HashSet<>();
+ for (Achievement achievement : Achievement.values()) {
+ String key = achievement.key();
+ assertTrue(seen.add(key), "duplicate key: " + key);
+ assertEquals(key.toLowerCase(Locale.ROOT), key);
+ // Keys go into YAML paths and are typed by operators; keep them
+ // plain, with no accents.
+ assertTrue(key.matches("[a-z-]+"), "key must be plain ascii: " + key);
+ }
+ }
+
+ @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");
+ }
+ }
+
+ @Test
+ void byKeyFindsOrReturnsNull() {
+ assertEquals(Achievement.VETERANO, Achievement.byKey("veterano"));
+ assertEquals(Achievement.VETERANO, Achievement.byKey(" VETERANO "));
+ assertNull(Achievement.byKey("nao-existe"));
+ assertNull(Achievement.byKey(null));
+ }
+
+ @Test
+ void nothingIsEarnedWithZeroedStats() {
+ // A brand-new player must not be handed anything on their first check.
+ assertTrue(Achievement.earned(stats()).isEmpty());
+ }
+
+ @Test
+ void metIsFalseForNullStats() {
+ for (Achievement achievement : Achievement.values()) {
+ assertFalse(achievement.met(null), achievement.key() + " must handle null");
+ }
+ }
+
+ // --- mining -------------------------------------------------------------
+
+ @Test
+ void pedreiroNeedsTenThousandBlocks() {
+ Map stats = stats();
+ stats.put("mineracao", 9_999L);
+ assertFalse(Achievement.PEDREIRO.met(stats));
+ stats.put("mineracao", 10_000L);
+ assertTrue(Achievement.PEDREIRO.met(stats));
+ }
+
+ @Test
+ void escavadeiraNeedsAHundredThousand() {
+ Map stats = stats();
+ stats.put("mineracao", 99_999L);
+ assertFalse(Achievement.ESCAVADEIRA.met(stats));
+ stats.put("mineracao", 100_000L);
+ assertTrue(Achievement.ESCAVADEIRA.met(stats));
+ }
+
+ // --- travel and time ----------------------------------------------------
+
+ @Test
+ void maratonistaConvertsCentimetresToKilometres() {
+ Map stats = stats();
+ stats.put("distancia", 41 * CM_PER_KM);
+ assertFalse(Achievement.MARATONISTA.met(stats));
+ stats.put("distancia", 42 * CM_PER_KM);
+ assertTrue(Achievement.MARATONISTA.met(stats));
+ }
+
+ @Test
+ void veteranoConvertsTicksToHours() {
+ Map stats = stats();
+ stats.put("tempo", 199 * TICKS_PER_HOUR);
+ assertFalse(Achievement.VETERANO.met(stats));
+ stats.put("tempo", 200 * TICKS_PER_HOUR);
+ assertTrue(Achievement.VETERANO.met(stats));
+ }
+
+ // --- the ratio ones -----------------------------------------------------
+
+ @Test
+ void imortalAsAvessasNeedsBothTheRatioAndRealMining() {
+ Map stats = stats();
+ // A brand-new player with 2 deaths and almost no mining satisfies the
+ // ratio but must NOT get a joke achievement on their second death.
+ stats.put("mineracao", 100L);
+ stats.put("mortes", 50L);
+ assertFalse(Achievement.IMORTAL_AS_AVESSAS.met(stats),
+ "the mining floor must gate this");
+
+ // 2000 mined, 21 deaths: over one per hundred blocks.
+ stats.put("mineracao", 2_000L);
+ stats.put("mortes", 21L);
+ assertTrue(Achievement.IMORTAL_AS_AVESSAS.met(stats));
+
+ // Exactly at the ratio is not over it.
+ stats.put("mortes", 20L);
+ assertFalse(Achievement.IMORTAL_AS_AVESSAS.met(stats));
+ }
+
+ @Test
+ void turistaNeedsHoursAndLittleMining() {
+ Map stats = stats();
+ stats.put("tempo", 100 * TICKS_PER_HOUR);
+ stats.put("mineracao", 4_999L);
+ assertTrue(Achievement.TURISTA.met(stats));
+
+ // Mines plenty: not a tourist.
+ stats.put("mineracao", 5_000L);
+ assertFalse(Achievement.TURISTA.met(stats));
+
+ // Not enough hours yet.
+ stats.put("mineracao", 100L);
+ stats.put("tempo", 99 * TICKS_PER_HOUR);
+ assertFalse(Achievement.TURISTA.met(stats));
+ }
+
+ @Test
+ void cascaGrossaNeedsHoursAndFewDeaths() {
+ Map stats = stats();
+ stats.put("tempo", 50 * TICKS_PER_HOUR);
+ stats.put("mortes", 9L);
+ assertTrue(Achievement.CASCA_GROSSA.met(stats));
+ stats.put("mortes", 10L);
+ assertFalse(Achievement.CASCA_GROSSA.met(stats));
+ }
+
+ @Test
+ void turistaAndCascaGrossaCanBothApply() {
+ // They are not mutually exclusive, and nothing in the model pretends
+ // they are — a long-lived careful player who does not mine gets both.
+ Map stats = stats();
+ stats.put("tempo", 100 * TICKS_PER_HOUR);
+ stats.put("mineracao", 10L);
+ stats.put("mortes", 1L);
+ assertTrue(Achievement.TURISTA.met(stats));
+ assertTrue(Achievement.CASCA_GROSSA.met(stats));
+ }
+
+ // --- earned -------------------------------------------------------------
+
+ @Test
+ void earnedCollectsEverythingThatQualifies() {
+ Map stats = stats();
+ stats.put("mineracao", 100_000L);
+ stats.put("combate", 1_000L);
+ var earned = Achievement.earned(stats);
+ assertTrue(earned.contains(Achievement.PEDREIRO));
+ assertTrue(earned.contains(Achievement.ESCAVADEIRA));
+ assertTrue(earned.contains(Achievement.EXTERMINADOR));
+ assertFalse(earned.contains(Achievement.VETERANO));
+ }
+
+ @Test
+ void earnedHandlesAMapMissingKeys() {
+ // The snapshot always fills every key, but a condition reading a key
+ // that is absent must default to zero rather than throw.
+ assertNotNull(Achievement.earned(new HashMap<>()));
+ assertTrue(Achievement.earned(new HashMap<>()).isEmpty());
+ }
+}
diff --git a/src/test/java/dev/marcospaulo/canalhandia/DeathLogTest.java b/src/test/java/dev/marcospaulo/canalhandia/DeathLogTest.java
new file mode 100644
index 0000000..62c128b
--- /dev/null
+++ b/src/test/java/dev/marcospaulo/canalhandia/DeathLogTest.java
@@ -0,0 +1,129 @@
+package dev.marcospaulo.canalhandia;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.nio.file.Path;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class DeathLogTest {
+
+ @TempDir
+ Path dir;
+
+ private DeathLog fresh(String name) {
+ return new DeathLog(new File(dir.toFile(), name));
+ }
+
+ @Test
+ void recordsAndReturnsNewestFirst() throws Exception {
+ DeathLog log = fresh("a.yml");
+ log.record("ana", "foi achatado como panqueca", "Mundo normal", 1, 2, 3);
+ // The ordering key is a millisecond wall-clock stamp, so two records in
+ // the same millisecond would tie; sleep past it.
+ Thread.sleep(2);
+ log.record("ana", "virou churrasco no lava", "Nether", 4, 5, 6);
+
+ List deaths = log.forPlayer("ana");
+ assertEquals(2, deaths.size());
+ assertEquals("virou churrasco no lava", deaths.get(0).flavor(), "newest first");
+ assertEquals("foi achatado como panqueca", deaths.get(1).flavor());
+ }
+
+ @Test
+ void keepsEachPlayerSeparate() {
+ DeathLog log = fresh("b.yml");
+ log.record("ana", "morreu", "w", 1, 1, 1);
+ log.record("bia", "morreu também", "w", 2, 2, 2);
+ assertEquals(1, log.forPlayer("ana").size());
+ assertEquals(1, log.forPlayer("bia").size());
+ assertTrue(log.forPlayer("caio").isEmpty());
+ }
+
+ @Test
+ void capIsPerPlayerAndDropsTheOldest() throws Exception {
+ DeathLog log = fresh("c.yml");
+ for (int i = 0; i < DeathLog.MAX_PER_PLAYER + 5; i++) {
+ log.record("ana", "morte " + i, "w", i, i, i);
+ Thread.sleep(2);
+ }
+ List deaths = log.forPlayer("ana");
+ assertEquals(DeathLog.MAX_PER_PLAYER, deaths.size());
+ assertEquals("morte " + (DeathLog.MAX_PER_PLAYER + 4), deaths.get(0).flavor());
+ // The first five fell off the end.
+ for (DeathLog.Entry death : deaths) {
+ assertTrue(!death.flavor().equals("morte 0"), "oldest should have been evicted");
+ }
+ }
+
+ @Test
+ void oneBusyPlayerDoesNotEvictAnother() {
+ // A global cap would let one player's bad night erase everyone else's
+ // history.
+ DeathLog log = fresh("d.yml");
+ log.record("bia", "a única morte da bia", "w", 0, 0, 0);
+ for (int i = 0; i < DeathLog.MAX_PER_PLAYER * 3; i++) {
+ log.record("ana", "morte " + i, "w", i, i, i);
+ }
+ assertEquals(1, log.forPlayer("bia").size());
+ assertEquals("a única morte da bia", log.forPlayer("bia").get(0).flavor());
+ }
+
+ @Test
+ void placeAndCoords() {
+ DeathLog.Entry entry = new DeathLog.Entry("ana", "morreu", "Nether", 10, 64, -20, 0L);
+ assertEquals("10, 64, -20", entry.coords());
+ assertEquals("10, 64, -20 (Nether)", entry.place());
+ }
+
+ @Test
+ void placeWithoutAWorldOmitsTheParentheses() {
+ assertEquals("1, 2, 3", new DeathLog.Entry("ana", "x", "", 1, 2, 3, 0L).place());
+ }
+
+ @Test
+ void clearRemovesOnlyThatPlayer() {
+ DeathLog log = fresh("e.yml");
+ log.record("ana", "x", "w", 0, 0, 0);
+ log.record("bia", "y", "w", 0, 0, 0);
+ log.clear("ana");
+ assertTrue(log.forPlayer("ana").isEmpty());
+ assertEquals(1, log.forPlayer("bia").size());
+ }
+
+ @Test
+ void historySurvivesARestart() {
+ File file = new File(dir.toFile(), "f.yml");
+ DeathLog first = new DeathLog(file);
+ first.record("ana", "virou picolé", "End", 100, 50, -7);
+
+ DeathLog reloaded = new DeathLog(file);
+ DeathLog.Entry entry = reloaded.forPlayer("ana").get(0);
+ assertEquals("virou picolé", entry.flavor());
+ assertEquals("End", entry.world());
+ assertEquals(100, entry.x());
+ assertEquals(-7, entry.z());
+ }
+
+ @Test
+ void theCapSurvivesARestart() throws Exception {
+ File file = new File(dir.toFile(), "g.yml");
+ DeathLog first = new DeathLog(file);
+ for (int i = 0; i < DeathLog.MAX_PER_PLAYER; i++) {
+ first.record("ana", "m" + i, "w", 0, 0, 0);
+ Thread.sleep(2);
+ }
+ DeathLog reloaded = new DeathLog(file);
+ reloaded.record("ana", "depois do restart", "w", 0, 0, 0);
+ assertEquals(DeathLog.MAX_PER_PLAYER, reloaded.forPlayer("ana").size());
+ assertEquals("depois do restart", reloaded.forPlayer("ana").get(0).flavor());
+ }
+
+ @Test
+ void aMissingFileLoadsAsEmpty() {
+ assertEquals(0, new DeathLog(new File(dir.toFile(), "nao-existe.yml")).size());
+ }
+}
diff --git a/src/test/java/dev/marcospaulo/canalhandia/MailTest.java b/src/test/java/dev/marcospaulo/canalhandia/MailTest.java
new file mode 100644
index 0000000..f231a04
--- /dev/null
+++ b/src/test/java/dev/marcospaulo/canalhandia/MailTest.java
@@ -0,0 +1,158 @@
+package dev.marcospaulo.canalhandia;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.File;
+import java.nio.file.Path;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+class MailTest {
+
+ @TempDir
+ Path dir;
+
+ private Mail fresh(String name) {
+ return new Mail(new File(dir.toFile(), name));
+ }
+
+ private static Mail.Message send(Mail mail, String from, String to, String text) {
+ return mail.send(from, "uuid-" + from, "uuid-" + to, text);
+ }
+
+ // --- sending ------------------------------------------------------------
+
+ @Test
+ void sendStoresAndNumbers() {
+ Mail mail = fresh("a.yml");
+ assertEquals(1, send(mail, "ana", "bia", "oi").id());
+ assertEquals(2, send(mail, "ana", "bia", "de novo").id());
+ assertEquals(2, mail.size());
+ }
+
+ @Test
+ void countForCountsOnlyThatRecipient() {
+ Mail mail = fresh("b.yml");
+ send(mail, "ana", "bia", "1");
+ send(mail, "ana", "bia", "2");
+ send(mail, "ana", "caio", "3");
+ assertEquals(2, mail.countFor("uuid-bia"));
+ assertEquals(1, mail.countFor("uuid-caio"));
+ assertEquals(0, mail.countFor("uuid-ninguem"));
+ }
+
+ @Test
+ void countFromCountsUndeliveredBySender() {
+ Mail mail = fresh("c.yml");
+ send(mail, "ana", "bia", "1");
+ send(mail, "caio", "bia", "2");
+ assertEquals(1, mail.countFrom("uuid-ana"));
+ // Delivery clears it: the sender is told what is still unread.
+ mail.takeFor("uuid-bia");
+ assertEquals(0, mail.countFrom("uuid-ana"));
+ }
+
+ @Test
+ void inboxCapIsPerRecipient() {
+ Mail mail = fresh("d.yml");
+ for (int i = 0; i < Mail.MAX_PER_RECIPIENT; i++) {
+ assertNotNull(send(mail, "ana", "bia", "spam " + i));
+ }
+ assertNull(send(mail, "ana", "bia", "uma a mais"), "should refuse past the cap");
+ // A full inbox for one player must not block another.
+ assertNotNull(send(mail, "ana", "caio", "para você"));
+ }
+
+ @Test
+ void capCountsMessagesFromEverySender() {
+ // The cap protects the recipient, so it cannot be bypassed by using a
+ // second account to send the rest.
+ Mail mail = fresh("e.yml");
+ for (int i = 0; i < Mail.MAX_PER_RECIPIENT; i++) {
+ send(mail, i % 2 == 0 ? "ana" : "caio", "bia", "m" + i);
+ }
+ assertNull(send(mail, "dani", "bia", "mais uma"));
+ }
+
+ // --- delivery -----------------------------------------------------------
+
+ @Test
+ void takeForReturnsOldestFirst() {
+ Mail mail = fresh("f.yml");
+ send(mail, "ana", "bia", "primeira");
+ send(mail, "ana", "bia", "segunda");
+ List got = mail.takeFor("uuid-bia");
+ assertEquals(2, got.size());
+ assertEquals("primeira", got.get(0).text(), "reading order for a conversation");
+ assertEquals("segunda", got.get(1).text());
+ }
+
+ @Test
+ void takeForIsDestructive() {
+ // A message that stayed queued would be re-read on every single join.
+ Mail mail = fresh("g.yml");
+ send(mail, "ana", "bia", "oi");
+ assertEquals(1, mail.takeFor("uuid-bia").size());
+ assertTrue(mail.takeFor("uuid-bia").isEmpty(), "must not be delivered twice");
+ assertEquals(0, mail.size());
+ }
+
+ @Test
+ void takeForLeavesOtherPeoplesMailAlone() {
+ Mail mail = fresh("h.yml");
+ send(mail, "ana", "bia", "para bia");
+ send(mail, "ana", "caio", "para caio");
+ mail.takeFor("uuid-bia");
+ assertEquals(1, mail.countFor("uuid-caio"));
+ assertEquals("para caio", mail.takeFor("uuid-caio").get(0).text());
+ }
+
+ @Test
+ void takeForWithNothingWaitingIsEmpty() {
+ assertTrue(fresh("i.yml").takeFor("uuid-ninguem").isEmpty());
+ }
+
+ // --- persistence --------------------------------------------------------
+
+ @Test
+ void mailSurvivesARestart() {
+ File file = new File(dir.toFile(), "j.yml");
+ Mail first = new Mail(file);
+ first.send("ana", "uuid-ana", "uuid-bia", "achei diamante em -400 70 200");
+
+ Mail reloaded = new Mail(file);
+ assertEquals(1, reloaded.countFor("uuid-bia"));
+ Mail.Message message = reloaded.takeFor("uuid-bia").get(0);
+ assertEquals("ana", message.fromName());
+ assertEquals("achei diamante em -400 70 200", message.text());
+ }
+
+ @Test
+ void deliverySurvivesARestart() {
+ // The dangerous direction: a delivered message coming back after a
+ // restart would be read again on the next join.
+ File file = new File(dir.toFile(), "k.yml");
+ Mail first = new Mail(file);
+ first.send("ana", "uuid-ana", "uuid-bia", "oi");
+ first.takeFor("uuid-bia");
+ assertEquals(0, new Mail(file).countFor("uuid-bia"));
+ }
+
+ @Test
+ void idsKeepCountingAfterAReload() {
+ File file = new File(dir.toFile(), "l.yml");
+ Mail first = new Mail(file);
+ first.send("ana", "uuid-ana", "uuid-bia", "a");
+ first.send("ana", "uuid-ana", "uuid-bia", "b");
+ assertEquals(3, new Mail(file).send("ana", "uuid-ana", "uuid-bia", "c").id());
+ }
+
+ @Test
+ void aMissingFileLoadsAsEmpty() {
+ assertEquals(0, new Mail(new File(dir.toFile(), "nao-existe.yml")).size());
+ }
+}
diff --git a/src/test/java/dev/marcospaulo/canalhandia/MsgAgoTest.java b/src/test/java/dev/marcospaulo/canalhandia/MsgAgoTest.java
new file mode 100644
index 0000000..62180ba
--- /dev/null
+++ b/src/test/java/dev/marcospaulo/canalhandia/MsgAgoTest.java
@@ -0,0 +1,64 @@
+package dev.marcospaulo.canalhandia;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import org.junit.jupiter.api.Test;
+
+class MsgAgoTest {
+
+ private static final long NOW = 1_000_000_000_000L;
+ private static final long SECOND = 1000L;
+ private static final long MINUTE = 60 * SECOND;
+ private static final long HOUR = 60 * MINUTE;
+ private static final long DAY = 24 * HOUR;
+
+ private static String ago(long millisAgo) {
+ return Msg.ago(NOW - millisAgo, NOW);
+ }
+
+ @Test
+ void underAMinuteIsNow() {
+ assertEquals("agora", ago(0));
+ assertEquals("agora", ago(59 * SECOND));
+ }
+
+ @Test
+ void minutes() {
+ assertEquals("há 1 minuto", ago(MINUTE));
+ assertEquals("há 5 minutos", ago(5 * MINUTE));
+ assertEquals("há 59 minutos", ago(59 * MINUTE));
+ }
+
+ @Test
+ void hours() {
+ assertEquals("há 1 hora", ago(HOUR));
+ assertEquals("há 23 horas", ago(23 * HOUR));
+ }
+
+ @Test
+ void days() {
+ assertEquals("há 1 dia", ago(DAY));
+ assertEquals("há 29 dias", ago(29 * DAY));
+ }
+
+ @Test
+ void monthsAndYears() {
+ assertEquals("há 1 mês", ago(30 * DAY));
+ assertEquals("há 2 meses", ago(60 * DAY));
+ assertEquals("há 1 ano", ago(365 * DAY));
+ }
+
+ @Test
+ void aFutureTimestampReadsAsNow() {
+ // These timestamps are wall-clock and persisted, so an NTP step or a
+ // hand-edited YAML can put one in the future. Clamping beats printing a
+ // negative age.
+ assertEquals("agora", Msg.ago(NOW + DAY, NOW));
+ }
+
+ @Test
+ void pluralAgreesWithTheNumber() {
+ assertEquals("há 1 minuto", ago(MINUTE));
+ assertEquals("há 2 minutos", ago(2 * MINUTE));
+ }
+}