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)); + } +}