Files
canalhandia/src/main/java/dev/marcospaulo/curiosidades/Curiosidades.java
T
marcos 7fd3fc157c feat: join-triggered by default, plus runtime admin controls
Default trigger is now player join rather than a timer, with a short
delay so the curiosity lands after the join message instead of racing
it. Modes entrada/intervalo/ambos/manual select the triggers.

Adds a full /curiosidade tree so behaviour is adjustable in-game
without editing config.yml: mode, interval, join delay, per-player
cooldown, no-repeat history, reaction window, reaction labels, and
per-category toggles. Every setter writes through to disk immediately
so changes survive a restart.

Facts are now tagged with a category so they can be filtered, and a
per-player cooldown plus recent-fact history stop repeats when someone
relogs.

Config changes are gated behind curiosidades.admin; reacting and
opting out stay default-true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ActvLGJApdxEAd2yfKPwqv
2026-08-04 20:24:54 +00:00

237 lines
8.4 KiB
Java

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 net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey;
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 org.bukkit.scheduler.BukkitTask;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.UUID;
public final class Curiosidades extends JavaPlugin implements Listener {
private final Random random = new Random();
private final Map<UUID, Long> lastFeatured = new HashMap<>();
private final Deque<String> recentFacts = new ArrayDeque<>();
private Settings settings;
private NamespacedKey optOutKey;
private BukkitTask timerTask;
private int nextSessionId = 1;
private ReactionSession active;
@Override
public void onEnable() {
saveDefaultConfig();
settings = new Settings(this);
optOutKey = new NamespacedKey(this, "opt_out");
CuriosidadeCommand command = new CuriosidadeCommand(this);
if (getCommand("curiosidade") != null) {
getCommand("curiosidade").setExecutor(command);
getCommand("curiosidade").setTabCompleter(command);
}
getServer().getPluginManager().registerEvents(this, this);
rescheduleTimer();
getLogger().info("Curiosidades ativo — modo " + settings.mode()
+ (settings.mode().firesOnTimer()
? " (intervalo de " + settings.intervalMinutes() + " min)" : ""));
}
@Override
public void onDisable() {
if (active != null) {
active.hide();
}
}
Settings settings() {
return settings;
}
/** Starts, stops or restarts the repeating announcement task to match the mode. */
void rescheduleTimer() {
if (timerTask != null) {
timerTask.cancel();
timerTask = null;
}
if (!settings.mode().firesOnTimer()) {
return;
}
long ticks = settings.intervalMinutes() * 60L * 20L;
timerTask = getServer().getScheduler().runTaskTimer(this, () -> announceRandom(null), ticks, ticks);
}
// --- announcing ---------------------------------------------------------
/**
* Announces one curiosity.
*
* @param subject who to talk about, or null to pick a random eligible player
* @return false if there was nobody eligible or nothing notable to say
*/
boolean announceRandom(Player subject) {
if (subject == null) {
List<Player> candidates = new ArrayList<>();
for (Player player : Bukkit.getOnlinePlayers()) {
if (isEligible(player)) {
candidates.add(player);
}
}
if (candidates.isEmpty()) {
return false;
}
subject = candidates.get(random.nextInt(candidates.size()));
}
List<Fact> facts = CuriosityFactory.facts(subject, settings);
if (facts.isEmpty()) {
return false;
}
// Prefer a fact that has not been announced recently; fall back to any.
List<Fact> fresh = new ArrayList<>(facts);
fresh.removeIf(fact -> recentFacts.contains(plain(fact.text())));
Fact chosen = (fresh.isEmpty() ? facts : fresh).get(random.nextInt(
(fresh.isEmpty() ? facts : fresh).size()));
remember(chosen);
lastFeatured.put(subject.getUniqueId(), System.currentTimeMillis());
broadcast(subject, chosen.text());
return true;
}
private void remember(Fact fact) {
recentFacts.addLast(plain(fact.text()));
while (recentFacts.size() > settings.noRepeat()) {
recentFacts.removeFirst();
}
}
private static String plain(Component component) {
return PlainTextComponentSerializer.plainText().serialize(component);
}
/** True if the player can currently be the subject of an announcement. */
boolean isEligible(Player player) {
if (isOptedOut(player) || player.hasPermission("curiosidades.isento")) {
return false;
}
Long last = lastFeatured.get(player.getUniqueId());
if (last == null) {
return true;
}
long cooldownMillis = settings.cooldownMinutes() * 60_000L;
return System.currentTimeMillis() - last >= cooldownMillis;
}
/** Builds the message for {@code subject} without broadcasting it. */
Component render(Player subject, Component fact) {
return 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));
}
private void broadcast(Player subject, Component fact) {
if (active != null) {
active.hide();
active = null;
}
Bukkit.broadcast(render(subject, fact));
if (!settings.reactionsEnabled()) {
return;
}
ReactionSession session = new ReactionSession(nextSessionId++, settings.reactions());
active = session;
Bukkit.broadcast(buttons(session));
session.show();
getServer().getScheduler().runTaskLater(this, () -> {
session.hide();
if (active == session) {
active = null;
}
}, settings.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 : settings.reactions().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;
}
ReactionSession activeSession() {
return active;
}
// --- events -------------------------------------------------------------
@EventHandler
public void onJoin(PlayerJoinEvent event) {
Player player = event.getPlayer();
if (active != null) {
active.showTo(player);
}
if (!settings.mode().firesOnJoin() || !isEligible(player)) {
return;
}
// Delayed so the curiosity lands after the join message rather than racing it.
getServer().getScheduler().runTaskLater(this, () -> {
if (player.isOnline()) {
announceRandom(player);
}
}, settings.joinDelaySeconds() * 20L);
}
// --- per-player opt out -------------------------------------------------
boolean isOptedOut(Player player) {
Byte value = player.getPersistentDataContainer().get(optOutKey, PersistentDataType.BYTE);
return value != null && value == 1;
}
void setOptedOut(Player player, boolean optedOut) {
player.getPersistentDataContainer()
.set(optOutKey, PersistentDataType.BYTE, (byte) (optedOut ? 1 : 0));
}
void clearCooldowns() {
lastFeatured.clear();
}
void clearHistory() {
recentFacts.clear();
}
}