i18n: per-player EN/PT via Adventure GlobalTranslator #1

Merged
masi merged 20 commits from feat/ia-grounding into main 2026-08-12 15:53:28 +00:00
14 changed files with 1177 additions and 15 deletions
Showing only changes of commit 4ec9817636 - Show all commits
+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, () -> {
milestones.check(); if (settings.moduleEnabled(Module.MARCOS)) {
// Same cadence as milestones: both read statistics for every online milestones.check();
// 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());
}
}