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 Settings settings;
|
||||||
private Notes notes;
|
private Notes notes;
|
||||||
|
private Mail mail;
|
||||||
|
private DeathLog deathLog;
|
||||||
private OfflineStats offlineStats;
|
private OfflineStats offlineStats;
|
||||||
private Milestones milestones;
|
private Milestones milestones;
|
||||||
|
private Achievements achievements;
|
||||||
private Ai ai;
|
private Ai ai;
|
||||||
private NamespacedKey optOutKey;
|
private NamespacedKey optOutKey;
|
||||||
private BukkitTask timerTask;
|
private BukkitTask timerTask;
|
||||||
@@ -84,8 +87,11 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
|||||||
saveDefaultConfig();
|
saveDefaultConfig();
|
||||||
settings = new Settings(this);
|
settings = new Settings(this);
|
||||||
notes = new Notes(new java.io.File(getDataFolder(), "notas.yml"));
|
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);
|
offlineStats = new OfflineStats(this);
|
||||||
milestones = new Milestones(this);
|
milestones = new Milestones(this);
|
||||||
|
achievements = new Achievements(this);
|
||||||
ai = new Ai(this);
|
ai = new Ai(this);
|
||||||
// Snapshot the server's recipes on the main thread; RecipeBook.describe
|
// Snapshot the server's recipes on the main thread; RecipeBook.describe
|
||||||
// reads from the async answer path and Bukkit.recipeIterator() is not
|
// 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);
|
CanalhandiaCommand root = new CanalhandiaCommand(this);
|
||||||
for (String name : List.of("canalhandia", "curiosidade", "adivinha", "enquete", "ranking",
|
for (String name : List.of("canalhandia", "curiosidade", "adivinha", "enquete", "ranking",
|
||||||
"reagir", "reacoes", "palpite", "votar", "legal", "wow", "top", "f", "ia", "iap",
|
"reagir", "reacoes", "palpite", "votar", "legal", "wow", "top", "f", "ia", "iap",
|
||||||
"errado", "nota", "save")) {
|
"errado", "nota", "save", "recado", "recados", "mortes", "conquistas")) {
|
||||||
register(name, root);
|
register(name, root);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,6 +163,21 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
|||||||
return notes;
|
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 ---------------------------------------------------------
|
// --- scheduling ---------------------------------------------------------
|
||||||
|
|
||||||
/** Starts, stops or restarts the repeating curiosity task to match the mode. */
|
/** 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;
|
return;
|
||||||
}
|
}
|
||||||
long ticks = 5L * 60L * 20L;
|
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 --------------------------------------------------------
|
// --- curiosities --------------------------------------------------------
|
||||||
@@ -494,6 +520,52 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
|||||||
}, settings.joinDelaySeconds() * 20L);
|
}, 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
|
* Chat gag: a message matching the {@code zoacao} trigger (pattern + match
|
||||||
* mode, default a bare "f") gets replaced with a random line from
|
* 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(
|
boolean keepInventory = Boolean.TRUE.equals(
|
||||||
loc.getWorld().getGameRuleValue(GameRule.KEEP_INVENTORY));
|
loc.getWorld().getGameRuleValue(GameRule.KEEP_INVENTORY));
|
||||||
pendingDeathCoords.put(player.getUniqueId(), new DeathCoords(coords, keepInventory));
|
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
|
// it with a note. Private by default — the safe default for a
|
||||||
// one-word command nobody reads the help for first.
|
// one-word command nobody reads the help for first.
|
||||||
case "save" -> saveShortcut(sender, args);
|
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
|
// 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
|
// reaction (the mourning set is hardcoded), so it can't be found via
|
||||||
// reactionForCommand; route it directly. Acts on the latest message,
|
// reactionForCommand; route it directly. Acts on the latest message,
|
||||||
@@ -1056,6 +1060,166 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
|||||||
return true;
|
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) {
|
private void notaAdd(CommandSender sender, String[] args, Note.Scope scope) {
|
||||||
if (!(sender instanceof Player player)) {
|
if (!(sender instanceof Player player)) {
|
||||||
Msg.error(sender, "Só jogadores podem anotar (a anotação guarda onde você está).");
|
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();
|
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")) {
|
if (name.equals("save")) {
|
||||||
return args.length == 1 ? filter(List.of("coords"), args[0]) : List.of();
|
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"),
|
MORTES("mortes", "Mortes com humor e coordenadas"),
|
||||||
ZOACAO("zoacao", "Zoa de quem manda só 'f' no chat"),
|
ZOACAO("zoacao", "Zoa de quem manda só 'f' no chat"),
|
||||||
NOTAS("notas", "Anotações públicas e privadas 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");
|
IA("ia", "Perguntas para a IA");
|
||||||
|
|
||||||
private final String key;
|
private final String key;
|
||||||
|
|||||||
@@ -64,4 +64,42 @@ final class Msg {
|
|||||||
private static String plural(long value, String singular, String plural) {
|
private static String plural(long value, String singular, String plural) {
|
||||||
return value + " " + (value == 1 ? singular : 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. */
|
/** 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() {
|
private Map<String, String> names() {
|
||||||
Map<String, String> names = new HashMap<>();
|
Map<String, String> names = new HashMap<>();
|
||||||
File cache = new File(Bukkit.getWorldContainer(), "usercache.json");
|
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. */
|
/** A (subject, value) pair for a statistic that is keyed by material or entity. */
|
||||||
record Entry<T>(T subject, int value) {
|
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;
|
# Anotações no chat: /save e /nota. Privadas (só o autor vê) para todos;
|
||||||
# públicas só para quem tiver canalhandia.nota.publica.
|
# públicas só para quem tiver canalhandia.nota.publica.
|
||||||
notas: true
|
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
|
ia: true # /ia <pergunta> — só para quem tem canalhandia.ia
|
||||||
|
|
||||||
# --- Curiosidades ------------------------------------------------------------
|
# --- Curiosidades ------------------------------------------------------------
|
||||||
|
|||||||
@@ -75,6 +75,21 @@ commands:
|
|||||||
description: Atalho para anotar rapidamente onde você está.
|
description: Atalho para anotar rapidamente onde você está.
|
||||||
usage: /save [coords|<texto>]
|
usage: /save [coords|<texto>]
|
||||||
aliases: [anotar]
|
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:
|
permissions:
|
||||||
# Declared explicitly: an undeclared Bukkit permission falls back to op-only,
|
# Declared explicitly: an undeclared Bukkit permission falls back to op-only,
|
||||||
@@ -112,6 +127,9 @@ permissions:
|
|||||||
canalhandia.nota.publica:
|
canalhandia.nota.publica:
|
||||||
description: Permite criar anotações públicas, que todos veem. Padrão op; o LuckPerms pode conceder a outros.
|
description: Permite criar anotações públicas, que todos veem. Padrão op; o LuckPerms pode conceder a outros.
|
||||||
default: op
|
default: op
|
||||||
|
canalhandia.recado:
|
||||||
|
description: Permite deixar recados para outros jogadores.
|
||||||
|
default: true
|
||||||
canalhandia.isento:
|
canalhandia.isento:
|
||||||
description: Quem tem isto nunca é sorteado como assunto.
|
description: Quem tem isto nunca é sorteado como assunto.
|
||||||
default: false
|
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