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:
+ *
+ * - a minimum gap between any two spontaneous lines,
+ * - a daily cap of its own, separate from the {@code /ia} cap,
+ * - a per-subject cooldown, so one unlucky player is not narrated all
+ * evening while everyone else is ignored.
+ *
+ *
+ * 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 "<".
+ assertEquals("<", 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());
+ }
+}