i18n: per-player EN/PT via Adventure GlobalTranslator #1
@@ -20,6 +20,10 @@
|
||||
<id>papermc</id>
|
||||
<url>https://repo.papermc.io/repository/maven-public/</url>
|
||||
</repository>
|
||||
<repository>
|
||||
<id>bluecolored</id>
|
||||
<url>https://repo.bluecolored.de/releases</url>
|
||||
</repository>
|
||||
</repositories>
|
||||
|
||||
<dependencyManagement>
|
||||
@@ -44,6 +48,17 @@
|
||||
<version>26.2.build.92-stable</version>
|
||||
<scope>provided</scope>
|
||||
</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>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
|
||||
+11
-4
@@ -47,13 +47,12 @@ else
|
||||
else
|
||||
bad "plugin.yml MISSING from jar — the plugin would not load at all"
|
||||
fi
|
||||
# Every command the code registers must exist in plugin.yml, or register()
|
||||
# logs a warning and the command silently does nothing in game.
|
||||
# Kept in step with Canalhandia.onEnable's register() list. A command that is
|
||||
# registered in code but absent here logs a warning at boot and then silently
|
||||
# does nothing in game, which is a hard failure to diagnose from inside.
|
||||
CMDS="canalhandia curiosidade adivinha enquete ranking reagir reacoes \
|
||||
palpite votar legal wow top f ia iap errado nota save"
|
||||
palpite votar legal wow top f ia iap errado nota save \
|
||||
recado recados mortes conquistas"
|
||||
missing=""
|
||||
for cmd in $CMDS; do
|
||||
unzip -p "$JAR" plugin.yml 2>/dev/null | grep -qE "^ ${cmd}:" || missing="$missing $cmd"
|
||||
@@ -65,13 +64,21 @@ else
|
||||
fi
|
||||
# Permissions the new features gate on. An undeclared Bukkit permission falls
|
||||
# back to op-only, which would silently stop normal players writing notes.
|
||||
for perm in canalhandia.nota canalhandia.nota.publica; do
|
||||
for perm in canalhandia.nota canalhandia.nota.publica canalhandia.recado; do
|
||||
if unzip -p "$JAR" plugin.yml 2>/dev/null | grep -q " ${perm}:"; then
|
||||
pass "permission ${perm} declared"
|
||||
else
|
||||
bad "permission ${perm} MISSING — would default to op-only"
|
||||
fi
|
||||
done
|
||||
# BlueMap's API is compile-only (provided scope): BlueMap ships those classes
|
||||
# itself, and a second copy inside this jar would shadow them and break the
|
||||
# real plugin. This check is the guard against someone dropping the scope.
|
||||
if unzip -l "$JAR" 2>/dev/null | grep -q "bluecolored"; then
|
||||
bad "BlueMap classes are BUNDLED in the jar — the dependency must stay 'provided'"
|
||||
else
|
||||
pass "BlueMap API not bundled (provided scope intact)"
|
||||
fi
|
||||
# config.yml ships defaults; a jar without it means saveDefaultConfig() writes
|
||||
# nothing and every setting silently falls back to the hardcoded default.
|
||||
if unzip -p "$JAR" config.yml >/dev/null 2>&1; then
|
||||
|
||||
@@ -452,6 +452,76 @@ final class Ai {
|
||||
return Msg.tag("IA", NamedTextColor.LIGHT_PURPLE).append(body);
|
||||
}
|
||||
|
||||
// --- spontaneous lines --------------------------------------------------
|
||||
|
||||
/**
|
||||
* Says something unprompted, in the active persona — a jab at a death
|
||||
* streak, a greeting for someone who just joined.
|
||||
*
|
||||
* <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 -------------------------------------------------
|
||||
|
||||
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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace("\"", """);
|
||||
}
|
||||
}
|
||||
@@ -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<>();
|
||||
/** Rolling window of public chat, fed to the AI so it can follow the room. */
|
||||
private final ChatLog chatLog = new ChatLog();
|
||||
/** Consecutive deaths per player, and when the last one happened. */
|
||||
private final Map<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 Notes notes;
|
||||
@@ -71,6 +83,10 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
||||
private OfflineStats offlineStats;
|
||||
private Milestones milestones;
|
||||
private Achievements achievements;
|
||||
private WeeklyStats weeklyStats;
|
||||
/** Gate for spontaneous AI lines; see Budget for why this is strict. */
|
||||
private Budget aiBudget;
|
||||
private BlueMapBridge blueMap;
|
||||
private Ai ai;
|
||||
private NamespacedKey optOutKey;
|
||||
private BukkitTask timerTask;
|
||||
@@ -92,7 +108,15 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
||||
offlineStats = new OfflineStats(this);
|
||||
milestones = new Milestones(this);
|
||||
achievements = new Achievements(this);
|
||||
weeklyStats = new WeeklyStats(new java.io.File(getDataFolder(), "semana.yml"));
|
||||
aiBudget = new Budget(settings.aiSpontaneousPerDay(),
|
||||
settings.aiSpontaneousGapMinutes() * 60_000L,
|
||||
settings.aiSubjectCooldownMinutes() * 60_000L);
|
||||
ai = new Ai(this);
|
||||
// Optional: does nothing (and logs nothing loud) without BlueMap.
|
||||
blueMap = new BlueMapBridge(notes, getLogger(),
|
||||
() -> settings.moduleEnabled(Module.NOTAS) && settings.notesOnMap());
|
||||
blueMap.hook();
|
||||
// Snapshot the server's recipes on the main thread; RecipeBook.describe
|
||||
// reads from the async answer path and Bukkit.recipeIterator() is not
|
||||
// safe off the main thread. Datapack reloads after this are not
|
||||
@@ -178,6 +202,41 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
||||
return achievements;
|
||||
}
|
||||
|
||||
/** The weekly ranking baseline. Never null. */
|
||||
WeeklyStats weeklyStats() {
|
||||
return weeklyStats;
|
||||
}
|
||||
|
||||
/** The spend gate for spontaneous AI lines. Never null. */
|
||||
Budget aiBudget() {
|
||||
return aiBudget;
|
||||
}
|
||||
|
||||
/** The BlueMap marker bridge. Never null, but a no-op without BlueMap. */
|
||||
BlueMapBridge blueMap() {
|
||||
return blueMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates the weekly ranking baseline if a week has elapsed.
|
||||
*
|
||||
* <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 ---------------------------------------------------------
|
||||
|
||||
/** Starts, stops or restarts the repeating curiosity task to match the mode. */
|
||||
@@ -194,20 +253,28 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
||||
.runTaskTimer(this, () -> announceCuriosity(null), ticks, ticks);
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared five-minute sweep: milestones, achievements and the weekly
|
||||
* ranking rotation.
|
||||
*
|
||||
* <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() {
|
||||
if (milestoneTask != null) {
|
||||
milestoneTask.cancel();
|
||||
milestoneTask = null;
|
||||
}
|
||||
if (!settings.moduleEnabled(Module.MARCOS)) {
|
||||
return;
|
||||
}
|
||||
long ticks = 5L * 60L * 20L;
|
||||
milestoneTask = getServer().getScheduler().runTaskTimer(this, () -> {
|
||||
if (settings.moduleEnabled(Module.MARCOS)) {
|
||||
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();
|
||||
rotateWeeklyIfDue();
|
||||
}, ticks, ticks);
|
||||
}
|
||||
|
||||
@@ -520,6 +587,36 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
||||
}, settings.joinDelaySeconds() * 20L);
|
||||
}
|
||||
|
||||
/**
|
||||
* Greets a joining player in the active persona, using their own numbers
|
||||
* ("olha quem voltou, o das 47 mortes").
|
||||
*
|
||||
* <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.
|
||||
*
|
||||
@@ -705,6 +802,26 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
||||
deathLog.record(player.getUniqueId().toString(), flavor,
|
||||
ServerState.worldLabel(loc.getWorld()),
|
||||
loc.getBlockX(), loc.getBlockY(), loc.getBlockZ());
|
||||
|
||||
// A run of deaths is worth a comment; a single one is just Tuesday.
|
||||
// The run has to be recent, or three deaths spread across an evening
|
||||
// would read as a streak.
|
||||
long now = System.currentTimeMillis();
|
||||
Streak previous = deathStreak.get(player.getUniqueId());
|
||||
int count = (previous != null && now - previous.at() < STREAK_WINDOW)
|
||||
? previous.count() + 1 : 1;
|
||||
deathStreak.put(player.getUniqueId(), new Streak(count, now));
|
||||
|
||||
if (settings.aiEvents() && count >= settings.aiDeathStreak()) {
|
||||
ai.saySomething(player.getName(),
|
||||
"O jogador " + player.getName() + " morreu " + count
|
||||
+ " vezes seguidas em poucos minutos. A última foi assim: " + flavor
|
||||
+ ". Comente com deboche, sem ofender.",
|
||||
aiBudget);
|
||||
// Reset so the next comment needs a fresh run rather than firing on
|
||||
// every death from here on.
|
||||
deathStreak.remove(player.getUniqueId());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -359,16 +359,40 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
||||
.clickEvent(ClickEvent.runCommand("/ranking " + metric.commandKey()))
|
||||
.append(Component.text(" — " + metric.label(), NamedTextColor.GRAY)));
|
||||
}
|
||||
sender.sendMessage(Component.text(" semanal [métrica]", NamedTextColor.GOLD)
|
||||
.clickEvent(ClickEvent.runCommand("/ranking semanal"))
|
||||
.append(Component.text(" — só o que foi ganho nesta semana",
|
||||
NamedTextColor.GRAY)));
|
||||
return true;
|
||||
}
|
||||
RankingMetric metric = RankingMetric.byKey(args[0]);
|
||||
// "/ranking semanal [metrica]" — the same boards, but showing only what
|
||||
// was gained since the weekly baseline. An all-time board on a small
|
||||
// server is decided by who started first; this makes it a contest again.
|
||||
boolean weekly = args[0].equalsIgnoreCase("semanal") || args[0].equalsIgnoreCase("semana");
|
||||
String[] rest = weekly ? Arrays.copyOfRange(args, 1, args.length) : args;
|
||||
if (weekly && rest.length == 0) {
|
||||
// Default to the metric people actually race on.
|
||||
rest = new String[]{RankingMetric.MINERACAO.commandKey()};
|
||||
}
|
||||
|
||||
RankingMetric metric = RankingMetric.byKey(rest[0]);
|
||||
if (metric == null) {
|
||||
Msg.error(sender, "Ranking desconhecido. Use /ranking para ver a lista.");
|
||||
return true;
|
||||
}
|
||||
List<OfflineStats.Row> rows =
|
||||
plugin.offlineStats().ranking(metric, plugin.settings().rankingSize());
|
||||
Msg.header(sender, "Ranking: " + metric.label());
|
||||
int size = plugin.settings().rankingSize();
|
||||
List<OfflineStats.Row> rows;
|
||||
if (weekly) {
|
||||
rows = WeeklyStats.delta(plugin.offlineStats().ranking(metric, Integer.MAX_VALUE),
|
||||
plugin.weeklyStats().baseline(metric), size);
|
||||
} else {
|
||||
rows = plugin.offlineStats().ranking(metric, size);
|
||||
}
|
||||
Msg.header(sender, (weekly ? "Ranking da semana: " : "Ranking: ") + metric.label());
|
||||
if (weekly) {
|
||||
long taken = plugin.weeklyStats().takenAt();
|
||||
Msg.line(sender, "desde", taken == 0 ? "o começo" : Msg.ago(taken));
|
||||
}
|
||||
if (rows.isEmpty()) {
|
||||
sender.sendMessage(Component.text(" (sem dados ainda)", NamedTextColor.GRAY));
|
||||
return true;
|
||||
@@ -784,6 +808,12 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
||||
+ " · estado do servidor " + (settings.aiServerState() ? "on" : "off")
|
||||
+ " · estatísticas " + (settings.aiPlayerStats() ? "on" : "off")
|
||||
+ " · estilo " + (settings.aiFancy() ? "rico" : "simples"));
|
||||
Msg.line(sender, "ia espontânea",
|
||||
(settings.aiEvents() ? "eventos on" : "eventos off")
|
||||
+ " · " + (settings.aiWelcome() ? "saudação on" : "saudação off")
|
||||
+ " · " + plugin.aiBudget().usedToday(System.currentTimeMillis())
|
||||
+ "/" + settings.aiSpontaneousPerDay() + " hoje"
|
||||
+ " · intervalo " + settings.aiSpontaneousGapMinutes() + "min");
|
||||
Msg.line(sender, "notas", plugin.notes().size() + " no total"
|
||||
+ (sender instanceof Player player
|
||||
? " · " + plugin.notes().countBy(player.getUniqueId().toString())
|
||||
@@ -848,6 +878,8 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
||||
commands.put("/canalhandia zoacao limpar", "volta para as frases padrão");
|
||||
commands.put("/ia personalidade", "lista as personalidades da IA");
|
||||
commands.put("/ia personalidade <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(
|
||||
Component.text(" " + cmd, NamedTextColor.AQUA)
|
||||
@@ -931,6 +963,25 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
||||
iaPersona(sender, args);
|
||||
return true;
|
||||
}
|
||||
// Toggles for the spontaneous lines. Same guard as above: only
|
||||
// hijack on an explicit on/off, so "/ia eventos do servidor?"
|
||||
// stays a question.
|
||||
if ((sub.equals("eventos") || sub.equals("saudacao")) && args.length == 2
|
||||
&& (args[1].equalsIgnoreCase("on") || args[1].equalsIgnoreCase("off"))) {
|
||||
if (!sender.hasPermission("canalhandia.ia.perfil")) {
|
||||
denied(sender);
|
||||
return true;
|
||||
}
|
||||
boolean on = args[1].equalsIgnoreCase("on");
|
||||
if (sub.equals("eventos")) {
|
||||
plugin.settings().aiEvents(on);
|
||||
Msg.ok(sender, "IA comentando eventos: " + (on ? "ligada" : "desligada") + ".");
|
||||
} else {
|
||||
plugin.settings().aiWelcome(on);
|
||||
Msg.ok(sender, "Saudação da IA: " + (on ? "ligada" : "desligada") + ".");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (sub.equals("feedback") && args.length >= 2 && args[1].equalsIgnoreCase("ruim")) {
|
||||
iaFeedback(sender, args);
|
||||
return true;
|
||||
@@ -1256,6 +1307,8 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
||||
Msg.ok(player, "Anotação #" + note.id() + " salva (" + scope.label() + ") em "
|
||||
+ note.place() + ".");
|
||||
if (scope == Note.Scope.PUBLICA) {
|
||||
// A new public note changes what the web map should show.
|
||||
plugin.blueMap().sync();
|
||||
// Public notes are announced, because a board nobody is told about
|
||||
// is a board nobody reads.
|
||||
plugin.broadcastPerPlatform(bedrock -> Msg.tag("Nota", NamedTextColor.GREEN)
|
||||
@@ -1317,6 +1370,9 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
||||
return;
|
||||
}
|
||||
plugin.notes().remove(note.id());
|
||||
if (note.scope() == Note.Scope.PUBLICA) {
|
||||
plugin.blueMap().sync();
|
||||
}
|
||||
Msg.ok(sender, "Anotação #" + note.id() + " apagada.");
|
||||
}
|
||||
|
||||
@@ -1495,7 +1551,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
||||
// Only the tuning subcommands are suggested — the rest of /ia is
|
||||
// free text, and completing a question would be noise.
|
||||
if (args.length == 1 && sender.hasPermission("canalhandia.ia.perfil")) {
|
||||
return filter(List.of("personalidade", "perfil"), args[0]);
|
||||
return filter(List.of("personalidade", "perfil", "eventos", "saudacao"), args[0]);
|
||||
}
|
||||
if (args.length == 2 && args[0].equalsIgnoreCase("personalidade")) {
|
||||
List<String> keys = new ArrayList<>();
|
||||
@@ -1507,6 +1563,10 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
||||
if (args.length == 2 && args[0].equalsIgnoreCase("perfil")) {
|
||||
return filter(List.of("economico", "preciso"), args[1]);
|
||||
}
|
||||
if (args.length == 2 && (args[0].equalsIgnoreCase("eventos")
|
||||
|| args[0].equalsIgnoreCase("saudacao"))) {
|
||||
return filter(List.of("on", "off"), args[1]);
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
if (name.equals("canalhandia")) {
|
||||
|
||||
@@ -65,6 +65,19 @@ final class OfflineStats {
|
||||
return rows.size() > limit ? rows.subList(0, limit) : rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every player's value for a metric, keyed by name — the whole board, not
|
||||
* the top slice, because the weekly baseline has to remember someone who
|
||||
* was not in the top five last week but is now.
|
||||
*/
|
||||
Map<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
|
||||
* 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)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) -----------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,12 @@ janela-reacao-segundos: 90
|
||||
luto:
|
||||
cabeca: true
|
||||
|
||||
# Anotações (/save e /nota).
|
||||
notas:
|
||||
# true: as anotações PÚBLICAS viram marcadores no mapa do BlueMap. Sem
|
||||
# BlueMap instalado não faz nada. Anotação privada nunca vai para o mapa.
|
||||
no-mapa: true
|
||||
|
||||
# Zoa de quem manda uma mensagem que bate com o padrão, trocando por uma frase
|
||||
# engraçada. Tudo editável em jogo com /canalhandia zoacao ...
|
||||
zoacao:
|
||||
@@ -249,6 +255,37 @@ ia:
|
||||
# a chamada da IA sai deste servidor para uma API de terceiros.
|
||||
contexto-notas: 10
|
||||
|
||||
# --- Falas espontâneas (a IA falando sem ninguém perguntar) ---
|
||||
#
|
||||
# DESLIGADAS por padrão. Uma IA tagarela que ninguém pediu é o jeito mais
|
||||
# rápido de fazer todo mundo odiar o recurso, então é o operador que liga.
|
||||
# Cada fala custa dinheiro, e os limites abaixo são o que impede virar spam.
|
||||
|
||||
# true: a IA comenta quando alguém morre várias vezes seguidas.
|
||||
comentar-eventos: false
|
||||
|
||||
# true: a IA dá as boas-vindas de quem entra, usando as estatísticas da pessoa.
|
||||
saudacao: false
|
||||
|
||||
# Quantas mortes seguidas (em poucos minutos) merecem comentário. Abaixo de 3
|
||||
# dispara em azar comum e deixa de ter graça.
|
||||
mortes-seguidas: 3
|
||||
|
||||
# Teto diário SÓ para falas espontâneas, separado do limite do /ia.
|
||||
espontaneas-por-dia: 20
|
||||
|
||||
# Minutos mínimos entre duas falas espontâneas quaisquer.
|
||||
espontaneas-intervalo-minutos: 10
|
||||
|
||||
# Minutos até o MESMO jogador poder ser assunto de novo. É o que impede
|
||||
# narrar a noite inteira de uma pessoa só, e o que evita saudação repetida
|
||||
# para quem cai da conexão toda hora.
|
||||
espontaneas-cooldown-jogador: 30
|
||||
|
||||
# Uma fala espontânea é uma linha de chat, não um parágrafo.
|
||||
espontaneas-max-tokens: 400
|
||||
espontaneas-max-caracteres: 180
|
||||
|
||||
# ECONOMICO pula a consulta à wiki (resposta rápida, sem fonte).
|
||||
# PRECISO consulta a wiki (mais lento, mais correto). Troque em jogo com
|
||||
# /ia perfil <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("<script>alert(1)</script>",
|
||||
BlueMapBridge.escape("<script>alert(1)</script>"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void escapeHandlesQuotesAndAmpersands() {
|
||||
assertEquals("a & b", BlueMapBridge.escape("a & b"));
|
||||
assertEquals(""base"", BlueMapBridge.escape("\"base\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ampersandIsEscapedFirst() {
|
||||
// If & were escaped last it would double-escape the entities produced
|
||||
// by the other replacements: "<" would become "&lt;".
|
||||
assertEquals("&lt;", BlueMapBridge.escape("<"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void escapeLeavesOrdinaryTextAlone() {
|
||||
assertEquals("base do caio, -400 70 200",
|
||||
BlueMapBridge.escape("base do caio, -400 70 200"));
|
||||
assertEquals("caverna após o rio", BlueMapBridge.escape("caverna após o rio"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void escapeHandlesNull() {
|
||||
assertEquals("", BlueMapBridge.escape(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void escapedTextCarriesNoRawAngleBrackets() {
|
||||
String nasty = "<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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user