Add weekly rankings, spontaneous AI lines and BlueMap note markers

Weekly rankings (/ranking semanal [metrica]). On a server with three regulars
an all-time board is decided by whoever started first and then stops being a
contest. Subtracting a baseline taken at the start of the week makes it one
again. Rotation is time-based and idempotent — the snapshot carries the
timestamp it was taken at and is replaced only once a week has actually
elapsed, so a server that restarts nightly does not reset the week every day.
There is a test for exactly that. Players who did not move are dropped, since
the point is who is playing this week; a player missing from the baseline
counts their whole value, having joined during the week; a negative difference
is dropped rather than shown, because statistics only go up and a negative
means a stale baseline, not a result worth printing.

Spontaneous AI lines (ia.comentar-eventos, ia.saudacao — both off by default).
The persona comments on a run of deaths, and greets players as they join using
their own numbers. Both are opt-in: a chatty AI nobody asked for is the fastest
way to make players hate the feature.

Budget is what makes this tolerable rather than obnoxious, and it is the piece
worth reading. A player question is self-limiting — someone chose to spend it.
A line the AI decides to make on its own is not, and it costs money every time,
so three limits all have to pass: a gap between any two lines, a daily cap of
its own separate from the /ia cap, and a per-subject cooldown so one unlucky
player is not narrated all evening. allows() does not spend, so a caller that
decides not to fire (nobody online, the model returned nothing) has burned
nothing; saySomething spends up front rather than on success, because two
events in the same tick would otherwise both pass allows() and fire together —
the exact double-message the gap exists to prevent. The subject map drops
expired entries on each spend, or a long-lived server would leak one entry per
player who ever triggered a line.

A death streak now decays: three deaths across an evening is not a streak,
three in ten minutes is. Spontaneous lines are silent on failure — nobody asked
for it, so nobody should see it fail.

The five-minute sweep no longer hides behind the marcos toggle. It was a
milestones-only task, so turning off marcos also silenced achievements and
froze the weekly board; each of the three now checks its own module inside the
body.

BlueMap markers (notas.no-mapa, default on). Public notes already carry a world
and coordinates and the server already runs BlueMap, so this joins the two.
BlueMapBridge is the only class that touches BlueMap's API and every entry
point catches NoClassDefFoundError as well as Exception — the failure mode of a
missing optional dependency is a linkage error, not an exception — so a server
without BlueMap logs one fine-level line and carries on. The dependency is
'provided' because BlueMap ships those classes itself; a second copy inside our
jar would shadow them and break the real plugin, and preflight now fails if the
scope is ever dropped.

Markers are rebuilt rather than incrementally patched: BlueMap discards
everything when it unloads and expects addons to re-create markers on its
enable callback, and a full rebuild of a tiny list cannot drift out of sync the
way a missed delete would. Notes are matched to the map that renders their
world, or a Nether note would be drawn at the same numeric coordinates in the
overworld, pointing at nothing. Note text is player-written and lands in a web
page, so it is HTML-escaped — ampersand first, or the other replacements would
be double-escaped. Private notes are never drawn, at any setting.

295 tests, up from 263.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016pEyCmrAYHBFgpYjwFxKxh
This commit is contained in:
marcos
2026-08-08 01:03:10 +00:00
parent 26148740ef
commit 4ec9817636
14 changed files with 1177 additions and 15 deletions
+15
View File
@@ -20,6 +20,10 @@
<id>papermc</id> <id>papermc</id>
<url>https://repo.papermc.io/repository/maven-public/</url> <url>https://repo.papermc.io/repository/maven-public/</url>
</repository> </repository>
<repository>
<id>bluecolored</id>
<url>https://repo.bluecolored.de/releases</url>
</repository>
</repositories> </repositories>
<dependencyManagement> <dependencyManagement>
@@ -44,6 +48,17 @@
<version>26.2.build.92-stable</version> <version>26.2.build.92-stable</version>
<scope>provided</scope> <scope>provided</scope>
</dependency> </dependency>
<!-- BlueMap's API, for putting public notes on the web map. "provided"
because BlueMap ships these classes itself: the plugin compiles
against them but never bundles them, and BlueMapBridge is the only
class that touches them, so the plugin still loads on a server that
has no BlueMap at all. -->
<dependency>
<groupId>de.bluecolored</groupId>
<artifactId>bluemap-api</artifactId>
<version>2.7.4</version>
<scope>provided</scope>
</dependency>
<dependency> <dependency>
<groupId>org.junit.jupiter</groupId> <groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId> <artifactId>junit-jupiter</artifactId>
+11 -4
View File
@@ -47,13 +47,12 @@ else
else else
bad "plugin.yml MISSING from jar — the plugin would not load at all" bad "plugin.yml MISSING from jar — the plugin would not load at all"
fi 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 # 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 # 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. # does nothing in game, which is a hard failure to diagnose from inside.
CMDS="canalhandia curiosidade adivinha enquete ranking reagir reacoes \ 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="" missing=""
for cmd in $CMDS; do for cmd in $CMDS; do
unzip -p "$JAR" plugin.yml 2>/dev/null | grep -qE "^ ${cmd}:" || missing="$missing $cmd" unzip -p "$JAR" plugin.yml 2>/dev/null | grep -qE "^ ${cmd}:" || missing="$missing $cmd"
@@ -65,13 +64,21 @@ else
fi fi
# Permissions the new features gate on. An undeclared Bukkit permission falls # Permissions the new features gate on. An undeclared Bukkit permission falls
# back to op-only, which would silently stop normal players writing notes. # 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 if unzip -p "$JAR" plugin.yml 2>/dev/null | grep -q " ${perm}:"; then
pass "permission ${perm} declared" pass "permission ${perm} declared"
else else
bad "permission ${perm} MISSING — would default to op-only" bad "permission ${perm} MISSING — would default to op-only"
fi fi
done 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 # config.yml ships defaults; a jar without it means saveDefaultConfig() writes
# nothing and every setting silently falls back to the hardcoded default. # nothing and every setting silently falls back to the hardcoded default.
if unzip -p "$JAR" config.yml >/dev/null 2>&1; then if unzip -p "$JAR" config.yml >/dev/null 2>&1; then
@@ -452,6 +452,76 @@ final class Ai {
return Msg.tag("IA", NamedTextColor.LIGHT_PURPLE).append(body); 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.
*
* <p>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<MiniMax.Turn> 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 ------------------------------------------------- // --- limits and cleanup -------------------------------------------------
private boolean withinDailyLimit(Settings settings) { private boolean withinDailyLimit(Settings settings) {
@@ -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.
*
* <p>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.
*
* <p><b>BlueMap is optional.</b> 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.
*
* <p>Markers are <b>not persistent</b>: 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.
*
* <p>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<Note> 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()) + "<br><i>por "
+ escape(note.author()) + "</i>")
.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.
*
* <p>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.
*
* <p>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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;");
}
}
@@ -0,0 +1,112 @@
package dev.marcospaulo.canalhandia;
/**
* The gate on <em>spontaneous</em> AI lines — the ones nobody asked for.
*
* <p>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.
*
* <p>Three limits, all of which must pass:
* <ul>
* <li>a minimum gap between any two spontaneous lines,</li>
* <li>a daily cap of its own, separate from the {@code /ia} cap,</li>
* <li>a per-subject cooldown, so one unlucky player is not narrated all
* evening while everyone else is ignored.</li>
* </ul>
*
* <p>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<String, Long> 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.
*
* <p>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;
}
}
}
@@ -63,6 +63,18 @@ public final class Canalhandia extends JavaPlugin implements Listener {
private final Map<UUID, DeathCoords> pendingDeathCoords = new ConcurrentHashMap<>(); private final Map<UUID, DeathCoords> pendingDeathCoords = new ConcurrentHashMap<>();
/** Rolling window of public chat, fed to the AI so it can follow the room. */ /** Rolling window of public chat, fed to the AI so it can follow the room. */
private final ChatLog chatLog = new ChatLog(); private final ChatLog chatLog = new ChatLog();
/** Consecutive deaths per player, and when the last one happened. */
private final Map<UUID, Streak> 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 Settings settings;
private Notes notes; private Notes notes;
@@ -71,6 +83,10 @@ public final class Canalhandia extends JavaPlugin implements Listener {
private OfflineStats offlineStats; private OfflineStats offlineStats;
private Milestones milestones; private Milestones milestones;
private Achievements achievements; 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 Ai ai;
private NamespacedKey optOutKey; private NamespacedKey optOutKey;
private BukkitTask timerTask; private BukkitTask timerTask;
@@ -92,7 +108,15 @@ public final class Canalhandia extends JavaPlugin implements Listener {
offlineStats = new OfflineStats(this); offlineStats = new OfflineStats(this);
milestones = new Milestones(this); milestones = new Milestones(this);
achievements = new Achievements(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); 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 // Snapshot the server's recipes on the main thread; RecipeBook.describe
// reads from the async answer path and Bukkit.recipeIterator() is not // reads from the async answer path and Bukkit.recipeIterator() is not
// safe off the main thread. Datapack reloads after this are 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; 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.
*
* <p>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<RankingMetric, Map<String, Long>> 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 --------------------------------------------------------- // --- scheduling ---------------------------------------------------------
/** Starts, stops or restarts the repeating curiosity task to match the mode. */ /** Starts, stops or restarts the repeating curiosity task to match the mode. */
@@ -194,20 +253,28 @@ public final class Canalhandia extends JavaPlugin implements Listener {
.runTaskTimer(this, () -> announceCuriosity(null), ticks, ticks); .runTaskTimer(this, () -> announceCuriosity(null), ticks, ticks);
} }
/**
* The shared five-minute sweep: milestones, achievements and the weekly
* ranking rotation.
*
* <p>All three read statistics for every online player, so one task does
* the work of three. Each checks its <em>own</em> 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() { void rescheduleMilestones() {
if (milestoneTask != null) { if (milestoneTask != null) {
milestoneTask.cancel(); milestoneTask.cancel();
milestoneTask = null; milestoneTask = null;
} }
if (!settings.moduleEnabled(Module.MARCOS)) {
return;
}
long ticks = 5L * 60L * 20L; long ticks = 5L * 60L * 20L;
milestoneTask = getServer().getScheduler().runTaskTimer(this, () -> { milestoneTask = getServer().getScheduler().runTaskTimer(this, () -> {
if (settings.moduleEnabled(Module.MARCOS)) {
milestones.check(); milestones.check();
// Same cadence as milestones: both read statistics for every online }
// player, so sharing one task keeps that cost to a single sweep.
achievements.check(); achievements.check();
rotateWeeklyIfDue();
}, ticks, ticks); }, ticks, ticks);
} }
@@ -520,6 +587,36 @@ public final class Canalhandia extends JavaPlugin implements Listener {
}, settings.joinDelaySeconds() * 20L); }, settings.joinDelaySeconds() * 20L);
} }
/**
* Greets a joining player in the active persona, using their own numbers
* ("olha quem voltou, o das 47 mortes").
*
* <p>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.
*
* <p>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. * 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, deathLog.record(player.getUniqueId().toString(), flavor,
ServerState.worldLabel(loc.getWorld()), ServerState.worldLabel(loc.getWorld()),
loc.getBlockX(), loc.getBlockY(), loc.getBlockZ()); 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());
}
} }
/** /**
@@ -359,16 +359,40 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
.clickEvent(ClickEvent.runCommand("/ranking " + metric.commandKey())) .clickEvent(ClickEvent.runCommand("/ranking " + metric.commandKey()))
.append(Component.text("" + metric.label(), NamedTextColor.GRAY))); .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; 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) { if (metric == null) {
Msg.error(sender, "Ranking desconhecido. Use /ranking para ver a lista."); Msg.error(sender, "Ranking desconhecido. Use /ranking para ver a lista.");
return true; return true;
} }
List<OfflineStats.Row> rows = int size = plugin.settings().rankingSize();
plugin.offlineStats().ranking(metric, plugin.settings().rankingSize()); List<OfflineStats.Row> rows;
Msg.header(sender, "Ranking: " + metric.label()); 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()) { if (rows.isEmpty()) {
sender.sendMessage(Component.text(" (sem dados ainda)", NamedTextColor.GRAY)); sender.sendMessage(Component.text(" (sem dados ainda)", NamedTextColor.GRAY));
return true; return true;
@@ -784,6 +808,12 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
+ " · estado do servidor " + (settings.aiServerState() ? "on" : "off") + " · estado do servidor " + (settings.aiServerState() ? "on" : "off")
+ " · estatísticas " + (settings.aiPlayerStats() ? "on" : "off") + " · estatísticas " + (settings.aiPlayerStats() ? "on" : "off")
+ " · estilo " + (settings.aiFancy() ? "rico" : "simples")); + " · 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" Msg.line(sender, "notas", plugin.notes().size() + " no total"
+ (sender instanceof Player player + (sender instanceof Player player
? " · " + plugin.notes().countBy(player.getUniqueId().toString()) ? " · " + 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("/canalhandia zoacao limpar", "volta para as frases padrão");
commands.put("/ia personalidade", "lista as personalidades da IA"); commands.put("/ia personalidade", "lista as personalidades da IA");
commands.put("/ia personalidade <nome>", "muda o tom da IA (zoeiro, amigao, seco…)"); commands.put("/ia personalidade <nome>", "muda o tom da IA (zoeiro, amigao, seco…)");
commands.put("/ia eventos <on|off>", "IA comenta mortes seguidas sozinha");
commands.put("/ia saudacao <on|off>", "IA dá as boas-vindas de quem entra");
} }
commands.forEach((cmd, description) -> sender.sendMessage( commands.forEach((cmd, description) -> sender.sendMessage(
Component.text(" " + cmd, NamedTextColor.AQUA) Component.text(" " + cmd, NamedTextColor.AQUA)
@@ -931,6 +963,25 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
iaPersona(sender, args); iaPersona(sender, args);
return true; 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")) { if (sub.equals("feedback") && args.length >= 2 && args[1].equalsIgnoreCase("ruim")) {
iaFeedback(sender, args); iaFeedback(sender, args);
return true; return true;
@@ -1256,6 +1307,8 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
Msg.ok(player, "Anotação #" + note.id() + " salva (" + scope.label() + ") em " Msg.ok(player, "Anotação #" + note.id() + " salva (" + scope.label() + ") em "
+ note.place() + "."); + note.place() + ".");
if (scope == Note.Scope.PUBLICA) { 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 // Public notes are announced, because a board nobody is told about
// is a board nobody reads. // is a board nobody reads.
plugin.broadcastPerPlatform(bedrock -> Msg.tag("Nota", NamedTextColor.GREEN) plugin.broadcastPerPlatform(bedrock -> Msg.tag("Nota", NamedTextColor.GREEN)
@@ -1317,6 +1370,9 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
return; return;
} }
plugin.notes().remove(note.id()); plugin.notes().remove(note.id());
if (note.scope() == Note.Scope.PUBLICA) {
plugin.blueMap().sync();
}
Msg.ok(sender, "Anotação #" + note.id() + " apagada."); 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 // Only the tuning subcommands are suggested — the rest of /ia is
// free text, and completing a question would be noise. // free text, and completing a question would be noise.
if (args.length == 1 && sender.hasPermission("canalhandia.ia.perfil")) { 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")) { if (args.length == 2 && args[0].equalsIgnoreCase("personalidade")) {
List<String> keys = new ArrayList<>(); List<String> keys = new ArrayList<>();
@@ -1507,6 +1563,10 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
if (args.length == 2 && args[0].equalsIgnoreCase("perfil")) { if (args.length == 2 && args[0].equalsIgnoreCase("perfil")) {
return filter(List.of("economico", "preciso"), args[1]); 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(); return List.of();
} }
if (name.equals("canalhandia")) { if (name.equals("canalhandia")) {
@@ -65,6 +65,19 @@ final class OfflineStats {
return rows.size() > limit ? rows.subList(0, limit) : rows; 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<String, Long> allValues(RankingMetric metric) {
Map<String, Long> 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 * 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. * answer "quantos blocos eu minerei?" with the asker's own numbers.
@@ -413,6 +413,79 @@ final class Settings {
set("ia.contexto-notas", Math.max(0, Math.min(50, max))); 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) ----------------------------------------------------- // --- zoacao (f-gag) -----------------------------------------------------
/** /**
@@ -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.
*
* <p>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.
*
* <p>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<RankingMetric, Map<String, Long>> 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<RankingMetric, Map<String, Long>> current, long now) {
for (String key : new ArrayList<>(data.getKeys(false))) {
data.set(key, null);
}
data.set("em", now);
for (Map.Entry<RankingMetric, Map<String, Long>> metric : current.entrySet()) {
for (Map.Entry<String, Long> 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<String, Long> baseline(RankingMetric metric) {
Map<String, Long> 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.
*
* <p>Pure, so the arithmetic is testable without a server or a file.
*
* <p>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<OfflineStats.Row> delta(List<OfflineStats.Row> current,
Map<String, Long> baseline, int limit) {
List<OfflineStats.Row> 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);
}
}
}
+37
View File
@@ -81,6 +81,12 @@ janela-reacao-segundos: 90
luto: luto:
cabeca: true 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 # 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 ... # engraçada. Tudo editável em jogo com /canalhandia zoacao ...
zoacao: zoacao:
@@ -249,6 +255,37 @@ ia:
# a chamada da IA sai deste servidor para uma API de terceiros. # a chamada da IA sai deste servidor para uma API de terceiros.
contexto-notas: 10 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). # ECONOMICO pula a consulta à wiki (resposta rápida, sem fonte).
# PRECISO consulta a wiki (mais lento, mais correto). Troque em jogo com # PRECISO consulta a wiki (mais lento, mais correto). Troque em jogo com
# /ia perfil <nome>. # /ia perfil <nome>.
@@ -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("&lt;script&gt;alert(1)&lt;/script&gt;",
BlueMapBridge.escape("<script>alert(1)</script>"));
}
@Test
void escapeHandlesQuotesAndAmpersands() {
assertEquals("a &amp; b", BlueMapBridge.escape("a & b"));
assertEquals("&quot;base&quot;", BlueMapBridge.escape("\"base\""));
}
@Test
void ampersandIsEscapedFirst() {
// If & were escaped last it would double-escape the entities produced
// by the other replacements: "&lt;" would become "&amp;lt;".
assertEquals("&amp;lt;", BlueMapBridge.escape("&lt;"));
}
@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 = "<img src=x onerror=\"alert('x')\">";
String escaped = BlueMapBridge.escape(nasty);
assertFalse(escaped.contains("<"));
assertFalse(escaped.contains(">"));
assertFalse(escaped.contains("\""));
}
}
@@ -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));
}
}
@@ -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<OfflineStats.Row> rows(Object... pairs) {
List<OfflineStats.Row> 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<String, Long> baseline(Object... pairs) {
Map<String, Long> 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<OfflineStats.Row> 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<OfflineStats.Row> 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<OfflineStats.Row> 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<OfflineStats.Row> out = WeeklyStats.delta(
rows("ana", 100), baseline("ana", 500), 10);
assertTrue(out.isEmpty());
}
@Test
void deltaRespectsTheLimit() {
List<OfflineStats.Row> 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<RankingMetric, Map<String, Long>> snapshot(long mined) {
Map<RankingMetric, Map<String, Long>> 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<RankingMetric, Map<String, Long>> 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<RankingMetric, Map<String, Long>> first = new HashMap<>();
first.put(RankingMetric.MINERACAO, baseline("ana", 100, "saiu", 50));
weekly.write(first, T0);
Map<RankingMetric, Map<String, Long>> second = new HashMap<>();
second.put(RankingMetric.MINERACAO, baseline("ana", 200));
weekly.write(second, T0 + WEEK);
Map<String, Long> 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());
}
}