i18n: per-player EN/PT via Adventure GlobalTranslator #1
@@ -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 "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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
|
||||
@@ -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 ------------------------------------------------------------
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user