feat: curiosidades plugin for stat-based server announcements

Announces a random Portuguese fact about a random online player on a
timer, sourced from vanilla statistics, with clickable reactions.

Block/item/mob names are emitted as translatable components so each
client renders them in its own language instead of shipping a
translation table. Sentences are phrased to avoid number agreement with
the translated noun ("5.966 blocos de Pedra", not "5.966 Pedras").

Statistic constants are resolved by name with fallbacks because they get
renamed across Minecraft releases; a rename degrades one curiosity
rather than breaking the announcement.

Chat cannot be edited after sending, so live reaction tallies ride on a
boss bar while the in-chat buttons stay frozen at send time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ActvLGJApdxEAd2yfKPwqv
This commit is contained in:
marcos
2026-08-04 20:18:05 +00:00
commit c528ed55c8
8 changed files with 670 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
target/
+43
View File
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>dev.marcospaulo</groupId>
<artifactId>curiosidades</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<!-- Paper 26.2's API ships Java 25 class files, so the build JDK must be 25. -->
<maven.compiler.release>25</maven.compiler.release>
</properties>
<repositories>
<repository>
<id>papermc</id>
<url>https://repo.papermc.io/repository/maven-public/</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>io.papermc.paper</groupId>
<artifactId>paper-api</artifactId>
<version>26.2.build.92-stable</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<finalName>Curiosidades-${project.version}</finalName>
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
</resource>
</resources>
</build>
</project>
@@ -0,0 +1,213 @@
package dev.marcospaulo.curiosidades;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.event.HoverEvent;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextDecoration;
import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.persistence.PersistentDataType;
import org.bukkit.plugin.java.JavaPlugin;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
public final class Curiosidades extends JavaPlugin implements CommandExecutor, TabCompleter, Listener {
private final Random random = new Random();
private NamespacedKey optOutKey;
private Map<String, String> reactionLabels;
private int reactionWindowSeconds;
private int nextSessionId = 1;
private ReactionSession active;
@Override
public void onEnable() {
saveDefaultConfig();
optOutKey = new NamespacedKey(this, "opt_out");
loadReactionSettings();
getCommand("curiosidade").setExecutor(this);
getCommand("curiosidade").setTabCompleter(this);
getServer().getPluginManager().registerEvents(this, this);
long periodTicks = Math.max(1, getConfig().getLong("intervalo-minutos", 20)) * 60L * 20L;
getServer().getScheduler().runTaskTimer(this, this::announceRandom, periodTicks, periodTicks);
getLogger().info("Curiosidades ativo — anúncio a cada "
+ getConfig().getLong("intervalo-minutos", 20) + " minutos.");
}
private void loadReactionSettings() {
reactionWindowSeconds = getConfig().getInt("janela-reacao-segundos", 90);
reactionLabels = new LinkedHashMap<>();
var section = getConfig().getConfigurationSection("reacoes");
if (section != null) {
for (String key : section.getKeys(false)) {
reactionLabels.put(key, section.getString(key, key));
}
}
if (reactionLabels.isEmpty()) {
reactionLabels.put("joia", "[+1]");
}
}
// --- announcing ---------------------------------------------------------
/** Picks a random eligible online player and broadcasts one fact about them. */
void announceRandom() {
List<Player> candidates = new ArrayList<>();
for (Player player : Bukkit.getOnlinePlayers()) {
if (!isOptedOut(player)) {
candidates.add(player);
}
}
if (candidates.isEmpty()) {
return;
}
Player subject = candidates.get(random.nextInt(candidates.size()));
Component fact = CuriosityFactory.random(subject, random);
if (fact == null) {
return; // Nothing notable about this player yet.
}
broadcast(subject, fact);
}
private void broadcast(Player subject, Component fact) {
if (active != null) {
active.hide();
}
ReactionSession session = new ReactionSession(nextSessionId++, reactionLabels);
active = session;
Component message = Component.text("[Curiosidade] ", NamedTextColor.GOLD, TextDecoration.BOLD)
.append(Component.text("Sabia que o ", NamedTextColor.WHITE)
.decoration(TextDecoration.BOLD, false))
.append(Component.text(subject.getName(), NamedTextColor.GREEN)
.decoration(TextDecoration.BOLD, false))
.append(Component.text(" ", NamedTextColor.WHITE).decoration(TextDecoration.BOLD, false))
.append(fact.decoration(TextDecoration.BOLD, false))
.append(Component.text("?", NamedTextColor.WHITE).decoration(TextDecoration.BOLD, false));
Bukkit.broadcast(message);
Bukkit.broadcast(buttons(session));
session.show();
getServer().getScheduler().runTaskLater(this, () -> {
session.hide();
if (active == session) {
active = null;
}
}, reactionWindowSeconds * 20L);
}
/** The clickable reaction row. Counts are frozen at send time; the boss bar is live. */
private Component buttons(ReactionSession session) {
Component row = Component.text(" ");
for (Map.Entry<String, String> entry : reactionLabels.entrySet()) {
row = row.append(Component.text(entry.getValue() + " ", NamedTextColor.YELLOW)
.clickEvent(ClickEvent.runCommand(
"/curiosidade reagir " + session.id() + " " + entry.getKey()))
.hoverEvent(HoverEvent.showText(
Component.text("Clique para reagir com " + entry.getValue(),
NamedTextColor.GRAY))));
}
return row;
}
// --- commands -----------------------------------------------------------
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (args.length == 0) {
if (!sender.hasPermission("curiosidades.forcar")) {
sender.sendMessage(Component.text("Sem permissão.", NamedTextColor.RED));
return true;
}
announceRandom();
return true;
}
switch (args[0].toLowerCase()) {
case "reagir" -> {
if (!(sender instanceof Player player)) {
return true;
}
if (args.length < 3 || active == null) {
return true;
}
int id;
try {
id = Integer.parseInt(args[1]);
} catch (NumberFormatException e) {
return true;
}
if (id != active.id()) {
player.sendActionBar(Component.text("Essa curiosidade já expirou.", NamedTextColor.RED));
return true;
}
if (active.react(player, args[2])) {
player.sendActionBar(Component.text("Você reagiu!", NamedTextColor.GREEN));
}
}
case "toggle" -> {
if (!(sender instanceof Player player)) {
return true;
}
boolean nowOptedOut = !isOptedOut(player);
player.getPersistentDataContainer()
.set(optOutKey, PersistentDataType.BYTE, (byte) (nowOptedOut ? 1 : 0));
player.sendMessage(nowOptedOut
? Component.text("Você não aparecerá mais nas curiosidades.", NamedTextColor.YELLOW)
: Component.text("Você voltou a aparecer nas curiosidades.", NamedTextColor.GREEN));
}
case "reload" -> {
if (!sender.hasPermission("curiosidades.admin")) {
sender.sendMessage(Component.text("Sem permissão.", NamedTextColor.RED));
return true;
}
reloadConfig();
loadReactionSettings();
sender.sendMessage(Component.text("Configuração recarregada.", NamedTextColor.GREEN));
}
default -> sender.sendMessage(
Component.text("Uso: /curiosidade [toggle|reload]", NamedTextColor.GRAY));
}
return true;
}
@Override
public List<String> onTabComplete(CommandSender sender, Command command, String label, String[] args) {
if (args.length == 1) {
return List.of("toggle", "reload");
}
return List.of();
}
// --- events -------------------------------------------------------------
@EventHandler
public void onJoin(PlayerJoinEvent event) {
if (active != null) {
active.showTo(event.getPlayer());
}
}
private boolean isOptedOut(Player player) {
Byte value = player.getPersistentDataContainer()
.get(optOutKey, PersistentDataType.BYTE);
return value != null && value == 1;
}
}
@@ -0,0 +1,182 @@
package dev.marcospaulo.curiosidades;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Material;
import org.bukkit.Statistic;
import org.bukkit.entity.EntityType;
import org.bukkit.entity.Player;
import java.text.NumberFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
/**
* Turns a player's vanilla statistics into Portuguese "did you know" sentences.
*
* <p>Block, item and mob names are emitted as translatable components rather than
* hardcoded strings, so each client renders them in its own language — a pt-BR
* player reads "Pedra" and an en-US player reads "Stone" from the same broadcast.
* Every sentence is phrased so the count never has to agree in number with the
* translated noun ("5.966 blocos de Pedra", not "5.966 Pedras").
*/
final class CuriosityFactory {
private static final Locale PT_BR = Locale.of("pt", "BR");
private static final NumberFormat NUMBERS = NumberFormat.getInstance(PT_BR);
/** Below this, a statistic is too boring to announce. */
private static final int MIN_COUNT = 10;
private static final int MIN_DEATHS = 1;
private static final int MIN_CENTIMETRES = 100_000; // 1 km
private CuriosityFactory() {
}
/**
* Every sentence that currently applies to {@code player}. May be empty for a
* brand-new player who has not done anything worth mentioning yet.
*/
static List<Component> facts(Player player) {
List<Component> facts = new ArrayList<>();
materialFact(facts, player, Stats.resolve("MINE_BLOCK"), MIN_COUNT,
"já minerou ", " blocos de ");
materialFact(facts, player, Stats.resolve("BREAK_ITEM"), 3,
"já quebrou ", " unidades de ");
materialFact(facts, player, Stats.resolve("CRAFT_ITEM"), MIN_COUNT,
"já fabricou ", " unidades de ");
materialFact(facts, player, Stats.resolve("USE_ITEM"), 200,
"já usou ", " vezes o item ");
materialFact(facts, player, Stats.resolve("PICKUP", "PICKED_UP"), 500,
"já coletou ", " unidades de ");
entityFacts(facts, player, Stats.resolve("ENTITY_KILLED_BY"), MIN_DEATHS,
"já morreu ", " vezes para ");
entityFacts(facts, player, Stats.resolve("KILL_ENTITY"), MIN_COUNT,
"já derrotou ", " inimigos do tipo ");
distanceFact(facts, player, Stats.resolve("WALK_ONE_CM"), "já caminhou ", " a pé");
distanceFact(facts, player, Stats.resolve("SPRINT_ONE_CM"), "já correu ", "");
distanceFact(facts, player, Stats.resolve("FLY_ONE_CM"), "já voou ", "");
distanceFact(facts, player, Stats.resolve("BOAT_ONE_CM"), "já navegou ", " de barco");
distanceFact(facts, player, Stats.resolve("HORSE_ONE_CM"), "já cavalgou ", "");
distanceFact(facts, player, Stats.resolve("SWIM_ONE_CM"), "já nadou ", "");
distanceFact(facts, player, Stats.resolve("MINECART_ONE_CM"), "já andou ", " de carrinho");
timeFact(facts, player, Stats.resolve("PLAY_TIME", "PLAY_ONE_MINUTE"),
"já passou ", " dentro do servidor");
timeFact(facts, player, Stats.resolve("TIME_SINCE_DEATH"),
"está há ", " sem morrer");
timeFact(facts, player, Stats.resolve("TIME_SINCE_REST"),
"está há ", " sem dormir");
countFact(facts, player, Stats.resolve("JUMP"), 500, "já pulou ", " vezes");
countFact(facts, player, Stats.resolve("DEATHS"), MIN_DEATHS, "já morreu ", " vezes no total");
countFact(facts, player, Stats.resolve("MOB_KILLS"), MIN_COUNT, "já derrotou ", " monstros");
countFact(facts, player, Stats.resolve("DAMAGE_DEALT"), 1000, "já causou ", " de dano");
countFact(facts, player, Stats.resolve("DAMAGE_TAKEN"), 1000, "já levou ", " de dano");
countFact(facts, player, Stats.resolve("FISH_CAUGHT"), 5, "já pescou ", " peixes");
countFact(facts, player, Stats.resolve("ANIMALS_BRED"), 5, "já acasalou ", " animais");
countFact(facts, player, Stats.resolve("ITEM_ENCHANTED"), 3, "já encantou ", " itens");
countFact(facts, player, Stats.resolve("TRADED_WITH_VILLAGER"), 5,
"já negociou ", " vezes com aldeões");
countFact(facts, player, Stats.resolve("RAID_WIN"), 1, "já venceu ", " invasões");
return facts;
}
/** One random applicable sentence, or null if the player has nothing notable yet. */
static Component random(Player player, java.util.Random random) {
List<Component> facts = facts(player);
if (facts.isEmpty()) {
return null;
}
return facts.get(random.nextInt(facts.size()));
}
// --- builders -----------------------------------------------------------
private static void materialFact(List<Component> out, Player player, Statistic statistic,
int minimum, String prefix, String middle) {
Stats.Entry<Material> top = Stats.topMaterial(player, statistic, minimum);
if (top == null) {
return;
}
out.add(Component.text(prefix, NamedTextColor.WHITE)
.append(number(top.value()))
.append(Component.text(middle, NamedTextColor.WHITE))
.append(name(top.subject())));
}
private static void entityFacts(List<Component> out, Player player, Statistic statistic,
int minimum, String prefix, String middle) {
List<Stats.Entry<EntityType>> entries = Stats.entities(player, statistic, minimum);
// Deaths-by-mob are interesting per mob, not just for the worst offender.
Collections.shuffle(entries);
for (Stats.Entry<EntityType> entry : entries.subList(0, Math.min(3, entries.size()))) {
out.add(Component.text(prefix, NamedTextColor.WHITE)
.append(number(entry.value()))
.append(Component.text(middle, NamedTextColor.WHITE))
.append(name(entry.subject())));
}
}
private static void distanceFact(List<Component> out, Player player, Statistic statistic,
String prefix, String suffix) {
int centimetres = Stats.untyped(player, statistic);
if (centimetres < MIN_CENTIMETRES) {
return;
}
double km = centimetres / 100_000.0;
out.add(Component.text(prefix, NamedTextColor.WHITE)
.append(Component.text(String.format(PT_BR, "%,.1f km", km), NamedTextColor.AQUA))
.append(Component.text(suffix, NamedTextColor.WHITE)));
}
private static void timeFact(List<Component> out, Player player, Statistic statistic,
String prefix, String suffix) {
int ticks = Stats.untyped(player, statistic);
long hours = ticks / 20L / 3600L;
if (hours < 1) {
return;
}
String text = hours >= 24
? String.format(PT_BR, "%d dias e %d horas", hours / 24, hours % 24)
: hours + " horas";
out.add(Component.text(prefix, NamedTextColor.WHITE)
.append(Component.text(text, NamedTextColor.AQUA))
.append(Component.text(suffix, NamedTextColor.WHITE)));
}
private static void countFact(List<Component> out, Player player, Statistic statistic,
int minimum, String prefix, String suffix) {
int value = Stats.untyped(player, statistic);
if (value < minimum) {
return;
}
// Damage statistics are stored in tenths of a heart.
if (statistic != null && statistic.name().startsWith("DAMAGE_")) {
value = value / 10;
}
out.add(Component.text(prefix, NamedTextColor.WHITE)
.append(number(value))
.append(Component.text(suffix, NamedTextColor.WHITE)));
}
// --- pieces -------------------------------------------------------------
private static Component number(int value) {
return Component.text(NUMBERS.format(value), NamedTextColor.AQUA);
}
private static Component name(Material material) {
return Component.translatable(material.translationKey(), NamedTextColor.LIGHT_PURPLE);
}
private static Component name(EntityType type) {
return Component.translatable(type.translationKey(), NamedTextColor.LIGHT_PURPLE);
}
}
@@ -0,0 +1,84 @@
package dev.marcospaulo.curiosidades;
import net.kyori.adventure.bossbar.BossBar;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
/**
* Reaction state for one announced curiosity.
*
* <p>Chat messages cannot be edited after the fact in vanilla, so the counts baked
* into the clickable buttons are frozen at send time. The live tally is carried by
* a boss bar instead, which <em>can</em> be updated in place and is visible to
* everyone until the window closes.
*/
final class ReactionSession {
private final int id;
private final Map<String, Set<UUID>> votes = new LinkedHashMap<>();
private final Map<String, String> labels;
private final BossBar bar;
ReactionSession(int id, Map<String, String> labels) {
this.id = id;
this.labels = labels;
labels.keySet().forEach(key -> votes.put(key, new LinkedHashSet<>()));
this.bar = BossBar.bossBar(renderTally(), 1.0f, BossBar.Color.PURPLE, BossBar.Overlay.PROGRESS);
}
int id() {
return id;
}
/**
* Records a reaction. A player gets one reaction per curiosity; reacting again
* with a different key moves their vote rather than double-counting it.
*
* @return false if the key is not a configured reaction
*/
boolean react(Player player, String key) {
if (!votes.containsKey(key)) {
return false;
}
votes.values().forEach(set -> set.remove(player.getUniqueId()));
votes.get(key).add(player.getUniqueId());
bar.name(renderTally());
return true;
}
int count(String key) {
Set<UUID> set = votes.get(key);
return set == null ? 0 : set.size();
}
void show() {
Bukkit.getOnlinePlayers().forEach(p -> p.showBossBar(bar));
}
void hide() {
Bukkit.getOnlinePlayers().forEach(p -> p.hideBossBar(bar));
}
/** Shows the bar to someone who joined mid-window. */
void showTo(Player player) {
player.showBossBar(bar);
}
private Component renderTally() {
Component tally = Component.text("Reações: ", NamedTextColor.WHITE);
for (Map.Entry<String, String> entry : labels.entrySet()) {
tally = tally
.append(Component.text(entry.getValue() + " ", NamedTextColor.YELLOW))
.append(Component.text(count(entry.getKey()) + " ", NamedTextColor.AQUA));
}
return tally;
}
}
@@ -0,0 +1,105 @@
package dev.marcospaulo.curiosidades;
import org.bukkit.Material;
import org.bukkit.Statistic;
import org.bukkit.entity.EntityType;
import org.bukkit.entity.Player;
import java.util.ArrayList;
import java.util.List;
/**
* Thin, defensive wrapper around Bukkit's statistics API.
*
* <p>Statistic constants get renamed between Minecraft releases (PLAY_ONE_MINUTE
* became PLAY_TIME, for one), and asking for a statistic with the wrong subject
* type throws. Everything here resolves by name with fallbacks and swallows the
* per-lookup failures, so a rename downgrades one curiosity instead of breaking
* the whole announcement.
*/
final class Stats {
private Stats() {
}
/** First statistic in {@code names} that exists in this server's API, or null. */
static Statistic resolve(String... names) {
for (String name : names) {
try {
return Statistic.valueOf(name);
} catch (IllegalArgumentException ignored) {
// Try the next alias.
}
}
return null;
}
/** Untyped statistic value, or 0 if the statistic does not exist here. */
static int untyped(Player player, Statistic statistic) {
if (statistic == null || statistic.getType() != Statistic.Type.UNTYPED) {
return 0;
}
try {
return player.getStatistic(statistic);
} catch (RuntimeException e) {
return 0;
}
}
/** A (subject, value) pair for a statistic that is keyed by material or entity. */
record Entry<T>(T subject, int value) {
}
/**
* Highest-valued material for a material-keyed statistic, ignoring anything at
* or below {@code minimum}.
*/
static Entry<Material> topMaterial(Player player, Statistic statistic, int minimum) {
if (statistic == null) {
return null;
}
boolean block = statistic.getType() == Statistic.Type.BLOCK;
if (!block && statistic.getType() != Statistic.Type.ITEM) {
return null;
}
Entry<Material> best = null;
for (Material material : Material.values()) {
if (material.isLegacy() || material.isAir()) {
continue;
}
if (block ? !material.isBlock() : !material.isItem()) {
continue;
}
int value;
try {
value = player.getStatistic(statistic, material);
} catch (RuntimeException e) {
continue; // Not a valid subject for this statistic on this version.
}
if (value > minimum && (best == null || value > best.value())) {
best = new Entry<>(material, value);
}
}
return best;
}
/** All entity subjects with a value above {@code minimum}, for an entity-keyed statistic. */
static List<Entry<EntityType>> entities(Player player, Statistic statistic, int minimum) {
List<Entry<EntityType>> found = new ArrayList<>();
if (statistic == null || statistic.getType() != Statistic.Type.ENTITY) {
return found;
}
for (EntityType type : EntityType.values()) {
int value;
try {
value = player.getStatistic(statistic, type);
} catch (RuntimeException e) {
continue;
}
if (value > minimum) {
found.add(new Entry<>(type, value));
}
}
return found;
}
}
+16
View File
@@ -0,0 +1,16 @@
# Curiosidades — anúncios automáticos sobre os jogadores online.
# Intervalo entre anúncios, em minutos.
intervalo-minutos: 20
# Por quanto tempo a barra de reações fica visível, em segundos.
janela-reacao-segundos: 90
# Reações disponíveis. A chave é usada no comando; o valor é o que aparece no chat.
# Emoji funcionam no Java; no Bedrock (Geyser) alguns não renderizam, então
# rótulos em texto puro são a opção segura para servidores com muitos jogadores
# de Bedrock.
reacoes:
joia: "[👍]"
uau: "[😮]"
fogo: "[🔥]"
+26
View File
@@ -0,0 +1,26 @@
name: Curiosidades
version: "${project.version}"
main: dev.marcospaulo.curiosidades.Curiosidades
api-version: "26.1.1"
description: Anuncia curiosidades sobre os jogadores com base nas estatísticas do servidor.
author: Canalhandia
folia-supported: false
commands:
curiosidade:
description: Anuncia uma curiosidade agora, ou gerencia suas preferências.
usage: /curiosidade [toggle|reload]
aliases: [curiosidades]
permissions:
# Declared explicitly: an undeclared Bukkit permission falls back to op-only,
# which would silently stop normal players from being able to react.
curiosidades.reagir:
description: Permite reagir às curiosidades.
default: true
curiosidades.forcar:
description: Permite forçar um anúncio com /curiosidade.
default: op
curiosidades.admin:
description: Permite recarregar a configuração.
default: op