1092 lines
45 KiB
Java
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();
|
|
}
|
|
}
|