diff --git a/pom.xml b/pom.xml index 97bf42c..51c0b74 100644 --- a/pom.xml +++ b/pom.xml @@ -20,6 +20,10 @@ papermc https://repo.papermc.io/repository/maven-public/ + + bluecolored + https://repo.bluecolored.de/releases + @@ -44,6 +48,17 @@ 26.2.build.92-stable provided + + + de.bluecolored + bluemap-api + 2.7.4 + provided + org.junit.jupiter junit-jupiter diff --git a/preflight.sh b/preflight.sh index 0d673f7..6a0ba4b 100755 --- a/preflight.sh +++ b/preflight.sh @@ -47,13 +47,12 @@ else else bad "plugin.yml MISSING from jar — the plugin would not load at all" fi - # Every command the code registers must exist in plugin.yml, or register() - # logs a warning and the command silently does nothing in game. # Kept in step with Canalhandia.onEnable's register() list. A command that is # registered in code but absent here logs a warning at boot and then silently # does nothing in game, which is a hard failure to diagnose from inside. CMDS="canalhandia curiosidade adivinha enquete ranking reagir reacoes \ - palpite votar legal wow top f ia iap errado nota save" + palpite votar legal wow top f ia iap errado nota save \ + recado recados mortes conquistas" missing="" for cmd in $CMDS; do unzip -p "$JAR" plugin.yml 2>/dev/null | grep -qE "^ ${cmd}:" || missing="$missing $cmd" @@ -65,13 +64,21 @@ else fi # Permissions the new features gate on. An undeclared Bukkit permission falls # back to op-only, which would silently stop normal players writing notes. - for perm in canalhandia.nota canalhandia.nota.publica; do + for perm in canalhandia.nota canalhandia.nota.publica canalhandia.recado; do if unzip -p "$JAR" plugin.yml 2>/dev/null | grep -q " ${perm}:"; then pass "permission ${perm} declared" else bad "permission ${perm} MISSING — would default to op-only" fi done + # BlueMap's API is compile-only (provided scope): BlueMap ships those classes + # itself, and a second copy inside this jar would shadow them and break the + # real plugin. This check is the guard against someone dropping the scope. + if unzip -l "$JAR" 2>/dev/null | grep -q "bluecolored"; then + bad "BlueMap classes are BUNDLED in the jar — the dependency must stay 'provided'" + else + pass "BlueMap API not bundled (provided scope intact)" + fi # config.yml ships defaults; a jar without it means saveDefaultConfig() writes # nothing and every setting silently falls back to the hardcoded default. if unzip -p "$JAR" config.yml >/dev/null 2>&1; then diff --git a/src/main/java/dev/marcospaulo/canalhandia/Ai.java b/src/main/java/dev/marcospaulo/canalhandia/Ai.java index 65b6ecd..8e30d5f 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Ai.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Ai.java @@ -452,6 +452,76 @@ final class Ai { return Msg.tag("IA", NamedTextColor.LIGHT_PURPLE).append(body); } + // --- spontaneous lines -------------------------------------------------- + + /** + * Says something unprompted, in the active persona — a jab at a death + * streak, a greeting for someone who just joined. + * + *

Everything about this is deliberately more restricted than {@code /ia}: + * it is gated by {@link Budget} (see the reasons there), it never consults + * the wiki, it asks for a much smaller answer, and it is silent on failure. + * A spontaneous line that errors should leave no trace — nobody asked for + * it, so nobody should see it fail. + * + * @param subject the player it is about, for the per-subject cooldown; may + * be null + * @param prompt what to comment on, already phrased as an instruction + */ + void saySomething(String subject, String prompt, Budget budget) { + Settings settings = plugin.settings(); + if (!settings.moduleEnabled(Module.IA)) { + return; + } + String key = apiKey(); + if (key == null) { + return; + } + long now = System.currentTimeMillis(); + if (!budget.allows(subject, now)) { + return; + } + // Spent up front, not on success: two events landing in the same tick + // would otherwise both pass allows() and fire together, which is the + // exact double-message the gap exists to prevent. + budget.spend(subject, now); + + java.util.List messages = new java.util.ArrayList<>(); + messages.add(new MiniMax.Turn("system", settings.aiInstructions())); + messages.add(new MiniMax.Turn("system", settings.aiPersona().systemText())); + String serverContext = settings.aiServerContext(); + if (!serverContext.isBlank()) { + messages.add(new MiniMax.Turn("system", "Sobre este servidor: " + serverContext)); + } + messages.add(new MiniMax.Turn("system", + "Escreva UMA frase curta de no máximo 20 palavras para o chat do servidor, " + + "no seu tom de sempre. Não faça perguntas, não cumprimente o chat, " + + "não explique o que você está fazendo: só a frase.")); + messages.add(new MiniMax.Turn("user", prompt)); + + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { + String answer; + try { + answer = api.answer(key, settings.aiModel(), messages, + settings.aiSpontaneousTokens(), settings.aiTemperature()); + } catch (Exception e) { + warnWithout(key, "Falha na fala espontânea da IA: " + e); + return; + } + if (answer == null || answer.isBlank() || AiText.hasForeignScript(answer)) { + return; + } + String clean = AiText.sanitise(answer, settings.aiSpontaneousChars()); + if (clean.isBlank()) { + return; + } + Bukkit.getScheduler().runTask(plugin, () -> Bukkit.broadcast( + Msg.tag("IA", NamedTextColor.LIGHT_PURPLE) + .append(Component.text(clean, NamedTextColor.WHITE) + .decoration(TextDecoration.BOLD, false)))); + }); + } + // --- limits and cleanup ------------------------------------------------- private boolean withinDailyLimit(Settings settings) { diff --git a/src/main/java/dev/marcospaulo/canalhandia/BlueMapBridge.java b/src/main/java/dev/marcospaulo/canalhandia/BlueMapBridge.java new file mode 100644 index 0000000..f8ca733 --- /dev/null +++ b/src/main/java/dev/marcospaulo/canalhandia/BlueMapBridge.java @@ -0,0 +1,144 @@ +package dev.marcospaulo.canalhandia; + +import java.util.List; +import java.util.logging.Logger; + +/** + * Puts public notes on the BlueMap web map as markers. + * + *

Notes already record a world and coordinates, and the server already runs + * BlueMap — this joins the two, so "onde fica a base?" is answerable by looking + * at the map instead of by reading chat. + * + *

BlueMap is optional. This is the only class that references its + * classes, and every entry point is wrapped so that a server without BlueMap + * installed — or with an incompatible version — logs one line and carries on. + * A {@link NoClassDefFoundError} is caught rather than only {@link Exception} + * precisely because the failure mode of a missing optional dependency is a + * linkage error, not an exception. + * + *

Markers are not persistent: BlueMap drops everything when it + * unloads, so an addon is expected to re-create its markers each time the API + * fires its enable callback. That is why {@link #hook} registers a consumer + * that rebuilds the whole set rather than adding markers once at startup. + */ +final class BlueMapBridge { + + /** Id and label of the marker set this plugin owns on the map. */ + private static final String SET_ID = "canalhandia-notas"; + private static final String SET_LABEL = "Anotações"; + + private final Notes notes; + private final Logger logger; + private final java.util.function.BooleanSupplier enabled; + + /** False once we know BlueMap is not usable, so we stop retrying. */ + private boolean available = true; + + BlueMapBridge(Notes notes, Logger logger, java.util.function.BooleanSupplier enabled) { + this.notes = notes; + this.logger = logger; + this.enabled = enabled; + } + + /** + * Registers the BlueMap enable callback. Safe to call on a server with no + * BlueMap: it logs at fine level and disables itself. + */ + void hook() { + try { + de.bluecolored.bluemap.api.BlueMapAPI.onEnable(api -> sync()); + logger.info("BlueMap encontrado — anotações públicas vão para o mapa."); + } catch (NoClassDefFoundError | Exception e) { + available = false; + logger.fine("BlueMap não está instalado; anotações ficam só no chat."); + } + } + + /** + * Rebuilds the marker set from the current public notes. + * + *

Rebuild rather than incremental add/remove: the note list is tiny, and + * a full rebuild cannot drift out of sync with the notes file the way a + * missed delete would. + */ + void sync() { + if (!available || !enabled.getAsBoolean()) { + return; + } + try { + var maybeApi = de.bluecolored.bluemap.api.BlueMapAPI.getInstance(); + if (maybeApi.isEmpty()) { + return; + } + var api = maybeApi.get(); + List publicNotes = notes.visibleTo(null, Note.Scope.PUBLICA, null); + + for (var map : api.getMaps()) { + var set = de.bluecolored.bluemap.api.markers.MarkerSet.builder() + .label(SET_LABEL) + .build(); + for (Note note : publicNotes) { + // Only notes from the world this map renders. Without the + // check, a Nether note would be drawn at the same numeric + // coordinates in the overworld map, pointing at nothing. + if (!sameWorld(map, note.world())) { + continue; + } + var marker = de.bluecolored.bluemap.api.markers.POIMarker.builder() + .label(note.text()) + .detail(escape(note.text()) + "
por " + + escape(note.author()) + "") + .position(note.x(), note.y(), note.z()) + .build(); + set.getMarkers().put("nota-" + note.id(), marker); + } + map.getMarkerSets().put(SET_ID, set); + } + } catch (NoClassDefFoundError | Exception e) { + // One line, then stop trying: a broken bridge must never turn into + // a log flood on every note edit. + available = false; + logger.warning("Não consegui atualizar os marcadores do BlueMap: " + e); + } + } + + /** + * Whether a map renders the world a note was written in. + * + *

Notes store the pt-BR label ("Mundo normal", "Nether", "End") rather + * than the raw world name, because that label is what players read in chat. + * Matching therefore goes through the same vocabulary rather than comparing + * world names directly. + */ + private boolean sameWorld(de.bluecolored.bluemap.api.BlueMapMap map, String noteWorld) { + if (noteWorld == null || noteWorld.isBlank()) { + return false; + } + String mapId = map.getId().toLowerCase(java.util.Locale.ROOT); + return switch (noteWorld) { + case "Nether" -> mapId.contains("nether"); + case "End" -> mapId.contains("end"); + case "Mundo normal" -> !mapId.contains("nether") && !mapId.contains("end"); + // A custom world: fall back to matching its name against the map id. + default -> mapId.contains(noteWorld.toLowerCase(java.util.Locale.ROOT)); + }; + } + + /** + * Escapes a note for the marker's HTML detail popup. + * + *

Note text is player-written and lands in a web page, so the four + * characters that could open a tag or break out of one are replaced. Kept + * package-private and pure so the escaping is unit-testable. + */ + static String escape(String text) { + if (text == null) { + return ""; + } + return text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """); + } +} diff --git a/src/main/java/dev/marcospaulo/canalhandia/Budget.java b/src/main/java/dev/marcospaulo/canalhandia/Budget.java new file mode 100644 index 0000000..dbb8fc2 --- /dev/null +++ b/src/main/java/dev/marcospaulo/canalhandia/Budget.java @@ -0,0 +1,112 @@ +package dev.marcospaulo.canalhandia; + +/** + * The gate on spontaneous AI lines — the ones nobody asked for. + * + *

A player question is self-limiting: someone chose to spend it. A comment + * the AI decides to make on its own is not, and two failure modes follow from + * that. It can become chat spam, which makes the feature hated within a day. + * And it costs money on every fire, so left alone it would eat the daily budget + * that {@code /ia} needs. + * + *

Three limits, all of which must pass: + *

+ * + *

Pure and clock-injectable, so the whole policy is unit-testable without a + * server or a wall clock. + */ +final class Budget { + + private final int perDay; + private final long gapMillis; + private final long subjectCooldownMillis; + + /** Wall-clock day boundary, so "per day" means a calendar day like /ia's cap. */ + private long dayStart; + private int usedToday; + private long lastFire; + private final java.util.Map lastBySubject = new java.util.HashMap<>(); + + Budget(int perDay, long gapMillis, long subjectCooldownMillis) { + this.perDay = Math.max(0, perDay); + this.gapMillis = Math.max(0, gapMillis); + this.subjectCooldownMillis = Math.max(0, subjectCooldownMillis); + } + + /** + * Whether a spontaneous line about {@code subject} may fire now. Read-only: + * {@link #spend} records it, so a caller that decides not to fire after all + * (no players online, the model returned nothing) has not burned anything. + * + * @param subject who the line is about; null for a line about nobody + */ + boolean allows(String subject, long now) { + if (perDay == 0) { + return false; + } + rollDay(now); + if (usedToday >= perDay) { + return false; + } + if (lastFire != 0 && now - lastFire < gapMillis) { + return false; + } + if (subject != null) { + Long last = lastBySubject.get(subject); + if (last != null && now - last < subjectCooldownMillis) { + return false; + } + } + return true; + } + + /** Records a fire. Call only once the line has actually been sent. */ + void spend(String subject, long now) { + rollDay(now); + usedToday++; + lastFire = now; + if (subject != null) { + lastBySubject.put(subject, now); + // Bound the map: a long-lived server would otherwise accumulate one + // entry per player who ever triggered a comment. Anything older + // than the cooldown can no longer block anything. + lastBySubject.entrySet().removeIf(e -> now - e.getValue() >= subjectCooldownMillis); + } + } + + /** How many spontaneous lines have fired today. Shown in /canalhandia status. */ + int usedToday(long now) { + rollDay(now); + return usedToday; + } + + int perDay() { + return perDay; + } + + /** + * Resets the counter when the calendar day changes. + * + *

Days are measured in whole 24-hour blocks from the first use rather + * than against a local midnight: it needs no time zone, and for a spend cap + * "at most N per 24h" is the property that actually matters. + */ + private void rollDay(long now) { + if (dayStart == 0) { + dayStart = now; + return; + } + long day = 24L * 60L * 60L * 1000L; + if (now - dayStart >= day) { + // Advance by whole days so a long gap does not leave the window + // permanently offset from when use actually resumed. + dayStart += ((now - dayStart) / day) * day; + usedToday = 0; + } + } +} diff --git a/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java b/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java index 1c956d2..79fcfec 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java @@ -63,6 +63,18 @@ public final class Canalhandia extends JavaPlugin implements Listener { private final Map pendingDeathCoords = new ConcurrentHashMap<>(); /** Rolling window of public chat, fed to the AI so it can follow the room. */ private final ChatLog chatLog = new ChatLog(); + /** Consecutive deaths per player, and when the last one happened. */ + private final Map deathStreak = new ConcurrentHashMap<>(); + + /** + * How long a death streak survives without a new death. Dying three times + * across an evening is not a streak; dying three times in ten minutes is. + */ + private static final long STREAK_WINDOW = 15L * 60L * 1000L; + + /** A run of deaths: how many, and when the last one landed. */ + private record Streak(int count, long at) { + } private Settings settings; private Notes notes; @@ -71,6 +83,10 @@ public final class Canalhandia extends JavaPlugin implements Listener { private OfflineStats offlineStats; private Milestones milestones; private Achievements achievements; + private WeeklyStats weeklyStats; + /** Gate for spontaneous AI lines; see Budget for why this is strict. */ + private Budget aiBudget; + private BlueMapBridge blueMap; private Ai ai; private NamespacedKey optOutKey; private BukkitTask timerTask; @@ -92,7 +108,15 @@ public final class Canalhandia extends JavaPlugin implements Listener { offlineStats = new OfflineStats(this); milestones = new Milestones(this); achievements = new Achievements(this); + weeklyStats = new WeeklyStats(new java.io.File(getDataFolder(), "semana.yml")); + aiBudget = new Budget(settings.aiSpontaneousPerDay(), + settings.aiSpontaneousGapMinutes() * 60_000L, + settings.aiSubjectCooldownMinutes() * 60_000L); ai = new Ai(this); + // Optional: does nothing (and logs nothing loud) without BlueMap. + blueMap = new BlueMapBridge(notes, getLogger(), + () -> settings.moduleEnabled(Module.NOTAS) && settings.notesOnMap()); + blueMap.hook(); // Snapshot the server's recipes on the main thread; RecipeBook.describe // reads from the async answer path and Bukkit.recipeIterator() is not // safe off the main thread. Datapack reloads after this are not @@ -178,6 +202,41 @@ public final class Canalhandia extends JavaPlugin implements Listener { return achievements; } + /** The weekly ranking baseline. Never null. */ + WeeklyStats weeklyStats() { + return weeklyStats; + } + + /** The spend gate for spontaneous AI lines. Never null. */ + Budget aiBudget() { + return aiBudget; + } + + /** The BlueMap marker bridge. Never null, but a no-op without BlueMap. */ + BlueMapBridge blueMap() { + return blueMap; + } + + /** + * Rotates the weekly ranking baseline if a week has elapsed. + * + *

Reads every stats JSON on disk, so it runs on the milestone timer + * rather than on join: once every five minutes is far more often than a + * weekly rotation needs, and it keeps the file I/O off the join path. + */ + private void rotateWeeklyIfDue() { + if (!settings.moduleEnabled(Module.RANKING)) { + return; + } + Map> current = new HashMap<>(); + for (RankingMetric metric : RankingMetric.values()) { + current.put(metric, offlineStats().allValues(metric)); + } + if (weeklyStats.rotateIfDue(current, System.currentTimeMillis())) { + getLogger().info("[ranking] nova semana começou — placar semanal zerado"); + } + } + // --- scheduling --------------------------------------------------------- /** Starts, stops or restarts the repeating curiosity task to match the mode. */ @@ -194,20 +253,28 @@ public final class Canalhandia extends JavaPlugin implements Listener { .runTaskTimer(this, () -> announceCuriosity(null), ticks, ticks); } + /** + * The shared five-minute sweep: milestones, achievements and the weekly + * ranking rotation. + * + *

All three read statistics for every online player, so one task does + * the work of three. Each checks its own module toggle inside the + * body rather than gating the task itself — turning off {@code marcos} must + * not also silence achievements and freeze the weekly board, which is what + * happened when this was a milestones-only task. + */ void rescheduleMilestones() { if (milestoneTask != null) { milestoneTask.cancel(); milestoneTask = null; } - if (!settings.moduleEnabled(Module.MARCOS)) { - return; - } long ticks = 5L * 60L * 20L; 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. + if (settings.moduleEnabled(Module.MARCOS)) { + milestones.check(); + } achievements.check(); + rotateWeeklyIfDue(); }, ticks, ticks); } @@ -520,6 +587,36 @@ public final class Canalhandia extends JavaPlugin implements Listener { }, settings.joinDelaySeconds() * 20L); } + /** + * Greets a joining player in the active persona, using their own numbers + * ("olha quem voltou, o das 47 mortes"). + * + *

Rate limiting is what makes this tolerable rather than obnoxious: the + * shared {@link Budget} enforces a per-player cooldown, so someone whose + * connection keeps dropping is greeted once, not on every reconnect. + * + *

Delayed like the curiosity so it lands after the join message rather + * than racing it. + */ + @EventHandler + public void onJoinWelcome(PlayerJoinEvent event) { + if (!settings.aiWelcome() || !settings.moduleEnabled(Module.IA)) { + return; + } + Player player = event.getPlayer(); + getServer().getScheduler().runTaskLater(this, () -> { + if (!player.isOnline()) { + return; + } + String stats = offlineStats.summary(player.getUniqueId()); + ai.saySomething(player.getName(), + "O jogador " + player.getName() + " acabou de entrar no servidor." + + (stats == null ? "" : " Estatísticas dele: " + stats) + + " Dê as boas-vindas do seu jeito, em uma frase.", + aiBudget); + }, Math.max(1, settings.joinDelaySeconds()) * 20L); + } + /** * Delivers any messages waiting for a joining player. * @@ -705,6 +802,26 @@ public final class Canalhandia extends JavaPlugin implements Listener { deathLog.record(player.getUniqueId().toString(), flavor, ServerState.worldLabel(loc.getWorld()), loc.getBlockX(), loc.getBlockY(), loc.getBlockZ()); + + // A run of deaths is worth a comment; a single one is just Tuesday. + // The run has to be recent, or three deaths spread across an evening + // would read as a streak. + long now = System.currentTimeMillis(); + Streak previous = deathStreak.get(player.getUniqueId()); + int count = (previous != null && now - previous.at() < STREAK_WINDOW) + ? previous.count() + 1 : 1; + deathStreak.put(player.getUniqueId(), new Streak(count, now)); + + if (settings.aiEvents() && count >= settings.aiDeathStreak()) { + ai.saySomething(player.getName(), + "O jogador " + player.getName() + " morreu " + count + + " vezes seguidas em poucos minutos. A última foi assim: " + flavor + + ". Comente com deboche, sem ofender.", + aiBudget); + // Reset so the next comment needs a fresh run rather than firing on + // every death from here on. + deathStreak.remove(player.getUniqueId()); + } } /** diff --git a/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java index 048f1d0..a4405e4 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java +++ b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java @@ -359,16 +359,40 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { .clickEvent(ClickEvent.runCommand("/ranking " + metric.commandKey())) .append(Component.text(" — " + metric.label(), NamedTextColor.GRAY))); } + sender.sendMessage(Component.text(" semanal [métrica]", NamedTextColor.GOLD) + .clickEvent(ClickEvent.runCommand("/ranking semanal")) + .append(Component.text(" — só o que foi ganho nesta semana", + NamedTextColor.GRAY))); return true; } - RankingMetric metric = RankingMetric.byKey(args[0]); + // "/ranking semanal [metrica]" — the same boards, but showing only what + // was gained since the weekly baseline. An all-time board on a small + // server is decided by who started first; this makes it a contest again. + boolean weekly = args[0].equalsIgnoreCase("semanal") || args[0].equalsIgnoreCase("semana"); + String[] rest = weekly ? Arrays.copyOfRange(args, 1, args.length) : args; + if (weekly && rest.length == 0) { + // Default to the metric people actually race on. + rest = new String[]{RankingMetric.MINERACAO.commandKey()}; + } + + RankingMetric metric = RankingMetric.byKey(rest[0]); if (metric == null) { Msg.error(sender, "Ranking desconhecido. Use /ranking para ver a lista."); return true; } - List rows = - plugin.offlineStats().ranking(metric, plugin.settings().rankingSize()); - Msg.header(sender, "Ranking: " + metric.label()); + int size = plugin.settings().rankingSize(); + List rows; + if (weekly) { + rows = WeeklyStats.delta(plugin.offlineStats().ranking(metric, Integer.MAX_VALUE), + plugin.weeklyStats().baseline(metric), size); + } else { + rows = plugin.offlineStats().ranking(metric, size); + } + Msg.header(sender, (weekly ? "Ranking da semana: " : "Ranking: ") + metric.label()); + if (weekly) { + long taken = plugin.weeklyStats().takenAt(); + Msg.line(sender, "desde", taken == 0 ? "o começo" : Msg.ago(taken)); + } if (rows.isEmpty()) { sender.sendMessage(Component.text(" (sem dados ainda)", NamedTextColor.GRAY)); return true; @@ -784,6 +808,12 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { + " · estado do servidor " + (settings.aiServerState() ? "on" : "off") + " · estatísticas " + (settings.aiPlayerStats() ? "on" : "off") + " · estilo " + (settings.aiFancy() ? "rico" : "simples")); + Msg.line(sender, "ia espontânea", + (settings.aiEvents() ? "eventos on" : "eventos off") + + " · " + (settings.aiWelcome() ? "saudação on" : "saudação off") + + " · " + plugin.aiBudget().usedToday(System.currentTimeMillis()) + + "/" + settings.aiSpontaneousPerDay() + " hoje" + + " · intervalo " + settings.aiSpontaneousGapMinutes() + "min"); Msg.line(sender, "notas", plugin.notes().size() + " no total" + (sender instanceof Player player ? " · " + plugin.notes().countBy(player.getUniqueId().toString()) @@ -848,6 +878,8 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { commands.put("/canalhandia zoacao limpar", "volta para as frases padrão"); commands.put("/ia personalidade", "lista as personalidades da IA"); commands.put("/ia personalidade ", "muda o tom da IA (zoeiro, amigao, seco…)"); + commands.put("/ia eventos ", "IA comenta mortes seguidas sozinha"); + commands.put("/ia saudacao ", "IA dá as boas-vindas de quem entra"); } commands.forEach((cmd, description) -> sender.sendMessage( Component.text(" " + cmd, NamedTextColor.AQUA) @@ -931,6 +963,25 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { iaPersona(sender, args); return true; } + // Toggles for the spontaneous lines. Same guard as above: only + // hijack on an explicit on/off, so "/ia eventos do servidor?" + // stays a question. + if ((sub.equals("eventos") || sub.equals("saudacao")) && args.length == 2 + && (args[1].equalsIgnoreCase("on") || args[1].equalsIgnoreCase("off"))) { + if (!sender.hasPermission("canalhandia.ia.perfil")) { + denied(sender); + return true; + } + boolean on = args[1].equalsIgnoreCase("on"); + if (sub.equals("eventos")) { + plugin.settings().aiEvents(on); + Msg.ok(sender, "IA comentando eventos: " + (on ? "ligada" : "desligada") + "."); + } else { + plugin.settings().aiWelcome(on); + Msg.ok(sender, "Saudação da IA: " + (on ? "ligada" : "desligada") + "."); + } + return true; + } if (sub.equals("feedback") && args.length >= 2 && args[1].equalsIgnoreCase("ruim")) { iaFeedback(sender, args); return true; @@ -1256,6 +1307,8 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { Msg.ok(player, "Anotação #" + note.id() + " salva (" + scope.label() + ") em " + note.place() + "."); if (scope == Note.Scope.PUBLICA) { + // A new public note changes what the web map should show. + plugin.blueMap().sync(); // Public notes are announced, because a board nobody is told about // is a board nobody reads. plugin.broadcastPerPlatform(bedrock -> Msg.tag("Nota", NamedTextColor.GREEN) @@ -1317,6 +1370,9 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { return; } plugin.notes().remove(note.id()); + if (note.scope() == Note.Scope.PUBLICA) { + plugin.blueMap().sync(); + } Msg.ok(sender, "Anotação #" + note.id() + " apagada."); } @@ -1495,7 +1551,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { // Only the tuning subcommands are suggested — the rest of /ia is // free text, and completing a question would be noise. if (args.length == 1 && sender.hasPermission("canalhandia.ia.perfil")) { - return filter(List.of("personalidade", "perfil"), args[0]); + return filter(List.of("personalidade", "perfil", "eventos", "saudacao"), args[0]); } if (args.length == 2 && args[0].equalsIgnoreCase("personalidade")) { List keys = new ArrayList<>(); @@ -1507,6 +1563,10 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { if (args.length == 2 && args[0].equalsIgnoreCase("perfil")) { return filter(List.of("economico", "preciso"), args[1]); } + if (args.length == 2 && (args[0].equalsIgnoreCase("eventos") + || args[0].equalsIgnoreCase("saudacao"))) { + return filter(List.of("on", "off"), args[1]); + } return List.of(); } if (name.equals("canalhandia")) { diff --git a/src/main/java/dev/marcospaulo/canalhandia/OfflineStats.java b/src/main/java/dev/marcospaulo/canalhandia/OfflineStats.java index be8f5e8..87830d9 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/OfflineStats.java +++ b/src/main/java/dev/marcospaulo/canalhandia/OfflineStats.java @@ -65,6 +65,19 @@ final class OfflineStats { return rows.size() > limit ? rows.subList(0, limit) : rows; } + /** + * Every player's value for a metric, keyed by name — the whole board, not + * the top slice, because the weekly baseline has to remember someone who + * was not in the top five last week but is now. + */ + Map allValues(RankingMetric metric) { + Map out = new HashMap<>(); + for (Row row : ranking(metric, Integer.MAX_VALUE)) { + out.put(row.name(), row.value()); + } + return out; + } + /** * One player's headline stats as a compact pt-BR line, for the IA module to * answer "quantos blocos eu minerei?" with the asker's own numbers. diff --git a/src/main/java/dev/marcospaulo/canalhandia/Settings.java b/src/main/java/dev/marcospaulo/canalhandia/Settings.java index 7367c00..7d2e217 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Settings.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Settings.java @@ -413,6 +413,79 @@ final class Settings { set("ia.contexto-notas", Math.max(0, Math.min(50, max))); } + /** + * Whether public notes are drawn on the BlueMap web map. No effect on a + * server without BlueMap; private notes are never drawn, at any setting. + */ + boolean notesOnMap() { + return plugin.getConfig().getBoolean("notas.no-mapa", true); + } + + void notesOnMap(boolean value) { + set("notas.no-mapa", value); + } + + // --- spontaneous AI lines ----------------------------------------------- + + /** + * Whether the AI comments on its own when something happens (a death + * streak, a milestone). Off by default: a chatty AI nobody asked for is the + * fastest way to make players hate the feature, so an operator opts in. + */ + boolean aiEvents() { + return plugin.getConfig().getBoolean("ia.comentar-eventos", false); + } + + void aiEvents(boolean value) { + set("ia.comentar-eventos", value); + } + + /** Whether the AI greets players as they join, in the active persona. */ + boolean aiWelcome() { + return plugin.getConfig().getBoolean("ia.saudacao", false); + } + + void aiWelcome(boolean value) { + set("ia.saudacao", value); + } + + /** Daily cap for spontaneous lines, separate from the {@code /ia} cap. */ + int aiSpontaneousPerDay() { + return Math.max(0, plugin.getConfig().getInt("ia.espontaneas-por-dia", 20)); + } + + void aiSpontaneousPerDay(int value) { + set("ia.espontaneas-por-dia", Math.max(0, value)); + } + + /** Minimum minutes between any two spontaneous lines. */ + int aiSpontaneousGapMinutes() { + return Math.max(0, plugin.getConfig().getInt("ia.espontaneas-intervalo-minutos", 10)); + } + + /** Minutes before the same player can be the subject again. */ + int aiSubjectCooldownMinutes() { + return Math.max(0, plugin.getConfig().getInt("ia.espontaneas-cooldown-jogador", 30)); + } + + /** + * How many consecutive deaths in a row earn a comment. Below three it fires + * on ordinary bad luck and stops being funny. + */ + int aiDeathStreak() { + return Math.max(2, plugin.getConfig().getInt("ia.mortes-seguidas", 3)); + } + + /** Token budget for one spontaneous line. Much smaller than a question. */ + int aiSpontaneousTokens() { + return Math.max(32, plugin.getConfig().getInt("ia.espontaneas-max-tokens", 400)); + } + + /** Character cut for a spontaneous line — one chat line, not a paragraph. */ + int aiSpontaneousChars() { + return Math.max(32, plugin.getConfig().getInt("ia.espontaneas-max-caracteres", 180)); + } + // --- zoacao (f-gag) ----------------------------------------------------- /** diff --git a/src/main/java/dev/marcospaulo/canalhandia/WeeklyStats.java b/src/main/java/dev/marcospaulo/canalhandia/WeeklyStats.java new file mode 100644 index 0000000..976418d --- /dev/null +++ b/src/main/java/dev/marcospaulo/canalhandia/WeeklyStats.java @@ -0,0 +1,122 @@ +package dev.marcospaulo.canalhandia; + +import org.bukkit.configuration.file.YamlConfiguration; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * A weekly baseline of every ranking metric, so {@code /ranking semanal} can + * show what changed instead of an all-time board that never moves. + * + *

On a server with three regulars, an all-time leaderboard is decided by who + * started first and then stops being a contest. Subtracting a snapshot taken at + * the start of the week makes it one again. + * + *

The rotation is time-based and idempotent: the snapshot carries the + * timestamp it was taken at, and it is replaced only once a week has actually + * elapsed. A restart therefore never rotates it, which matters because a server + * that restarts nightly would otherwise reset the week every day. + */ +final class WeeklyStats { + + private static final long WEEK_MILLIS = 7L * 24L * 60L * 60L * 1000L; + + private final File file; + private final YamlConfiguration data; + + WeeklyStats(File file) { + this.file = file; + this.data = YamlConfiguration.loadConfiguration(file); + } + + /** When the current baseline was taken, or 0 if there is none. */ + long takenAt() { + return data.getLong("em", 0L); + } + + /** + * Replaces the baseline if a week has passed (or there is none yet). + * + * @param current per-metric, per-player values as they are right now + * @param now wall-clock millis, injectable so the rotation is testable + * @return true if a new baseline was written + */ + boolean rotateIfDue(Map> current, long now) { + long taken = takenAt(); + if (taken != 0 && now - taken < WEEK_MILLIS) { + return false; + } + write(current, now); + return true; + } + + /** Unconditionally replaces the baseline. Used by rotation and by an operator reset. */ + void write(Map> current, long now) { + for (String key : new ArrayList<>(data.getKeys(false))) { + data.set(key, null); + } + data.set("em", now); + for (Map.Entry> metric : current.entrySet()) { + for (Map.Entry row : metric.getValue().entrySet()) { + // Player names can contain no dots, but a YAML path splits on + // them, so the name is stored as a child of a fixed key rather + // than interpolated into the path. + data.set("dados." + metric.getKey().commandKey() + "." + row.getKey(), + row.getValue()); + } + } + save(); + } + + /** The stored baseline for one metric: player name to value. */ + Map baseline(RankingMetric metric) { + Map out = new HashMap<>(); + var section = data.getConfigurationSection("dados." + metric.commandKey()); + if (section == null) { + return out; + } + for (String name : section.getKeys(false)) { + out.put(name, section.getLong(name)); + } + return out; + } + + /** + * Current values minus the baseline, highest first, dropping anything that + * did not move. + * + *

Pure, so the arithmetic is testable without a server or a file. + * + *

A player missing from the baseline counts their whole current value: + * they joined during the week, so all of it was earned in it. A negative + * difference is clamped to zero rather than shown — statistics only go up, + * so a negative means the baseline is stale or the stats file was reset, + * and a leaderboard of negative numbers helps nobody. + */ + static List delta(List current, + Map baseline, int limit) { + List out = new ArrayList<>(); + for (OfflineStats.Row row : current) { + long before = baseline.getOrDefault(row.name(), 0L); + long gained = row.value() - before; + if (gained > 0) { + out.add(new OfflineStats.Row(row.name(), gained)); + } + } + out.sort((a, b) -> Long.compare(b.value(), a.value())); + return out.size() > limit ? new ArrayList<>(out.subList(0, limit)) : out; + } + + private void save() { + try { + data.save(file); + } catch (IOException e) { + throw new IllegalStateException("não consegui gravar " + file, e); + } + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index d80824c..562dd88 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -81,6 +81,12 @@ janela-reacao-segundos: 90 luto: cabeca: true +# Anotações (/save e /nota). +notas: + # true: as anotações PÚBLICAS viram marcadores no mapa do BlueMap. Sem + # BlueMap instalado não faz nada. Anotação privada nunca vai para o mapa. + no-mapa: true + # Zoa de quem manda uma mensagem que bate com o padrão, trocando por uma frase # engraçada. Tudo editável em jogo com /canalhandia zoacao ... zoacao: @@ -249,6 +255,37 @@ ia: # a chamada da IA sai deste servidor para uma API de terceiros. contexto-notas: 10 + # --- Falas espontâneas (a IA falando sem ninguém perguntar) --- + # + # DESLIGADAS por padrão. Uma IA tagarela que ninguém pediu é o jeito mais + # rápido de fazer todo mundo odiar o recurso, então é o operador que liga. + # Cada fala custa dinheiro, e os limites abaixo são o que impede virar spam. + + # true: a IA comenta quando alguém morre várias vezes seguidas. + comentar-eventos: false + + # true: a IA dá as boas-vindas de quem entra, usando as estatísticas da pessoa. + saudacao: false + + # Quantas mortes seguidas (em poucos minutos) merecem comentário. Abaixo de 3 + # dispara em azar comum e deixa de ter graça. + mortes-seguidas: 3 + + # Teto diário SÓ para falas espontâneas, separado do limite do /ia. + espontaneas-por-dia: 20 + + # Minutos mínimos entre duas falas espontâneas quaisquer. + espontaneas-intervalo-minutos: 10 + + # Minutos até o MESMO jogador poder ser assunto de novo. É o que impede + # narrar a noite inteira de uma pessoa só, e o que evita saudação repetida + # para quem cai da conexão toda hora. + espontaneas-cooldown-jogador: 30 + + # Uma fala espontânea é uma linha de chat, não um parágrafo. + espontaneas-max-tokens: 400 + espontaneas-max-caracteres: 180 + # ECONOMICO pula a consulta à wiki (resposta rápida, sem fonte). # PRECISO consulta a wiki (mais lento, mais correto). Troque em jogo com # /ia perfil . diff --git a/src/test/java/dev/marcospaulo/canalhandia/BlueMapBridgeTest.java b/src/test/java/dev/marcospaulo/canalhandia/BlueMapBridgeTest.java new file mode 100644 index 0000000..712b7c2 --- /dev/null +++ b/src/test/java/dev/marcospaulo/canalhandia/BlueMapBridgeTest.java @@ -0,0 +1,50 @@ +package dev.marcospaulo.canalhandia; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +import org.junit.jupiter.api.Test; + +class BlueMapBridgeTest { + + @Test + void escapeNeutralisesTags() { + // Note text is player-written and lands in a web page. + assertEquals("<script>alert(1)</script>", + BlueMapBridge.escape("")); + } + + @Test + void escapeHandlesQuotesAndAmpersands() { + assertEquals("a & b", BlueMapBridge.escape("a & b")); + assertEquals(""base"", BlueMapBridge.escape("\"base\"")); + } + + @Test + void ampersandIsEscapedFirst() { + // If & were escaped last it would double-escape the entities produced + // by the other replacements: "<" would become "&lt;". + assertEquals("&lt;", BlueMapBridge.escape("<")); + } + + @Test + void escapeLeavesOrdinaryTextAlone() { + assertEquals("base do caio, -400 70 200", + BlueMapBridge.escape("base do caio, -400 70 200")); + assertEquals("caverna após o rio", BlueMapBridge.escape("caverna após o rio")); + } + + @Test + void escapeHandlesNull() { + assertEquals("", BlueMapBridge.escape(null)); + } + + @Test + void escapedTextCarriesNoRawAngleBrackets() { + String nasty = ""; + String escaped = BlueMapBridge.escape(nasty); + assertFalse(escaped.contains("<")); + assertFalse(escaped.contains(">")); + assertFalse(escaped.contains("\"")); + } +} diff --git a/src/test/java/dev/marcospaulo/canalhandia/BudgetTest.java b/src/test/java/dev/marcospaulo/canalhandia/BudgetTest.java new file mode 100644 index 0000000..82136d5 --- /dev/null +++ b/src/test/java/dev/marcospaulo/canalhandia/BudgetTest.java @@ -0,0 +1,156 @@ +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.assertTrue; + +import org.junit.jupiter.api.Test; + +class BudgetTest { + + private static final long MINUTE = 60_000L; + private static final long HOUR = 60 * MINUTE; + private static final long DAY = 24 * HOUR; + private static final long T0 = 1_000_000_000_000L; + + /** 5 per day, 10 minutes apart, 30 minutes per subject. */ + private static Budget budget() { + return new Budget(5, 10 * MINUTE, 30 * MINUTE); + } + + // --- the gap ------------------------------------------------------------ + + @Test + void theFirstLineIsAllowed() { + assertTrue(budget().allows("ana", T0)); + } + + @Test + void aSecondLineIsBlockedInsideTheGap() { + Budget budget = budget(); + budget.spend("ana", T0); + // Different subject, so only the global gap can block it. + assertFalse(budget.allows("bia", T0 + 9 * MINUTE)); + assertTrue(budget.allows("bia", T0 + 10 * MINUTE)); + } + + @Test + void allowsDoesNotSpend() { + // A caller that decides not to fire after all (nobody online, the model + // returned nothing) must not have burned anything. + Budget budget = budget(); + assertTrue(budget.allows("ana", T0)); + assertTrue(budget.allows("ana", T0)); + assertEquals(0, budget.usedToday(T0)); + } + + // --- the per-subject cooldown ------------------------------------------- + + @Test + void theSameSubjectIsBlockedForLonger() { + Budget budget = budget(); + budget.spend("ana", T0); + // Past the global gap, but still inside ana's own cooldown. + assertFalse(budget.allows("ana", T0 + 20 * MINUTE)); + assertTrue(budget.allows("bia", T0 + 20 * MINUTE), "someone else is fine"); + assertTrue(budget.allows("ana", T0 + 30 * MINUTE)); + } + + @Test + void oneUnluckyPlayerIsNotNarratedAllEvening() { + // The property the per-subject cooldown exists for. + Budget budget = budget(); + budget.spend("ana", T0); + int fired = 1; + for (long t = T0 + 10 * MINUTE; t < T0 + 30 * MINUTE; t += 10 * MINUTE) { + if (budget.allows("ana", t)) { + budget.spend("ana", t); + fired++; + } + } + assertEquals(1, fired, "ana should be the subject only once in 30 minutes"); + } + + @Test + void aNullSubjectSkipsTheSubjectCooldown() { + Budget budget = budget(); + budget.spend(null, T0); + assertTrue(budget.allows(null, T0 + 10 * MINUTE), "only the global gap applies"); + } + + // --- the daily cap ------------------------------------------------------ + + @Test + void theDailyCapStopsFurtherLines() { + Budget budget = new Budget(3, 0, 0); + for (int i = 0; i < 3; i++) { + assertTrue(budget.allows(null, T0 + i)); + budget.spend(null, T0 + i); + } + assertFalse(budget.allows(null, T0 + 10), "cap reached"); + assertEquals(3, budget.usedToday(T0)); + } + + @Test + void theCapResetsAfterADay() { + Budget budget = new Budget(2, 0, 0); + budget.spend(null, T0); + budget.spend(null, T0 + 1); + assertFalse(budget.allows(null, T0 + 2)); + assertTrue(budget.allows(null, T0 + DAY), "a new day"); + assertEquals(0, budget.usedToday(T0 + DAY)); + } + + @Test + void aLongGapDoesNotLeaveTheWindowOffset() { + // Advancing by whole days means a week of downtime does not leave the + // reset permanently misaligned with when use actually resumed. + Budget budget = new Budget(1, 0, 0); + budget.spend(null, T0); + assertTrue(budget.allows(null, T0 + 7 * DAY)); + budget.spend(null, T0 + 7 * DAY); + assertFalse(budget.allows(null, T0 + 7 * DAY + HOUR), "still the same day"); + } + + @Test + void zeroPerDayDisablesEverything() { + // The config's off switch: it must block, not divide by zero or fire. + Budget budget = new Budget(0, 0, 0); + assertFalse(budget.allows("ana", T0)); + assertFalse(budget.allows(null, T0 + DAY)); + } + + @Test + void negativeSettingsAreClampedNotHonoured() { + Budget budget = new Budget(-5, -1000, -1000); + assertEquals(0, budget.perDay()); + assertFalse(budget.allows("ana", T0), "a negative cap must not mean unlimited"); + } + + // --- combined ----------------------------------------------------------- + + @Test + void allThreeLimitsMustPass() { + Budget budget = new Budget(2, 10 * MINUTE, 30 * MINUTE); + budget.spend("ana", T0); + assertFalse(budget.allows("ana", T0 + MINUTE), "gap and subject both block"); + assertFalse(budget.allows("bia", T0 + MINUTE), "gap blocks"); + assertTrue(budget.allows("bia", T0 + 10 * MINUTE)); + budget.spend("bia", T0 + 10 * MINUTE); + // Daily cap of 2 is now reached, even though the gap has passed. + assertFalse(budget.allows("caio", T0 + 30 * MINUTE), "daily cap blocks"); + } + + @Test + void theSubjectMapDoesNotGrowForever() { + // One entry per player who ever triggered a line would leak on a + // long-lived server; expired entries are dropped on each spend. + Budget budget = new Budget(100_000, 0, MINUTE); + for (int i = 0; i < 1000; i++) { + budget.spend("player" + i, T0 + i * 2L * MINUTE); + } + // A subject from long ago no longer blocks, proving it was cleaned up + // (and would be allowed again). + assertTrue(budget.allows("player0", T0 + 1000 * 2L * MINUTE)); + } +} diff --git a/src/test/java/dev/marcospaulo/canalhandia/WeeklyStatsTest.java b/src/test/java/dev/marcospaulo/canalhandia/WeeklyStatsTest.java new file mode 100644 index 0000000..326f362 --- /dev/null +++ b/src/test/java/dev/marcospaulo/canalhandia/WeeklyStatsTest.java @@ -0,0 +1,186 @@ +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.assertTrue; + +import java.io.File; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class WeeklyStatsTest { + + @TempDir + Path dir; + + private static final long WEEK = 7L * 24 * 60 * 60 * 1000L; + private static final long T0 = 1_000_000_000_000L; + + private static List rows(Object... pairs) { + List out = new java.util.ArrayList<>(); + for (int i = 0; i < pairs.length; i += 2) { + out.add(new OfflineStats.Row((String) pairs[i], ((Number) pairs[i + 1]).longValue())); + } + return out; + } + + private static Map baseline(Object... pairs) { + Map out = new HashMap<>(); + for (int i = 0; i < pairs.length; i += 2) { + out.put((String) pairs[i], ((Number) pairs[i + 1]).longValue()); + } + return out; + } + + // --- delta (pure) ------------------------------------------------------- + + @Test + void deltaSubtractsTheBaseline() { + List out = WeeklyStats.delta( + rows("ana", 1000, "bia", 500), + baseline("ana", 900, "bia", 100), + 10); + assertEquals(2, out.size()); + assertEquals("bia", out.get(0).name(), "400 gained beats 100"); + assertEquals(400, out.get(0).value()); + assertEquals("ana", out.get(1).name()); + assertEquals(100, out.get(1).value()); + } + + @Test + void aPlayerMissingFromTheBaselineCountsEverything() { + // They joined during the week, so all of it was earned in it. + List out = WeeklyStats.delta( + rows("novato", 250), baseline(), 10); + assertEquals(1, out.size()); + assertEquals(250, out.get(0).value()); + } + + @Test + void playersWhoDidNotMoveAreDropped() { + // The whole point of the weekly board is who is *playing* this week. + List out = WeeklyStats.delta( + rows("ana", 1000, "parado", 500), + baseline("ana", 900, "parado", 500), + 10); + assertEquals(1, out.size()); + assertEquals("ana", out.get(0).name()); + } + + @Test + void negativeDifferencesAreDroppedNotShown() { + // Statistics only go up; a negative means a stale baseline or a reset + // stats file, and a board of negative numbers helps nobody. + List out = WeeklyStats.delta( + rows("ana", 100), baseline("ana", 500), 10); + assertTrue(out.isEmpty()); + } + + @Test + void deltaRespectsTheLimit() { + List out = WeeklyStats.delta( + rows("a", 10, "b", 20, "c", 30, "d", 40), baseline(), 2); + assertEquals(2, out.size()); + assertEquals("d", out.get(0).name()); + assertEquals("c", out.get(1).name()); + } + + @Test + void deltaOfNothingIsEmpty() { + assertTrue(WeeklyStats.delta(List.of(), baseline(), 5).isEmpty()); + } + + // --- rotation ----------------------------------------------------------- + + private Map> snapshot(long mined) { + Map> out = new HashMap<>(); + out.put(RankingMetric.MINERACAO, baseline("ana", mined)); + return out; + } + + @Test + void theFirstRotationAlwaysWrites() { + WeeklyStats weekly = new WeeklyStats(new File(dir.toFile(), "a.yml")); + assertEquals(0, weekly.takenAt()); + assertTrue(weekly.rotateIfDue(snapshot(100), T0)); + assertEquals(T0, weekly.takenAt()); + } + + @Test + void rotationDoesNotHappenBeforeAWeek() { + WeeklyStats weekly = new WeeklyStats(new File(dir.toFile(), "b.yml")); + weekly.rotateIfDue(snapshot(100), T0); + assertFalse(weekly.rotateIfDue(snapshot(999), T0 + WEEK - 1)); + // The baseline is untouched, so the delta still measures from the start. + assertEquals(100, weekly.baseline(RankingMetric.MINERACAO).get("ana")); + } + + @Test + void rotationHappensOnceAWeekHasPassed() { + WeeklyStats weekly = new WeeklyStats(new File(dir.toFile(), "c.yml")); + weekly.rotateIfDue(snapshot(100), T0); + assertTrue(weekly.rotateIfDue(snapshot(900), T0 + WEEK)); + assertEquals(900, weekly.baseline(RankingMetric.MINERACAO).get("ana")); + assertEquals(T0 + WEEK, weekly.takenAt()); + } + + @Test + void restartsDoNotRotate() { + // A server that restarts nightly would otherwise reset the week every + // day, which is the failure this design exists to avoid. + File file = new File(dir.toFile(), "d.yml"); + new WeeklyStats(file).rotateIfDue(snapshot(100), T0); + for (int i = 1; i <= 5; i++) { + WeeklyStats afterRestart = new WeeklyStats(file); + assertFalse(afterRestart.rotateIfDue(snapshot(100 + i), T0 + i * 3600_000L), + "restart " + i + " must not rotate"); + } + assertEquals(100, new WeeklyStats(file).baseline(RankingMetric.MINERACAO).get("ana")); + } + + // --- persistence -------------------------------------------------------- + + @Test + void theBaselineSurvivesARestart() { + File file = new File(dir.toFile(), "e.yml"); + Map> current = new HashMap<>(); + current.put(RankingMetric.MINERACAO, baseline("ana", 500, "bia", 300)); + current.put(RankingMetric.MORTES, baseline("ana", 12)); + new WeeklyStats(file).write(current, T0); + + WeeklyStats reloaded = new WeeklyStats(file); + assertEquals(T0, reloaded.takenAt()); + assertEquals(500, reloaded.baseline(RankingMetric.MINERACAO).get("ana")); + assertEquals(300, reloaded.baseline(RankingMetric.MINERACAO).get("bia")); + assertEquals(12, reloaded.baseline(RankingMetric.MORTES).get("ana")); + } + + @Test + void writeReplacesRatherThanMerges() { + // A player who stopped playing must not linger in the baseline with an + // old value, which would make their delta look negative forever. + File file = new File(dir.toFile(), "f.yml"); + WeeklyStats weekly = new WeeklyStats(file); + Map> first = new HashMap<>(); + first.put(RankingMetric.MINERACAO, baseline("ana", 100, "saiu", 50)); + weekly.write(first, T0); + + Map> second = new HashMap<>(); + second.put(RankingMetric.MINERACAO, baseline("ana", 200)); + weekly.write(second, T0 + WEEK); + + Map stored = weekly.baseline(RankingMetric.MINERACAO); + assertEquals(1, stored.size()); + assertEquals(200, stored.get("ana")); + } + + @Test + void anUnknownMetricHasAnEmptyBaseline() { + WeeklyStats weekly = new WeeklyStats(new File(dir.toFile(), "g.yml")); + assertTrue(weekly.baseline(RankingMetric.PESCA).isEmpty()); + } +}