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

Merged
masi merged 20 commits from feat/ia-grounding into main 2026-08-12 15:53:28 +00:00
16 changed files with 1549 additions and 2 deletions
Showing only changes of commit 26148740ef - Show all commits
@@ -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.
*
* <p>{@link Milestones} already announces thresholds ("passou de 100 km"). This
* covers the other half — combinations and ratios that say something about
* <em>how</em> someone plays: dying more than they mine, walking a marathon
* without ever touching the Nether, killing a thousand mobs.
*
* <p>Every condition is a pure function of a stat map, so the whole catalogue is
* testable without a server. {@link Achievements} owns the "announce once"
* bookkeeping; this owns what the achievements <em>are</em>.
*
* <p>The reward is chat only — a name and a line. Nothing here touches
* gameplay, in keeping with the rest of the plugin.
*/
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<String, Long> 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<String, Long> 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<String, Long> stats) {
return stats.getOrDefault("tempo", 0L) / 20L / 3600L;
}
/** Distance walked in kilometres; the raw statistic is in centimetres. */
private static long km(Map<String, Long> stats) {
return stats.getOrDefault("distancia", 0L) / 100_000L;
}
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<Achievement> earned(Map<String, Long> stats) {
List<Achievement> out = new ArrayList<>();
for (Achievement achievement : values()) {
if (achievement.met(stats)) {
out.add(achievement);
}
}
return out;
}
}
@@ -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.
*
* <p>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 <em>silently</em>. 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<String, Long> 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<Achievement> earnedBy(Player player) {
List<Achievement> 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.
*
* <p>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<String, Long> snapshot(Player player) {
Map<String, Long> 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());
}
}
}
@@ -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.
*
* <p>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.
*
* <p>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<Mail.Message> 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());
}
/**
@@ -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 <jogador> <texto>} — leave a line for someone who is not
* online, delivered on their next join.
*
* <p>If the recipient <em>is</em> 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 <jogador> <texto>");
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.
*
* <p>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<DeathLog.Entry> 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<Achievement> 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();
}
@@ -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.
*
* <p>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?"
*
* <p>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<Entry> 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<Entry> 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<Entry> forPlayer(String playerId) {
synchronized (entries) {
return forPlayerLocked(playerId);
}
}
/** Caller must hold the lock. Newest first. */
private List<Entry> forPlayerLocked(String playerId) {
List<Entry> 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);
}
}
}
@@ -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.
*
* <p>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.
*
* <p>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<Message> 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.
*
* <p>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<Message> takeFor(String playerId) {
List<Message> 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);
}
}
}
@@ -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;
@@ -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".
*
* <p>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 "" + plural(minutes, "minuto", "minutos");
}
long hours = minutes / 60;
if (hours < 24) {
return "" + plural(hours, "hora", "horas");
}
long days = hours / 24;
if (days < 30) {
return "" + plural(days, "dia", "dias");
}
long months = days / 30;
return months < 12
? "" + plural(months, "mês", "meses")
: "" + plural(months / 12, "ano", "anos");
}
/** {@link #ago(long, long)} against the current clock. */
static String ago(long timestamp) {
return ago(timestamp, System.currentTimeMillis());
}
}
@@ -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.
*
* <p>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.
*
* <p>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<String, String> 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<String> knownNames() {
return new ArrayList<>(names().values());
}
private Map<String, String> names() {
Map<String, String> names = new HashMap<>();
File cache = new File(Bukkit.getWorldContainer(), "usercache.json");
@@ -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.
*
* <p>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>(T subject, int value) {
}
+7
View File
@@ -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 <jogador> <texto> — 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 <pergunta> — só para quem tem canalhandia.ia
# --- Curiosidades ------------------------------------------------------------
+18
View File
@@ -75,6 +75,21 @@ commands:
description: Atalho para anotar rapidamente onde você está.
usage: /save [coords|<texto>]
aliases: [anotar]
recado:
description: Deixa um recado para alguém, entregue quando a pessoa entrar.
usage: /recado <jogador> <texto>
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
@@ -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<String, Long> stats() {
Map<String, Long> 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<String> seen = new HashSet<>();
for (Achievement achievement : Achievement.values()) {
String key = achievement.key();
assertTrue(seen.add(key), "duplicate key: " + key);
assertEquals(key.toLowerCase(Locale.ROOT), key);
// Keys go into YAML paths and are typed by operators; keep them
// plain, with no accents.
assertTrue(key.matches("[a-z-]+"), "key must be plain ascii: " + key);
}
}
@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<String, Long> 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<String, Long> 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<String, Long> stats = stats();
stats.put("distancia", 41 * CM_PER_KM);
assertFalse(Achievement.MARATONISTA.met(stats));
stats.put("distancia", 42 * CM_PER_KM);
assertTrue(Achievement.MARATONISTA.met(stats));
}
@Test
void veteranoConvertsTicksToHours() {
Map<String, Long> stats = stats();
stats.put("tempo", 199 * TICKS_PER_HOUR);
assertFalse(Achievement.VETERANO.met(stats));
stats.put("tempo", 200 * TICKS_PER_HOUR);
assertTrue(Achievement.VETERANO.met(stats));
}
// --- the ratio ones -----------------------------------------------------
@Test
void imortalAsAvessasNeedsBothTheRatioAndRealMining() {
Map<String, Long> stats = stats();
// A brand-new player with 2 deaths and almost no mining satisfies the
// ratio but must NOT get a joke achievement on their second death.
stats.put("mineracao", 100L);
stats.put("mortes", 50L);
assertFalse(Achievement.IMORTAL_AS_AVESSAS.met(stats),
"the mining floor must gate this");
// 2000 mined, 21 deaths: over one per hundred blocks.
stats.put("mineracao", 2_000L);
stats.put("mortes", 21L);
assertTrue(Achievement.IMORTAL_AS_AVESSAS.met(stats));
// Exactly at the ratio is not over it.
stats.put("mortes", 20L);
assertFalse(Achievement.IMORTAL_AS_AVESSAS.met(stats));
}
@Test
void turistaNeedsHoursAndLittleMining() {
Map<String, Long> stats = stats();
stats.put("tempo", 100 * TICKS_PER_HOUR);
stats.put("mineracao", 4_999L);
assertTrue(Achievement.TURISTA.met(stats));
// Mines plenty: not a tourist.
stats.put("mineracao", 5_000L);
assertFalse(Achievement.TURISTA.met(stats));
// Not enough hours yet.
stats.put("mineracao", 100L);
stats.put("tempo", 99 * TICKS_PER_HOUR);
assertFalse(Achievement.TURISTA.met(stats));
}
@Test
void cascaGrossaNeedsHoursAndFewDeaths() {
Map<String, Long> stats = stats();
stats.put("tempo", 50 * TICKS_PER_HOUR);
stats.put("mortes", 9L);
assertTrue(Achievement.CASCA_GROSSA.met(stats));
stats.put("mortes", 10L);
assertFalse(Achievement.CASCA_GROSSA.met(stats));
}
@Test
void turistaAndCascaGrossaCanBothApply() {
// They are not mutually exclusive, and nothing in the model pretends
// they are — a long-lived careful player who does not mine gets both.
Map<String, Long> stats = stats();
stats.put("tempo", 100 * TICKS_PER_HOUR);
stats.put("mineracao", 10L);
stats.put("mortes", 1L);
assertTrue(Achievement.TURISTA.met(stats));
assertTrue(Achievement.CASCA_GROSSA.met(stats));
}
// --- earned -------------------------------------------------------------
@Test
void earnedCollectsEverythingThatQualifies() {
Map<String, Long> stats = stats();
stats.put("mineracao", 100_000L);
stats.put("combate", 1_000L);
var earned = Achievement.earned(stats);
assertTrue(earned.contains(Achievement.PEDREIRO));
assertTrue(earned.contains(Achievement.ESCAVADEIRA));
assertTrue(earned.contains(Achievement.EXTERMINADOR));
assertFalse(earned.contains(Achievement.VETERANO));
}
@Test
void earnedHandlesAMapMissingKeys() {
// The snapshot always fills every key, but a condition reading a key
// that is absent must default to zero rather than throw.
assertNotNull(Achievement.earned(new HashMap<>()));
assertTrue(Achievement.earned(new HashMap<>()).isEmpty());
}
}
@@ -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<DeathLog.Entry> 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<DeathLog.Entry> 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());
}
}
@@ -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<Mail.Message> 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());
}
}
@@ -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));
}
}