From 26148740ef59e195297b37038649f162c05ab0d5 Mon Sep 17 00:00:00 2001 From: marcos Date: Sat, 8 Aug 2026 00:49:45 +0000 Subject: [PATCH] Add offline mail, death history and named achievements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three features that all answer questions the server could already have answered but was throwing away. Mail (/recado , /recados). On a server where people rarely overlap, "achei diamante em -400 70 200" had to go through Discord or be lost. Messages are queued for anyone who has joined before — resolved through usercache.json, case-insensitively, because nobody types a name with the right capitalisation — and delivered on their next join. If the recipient is online it is delivered immediately instead of queued, since queueing it would mean the person standing next to you reads it only after a relog. Delivery is destructive: a message that stayed queued would be re-read on every single join, turning a helpful note into a nuisance. It is also delayed a few seconds and re-checks isOnline, because a player can leave inside the delay and the mail would otherwise be consumed with nobody there to read it. Its own join handler, not a branch inside onJoin, which returns early when the curiosidades module is off — mail must not depend on an unrelated module. Inbox capped per recipient, counting every sender, so the cap cannot be bypassed with a second account. Death history (/mortes). The mortes module already knew where and how someone died and discarded it once the coords were delivered on respawn. Keeping the last ten per player answers what people actually ask a day later. Eviction is per player, not global, or one player's bad night would erase everyone else's history. Your own deaths only: where someone died is where their stuff is, and a public list of that is a looting guide. Named achievements (/conquistas). Milestones covers round numbers; this covers the combinations that say something about how someone plays — "Casca Grossa" (50 hours, under 10 deaths), "Turista" (100 hours, barely mined), "Imortal às Avessas" (dies more than once per hundred blocks mined). Every condition is a pure function of a stat map, so the catalogue is unit-tested without a server. The ratio ones carry a floor on absolute mining, so a new player is not handed a joke achievement on their second death — there is a test for exactly that. First sight is silent, like Milestones: the first time a player is checked, whatever they have already earned is recorded without announcing it. Otherwise enabling the module would dump a dozen announcements for history earned months ago. Players who qualify for nothing are still marked as seen, or every later check would treat them as new and stay silent forever. Achievements share the milestone task rather than adding a second timer: both sweep every online player's statistics, so one pass does the work of two. Stats.totalOf sums a material-keyed statistic into a long — the per-material values are ints and a long-running player's total can pass Integer.MAX_VALUE. The /conquistas checklist uses "[x]" on Bedrock instead of "✔", which renders there as a tofu box — the same per-platform rule the reaction labels follow. Msg.ago renders wall-clock timestamps as "há 2 dias"; a timestamp from the future clamps to "agora" rather than printing a negative age. 263 tests, up from 218. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016pEyCmrAYHBFgpYjwFxKxh --- .../marcospaulo/canalhandia/Achievement.java | 159 ++++++++++++++ .../marcospaulo/canalhandia/Achievements.java | 147 +++++++++++++ .../marcospaulo/canalhandia/Canalhandia.java | 83 +++++++- .../canalhandia/CanalhandiaCommand.java | 169 +++++++++++++++ .../dev/marcospaulo/canalhandia/DeathLog.java | 141 +++++++++++++ .../dev/marcospaulo/canalhandia/Mail.java | 171 +++++++++++++++ .../dev/marcospaulo/canalhandia/Module.java | 2 + .../java/dev/marcospaulo/canalhandia/Msg.java | 38 ++++ .../marcospaulo/canalhandia/OfflineStats.java | 34 +++ .../dev/marcospaulo/canalhandia/Stats.java | 33 +++ src/main/resources/config.yml | 7 + src/main/resources/plugin.yml | 18 ++ .../canalhandia/AchievementTest.java | 198 ++++++++++++++++++ .../marcospaulo/canalhandia/DeathLogTest.java | 129 ++++++++++++ .../dev/marcospaulo/canalhandia/MailTest.java | 158 ++++++++++++++ .../marcospaulo/canalhandia/MsgAgoTest.java | 64 ++++++ 16 files changed, 1549 insertions(+), 2 deletions(-) create mode 100644 src/main/java/dev/marcospaulo/canalhandia/Achievement.java create mode 100644 src/main/java/dev/marcospaulo/canalhandia/Achievements.java create mode 100644 src/main/java/dev/marcospaulo/canalhandia/DeathLog.java create mode 100644 src/main/java/dev/marcospaulo/canalhandia/Mail.java create mode 100644 src/test/java/dev/marcospaulo/canalhandia/AchievementTest.java create mode 100644 src/test/java/dev/marcospaulo/canalhandia/DeathLogTest.java create mode 100644 src/test/java/dev/marcospaulo/canalhandia/MailTest.java create mode 100644 src/test/java/dev/marcospaulo/canalhandia/MsgAgoTest.java 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)); + } +}