Files
canalhandia/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java
T

1092 lines
45 KiB
Java

package dev.marcospaulo.canalhandia;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextDecoration;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import net.kyori.adventure.translation.TranslationStore;
import org.bukkit.Bukkit;
import org.bukkit.GameRule;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.Statistic;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.PlayerDeathEvent;
import org.bukkit.event.player.AsyncPlayerChatEvent;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerRespawnEvent;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.SkullMeta;
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.Collections;
import java.util.Deque;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.Random;
import java.util.UUID;
/**
* Chat-only social features for the Canalhandia server: curiosities, a guess
* game, polls, mourning reactions, milestones and rankings.
*
* <p>Almost nothing here touches gameplay — no items, no world edits, no
* attributes. The one exception is the {@code luto} tribute: pressing F to pay
* respects drops the dead player's head into the mourner's inventory, a symbolic
* memento. Toggle it with {@code luto.cabeca} in config. Every module can be
* switched off independently.
*/
public final class Canalhandia 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<>();
/** Recent reaction sets, newest last, so late clicks still land. */
private final Deque<Reactions> reactionHistory = new ArrayDeque<>();
/** Mourning tribute per reaction id: who died, and who already got the head. */
private final Map<Integer, Tribute> tributes = new ConcurrentHashMap<>();
/** Death coords awaiting delivery on the player's next respawn (see onDeathComic). */
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;
private Mail mail;
private DeathLog deathLog;
private OfflineStats offlineStats;
private Milestones milestones;
private Achievements achievements;
private WeeklyStats weeklyStats;
private PlayerMemory playerMemory;
private ChunkLoaders chunkLoaders;
/** 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;
private BukkitTask milestoneTask;
private long lastAnnouncement;
private int nextId = 1;
private Reactions liveReactions;
private GuessRound guessRound;
private Poll poll;
private Titles titles;
private DeathGift deathGift;
private TranslationStore<?> i18n;
@Override
public void onEnable() {
saveDefaultConfig();
// Ship the editable catalogues; false = never overwrite the operator's copy.
saveResource("conquistas-catalogo.yml", false);
saveResource("marcos-catalogo.yml", false);
i18n = I18n.install(i18n, getLogger());
settings = new Settings(this);
notes = new Notes(new java.io.File(getDataFolder(), "notas.yml"));
mail = new Mail(new java.io.File(getDataFolder(), "recados.yml"));
deathLog = new DeathLog(new java.io.File(getDataFolder(), "mortes.yml"));
offlineStats = new OfflineStats(this);
milestones = new Milestones(this);
achievements = new Achievements(this);
titles = new Titles(this);
deathGift = new DeathGift(this);
// Load the achievement catalogue from config, then silently bank any
// history the current definitions already imply (both here and for
// milestones), so an expanded catalogue never spams returning players.
Achievement.load(Achievement.loadFrom(conquistasCatalogo(), getLogger()));
achievements.syncCatalogue();
milestones.resyncSilently();
weeklyStats = new WeeklyStats(new java.io.File(getDataFolder(), "semana.yml"));
playerMemory = new PlayerMemory(new java.io.File(getDataFolder(), "ia-memoria.yml"));
chunkLoaders = new ChunkLoaders(this, new java.io.File(getDataFolder(), "chunks.yml"));
if (settings.moduleEnabled(Module.CHUNKLOADER)) {
chunkLoaders.loadAllTickets();
chunkLoaders.startSimulation();
ChunkAnchorItem.registerRecipe(this);
}
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, () -> chunkLoaders, getLogger(),
() -> settings.moduleEnabled(Module.NOTAS) && settings.notesOnMap(),
() -> settings.moduleEnabled(Module.CHUNKLOADER) && settings.chunkLoaderBlueMap());
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
// re-snapshotted.
RecipeBook.preload();
optOutKey = new NamespacedKey(this, "opt_out");
CanalhandiaCommand root = new CanalhandiaCommand(this);
for (String name : List.of("canalhandia", "curiosidade", "adivinha", "enquete", "ranking",
"reagir", "reacoes", "palpite", "votar", "legal", "wow", "top", "f", "ia", "iap",
"errado", "nota", "save", "recado", "recados", "mortes", "conquistas",
"perfil", "titulo", "chunkloader")) {
register(name, root);
}
getServer().getPluginManager().registerEvents(this, this);
getServer().getPluginManager().registerEvents(new TitleChatListener(this), this);
getServer().getPluginManager().registerEvents(new ChunkLoaderListener(this), this);
rescheduleTimer();
rescheduleMilestones();
getLogger().info("Canalhandia ativo — curiosidades em modo " + settings.mode()
+ ", módulos: " + enabledModules());
}
private void register(String name, CanalhandiaCommand handler) {
if (getCommand(name) != null) {
getCommand(name).setExecutor(handler);
getCommand(name).setTabCompleter(handler);
} else {
getLogger().warning("Comando /" + name + " não está no plugin.yml");
}
}
private String enabledModules() {
List<String> on = new ArrayList<>();
for (Module module : Module.values()) {
if (settings.moduleEnabled(module)) {
on.add(module.key());
}
}
return on.isEmpty() ? "nenhum" : String.join(", ", on);
}
@Override
public void onDisable() {
if (poll != null) {
poll.hide();
}
if (chunkLoaders != null) {
chunkLoaders.stopSimulation();
chunkLoaders.unloadAllTickets();
chunkLoaders.close();
ChunkAnchorItem.unregisterRecipe(this);
}
}
Settings settings() {
return settings;
}
OfflineStats offlineStats() {
return offlineStats;
}
Ai ai() {
return ai;
}
/** Persistent player memory and personal AI settings. Never null. */
PlayerMemory playerMemory() {
return playerMemory;
}
/** Recent public chat, for the AI's ambient context. Never null. */
ChatLog chatLog() {
return chatLog;
}
/** Player notes, public and private. Never null. */
Notes notes() {
return notes;
}
/** Offline messages waiting for delivery. Never null. */
Mail mail() {
return mail;
}
/** Recent deaths per player, for /mortes. Never null. */
DeathLog deathLog() {
return deathLog;
}
/** Named achievements. Never null. */
Achievements achievements() {
return achievements;
}
/** Active chunk loaders. Never null. */
ChunkLoaders chunkLoaders() {
return chunkLoaders;
}
/** The title each player has chosen to wear in chat. Never null. */
Titles titles() {
return titles;
}
/** Milestones, exposed for the reload confirmation. Never null. */
Milestones milestones() {
return milestones;
}
/** The achievement catalogue section from conquistas-catalogo.yml (may be null if malformed). */
org.bukkit.configuration.ConfigurationSection conquistasCatalogo() {
return org.bukkit.configuration.file.YamlConfiguration
.loadConfiguration(new java.io.File(getDataFolder(), "conquistas-catalogo.yml"))
.getConfigurationSection("conquistas");
}
/** The milestone catalogue section from marcos-catalogo.yml (may be null if malformed). */
org.bukkit.configuration.ConfigurationSection marcosCatalogo() {
return org.bukkit.configuration.file.YamlConfiguration
.loadConfiguration(new java.io.File(getDataFolder(), "marcos-catalogo.yml"))
.getConfigurationSection("marcos");
}
/**
* Reloads the achievement and milestone catalogues from disk and silently
* rebanks any newly implied history. Driven by {@code /canalhandia reload}.
*/
void reloadCatalogo() {
Achievement.load(Achievement.loadFrom(conquistasCatalogo(), getLogger()));
milestones.reload();
achievements.syncCatalogue();
deathGift.reload();
}
/** Reloads the i18n bundles from the jar and re-registers the translator. */
void reloadI18n() {
i18n = I18n.install(i18n, getLogger());
}
/** 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. */
void rescheduleTimer() {
if (timerTask != null) {
timerTask.cancel();
timerTask = null;
}
if (!settings.moduleEnabled(Module.CURIOSIDADES) || !settings.mode().firesOnTimer()) {
return;
}
long ticks = settings.intervalMinutes() * 60L * 20L;
timerTask = getServer().getScheduler()
.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;
}
long ticks = 5L * 60L * 20L;
milestoneTask = getServer().getScheduler().runTaskTimer(this, () -> {
if (settings.moduleEnabled(Module.MARCOS)) {
milestones.check();
}
achievements.check();
rotateWeeklyIfDue();
}, ticks, ticks);
}
// --- curiosities --------------------------------------------------------
/** A fact plus who it is about, so callers do not have to re-resolve the player. */
private record Chosen(Player subject, Fact fact) {
}
/**
* Announces one curiosity.
*
* @param subject who to talk about, or null to pick a random eligible player
* @return false if nobody was eligible or there was nothing notable to say
*/
boolean announceCuriosity(Player subject) {
return announceCuriosity(subject, true);
}
/**
* Announces one curiosity.
*
* @param subject who to talk about, or null to pick a random eligible player
* @param automatic true for join/timer triggers, which are rate-limited so a
* wave of simultaneous joins produces one curiosity instead
* of one per player; false for an explicit command
* @return false if nothing was announced
*/
boolean announceCuriosity(Player subject, boolean automatic) {
if (!settings.moduleEnabled(Module.CURIOSIDADES)) {
return false;
}
if (automatic && System.currentTimeMillis() - lastAnnouncement
< settings.minGapSeconds() * 1000L) {
return false;
}
Chosen chosen = pickFact(subject == null ? pickSubject() : subject);
if (chosen == null) {
return false;
}
lastAnnouncement = System.currentTimeMillis();
Component headline = Msg.tag("Curiosidade", NamedTextColor.GOLD)
.append(Component.text("Sabia que o ", NamedTextColor.WHITE)
.decoration(TextDecoration.BOLD, false))
.append(Component.text(chosen.subject().getName(), NamedTextColor.GREEN)
.decoration(TextDecoration.BOLD, false))
.append(Component.text(" ", NamedTextColor.WHITE)
.decoration(TextDecoration.BOLD, false))
.append(chosen.fact().text().decoration(TextDecoration.BOLD, false))
.append(Component.text("?", NamedTextColor.WHITE)
.decoration(TextDecoration.BOLD, false));
if (!settings.reactionsEnabled()) {
Bukkit.broadcast(headline);
return true;
}
// One message, not two: the buttons ride along on the same broadcast so
// a curiosity costs a single chat entry.
Reactions reactions = openReactions();
broadcastPerPlatform(bedrock -> headline.append(Component.newline())
.append(reactions.buttons(bedrock)));
return true;
}
private Chosen pickFact(Player subject) {
if (subject == null) {
return null;
}
List<Fact> facts = CuriosityFactory.facts(subject, settings);
if (facts.isEmpty()) {
return null;
}
List<Fact> fresh = new ArrayList<>(facts);
fresh.removeIf(fact -> recentFacts.contains(plain(fact.text())));
List<Fact> pool = fresh.isEmpty() ? facts : fresh;
Fact fact = pool.get(random.nextInt(pool.size()));
recentFacts.addLast(plain(fact.text()));
while (recentFacts.size() > settings.noRepeat()) {
recentFacts.removeFirst();
}
lastFeatured.put(subject.getUniqueId(), System.currentTimeMillis());
return new Chosen(subject, fact);
}
private Player pickSubject() {
List<Player> candidates = new ArrayList<>();
for (Player player : Bukkit.getOnlinePlayers()) {
if (isEligible(player)) {
candidates.add(player);
}
}
return candidates.isEmpty() ? null : candidates.get(random.nextInt(candidates.size()));
}
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("canalhandia.isento")) {
return false;
}
Long last = lastFeatured.get(player.getUniqueId());
if (last == null) {
return true;
}
return System.currentTimeMillis() - last >= settings.cooldownMinutes() * 60_000L;
}
// --- reactions ----------------------------------------------------------
/**
* Sends a message that renders differently per platform.
*
* <p>Bedrock cannot show emoji or run a chat clickEvent, so anything with
* buttons has to be built twice. The console gets the Java rendering.
*/
void broadcastPerPlatform(java.util.function.Function<Boolean, Component> builder) {
Component javaVersion = builder.apply(false);
Component bedrockVersion = builder.apply(true);
Bukkit.getConsoleSender().sendMessage(javaVersion);
for (Player player : Bukkit.getOnlinePlayers()) {
player.sendMessage(Platform.isBedrock(player) ? bedrockVersion : javaVersion);
}
}
/**
* Creates a reaction set and schedules its close. The caller is responsible
* for broadcasting {@link Reactions#buttons}, so the buttons can share a
* message with whatever they belong to.
*/
private Reactions openReactions() {
Reactions reactions = new Reactions(nextId++, settings.reactions());
liveReactions = reactions;
remember(reactions);
getServer().getScheduler().runTaskLater(this, () -> {
if (liveReactions == reactions) {
liveReactions = null;
}
// Chat cannot be edited, so the result gets one closing line — and
// only if anyone actually reacted, to avoid noise.
if (reactions.hasAnyVote()) {
broadcastPerPlatform(bedrock -> Component.text(" ")
.append(reactions.summary(bedrock, settings.summaryNames())));
}
}, settings.reactionWindowSeconds() * 20L);
return reactions;
}
/**
* Opens the reaction row on a public AI answer: 👍 (reuses the shared
* {@code joia}/{@code /legal} reaction so the typed Bedrock twin resolves
* via {@link Settings#reactionForCommand}) and ❌ ({@code /errado}, new).
*
* <p>The {@code askerId} param is kept in the signature because
* {@link Ai#deliver} passes it, but is not used structurally here.
*/
void openAiReactions(UUID askerId) {
if (!settings.reactionsEnabled()) {
return;
}
Reactions reactions = new Reactions(nextId++, List.of(
new ReactionDef("joia", "[👍]", "[+1]", "legal"),
new ReactionDef("errado", "[❌]", "[ERRADO]", "errado")));
liveReactions = reactions;
remember(reactions);
getServer().getScheduler().runTaskLater(this, () -> {
if (liveReactions == reactions) {
liveReactions = null;
}
if (reactions.hasAnyVote()) {
broadcastPerPlatform(bedrock -> Component.text(" ")
.append(reactions.summary(bedrock, settings.summaryNames())));
}
}, settings.reactionWindowSeconds() * 20L);
broadcastPerPlatform(bedrock -> Component.text(" ")
.append(reactions.buttons(bedrock)));
}
/**
* The newest reaction set still accepting clicks, for typed shortcuts like
* {@code /wow} where the player never sees an id.
*/
Reactions latestReactions() {
long limit = settings.reactionValidityMinutes() * 60_000L;
Reactions best = null;
for (Reactions reactions : reactionHistory) {
if (reactions.ageMillis() <= limit) {
best = reactions;
}
}
return best;
}
private void remember(Reactions reactions) {
reactionHistory.addLast(reactions);
while (reactionHistory.size() > 8) {
Reactions oldest = reactionHistory.removeFirst();
tributes.remove(oldest.id());
}
}
/**
* Finds a reaction set that is still accepting clicks. The reaction window
* closes after {@code janela-reacao-segundos}, but people scroll back and
* click minutes later, so clicks stay valid for {@code reacao-validade-minutos}.
*/
Reactions findReactions(int id) {
long limit = settings.reactionValidityMinutes() * 60_000L;
for (Reactions reactions : reactionHistory) {
if (reactions.id() == id) {
return reactions.ageMillis() <= limit ? reactions : null;
}
}
return null;
}
// --- guess game ---------------------------------------------------------
/** Starts a round of "adivinhe de quem é". Needs at least two players online. */
boolean startGuess() {
if (!settings.moduleEnabled(Module.ADIVINHA)) {
return false;
}
List<Player> online = new ArrayList<>(Bukkit.getOnlinePlayers());
if (online.size() < 2) {
return false;
}
Chosen chosen = pickFact(pickSubject());
if (chosen == null) {
return false;
}
List<String> candidates = new ArrayList<>();
for (Player player : online) {
candidates.add(player.getName());
}
Collections.shuffle(candidates);
// Trim to a readable row, but never drop the right answer.
while (candidates.size() > 6) {
int last = candidates.size() - 1;
if (candidates.get(last).equals(chosen.subject().getName())) {
Collections.swap(candidates, 0, last);
}
candidates.remove(candidates.size() - 1);
}
GuessRound round = new GuessRound(nextId++, chosen.subject(), chosen.fact().text(), candidates);
guessRound = round;
broadcastPerPlatform(bedrock -> round.question("/canalhandia palpite", bedrock));
getServer().getScheduler().runTaskLater(this, () -> {
if (guessRound == round && !round.finished()) {
Bukkit.broadcast(round.reveal());
guessRound = null;
}
}, settings.guessSeconds() * 20L);
return true;
}
GuessRound guessRound() {
return guessRound;
}
// --- polls --------------------------------------------------------------
Poll poll() {
return poll;
}
/** Opens a poll, closing any poll already running. */
void startPoll(String question, List<String> options, String author) {
if (poll != null && !poll.closed()) {
Bukkit.broadcast(poll.close());
}
Poll started = new Poll(nextId++, question, options, author);
poll = started;
broadcastPerPlatform(bedrock -> started.announcement("/canalhandia votar", bedrock));
started.show();
getServer().getScheduler().runTaskLater(this, () -> {
if (poll == started && !started.closed()) {
Bukkit.broadcast(started.close());
}
}, settings.pollMinutes() * 60L * 20L);
}
// --- events -------------------------------------------------------------
@EventHandler
public void onJoin(PlayerJoinEvent event) {
Player player = event.getPlayer();
if (poll != null) {
poll.showTo(player);
}
if (!settings.moduleEnabled(Module.CURIOSIDADES)
|| !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()) {
announceCuriosity(player, true);
}
}, 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());
Persona persona = playerMemory != null
? playerMemory.persona(player.getUniqueId(), settings.aiPersona())
: settings.aiPersona();
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 e na sua personalidade, em uma frase curta.",
aiBudget, persona);
}, Math.max(1, settings.joinDelaySeconds()) * 20L);
}
/**
* Delivers any messages waiting for a joining player.
*
* <p>A handler of its own rather than a branch inside {@link #onJoin},
* which returns early when the {@code curiosidades} module is off — mail
* must not depend on an unrelated module being enabled.
*
* <p>Delayed like the curiosity, so the messages land after the join line
* rather than racing it, and re-checked for {@code isOnline} because a
* player can leave inside the delay and the mail would then be consumed
* without anyone reading it.
*/
@EventHandler
public void onJoinMail(PlayerJoinEvent event) {
if (!settings.moduleEnabled(Module.RECADOS)) {
return;
}
Player player = event.getPlayer();
String id = player.getUniqueId().toString();
if (mail.countFor(id) == 0) {
return;
}
getServer().getScheduler().runTaskLater(this, () -> {
if (!player.isOnline()) {
return;
}
// takeFor is destructive, so it is called only once we know the
// player is still here to read the result.
List<Mail.Message> waiting = mail.takeFor(id);
if (waiting.isEmpty()) {
return;
}
player.sendMessage(Msg.tag("Recados", NamedTextColor.AQUA)
.append(Component.text(waiting.size() == 1
? "1 recado para você:"
: waiting.size() + " recados para você:", NamedTextColor.GRAY)));
for (Mail.Message message : waiting) {
player.sendMessage(Component.text(" " + message.fromName() + " ",
NamedTextColor.AQUA)
.append(Component.text("(" + Msg.ago(message.sentAt()) + "): ",
NamedTextColor.DARK_GRAY))
.append(Component.text(message.text(), NamedTextColor.WHITE)));
}
}, Math.max(1, settings.joinDelaySeconds()) * 20L);
}
/**
* Chat gag: a message matching the {@code zoacao} trigger (pattern + match
* mode, default a bare "f") gets replaced with a random line from
* {@code zoacao.mensagens}. Pure chat swap — the player's name still
* prefixes it as normal. Independent of {@code luto}: paying respects still
* needs the {@code [F]} button (Java) or {@code /f} command. Editable
* in-game via {@code /canalhandia zoacao ...}.
*/
@EventHandler
public void onChatF(AsyncPlayerChatEvent event) {
if (!settings.moduleEnabled(Module.ZOACAO)) {
return;
}
String gag = Zoacao.replace(event.getMessage(), settings.zoacaoMode(),
settings.zoacaoPattern(), settings.zoacaoMessages(), random);
if (gag != null) {
event.setMessage(gag);
}
}
/**
* Records public chat for the AI's ambient context.
*
* <p>{@code MONITOR} priority and {@code ignoreCancelled}: this runs after
* every other handler, so what is stored is the message the room actually
* saw — a {@code zoacao} swap included — and a message some plugin cancelled
* is never stored, because nobody read it.
*
* <p>Recording is unconditional apart from the IA module toggle: it is a
* plain in-memory ring buffer, nothing is written to disk, and it is only
* ever read when someone asks the AI a question.
*/
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onChatLog(AsyncPlayerChatEvent event) {
if (!settings.moduleEnabled(Module.IA) || settings.aiChatContextLines() == 0) {
return;
}
chatLog.add(event.getPlayer().getName(), event.getMessage());
}
/** "Press F" — a mourning button under each death message. */
@EventHandler
public void onDeath(PlayerDeathEvent event) {
if (!settings.moduleEnabled(Module.LUTO)) {
return;
}
Reactions mourning = new Reactions(nextId++,
List.of(new ReactionDef("f", "[F]", "[F]", "f")));
liveReactions = mourning;
remember(mourning);
String name = event.getEntity().getName();
if (settings.lutoHeadReward()) {
tributes.put(mourning.id(), new Tribute(event.getEntity().getUniqueId(), name));
}
// One tick later so it prints under the vanilla death message.
getServer().getScheduler().runTaskLater(this, () -> broadcastPerPlatform(bedrock -> {
Component button = Component.text("[F] ", NamedTextColor.YELLOW);
if (!bedrock) {
button = button.clickEvent(ClickEvent.runCommand(
"/canalhandia reagir " + mourning.id() + " f"));
}
Component prompt = Lang.tr(bedrock
? "canalhandia.morte.luto.digitar"
: "canalhandia.morte.luto.prestar",
Component.text(name));
return Component.text(" ").append(button)
.append(prompt.color(NamedTextColor.GRAY));
}), 2L);
getServer().getScheduler().runTaskLater(this, () -> {
if (mourning.hasAnyVote()) {
// One line, names truncated, so a busy death does not fill the screen.
List<String> who = mourning.names("f");
int shown = Math.min(who.size(), settings.summaryNames());
String text = String.join(", ", who.subList(0, shown))
+ (who.size() > shown ? " +" + (who.size() - shown) : "");
Component summary = Lang.tr("canalhandia.morte.luto.resumo",
Component.text(text), Component.text(name));
Bukkit.broadcast(Component.text(" ")
.append(summary.color(NamedTextColor.GRAY)));
}
if (liveReactions == mourning) {
liveReactions = null;
}
}, settings.reactionWindowSeconds() * 20L);
}
/**
* Rescues player items when falling into the void. Places a chest on the nearest
* safe ground block, or preserves items directly in the inventory if no solid ground is nearby.
*/
@EventHandler(priority = EventPriority.HIGH)
public void onVoidDeath(PlayerDeathEvent event) {
if (!settings.moduleEnabled(Module.SALVAVOID)) {
return;
}
Player player = event.getEntity();
EntityDamageEvent damage = player.getLastDamageCause();
EntityDamageEvent.DamageCause cause = damage == null ? null : damage.getCause();
if (!VoidProtection.isVoidDeath(player.getLocation().getY(), player.getWorld().getMinHeight(), cause)) {
return;
}
List<ItemStack> drops = new ArrayList<>(event.getDrops());
Location safeLoc = VoidProtection.findSafeChestLocation(player.getWorld(), player.getLocation(), settings.voidProtectionRadius());
if (safeLoc != null && !drops.isEmpty() && VoidProtection.rescueToChest(drops, safeLoc)) {
event.getDrops().clear();
if (settings.voidProtectionKeepXp()) {
event.setKeepLevel(true);
event.setDroppedExp(0);
}
int x = safeLoc.getBlockX();
int y = safeLoc.getBlockY();
int z = safeLoc.getBlockZ();
getServer().getScheduler().runTaskLater(this, () -> {
if (player.isOnline()) {
player.sendMessage(Component.text("[Canalhandia] ", NamedTextColor.GOLD)
.append(Component.text("Você caiu no vácuo! Seus itens foram guardados em segurança num baú em ", NamedTextColor.YELLOW))
.append(Component.text(x + ", " + y + ", " + z, NamedTextColor.AQUA, TextDecoration.BOLD))
.append(Component.text(".", NamedTextColor.YELLOW)));
}
}, 20L);
} else {
// No safe ground found within radius: keep inventory directly
event.setKeepInventory(true);
event.getDrops().clear();
if (settings.voidProtectionKeepXp()) {
event.setKeepLevel(true);
event.setDroppedExp(0);
}
getServer().getScheduler().runTaskLater(this, () -> {
if (player.isOnline()) {
player.sendMessage(Component.text("[Canalhandia] ", NamedTextColor.GOLD)
.append(Component.text("Você caiu no vácuo sem terra firme por perto! Seus itens foram mantidos no seu inventário.", NamedTextColor.GREEN)));
}
}, 20L);
}
}
/**
* Comic death broadcast + private coordinates, gated by the {@code mortes}
* module. Replaces the vanilla translatable death message with a pt-BR
* flavor line ("Fulano foi achatado como panqueca (47ª morte)") and sends
* the death location only to the dead player, so they can run back to their
* dropped items. Java players get a click-to-copy coordinate; Bedrock gets
* plain text (no chat clickEvent on Geyser).
*
* <p>Coexists with {@link #onDeath}: that handler only broadcasts the
* {@code [F]} mourning row and never touches {@code deathMessage}, so both
* fire on the same event without conflict.
*/
@EventHandler
public void onDeathComic(PlayerDeathEvent event) {
if (!settings.moduleEnabled(Module.MORTES)) {
return;
}
Player player = event.getEntity();
EntityDamageEvent damage = player.getLastDamageCause();
EntityDamageEvent.DamageCause cause = damage == null ? null : damage.getCause();
Entity killer = player.getKiller();
if (killer == null && damage instanceof EntityDamageByEntityEvent byEntity) {
killer = byEntity.getDamager();
}
String flavor = DeathFlavor.flavor(cause, killer);
// Paper fires PlayerDeathEvent inside LivingEntity.die, before the
// minecraft:deaths stat is awarded, so +1 makes this death count. The
// log line lets an operator confirm on the first real death and drop
// the +1 if their server increments the stat before the event.
long deaths = player.getStatistic(Statistic.DEATHS);
long shown = deaths + 1;
getLogger().info("[mortes] " + player.getName() + " stat=" + deaths + " mostrando=" + shown);
event.deathMessage(Component.text(player.getName() + " " + flavor + " ("
+ DeathFlavor.ordinal(shown) + " morte)", NamedTextColor.YELLOW));
// Private coords to the dead player only — never broadcast, so others
// don't learn where to loot. Delivered on respawn (not at death): the
// Java death screen swallows chat sent during PlayerDeathEvent, so
// sending it then quietly failed. Java: clickable copy; Bedrock: plain.
Location loc = player.getLocation();
String coords = loc.getBlockX() + " " + loc.getBlockY() + " " + loc.getBlockZ()
+ " (" + loc.getWorld().getName() + ")";
boolean keepInventory = event.getKeepInventory() || Boolean.TRUE.equals(
loc.getWorld().getGameRuleValue(GameRule.KEEP_INVENTORY));
pendingDeathCoords.put(player.getUniqueId(), new DeathCoords(coords, keepInventory));
// Keep the death instead of discarding it once the coords are delivered,
// so /mortes can answer "onde eu morri com o pico de diamante?" a day
// later. The world label is the pt-BR one, matching how notes read.
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()) {
Persona persona = playerMemory != null
? playerMemory.persona(player.getUniqueId(), settings.aiPersona())
: settings.aiPersona();
ai.saySomething(player.getName(),
"O jogador " + player.getName() + " morreu " + count
+ " vezes seguidas em poucos minutos. A última foi assim: " + flavor
+ ". Comente na sua personalidade, sem ofender de verdade.",
aiBudget, persona);
// Reset so the next comment needs a fresh run rather than firing on
// every death from here on.
deathStreak.remove(player.getUniqueId());
}
}
/**
* Sends the death coordinates once the player has actually respawned and
* can act on them. The death screen ate the message when it was sent
* synchronously during {@link PlayerDeathEvent}.
*/
@EventHandler
public void onRespawn(PlayerRespawnEvent event) {
Player player = event.getPlayer();
DeathCoords dc = pendingDeathCoords.remove(player.getUniqueId());
if (dc == null) {
return;
}
String tail = dc.keepInventory() ? "" : ". Corre buscar seus itens!";
getServer().getScheduler().runTaskLater(this, () -> {
Component msg;
if (Platform.isBedrock(player)) {
msg = Component.text("Você morreu em " + dc.coords() + tail, NamedTextColor.AQUA);
} else {
msg = Component.text("Você morreu em ", NamedTextColor.AQUA)
.append(Component.text(dc.coords(), NamedTextColor.WHITE)
.clickEvent(ClickEvent.copyToClipboard(dc.coords())))
.append(Component.text(tail, NamedTextColor.AQUA));
}
player.sendMessage(msg);
// A comic consolation item, given once they can actually hold it.
// Gameplay-neutral by design (a poppy, a wilted bush) — just a laugh.
if (deathGift.active()) {
deathGift.give(player);
}
}, 1L);
}
/**
* Called after any successful reaction. For the mourning {@code f} reaction
* this drops the dead player's head into the mourner's inventory — once per
* mourner per death, and never to the dead player themselves.
*/
void afterReact(Player mourner, int reactionId, String key) {
if (!"f".equals(key)) {
return;
}
Tribute tribute = tributes.get(reactionId);
if (tribute == null) {
return;
}
if (mourner.getUniqueId().equals(tribute.deadId)) {
return;
}
if (!tribute.rewarded.add(mourner.getUniqueId())) {
return; // already got the head for this death
}
ItemStack head = new ItemStack(Material.PLAYER_HEAD);
head.editMeta(SkullMeta.class, m -> {
m.setPlayerProfile(Bukkit.createProfile(tribute.deadId, tribute.deadName));
m.displayName(Component.text("Cabeça de " + tribute.deadName, NamedTextColor.GOLD));
});
for (ItemStack overflow : mourner.getInventory().addItem(head).values()) {
mourner.getWorld().dropItemNaturally(mourner.getLocation(), overflow);
}
mourner.sendMessage(Component.text(
"Você prestou luto e levou a cabeça de " + tribute.deadName + ".",
NamedTextColor.GOLD));
}
/** Who died for a mourning reaction set, and who has already been rewarded. */
private static final class Tribute {
final UUID deadId;
final String deadName;
final Set<UUID> rewarded = ConcurrentHashMap.newKeySet();
Tribute(UUID deadId, String deadName) {
this.deadId = deadId;
this.deadName = deadName;
}
}
/** Death location captured at death, delivered at respawn. */
private record DeathCoords(String coords, boolean keepInventory) {
}
/**
* Drops a player's short-term AI memory on quit, so a rejoin does not
* answer a fresh question with an old one (carry-forward #6).
*/
@EventHandler
public void onQuit(org.bukkit.event.player.PlayerQuitEvent event) {
if (ai != null) {
ai.conversations().forget(event.getPlayer().getUniqueId());
}
// Quitting on the death screen means no respawn fires for this death;
// drop the pending coords so they never deliver stale next session.
pendingDeathCoords.remove(event.getPlayer().getUniqueId());
}
// --- 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();
}
void runMilestoneCheck() {
milestones.check();
}
}