diff --git a/README.md b/README.md new file mode 100644 index 0000000..664713d --- /dev/null +++ b/README.md @@ -0,0 +1,201 @@ +# Canalhandia + +Chat-only social features for the Canalhandia Minecraft server (Paper 26.2). + +**Nothing here touches gameplay.** No items, no world edits, no attributes, no +economy. Everything is chat messages, boss bars and clickable buttons, and every +module can be switched off independently. + +All player-facing text is Portuguese (pt-BR). + +--- + +## Modules + +| Module | What it does | +|---|---| +| `curiosidades` | *"Sabia que o Fulano já minerou 5.966 blocos de Pedra?"* — a fact about a player, with reaction buttons. Fires on join by default. | +| `adivinha` | The same fact with the name hidden, plus clickable player names. Reveals after 45s and names who guessed right. | +| `luto` | A clickable `[F]` under each death message, with a count when the window closes. | +| `enquete` | `/enquete Pergunta \| A \| B` — clickable voting with a live tally on the boss bar. | +| `ranking` | `/ranking mineracao` and friends. Covers **offline players too**. | +| `marcos` | Announces round milestones — 100 km walked, 24 hours played — the first time someone crosses one. | + +Toggle any of them: `/canalhandia modulo ` + +--- + +## Where the data comes from + +Everything is derived from **vanilla statistics**. No database, no tracking code, +no extra writes — the server was already recording all of it. + +- **Online players** use the Bukkit API (`Player#getStatistic`). +- **Rankings** read `/players/stats/.json` directly, because Bukkit + only exposes statistics for players who are online. `usercache.json` maps those + UUIDs back to names, including Floodgate/Bedrock players whose UUIDs start with + `00000000-0000-0000-0009`. + +Note the path: Paper writes to `/players/stats`, **not** `/stats`. +`OfflineStats` checks both. + +--- + +## Two constraints worth knowing + +These shaped the design, and anyone changing the code should know them before +"fixing" what looks odd. + +### 1. Chat messages cannot be edited after sending + +There is no vanilla way to update a message that is already in the chat log. So +the counts baked into the reaction buttons are **frozen at send time** and never +change. The live numbers appear on three other surfaces instead: + +- a **boss bar** while the window is open (`janela-reacao-segundos`, default 90) +- an **action bar** shown to whoever just reacted +- a **final tally line** broadcast when the window closes + +An earlier version only had the boss bar, and it read as broken — the buttons +showed no number at all. + +### 2. Clicks arrive late + +People scroll back and click minutes after a message. Reactions therefore keep +counting for `reacao-validade-minutos` (default 15) even after the boss bar is +gone, and the last 8 reaction sets stay in memory for that reason. Silently +dropping a late click looks like a bug to the player. + +### 3. Names are translated by the client, not by us + +Block, item and mob names are emitted as **translatable components** +(`Component.translatable(material.translationKey())`), so a pt-BR client renders +"Pedra" and an en-US client renders "Stone" from the same broadcast. There is no +translation table to maintain. + +The consequence: the client only supplies the **singular** form. Every sentence +is therefore phrased so the number never has to agree with the noun — +*"5.966 blocos de Pedra"*, never *"5.966 Pedras"*. Keep that rule when adding +sentences to `CuriosityFactory`. + +Note that the **server console** renders translatable components in English, so +`[Curiosidade] ... 16 unidades de Copper Pickaxe` in `latest.log` does not mean +players saw English. + +--- + +## Commands + +Player-facing: + +``` +/curiosidade anuncia uma curiosidade agora +/curiosidade anuncia sobre alguém específico +/curiosidade ver [jogador] mostra só para você +/curiosidade listar [jogador] lista todas as curiosidades disponíveis +/curiosidade toggle entra/sai do sorteio +/adivinha inicia uma rodada de "adivinhe de quem é" +/enquete Pergunta | A | B abre uma enquete +/enquete encerrar encerra a enquete aberta +/ranking [categoria] placares do servidor +/canalhandia status mostra toda a configuração +/canalhandia modulos lista os módulos e seu estado +``` + +Admin (`canalhandia.admin`): + +``` +/canalhandia modulo liga/desliga um módulo +/canalhandia marcos força uma verificação de marcos +/canalhandia limpar [cooldown|historico|tudo] +/canalhandia reload +/curiosidade modo +/curiosidade intervalo intervalo do modo temporizado +/curiosidade atraso espera após o jogador entrar +/curiosidade cooldown mínimo entre citar o mesmo jogador +/curiosidade repetir quantas recentes evitar repetir +/curiosidade janela duração da barra de reações +/curiosidade validade por quanto tempo cliques ainda contam +/curiosidade reacoes +/curiosidade reacao add +/curiosidade reacao remover +/curiosidade categoria +``` + +Every setter **writes through to `config.yml` immediately**, so in-game changes +survive a restart. + +### Permissions + +| Permission | Default | Grants | +|---|---|---| +| `canalhandia.reagir` | everyone | react, press F | +| `canalhandia.ver` | everyone | `ver`, `listar`, `/ranking` | +| `canalhandia.enquete` | everyone | open polls | +| `canalhandia.forcar` | op | trigger curiosities and guess rounds | +| `canalhandia.admin` | op | change modules and all settings | +| `canalhandia.isento` | nobody | never be the subject | + +Permissions are **declared explicitly** in `plugin.yml`. An undeclared Bukkit +permission falls back to op-only, which would silently stop normal players from +reacting. + +--- + +## Building + +Requires **JDK 25**. Paper 26.2's API ships Java 25 class files, and JDK 21 fails +with a misleading `cannot access org.bukkit.Bukkit` — that phrasing means the +class-file version is too new, not that the dependency is missing. + +```bash +docker run --rm -v "$PWD":/work -v "$HOME/.m2":/root/.m2 -w /work \ + maven:3.9-eclipse-temurin-25 mvn -B package +``` + +Output: `target/Canalhandia-1.0.0.jar` + +The dependency uses Paper's newer coordinate scheme: +`io.papermc.paper:paper-api:26.2.build.92-stable`. + +### Deploying + +Copy the jar into the server's `plugins/` and restart. There is no hot-reload +path for a new jar — `/canalhandia reload` only re-reads `config.yml`. + +```bash +POD=$(microk8s kubectl get pod -n minecraft -l app=crafty-controller -o name | head -1) +SRV=/crafty/servers/6e39a8b2-300b-42d6-8139-f397c23e461b +microk8s kubectl cp target/Canalhandia-1.0.0.jar minecraft/${POD#pod/}:$SRV/plugins/Canalhandia-1.0.0.jar +``` + +--- + +## Source layout + +| File | Role | +|---|---| +| `Canalhandia.java` | Plugin entry point, scheduling, broadcasting, listeners | +| `CanalhandiaCommand.java` | Every command and all click callbacks | +| `Settings.java` | Typed config access; all setters persist immediately | +| `Module.java` / `Category.java` / `Mode.java` | Toggleable feature, fact group, trigger mode | +| `CuriosityFactory.java` | Builds the Portuguese sentences from statistics | +| `Stats.java` | Defensive Bukkit statistics access | +| `Fact.java` | One sentence plus its category | +| `Reactions.java` | Reaction state, buttons, boss bar, tally | +| `GuessRound.java` | "Adivinhe de quem é" round state | +| `Poll.java` | Poll state, voting, results | +| `Milestones.java` | Threshold tracking, persisted to `marcos.yml` | +| `OfflineStats.java` | Reads stats JSON for offline players | +| `RankingMetric.java` | Leaderboard columns and their formatting | +| `Msg.java` | Shared chat formatting and pt-BR number/duration formatting | + +### Adding a curiosity + +Add one line to `CuriosityFactory.facts(...)` using the existing helpers +(`material`, `entities`, `distance`, `time`, `count`), pick a `Category`, and +phrase it so the count never has to agree with a translated noun. + +Statistic constants get renamed between Minecraft releases, so resolve them via +`Stats.resolve("NEW_NAME", "OLD_NAME")` — a rename then degrades one curiosity +instead of breaking the whole announcement. diff --git a/pom.xml b/pom.xml index de024f5..0868175 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ 4.0.0 dev.marcospaulo - curiosidades + canalhandia 1.0.0 jar @@ -32,7 +32,7 @@ - Curiosidades-${project.version} + Canalhandia-${project.version} src/main/resources diff --git a/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java b/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java new file mode 100644 index 0000000..4d178cc --- /dev/null +++ b/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java @@ -0,0 +1,421 @@ +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 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.entity.PlayerDeathEvent; +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.Collections; +import java.util.Deque; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +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. + * + *

Nothing here touches gameplay — no items, no world edits, no attributes. + * Every module can be switched off independently. + */ +public final class Canalhandia extends JavaPlugin implements Listener { + + private final Random random = new Random(); + private final Map lastFeatured = new HashMap<>(); + private final Deque recentFacts = new ArrayDeque<>(); + /** Recent reaction sets, newest last, so late clicks still land. */ + private final Deque reactionHistory = new ArrayDeque<>(); + + private Settings settings; + private OfflineStats offlineStats; + private Milestones milestones; + private NamespacedKey optOutKey; + private BukkitTask timerTask; + private BukkitTask milestoneTask; + + private int nextId = 1; + private Reactions liveReactions; + private GuessRound guessRound; + private Poll poll; + + @Override + public void onEnable() { + saveDefaultConfig(); + settings = new Settings(this); + offlineStats = new OfflineStats(this); + milestones = new Milestones(this); + optOutKey = new NamespacedKey(this, "opt_out"); + + CanalhandiaCommand root = new CanalhandiaCommand(this); + register("canalhandia", root); + register("curiosidade", root); + register("adivinha", root); + register("enquete", root); + register("ranking", root); + + getServer().getPluginManager().registerEvents(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 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 (liveReactions != null) { + liveReactions.hide(); + } + if (poll != null) { + poll.hide(); + } + } + + Settings settings() { + return settings; + } + + OfflineStats offlineStats() { + return offlineStats; + } + + // --- 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); + } + + void rescheduleMilestones() { + if (milestoneTask != null) { + milestoneTask.cancel(); + milestoneTask = null; + } + if (!settings.moduleEnabled(Module.MARCOS)) { + return; + } + long ticks = 5L * 60L * 20L; + milestoneTask = getServer().getScheduler().runTaskTimer(this, milestones::check, 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) { + if (!settings.moduleEnabled(Module.CURIOSIDADES)) { + return false; + } + Chosen chosen = pickFact(subject == null ? pickSubject() : subject); + if (chosen == null) { + return false; + } + Bukkit.broadcast(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))); + openReactions(); + return true; + } + + private Chosen pickFact(Player subject) { + if (subject == null) { + return null; + } + List facts = CuriosityFactory.facts(subject, settings); + if (facts.isEmpty()) { + return null; + } + List fresh = new ArrayList<>(facts); + fresh.removeIf(fact -> recentFacts.contains(plain(fact.text()))); + List 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 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 ---------------------------------------------------------- + + /** Attaches a fresh reaction row to the message just broadcast. */ + private void openReactions() { + if (!settings.reactionsEnabled()) { + return; + } + Reactions reactions = new Reactions(nextId++, settings.reactions()); + liveReactions = reactions; + remember(reactions); + + Bukkit.broadcast(reactions.buttons("/canalhandia reagir")); + reactions.show(); + + getServer().getScheduler().runTaskLater(this, () -> { + reactions.hide(); + if (liveReactions == reactions) { + liveReactions = null; + } + // Chat cannot be edited, so post the final count as its own line. + if (reactions.hasAnyVote()) { + Bukkit.broadcast(Component.text(" ").append(reactions.tally())); + } + }, settings.reactionWindowSeconds() * 20L); + } + + private void remember(Reactions reactions) { + reactionHistory.addLast(reactions); + while (reactionHistory.size() > 8) { + reactionHistory.removeFirst(); + } + } + + /** + * Finds a reaction set that is still accepting clicks. The boss bar only + * lasts {@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 online = new ArrayList<>(Bukkit.getOnlinePlayers()); + if (online.size() < 2) { + return false; + } + Chosen chosen = pickFact(pickSubject()); + if (chosen == null) { + return false; + } + + List 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; + Bukkit.broadcast(round.question("/canalhandia palpite")); + + 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 options, String author) { + if (poll != null && !poll.closed()) { + Bukkit.broadcast(poll.close()); + } + Poll started = new Poll(nextId++, question, options, author); + poll = started; + Bukkit.broadcast(started.announcement("/canalhandia votar")); + 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 (liveReactions != null) { + liveReactions.showTo(player); + } + 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); + } + }, settings.joinDelaySeconds() * 20L); + } + + /** "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++, Map.of("f", "[F]")); + liveReactions = mourning; + remember(mourning); + String name = event.getEntity().getName(); + + // One tick later so it prints under the vanilla death message. + getServer().getScheduler().runTaskLater(this, () -> Bukkit.broadcast( + Component.text(" ") + .append(Component.text("[F] ", NamedTextColor.YELLOW) + .clickEvent(ClickEvent.runCommand( + "/canalhandia reagir " + mourning.id() + " f"))) + .append(Component.text("prestar luto por " + name, NamedTextColor.GRAY))), 2L); + + getServer().getScheduler().runTaskLater(this, () -> { + if (mourning.hasAnyVote()) { + Bukkit.broadcast(Component.text(" ") + .append(Component.text(mourning.count("f") + + " pessoa(s) prestaram luto por " + name + ".", NamedTextColor.GRAY))); + } + if (liveReactions == mourning) { + liveReactions = null; + } + }, settings.reactionWindowSeconds() * 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(); + } + + void runMilestoneCheck() { + milestones.check(); + } +} diff --git a/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java new file mode 100644 index 0000000..783d58d --- /dev/null +++ b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java @@ -0,0 +1,696 @@ +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 org.bukkit.Bukkit; +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 java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Every command the plugin owns. + * + *

{@code /curiosidade}, {@code /adivinha}, {@code /enquete} and + * {@code /ranking} are player-facing shortcuts; {@code /canalhandia} is the + * admin root and also carries the click callbacks that the chat buttons invoke. + */ +final class CanalhandiaCommand implements CommandExecutor, TabCompleter { + + private static final String ADMIN = "canalhandia.admin"; + + private final Canalhandia plugin; + + CanalhandiaCommand(Canalhandia plugin) { + this.plugin = plugin; + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + return switch (command.getName().toLowerCase()) { + case "curiosidade" -> curiosidade(sender, args); + case "adivinha" -> adivinha(sender); + case "enquete" -> enquete(sender, args); + case "ranking" -> ranking(sender, args); + default -> root(sender, args); + }; + } + + // --- /canalhandia ------------------------------------------------------- + + private boolean root(CommandSender sender, String[] args) { + if (args.length == 0) { + help(sender); + return true; + } + String[] rest = Arrays.copyOfRange(args, 1, args.length); + switch (args[0].toLowerCase()) { + // Click callbacks. + case "reagir" -> react(sender, rest); + case "palpite" -> guess(sender, rest); + case "votar" -> vote(sender, rest); + // Admin. + case "modulo" -> moduleToggle(sender, rest); + case "modulos" -> modules(sender); + case "status" -> status(sender); + case "marcos" -> { + if (admin(sender)) { + plugin.runMilestoneCheck(); + Msg.ok(sender, "Verificação de marcos executada."); + } + } + case "limpar" -> clear(sender, rest); + case "reload" -> { + if (admin(sender)) { + plugin.reloadConfig(); + plugin.rescheduleTimer(); + plugin.rescheduleMilestones(); + Msg.ok(sender, "Configuração recarregada."); + } + } + default -> help(sender); + } + return true; + } + + // --- /curiosidade ------------------------------------------------------- + + private boolean curiosidade(CommandSender sender, String[] args) { + if (args.length == 0) { + if (!sender.hasPermission("canalhandia.forcar")) { + return denied(sender); + } + if (!plugin.announceCuriosity(null)) { + Msg.error(sender, "Ninguém elegível online (ou sem estatísticas suficientes)."); + } + return true; + } + + String[] rest = Arrays.copyOfRange(args, 1, args.length); + switch (args[0].toLowerCase()) { + case "ver" -> preview(sender, rest); + case "listar" -> list(sender, rest); + case "toggle" -> toggle(sender); + case "status" -> status(sender); + case "categorias" -> categories(sender); + case "categoria" -> categoryToggle(sender, rest); + case "modo" -> mode(sender, rest); + case "intervalo" -> setNumber(sender, rest, "intervalo ", + v -> { + plugin.settings().intervalMinutes(v); + plugin.rescheduleTimer(); + return "Intervalo: " + plugin.settings().intervalMinutes() + " min."; + }); + case "atraso" -> setNumber(sender, rest, "atraso ", + v -> { + plugin.settings().joinDelaySeconds(v); + return "Atraso após entrar: " + plugin.settings().joinDelaySeconds() + "s."; + }); + case "cooldown" -> setNumber(sender, rest, "cooldown ", + v -> { + plugin.settings().cooldownMinutes(v); + return "Cooldown: " + plugin.settings().cooldownMinutes() + " min."; + }); + case "repetir" -> setNumber(sender, rest, "repetir ", + v -> { + plugin.settings().noRepeat(v); + return "Evitando repetir as últimas " + plugin.settings().noRepeat() + "."; + }); + case "janela" -> setNumber(sender, rest, "janela ", + v -> { + plugin.settings().reactionWindowSeconds(v); + return "Janela de reação: " + plugin.settings().reactionWindowSeconds() + "s."; + }); + case "validade" -> setNumber(sender, rest, "validade ", + v -> { + plugin.settings().reactionValidityMinutes(v); + return "Cliques válidos por " + + plugin.settings().reactionValidityMinutes() + " min."; + }); + case "reacoes" -> reactionsToggle(sender, rest); + case "reacao" -> reactionEdit(sender, rest); + case "ajuda" -> help(sender); + default -> { + Player target = Bukkit.getPlayerExact(args[0]); + if (target == null) { + Msg.error(sender, "Subcomando ou jogador desconhecido. Use /curiosidade ajuda"); + } else if (!sender.hasPermission("canalhandia.forcar")) { + denied(sender); + } else if (!plugin.announceCuriosity(target)) { + Msg.error(sender, target.getName() + " ainda não tem estatísticas suficientes."); + } + } + } + return true; + } + + private void preview(CommandSender sender, String[] args) { + Player target = resolveTarget(sender, args); + if (target == null) { + return; + } + List facts = CuriosityFactory.facts(target, plugin.settings()); + if (facts.isEmpty()) { + Msg.error(sender, "Nenhuma curiosidade disponível para " + target.getName() + "."); + return; + } + Fact fact = facts.get((int) (Math.random() * facts.size())); + sender.sendMessage(Msg.tag("Curiosidade", NamedTextColor.GOLD) + .append(Component.text(target.getName() + " ", NamedTextColor.GREEN)) + .append(fact.text())); + } + + /** Dumps every available fact for a player, privately. Handy for tuning thresholds. */ + private void list(CommandSender sender, String[] args) { + Player target = resolveTarget(sender, args); + if (target == null) { + return; + } + List facts = CuriosityFactory.facts(target, plugin.settings()); + Msg.header(sender, "Curiosidades de " + target.getName() + " (" + facts.size() + ")"); + for (Fact fact : facts) { + sender.sendMessage(Component.text(" [" + fact.category().label() + "] ", + NamedTextColor.DARK_AQUA).append(fact.text())); + } + if (facts.isEmpty()) { + sender.sendMessage(Component.text(" (nada ainda)", NamedTextColor.GRAY)); + } + } + + private Player resolveTarget(CommandSender sender, String[] args) { + if (!sender.hasPermission("canalhandia.ver")) { + denied(sender); + return null; + } + if (args.length > 0) { + Player target = Bukkit.getPlayerExact(args[0]); + if (target == null) { + Msg.error(sender, "Jogador '" + args[0] + "' não está online."); + } + return target; + } + if (sender instanceof Player player) { + return player; + } + Msg.error(sender, "Informe um jogador."); + return null; + } + + private void toggle(CommandSender sender) { + if (!(sender instanceof Player player)) { + Msg.error(sender, "Só jogadores podem usar isso."); + return; + } + boolean optedOut = !plugin.isOptedOut(player); + plugin.setOptedOut(player, optedOut); + sender.sendMessage(optedOut + ? Component.text("Você não aparecerá mais nas curiosidades.", NamedTextColor.YELLOW) + : Component.text("Você voltou a aparecer nas curiosidades.", NamedTextColor.GREEN)); + } + + // --- /adivinha ---------------------------------------------------------- + + private boolean adivinha(CommandSender sender) { + if (!sender.hasPermission("canalhandia.forcar")) { + return denied(sender); + } + if (!plugin.settings().moduleEnabled(Module.ADIVINHA)) { + Msg.error(sender, "O módulo adivinha está desativado."); + } else if (!plugin.startGuess()) { + Msg.error(sender, "Precisa de pelo menos 2 jogadores online com estatísticas."); + } + return true; + } + + private void guess(CommandSender sender, String[] args) { + if (!(sender instanceof Player player) || args.length < 2) { + return; + } + GuessRound round = plugin.guessRound(); + if (round == null || round.id() != parse(args[0], -1)) { + player.sendActionBar(Component.text("Essa rodada já acabou.", NamedTextColor.RED)); + return; + } + player.sendActionBar(round.guess(player, args[1])); + } + + // --- /enquete ----------------------------------------------------------- + + private boolean enquete(CommandSender sender, String[] args) { + if (!sender.hasPermission("canalhandia.enquete")) { + return denied(sender); + } + if (!plugin.settings().moduleEnabled(Module.ENQUETE)) { + Msg.error(sender, "O módulo enquete está desativado."); + return true; + } + if (args.length == 1 && args[0].equalsIgnoreCase("encerrar")) { + Poll poll = plugin.poll(); + if (poll == null || poll.closed()) { + Msg.error(sender, "Nenhuma enquete aberta."); + } else { + Bukkit.broadcast(poll.close()); + } + return true; + } + + // Everything is one string split on "|": question | option | option ... + String[] parts = String.join(" ", args).split("\\|"); + if (parts.length < 3) { + Msg.error(sender, "Uso: /enquete Pergunta | Opção 1 | Opção 2 [| Opção 3 ...]"); + return true; + } + List options = new ArrayList<>(); + for (int i = 1; i < parts.length && options.size() < 5; i++) { + String option = parts[i].trim(); + if (!option.isEmpty()) { + options.add(option); + } + } + if (options.size() < 2) { + Msg.error(sender, "Informe pelo menos duas opções."); + return true; + } + plugin.startPoll(parts[0].trim(), options, sender.getName()); + return true; + } + + private void vote(CommandSender sender, String[] args) { + if (!(sender instanceof Player player) || args.length < 2) { + return; + } + Poll poll = plugin.poll(); + if (poll == null || poll.id() != parse(args[0], -1)) { + player.sendActionBar(Component.text("Essa enquete já foi encerrada.", NamedTextColor.RED)); + return; + } + Component result = poll.vote(player, parse(args[1], -1)); + if (result != null) { + player.sendActionBar(result); + } + } + + // --- /ranking ----------------------------------------------------------- + + private boolean ranking(CommandSender sender, String[] args) { + if (!sender.hasPermission("canalhandia.ver")) { + return denied(sender); + } + if (!plugin.settings().moduleEnabled(Module.RANKING)) { + Msg.error(sender, "O módulo ranking está desativado."); + return true; + } + if (args.length == 0) { + Msg.header(sender, "Rankings disponíveis"); + for (RankingMetric metric : RankingMetric.values()) { + sender.sendMessage(Component.text(" " + metric.commandKey(), NamedTextColor.AQUA) + .clickEvent(ClickEvent.runCommand("/ranking " + metric.commandKey())) + .append(Component.text(" — " + metric.label(), NamedTextColor.GRAY))); + } + return true; + } + RankingMetric metric = RankingMetric.byKey(args[0]); + if (metric == null) { + Msg.error(sender, "Ranking desconhecido. Use /ranking para ver a lista."); + return true; + } + List rows = + plugin.offlineStats().ranking(metric, plugin.settings().rankingSize()); + Msg.header(sender, "Ranking: " + metric.label()); + if (rows.isEmpty()) { + sender.sendMessage(Component.text(" (sem dados ainda)", NamedTextColor.GRAY)); + return true; + } + for (int i = 0; i < rows.size(); i++) { + OfflineStats.Row row = rows.get(i); + NamedTextColor color = switch (i) { + case 0 -> NamedTextColor.GOLD; + case 1 -> NamedTextColor.GRAY; + case 2 -> NamedTextColor.DARK_RED; + default -> NamedTextColor.WHITE; + }; + sender.sendMessage(Component.text(" " + (i + 1) + ". ", color) + .append(Component.text(row.name() + " ", NamedTextColor.GREEN)) + .append(Component.text(metric.format(row.value()), NamedTextColor.AQUA))); + } + return true; + } + + // --- reactions ---------------------------------------------------------- + + private void react(CommandSender sender, String[] args) { + if (!(sender instanceof Player player) || args.length < 2) { + return; + } + if (!player.hasPermission("canalhandia.reagir")) { + denied(sender); + return; + } + Reactions reactions = plugin.findReactions(parse(args[0], -1)); + if (reactions == null) { + player.sendActionBar(Component.text("Essa mensagem já expirou.", NamedTextColor.RED)); + return; + } + if (!reactions.react(player, args[1])) { + player.sendActionBar(Component.text("Reação desconhecida.", NamedTextColor.RED)); + } + } + + // --- admin -------------------------------------------------------------- + + private void moduleToggle(CommandSender sender, String[] args) { + if (!admin(sender)) { + return; + } + if (args.length < 2) { + Msg.error(sender, "Uso: /canalhandia modulo "); + return; + } + Module module = Module.byKey(args[0]); + if (module == null) { + Msg.error(sender, "Módulo desconhecido. Veja /canalhandia modulos"); + return; + } + boolean on = args[1].equalsIgnoreCase("on"); + plugin.settings().moduleEnabled(module, on); + plugin.rescheduleTimer(); + plugin.rescheduleMilestones(); + Msg.ok(sender, "Módulo " + module.label() + " " + (on ? "ativado" : "desativado") + "."); + } + + private void modules(CommandSender sender) { + Msg.header(sender, "Módulos"); + for (Module module : Module.values()) { + boolean on = plugin.settings().moduleEnabled(module); + sender.sendMessage(Component.text(" " + module.key() + " ", NamedTextColor.WHITE) + .append(Component.text(on ? "ativado" : "desativado", + on ? NamedTextColor.GREEN : NamedTextColor.RED)) + .append(Component.text(" [alternar]", NamedTextColor.DARK_AQUA) + .clickEvent(ClickEvent.runCommand("/canalhandia modulo " + + module.key() + (on ? " off" : " on")))) + .append(Component.text(" " + module.label(), NamedTextColor.DARK_GRAY))); + } + } + + private void mode(CommandSender sender, String[] args) { + if (!admin(sender)) { + return; + } + if (args.length == 0) { + Msg.error(sender, "Uso: /curiosidade modo "); + return; + } + Mode mode = Mode.byKey(args[0]); + if (mode == null) { + Msg.error(sender, "Modo inválido. Use entrada, intervalo, ambos ou manual."); + return; + } + plugin.settings().mode(mode); + plugin.rescheduleTimer(); + Msg.ok(sender, "Modo alterado para " + mode + "."); + } + + private void categoryToggle(CommandSender sender, String[] args) { + if (!admin(sender)) { + return; + } + if (args.length < 2) { + Msg.error(sender, "Uso: /curiosidade categoria "); + return; + } + Category category = Category.byKey(args[0]); + if (category == null) { + Msg.error(sender, "Categoria desconhecida. Veja /curiosidade categorias"); + return; + } + boolean on = args[1].equalsIgnoreCase("on"); + plugin.settings().categoryEnabled(category, on); + Msg.ok(sender, "Categoria " + category.label() + " " + (on ? "ativada" : "desativada") + "."); + } + + private void categories(CommandSender sender) { + Msg.header(sender, "Categorias"); + for (Category category : Category.values()) { + boolean on = plugin.settings().categoryEnabled(category); + sender.sendMessage(Component.text(" " + category.key() + " ", NamedTextColor.WHITE) + .append(Component.text(on ? "ativada" : "desativada", + on ? NamedTextColor.GREEN : NamedTextColor.RED)) + .append(Component.text(" [alternar]", NamedTextColor.DARK_AQUA) + .clickEvent(ClickEvent.runCommand("/curiosidade categoria " + + category.key() + (on ? " off" : " on"))))); + } + } + + private void reactionsToggle(CommandSender sender, String[] args) { + if (!admin(sender)) { + return; + } + if (args.length == 0) { + Msg.error(sender, "Uso: /curiosidade reacoes "); + return; + } + boolean on = args[0].equalsIgnoreCase("on"); + plugin.settings().reactionsEnabled(on); + Msg.ok(sender, "Reações " + (on ? "ativadas" : "desativadas") + "."); + } + + private void reactionEdit(CommandSender sender, String[] args) { + if (!admin(sender)) { + return; + } + if (args.length >= 3 && args[0].equalsIgnoreCase("add")) { + String label = String.join(" ", Arrays.copyOfRange(args, 2, args.length)); + plugin.settings().reaction(args[1].toLowerCase(), label); + Msg.ok(sender, "Reação '" + args[1] + "' definida como " + label + "."); + } else if (args.length >= 2 && args[0].equalsIgnoreCase("remover")) { + plugin.settings().removeReaction(args[1].toLowerCase()); + Msg.ok(sender, "Reação '" + args[1] + "' removida."); + } else { + Msg.error(sender, "Uso: /curiosidade reacao add | remover "); + } + } + + private void clear(CommandSender sender, String[] args) { + if (!admin(sender)) { + return; + } + String what = args.length > 0 ? args[0].toLowerCase() : "tudo"; + if (what.equals("cooldown") || what.equals("tudo")) { + plugin.clearCooldowns(); + } + if (what.equals("historico") || what.equals("tudo")) { + plugin.clearHistory(); + } + Msg.ok(sender, "Limpo: " + what + "."); + } + + private void status(CommandSender sender) { + Settings settings = plugin.settings(); + Msg.header(sender, "Canalhandia — status"); + Msg.line(sender, "módulos ativos", enabledModules()); + Msg.line(sender, "modo", settings.mode().name().toLowerCase()); + Msg.line(sender, "intervalo", settings.intervalMinutes() + " min" + + (settings.mode().firesOnTimer() ? "" : " (inativo neste modo)")); + Msg.line(sender, "atraso após entrar", settings.joinDelaySeconds() + "s" + + (settings.mode().firesOnJoin() ? "" : " (inativo neste modo)")); + Msg.line(sender, "cooldown por jogador", settings.cooldownMinutes() + " min"); + Msg.line(sender, "evitar repetir", settings.noRepeat() + " últimas"); + Msg.line(sender, "reações", settings.reactionsEnabled() ? "ativas" : "desativadas"); + Msg.line(sender, "barra de reação", settings.reactionWindowSeconds() + "s"); + Msg.line(sender, "cliques válidos por", settings.reactionValidityMinutes() + " min"); + Msg.line(sender, "reações definidas", String.join(" ", settings.reactions().values())); + Msg.line(sender, "adivinha", settings.guessSeconds() + "s por rodada"); + Msg.line(sender, "enquete", settings.pollMinutes() + " min por enquete"); + Msg.line(sender, "ranking", "top " + settings.rankingSize()); + List enabled = new ArrayList<>(); + for (Category category : Category.values()) { + if (settings.categoryEnabled(category)) { + enabled.add(category.key()); + } + } + Msg.line(sender, "categorias ativas", enabled.isEmpty() ? "nenhuma" : String.join(", ", enabled)); + } + + private String enabledModules() { + List on = new ArrayList<>(); + for (Module module : Module.values()) { + if (plugin.settings().moduleEnabled(module)) { + on.add(module.key()); + } + } + return on.isEmpty() ? "nenhum" : String.join(", ", on); + } + + private void help(CommandSender sender) { + Msg.header(sender, "Canalhandia — comandos"); + Map commands = new LinkedHashMap<>(); + commands.put("/curiosidade", "anuncia uma curiosidade agora"); + commands.put("/curiosidade ", "anuncia sobre alguém específico"); + commands.put("/curiosidade ver [jogador]", "mostra só para você"); + commands.put("/curiosidade listar [jogador]", "lista todas as curiosidades disponíveis"); + commands.put("/curiosidade toggle", "entra/sai do sorteio"); + commands.put("/adivinha", "inicia uma rodada de 'adivinhe de quem é'"); + commands.put("/enquete P | A | B", "abre uma enquete com opções clicáveis"); + commands.put("/enquete encerrar", "encerra a enquete aberta"); + commands.put("/ranking [categoria]", "mostra os placares do servidor"); + commands.put("/canalhandia status", "mostra toda a configuração"); + commands.put("/canalhandia modulos", "lista os módulos e seu estado"); + if (sender.hasPermission(ADMIN)) { + commands.put("/canalhandia modulo ", "liga/desliga um módulo"); + commands.put("/canalhandia marcos", "força uma verificação de marcos"); + commands.put("/canalhandia limpar [cooldown|historico|tudo]", "zera estado temporário"); + commands.put("/canalhandia reload", "recarrega o config.yml"); + commands.put("/curiosidade modo ", "entrada | intervalo | ambos | manual"); + commands.put("/curiosidade intervalo ", "intervalo do modo temporizado"); + commands.put("/curiosidade atraso ", "espera após o jogador entrar"); + commands.put("/curiosidade cooldown ", "mínimo entre citar o mesmo jogador"); + commands.put("/curiosidade repetir ", "quantas recentes evitar repetir"); + commands.put("/curiosidade janela ", "duração da barra de reações"); + commands.put("/curiosidade validade ", "por quanto tempo cliques ainda contam"); + commands.put("/curiosidade reacoes ", "liga/desliga reações"); + commands.put("/curiosidade reacao add ", "cria ou muda uma reação"); + commands.put("/curiosidade reacao remover ", "remove uma reação"); + commands.put("/curiosidade categoria ", "liga/desliga uma categoria"); + } + commands.forEach((cmd, description) -> sender.sendMessage( + Component.text(" " + cmd, NamedTextColor.AQUA) + .append(Component.text(" — " + description, NamedTextColor.GRAY)))); + } + + // --- helpers ------------------------------------------------------------ + + /** Applies a validated positive-integer setting and reports the result. */ + private void setNumber(CommandSender sender, String[] args, String usage, + java.util.function.IntFunction apply) { + if (!admin(sender)) { + return; + } + if (args.length == 0) { + Msg.error(sender, "Uso: /curiosidade " + usage); + return; + } + int value = parse(args[0], Integer.MIN_VALUE); + if (value == Integer.MIN_VALUE) { + Msg.error(sender, "'" + args[0] + "' não é um número."); + return; + } + Msg.ok(sender, apply.apply(value)); + } + + private static int parse(String text, int fallback) { + try { + return Integer.parseInt(text); + } catch (NumberFormatException e) { + return fallback; + } + } + + private boolean admin(CommandSender sender) { + if (sender.hasPermission(ADMIN)) { + return true; + } + denied(sender); + return false; + } + + private boolean denied(CommandSender sender) { + Msg.error(sender, "Você não tem permissão para isso."); + return true; + } + + // --- tab completion ----------------------------------------------------- + + @Override + public List onTabComplete(CommandSender sender, Command command, String label, String[] args) { + String name = command.getName().toLowerCase(); + if (name.equals("ranking") && args.length == 1) { + List keys = new ArrayList<>(); + for (RankingMetric metric : RankingMetric.values()) { + keys.add(metric.commandKey()); + } + return filter(keys, args[0]); + } + if (name.equals("enquete") && args.length == 1) { + return filter(List.of("encerrar"), args[0]); + } + if (name.equals("canalhandia")) { + if (args.length == 1) { + List options = new ArrayList<>(List.of("status", "modulos")); + if (sender.hasPermission(ADMIN)) { + options.addAll(List.of("modulo", "marcos", "limpar", "reload")); + } + return filter(options, args[0]); + } + if (args.length == 2 && args[0].equalsIgnoreCase("modulo")) { + List keys = new ArrayList<>(); + for (Module module : Module.values()) { + keys.add(module.key()); + } + return filter(keys, args[1]); + } + if (args.length == 2 && args[0].equalsIgnoreCase("limpar")) { + return filter(List.of("cooldown", "historico", "tudo"), args[1]); + } + if (args.length == 3 && args[0].equalsIgnoreCase("modulo")) { + return filter(List.of("on", "off"), args[2]); + } + return List.of(); + } + if (name.equals("curiosidade")) { + if (args.length == 1) { + List options = new ArrayList<>(List.of("ver", "listar", "toggle", + "status", "categorias", "ajuda")); + if (sender.hasPermission(ADMIN)) { + options.addAll(List.of("modo", "intervalo", "atraso", "cooldown", "repetir", + "janela", "validade", "reacoes", "reacao", "categoria")); + } + options.addAll(onlineNames()); + return filter(options, args[0]); + } + if (args.length == 2) { + return switch (args[0].toLowerCase()) { + case "modo" -> filter(List.of("entrada", "intervalo", "ambos", "manual"), args[1]); + case "reacoes" -> filter(List.of("on", "off"), args[1]); + case "reacao" -> filter(List.of("add", "remover"), args[1]); + case "categoria" -> { + List keys = new ArrayList<>(); + for (Category category : Category.values()) { + keys.add(category.key()); + } + yield filter(keys, args[1]); + } + case "ver", "listar" -> filter(onlineNames(), args[1]); + default -> List.of(); + }; + } + if (args.length == 3 && args[0].equalsIgnoreCase("categoria")) { + return filter(List.of("on", "off"), args[2]); + } + } + return List.of(); + } + + private List onlineNames() { + List names = new ArrayList<>(); + Bukkit.getOnlinePlayers().forEach(p -> names.add(p.getName())); + return names; + } + + private static List filter(List options, String prefix) { + List matches = new ArrayList<>(); + for (String option : options) { + if (option.toLowerCase().startsWith(prefix.toLowerCase())) { + matches.add(option); + } + } + return matches; + } +} diff --git a/src/main/java/dev/marcospaulo/curiosidades/Category.java b/src/main/java/dev/marcospaulo/canalhandia/Category.java similarity index 96% rename from src/main/java/dev/marcospaulo/curiosidades/Category.java rename to src/main/java/dev/marcospaulo/canalhandia/Category.java index 2c418ed..11d1160 100644 --- a/src/main/java/dev/marcospaulo/curiosidades/Category.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Category.java @@ -1,4 +1,4 @@ -package dev.marcospaulo.curiosidades; +package dev.marcospaulo.canalhandia; /** Groups of curiosities that can be switched on and off independently. */ enum Category { diff --git a/src/main/java/dev/marcospaulo/curiosidades/CuriosityFactory.java b/src/main/java/dev/marcospaulo/canalhandia/CuriosityFactory.java similarity index 99% rename from src/main/java/dev/marcospaulo/curiosidades/CuriosityFactory.java rename to src/main/java/dev/marcospaulo/canalhandia/CuriosityFactory.java index 81fea68..981f6e4 100644 --- a/src/main/java/dev/marcospaulo/curiosidades/CuriosityFactory.java +++ b/src/main/java/dev/marcospaulo/canalhandia/CuriosityFactory.java @@ -1,4 +1,4 @@ -package dev.marcospaulo.curiosidades; +package dev.marcospaulo.canalhandia; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; diff --git a/src/main/java/dev/marcospaulo/curiosidades/Fact.java b/src/main/java/dev/marcospaulo/canalhandia/Fact.java similarity index 82% rename from src/main/java/dev/marcospaulo/curiosidades/Fact.java rename to src/main/java/dev/marcospaulo/canalhandia/Fact.java index 54b8da1..8d50304 100644 --- a/src/main/java/dev/marcospaulo/curiosidades/Fact.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Fact.java @@ -1,4 +1,4 @@ -package dev.marcospaulo.curiosidades; +package dev.marcospaulo.canalhandia; import net.kyori.adventure.text.Component; diff --git a/src/main/java/dev/marcospaulo/canalhandia/GuessRound.java b/src/main/java/dev/marcospaulo/canalhandia/GuessRound.java new file mode 100644 index 0000000..4e97d5d --- /dev/null +++ b/src/main/java/dev/marcospaulo/canalhandia/GuessRound.java @@ -0,0 +1,117 @@ +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 org.bukkit.entity.Player; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * One round of "adivinhe de quem é": a curiosity with the subject's name + * removed, and the online players offered as clickable answers. + * + *

Curiosity sentences never contain the subject's name — the name lives in + * the wrapper, not the fact — so the same generator feeds both features + * without any redaction step. + */ +final class GuessRound { + + private final int id; + private final UUID subject; + private final String subjectName; + private final Component fact; + private final List candidates; + private final Map guesses = new LinkedHashMap<>(); + private boolean finished; + + GuessRound(int id, Player subject, Component fact, List candidates) { + this.id = id; + this.subject = subject.getUniqueId(); + this.subjectName = subject.getName(); + this.fact = fact; + this.candidates = candidates; + } + + int id() { + return id; + } + + boolean finished() { + return finished; + } + + String subjectName() { + return subjectName; + } + + /** + * Records a guess. The subject is barred from guessing about themselves, + * and nobody gets a second attempt. + * + * @return a message explaining the outcome for the guesser + */ + Component guess(Player player, String name) { + if (finished) { + return Component.text("Essa rodada já acabou.", NamedTextColor.RED); + } + if (player.getUniqueId().equals(subject)) { + return Component.text("Você não pode adivinhar sobre você mesmo!", NamedTextColor.RED); + } + if (guesses.containsKey(player.getUniqueId())) { + return Component.text("Você já palpitou nessa rodada.", NamedTextColor.RED); + } + guesses.put(player.getUniqueId(), name); + return Component.text("Palpite registrado: " + name, NamedTextColor.GREEN); + } + + /** The question, with one clickable button per candidate. */ + Component question(String commandBase) { + Component message = Msg.tag("Adivinha", NamedTextColor.LIGHT_PURPLE) + .append(Component.text("Alguém aqui ", NamedTextColor.WHITE) + .decoration(TextDecoration.BOLD, false)) + .append(fact.decoration(TextDecoration.BOLD, false)) + .append(Component.text(". Quem será?", NamedTextColor.WHITE) + .decoration(TextDecoration.BOLD, false)); + + Component row = Component.text(" "); + for (String candidate : candidates) { + row = row.append(Component.text("[" + candidate + "] ", NamedTextColor.AQUA) + .clickEvent(ClickEvent.runCommand(commandBase + " " + id + " " + candidate))); + } + return message.append(Component.newline()).append(row); + } + + /** Marks the round over and builds the reveal message. */ + Component reveal() { + finished = true; + List correct = new ArrayList<>(); + for (Map.Entry entry : guesses.entrySet()) { + if (entry.getValue().equalsIgnoreCase(subjectName)) { + Player guesser = org.bukkit.Bukkit.getPlayer(entry.getKey()); + correct.add(guesser != null ? guesser.getName() : entry.getValue()); + } + } + + Component message = Msg.tag("Adivinha", NamedTextColor.LIGHT_PURPLE) + .append(Component.text("Era o ", NamedTextColor.WHITE) + .decoration(TextDecoration.BOLD, false)) + .append(Component.text(subjectName, NamedTextColor.GREEN) + .decoration(TextDecoration.BOLD, false)) + .append(Component.text("!", NamedTextColor.WHITE).decoration(TextDecoration.BOLD, false)); + + if (guesses.isEmpty()) { + return message.append(Component.text(" Ninguém palpitou.", NamedTextColor.GRAY)); + } + if (correct.isEmpty()) { + return message.append(Component.text(" Ninguém acertou!", NamedTextColor.GRAY)); + } + return message.append(Component.text(" Acertaram: ", NamedTextColor.GRAY)) + .append(Component.text(String.join(", ", correct), NamedTextColor.YELLOW)); + } +} diff --git a/src/main/java/dev/marcospaulo/canalhandia/Milestones.java b/src/main/java/dev/marcospaulo/canalhandia/Milestones.java new file mode 100644 index 0000000..5153693 --- /dev/null +++ b/src/main/java/dev/marcospaulo/canalhandia/Milestones.java @@ -0,0 +1,121 @@ +package dev.marcospaulo.canalhandia; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; +import org.bukkit.Bukkit; +import org.bukkit.Statistic; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; + +import java.io.File; +import java.io.IOException; +import java.util.List; + +/** + * Announces round-number milestones — 100 km walked, 24 hours played — the + * first time a player crosses one. + * + *

The highest already-announced threshold is stored per player per metric in + * {@code marcos.yml}, so a restart never replays old milestones and a player who + * joins already past a threshold does not trigger a burst of announcements: the + * first check records where they are without announcing anything. + */ +final class Milestones { + + /** A metric and the values worth celebrating. */ + private record Track(String key, String statistic, String verb, Unit unit, long[] thresholds) { + } + + private enum Unit { COUNT, HOURS, KILOMETRES } + + private static final List TRACKS = List.of( + new Track("distancia", "WALK_ONE_CM", "caminhados", Unit.KILOMETRES, + new long[]{50, 100, 250, 500, 1000, 2500}), + new Track("tempo", "PLAY_TIME", "jogadas", Unit.HOURS, + new long[]{10, 24, 50, 100, 250, 500, 1000}), + new Track("mortes", "DEATHS", "mortes", Unit.COUNT, + new long[]{10, 25, 50, 100, 250, 500}), + new Track("combate", "MOB_KILLS", "monstros derrotados", Unit.COUNT, + new long[]{100, 500, 1000, 5000, 10000}), + new Track("pulos", "JUMP", "pulos", Unit.COUNT, + new long[]{1000, 5000, 10000, 50000}), + new Track("pesca", "FISH_CAUGHT", "peixes pescados", Unit.COUNT, + new long[]{10, 50, 100, 500})); + + private final Canalhandia plugin; + private final File file; + private final YamlConfiguration data; + + Milestones(Canalhandia plugin) { + this.plugin = plugin; + this.file = new File(plugin.getDataFolder(), "marcos.yml"); + this.data = YamlConfiguration.loadConfiguration(file); + } + + /** Checks every online player and announces any newly crossed threshold. */ + void check() { + for (Player player : Bukkit.getOnlinePlayers()) { + for (Track track : TRACKS) { + check(player, track); + } + } + save(); + } + + private void check(Player player, Track track) { + Statistic statistic = Stats.resolve(track.statistic()); + long raw = Stats.untyped(player, statistic); + if (raw <= 0) { + return; + } + long value = switch (track.unit()) { + case COUNT -> raw; + case HOURS -> raw / 20L / 3600L; + case KILOMETRES -> raw / 100_000L; + }; + + long reached = 0; + for (long threshold : track.thresholds()) { + if (value >= threshold) { + reached = threshold; + } + } + if (reached == 0) { + return; + } + + String path = player.getUniqueId() + "." + track.key(); + long announced = data.getLong(path, -1); + data.set(path, reached); + + // -1 means we have never seen this player on this metric. Record where + // they already are instead of announcing history they earned earlier. + if (announced < 0 || reached <= announced) { + return; + } + + String amount = switch (track.unit()) { + case COUNT -> Msg.number(reached); + case HOURS -> Msg.number(reached) + " horas"; + case KILOMETRES -> Msg.number(reached) + " km"; + }; + Bukkit.broadcast(Msg.tag("Marco", NamedTextColor.GOLD) + .append(Component.text(player.getName(), NamedTextColor.GREEN) + .decoration(TextDecoration.BOLD, false)) + .append(Component.text(" acabou de passar de ", NamedTextColor.WHITE) + .decoration(TextDecoration.BOLD, false)) + .append(Component.text(amount + " " + track.verb(), NamedTextColor.AQUA) + .decoration(TextDecoration.BOLD, false)) + .append(Component.text("!", NamedTextColor.WHITE) + .decoration(TextDecoration.BOLD, false))); + } + + private void save() { + try { + data.save(file); + } catch (IOException e) { + plugin.getLogger().warning("Não consegui salvar marcos.yml: " + e.getMessage()); + } + } +} diff --git a/src/main/java/dev/marcospaulo/curiosidades/Mode.java b/src/main/java/dev/marcospaulo/canalhandia/Mode.java similarity index 94% rename from src/main/java/dev/marcospaulo/curiosidades/Mode.java rename to src/main/java/dev/marcospaulo/canalhandia/Mode.java index d5de416..989aaa6 100644 --- a/src/main/java/dev/marcospaulo/curiosidades/Mode.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Mode.java @@ -1,4 +1,4 @@ -package dev.marcospaulo.curiosidades; +package dev.marcospaulo.canalhandia; /** When curiosities fire on their own. */ enum Mode { diff --git a/src/main/java/dev/marcospaulo/canalhandia/Module.java b/src/main/java/dev/marcospaulo/canalhandia/Module.java new file mode 100644 index 0000000..4246d51 --- /dev/null +++ b/src/main/java/dev/marcospaulo/canalhandia/Module.java @@ -0,0 +1,37 @@ +package dev.marcospaulo.canalhandia; + +/** A feature that can be switched on or off on its own. */ +enum Module { + + CURIOSIDADES("curiosidades", "Curiosidades automáticas"), + ADIVINHA("adivinha", "Adivinhe de quem é"), + LUTO("luto", "Botão [F] nas mortes"), + ENQUETE("enquete", "Enquetes"), + RANKING("ranking", "Rankings"), + MARCOS("marcos", "Marcos e conquistas"); + + private final String key; + private final String label; + + Module(String key, String label) { + this.key = key; + this.label = label; + } + + String key() { + return key; + } + + String label() { + return label; + } + + static Module byKey(String key) { + for (Module module : values()) { + if (module.key.equalsIgnoreCase(key)) { + return module; + } + } + return null; + } +} diff --git a/src/main/java/dev/marcospaulo/canalhandia/Msg.java b/src/main/java/dev/marcospaulo/canalhandia/Msg.java new file mode 100644 index 0000000..a1dd172 --- /dev/null +++ b/src/main/java/dev/marcospaulo/canalhandia/Msg.java @@ -0,0 +1,60 @@ +package dev.marcospaulo.canalhandia; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; +import org.bukkit.command.CommandSender; + +import java.text.NumberFormat; +import java.util.Locale; + +/** Shared chat formatting so every module looks like the same plugin. */ +final class Msg { + + static final Locale PT_BR = Locale.of("pt", "BR"); + private static final NumberFormat NUMBERS = NumberFormat.getInstance(PT_BR); + + private Msg() { + } + + /** Thousands-separated in pt-BR ("5.966"). */ + static String number(long value) { + return NUMBERS.format(value); + } + + static Component tag(String label, NamedTextColor color) { + return Component.text("[" + label + "] ", color, TextDecoration.BOLD); + } + + static void ok(CommandSender sender, String text) { + sender.sendMessage(tag("Canalhandia", NamedTextColor.GOLD) + .append(Component.text(text, NamedTextColor.GREEN).decoration(TextDecoration.BOLD, false))); + } + + static void error(CommandSender sender, String text) { + sender.sendMessage(tag("Canalhandia", NamedTextColor.GOLD) + .append(Component.text(text, NamedTextColor.RED).decoration(TextDecoration.BOLD, false))); + } + + static void header(CommandSender sender, String text) { + sender.sendMessage(Component.text("— " + text + " —", NamedTextColor.GOLD, TextDecoration.BOLD)); + } + + static void line(CommandSender sender, String key, String value) { + sender.sendMessage(Component.text(" " + key + ": ", NamedTextColor.GRAY) + .append(Component.text(value, NamedTextColor.AQUA))); + } + + /** Renders a duration in ticks as "3 dias e 4 horas" / "5 horas" / "12 minutos". */ + static String duration(long ticks) { + long minutes = ticks / 20L / 60L; + if (minutes < 60) { + return minutes + " minutos"; + } + long hours = minutes / 60; + if (hours < 24) { + return hours + " horas"; + } + return hours / 24 + " dias e " + hours % 24 + " horas"; + } +} diff --git a/src/main/java/dev/marcospaulo/canalhandia/OfflineStats.java b/src/main/java/dev/marcospaulo/canalhandia/OfflineStats.java new file mode 100644 index 0000000..21b3969 --- /dev/null +++ b/src/main/java/dev/marcospaulo/canalhandia/OfflineStats.java @@ -0,0 +1,138 @@ +package dev.marcospaulo.canalhandia; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.bukkit.Bukkit; +import org.bukkit.plugin.Plugin; + +import java.io.File; +import java.io.FileReader; +import java.io.Reader; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Reads statistics straight off disk so rankings can include players who are + * not online. + * + *

Bukkit only exposes {@code getStatistic} for online players, which is fine + * for a curiosity about whoever is playing but useless for a leaderboard. The + * server already writes everything to {@code /players/stats/.json}, + * and {@code usercache.json} maps those UUIDs back to names — including + * Floodgate/Bedrock players, whose UUIDs start with {@code 00000000-0000-0000-0009}. + */ +final class OfflineStats { + + /** One player's entry in a ranking. */ + record Row(String name, long value) { + } + + private final Plugin plugin; + + OfflineStats(Plugin plugin) { + this.plugin = plugin; + } + + /** + * Ranking rows for a metric, highest first, excluding zero values. + * + * @param limit maximum rows to return + */ + List ranking(RankingMetric metric, int limit) { + Map names = names(); + List rows = new ArrayList<>(); + + File dir = statsDirectory(); + File[] files = dir == null ? null : dir.listFiles((d, n) -> n.endsWith(".json")); + if (files == null) { + return rows; + } + + for (File file : files) { + String uuid = file.getName().substring(0, file.getName().length() - ".json".length()); + long value = read(file, metric); + if (value <= 0) { + continue; + } + rows.add(new Row(names.getOrDefault(uuid, uuid.substring(0, 8)), value)); + } + + rows.sort((a, b) -> Long.compare(b.value(), a.value())); + return rows.size() > limit ? rows.subList(0, limit) : rows; + } + + private long read(File file, RankingMetric metric) { + try (Reader reader = new FileReader(file)) { + JsonElement root = JsonParser.parseReader(reader); + if (!root.isJsonObject()) { + return 0; + } + JsonElement stats = root.getAsJsonObject().get("stats"); + if (stats == null || !stats.isJsonObject()) { + return 0; + } + JsonElement section = stats.getAsJsonObject().get(metric.section()); + if (section == null || !section.isJsonObject()) { + return 0; + } + JsonObject object = section.getAsJsonObject(); + if (metric.statKey() == null) { + // Sum the whole section, e.g. every block ever mined. + long total = 0; + for (String key : object.keySet()) { + total += object.get(key).getAsLong(); + } + return total; + } + JsonElement value = object.get(metric.statKey()); + return value == null ? 0 : value.getAsLong(); + } catch (Exception e) { + plugin.getLogger().warning("Não consegui ler " + file.getName() + ": " + e.getMessage()); + return 0; + } + } + + /** UUID to last known name, from usercache.json. */ + private Map names() { + Map names = new HashMap<>(); + File cache = new File(Bukkit.getWorldContainer(), "usercache.json"); + if (!cache.isFile()) { + return names; + } + try (Reader reader = new FileReader(cache)) { + JsonElement root = JsonParser.parseReader(reader); + if (!root.isJsonArray()) { + return names; + } + root.getAsJsonArray().forEach(element -> { + JsonObject entry = element.getAsJsonObject(); + if (entry.has("uuid") && entry.has("name")) { + names.put(entry.get("uuid").getAsString(), entry.get("name").getAsString()); + } + }); + } catch (Exception e) { + plugin.getLogger().warning("Não consegui ler usercache.json: " + e.getMessage()); + } + return names; + } + + /** + * Paper writes to {@code /players/stats}; older layouts used + * {@code /stats}. Try both rather than assume. + */ + private File statsDirectory() { + if (Bukkit.getWorlds().isEmpty()) { + return null; + } + File world = Bukkit.getWorlds().get(0).getWorldFolder(); + File modern = new File(world, "players/stats"); + if (modern.isDirectory()) { + return modern; + } + File legacy = new File(world, "stats"); + return legacy.isDirectory() ? legacy : null; + } +} diff --git a/src/main/java/dev/marcospaulo/canalhandia/Poll.java b/src/main/java/dev/marcospaulo/canalhandia/Poll.java new file mode 100644 index 0000000..e405a54 --- /dev/null +++ b/src/main/java/dev/marcospaulo/canalhandia/Poll.java @@ -0,0 +1,145 @@ +package dev.marcospaulo.canalhandia; + +import net.kyori.adventure.bossbar.BossBar; +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 org.bukkit.Bukkit; +import org.bukkit.entity.Player; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** A chat poll with clickable options and a live tally on the boss bar. */ +final class Poll { + + private final int id; + private final String question; + private final List options; + private final String author; + private final Map votes = new HashMap<>(); + private final BossBar bar; + private boolean closed; + + Poll(int id, String question, List options, String author) { + this.id = id; + this.question = question; + this.options = options; + this.author = author; + this.bar = BossBar.bossBar(tally(), 1.0f, BossBar.Color.BLUE, BossBar.Overlay.PROGRESS); + } + + int id() { + return id; + } + + boolean closed() { + return closed; + } + + /** + * Records a vote. Voting again changes the existing vote rather than adding + * a second one. + * + * @param option 1-based option number as shown in chat + * @return a message for the voter, or null if the option does not exist + */ + Component vote(Player player, int option) { + if (closed) { + return Component.text("Essa enquete já foi encerrada.", NamedTextColor.RED); + } + if (option < 1 || option > options.size()) { + return null; + } + votes.put(player.getUniqueId(), option - 1); + bar.name(tally()); + return Component.text("Voto registrado: " + options.get(option - 1), NamedTextColor.GREEN); + } + + int count(int index) { + int total = 0; + for (int vote : votes.values()) { + if (vote == index) { + total++; + } + } + return total; + } + + Component announcement(String commandBase) { + Component message = Msg.tag("Enquete", NamedTextColor.BLUE) + .append(Component.text(question, NamedTextColor.WHITE) + .decoration(TextDecoration.BOLD, false)) + .append(Component.text(" (por " + author + ")", NamedTextColor.DARK_GRAY) + .decoration(TextDecoration.BOLD, false)); + + Component row = Component.text(" "); + for (int i = 0; i < options.size(); i++) { + row = row.append(Component.text("[" + (i + 1) + ". " + options.get(i) + "] ", + NamedTextColor.AQUA) + .clickEvent(ClickEvent.runCommand(commandBase + " " + id + " " + (i + 1)))); + } + return message.append(Component.newline()).append(row); + } + + Component tally() { + Component text = Component.text("Enquete: ", NamedTextColor.WHITE); + for (int i = 0; i < options.size(); i++) { + text = text.append(Component.text(options.get(i) + " ", NamedTextColor.YELLOW)) + .append(Component.text(count(i) + " ", NamedTextColor.AQUA)); + } + return text; + } + + /** Closes the poll and builds the result message, naming the winner. */ + Component close() { + closed = true; + hide(); + + int best = -1; + int bestCount = -1; + boolean tie = false; + for (int i = 0; i < options.size(); i++) { + int count = count(i); + if (count > bestCount) { + best = i; + bestCount = count; + tie = false; + } else if (count == bestCount) { + tie = true; + } + } + + Component header = Msg.tag("Enquete", NamedTextColor.BLUE) + .append(Component.text("Resultado de \"" + question + "\": ", NamedTextColor.WHITE) + .decoration(TextDecoration.BOLD, false)); + + if (votes.isEmpty()) { + return header.append(Component.text("ninguém votou.", NamedTextColor.GRAY)); + } + if (tie) { + return header.append(Component.text("empate!", NamedTextColor.YELLOW)) + .append(Component.newline()).append(tally()); + } + return header.append(Component.text(options.get(best), NamedTextColor.GREEN)) + .append(Component.text(" com " + bestCount + " voto(s).", NamedTextColor.GRAY)) + .append(Component.newline()).append(tally()); + } + + void show() { + Bukkit.getOnlinePlayers().forEach(p -> p.showBossBar(bar)); + } + + void hide() { + Bukkit.getOnlinePlayers().forEach(p -> p.hideBossBar(bar)); + } + + void showTo(Player player) { + if (!closed) { + player.showBossBar(bar); + } + } +} diff --git a/src/main/java/dev/marcospaulo/canalhandia/RankingMetric.java b/src/main/java/dev/marcospaulo/canalhandia/RankingMetric.java new file mode 100644 index 0000000..78506c8 --- /dev/null +++ b/src/main/java/dev/marcospaulo/canalhandia/RankingMetric.java @@ -0,0 +1,78 @@ +package dev.marcospaulo.canalhandia; + +/** + * A leaderboard column, expressed as a path into the stats JSON. + * + *

A null {@link #statKey()} means "sum every entry in the section", which is + * how total blocks mined is computed — vanilla has no single total-mined counter. + */ +enum RankingMetric { + + MINERACAO("mineracao", "Mineração", "minecraft:mined", null, Unit.COUNT, "blocos"), + MORTES("mortes", "Mortes", "minecraft:custom", "minecraft:deaths", Unit.COUNT, "mortes"), + TEMPO("tempo", "Tempo jogado", "minecraft:custom", "minecraft:play_time", Unit.TICKS, ""), + DISTANCIA("distancia", "Distância a pé", "minecraft:custom", "minecraft:walk_one_cm", + Unit.CENTIMETRES, ""), + COMBATE("combate", "Monstros derrotados", "minecraft:custom", "minecraft:mob_kills", + Unit.COUNT, "monstros"), + PESCA("pesca", "Peixes pescados", "minecraft:custom", "minecraft:fish_caught", + Unit.COUNT, "peixes"), + PULOS("pulos", "Pulos", "minecraft:custom", "minecraft:jump", Unit.COUNT, "pulos"); + + enum Unit { COUNT, TICKS, CENTIMETRES } + + private final String commandKey; + private final String label; + private final String section; + private final String statKey; + private final Unit unit; + private final String suffix; + + RankingMetric(String commandKey, String label, String section, String statKey, + Unit unit, String suffix) { + this.commandKey = commandKey; + this.label = label; + this.section = section; + this.statKey = statKey; + this.unit = unit; + this.suffix = suffix; + } + + /** What players type: /ranking mineracao. */ + String commandKey() { + return commandKey; + } + + /** Accented display name. */ + String label() { + return label; + } + + /** Top-level section of the stats JSON, e.g. "minecraft:custom". */ + String section() { + return section; + } + + /** Entry within the section, or null to sum the whole section. */ + String statKey() { + return statKey; + } + + /** Formats a raw value for display, including its unit. */ + String format(long value) { + return switch (unit) { + case COUNT -> Msg.number(value) + (suffix.isEmpty() ? "" : " " + suffix); + case TICKS -> Msg.duration(value); + case CENTIMETRES -> String.format(Msg.PT_BR, "%,.1f km", value / 100_000.0); + }; + } + + static RankingMetric byKey(String key) { + for (RankingMetric metric : values()) { + if (metric.commandKey.equalsIgnoreCase(key)) { + return metric; + } + } + return null; + } +} diff --git a/src/main/java/dev/marcospaulo/canalhandia/Reactions.java b/src/main/java/dev/marcospaulo/canalhandia/Reactions.java new file mode 100644 index 0000000..a84dc59 --- /dev/null +++ b/src/main/java/dev/marcospaulo/canalhandia/Reactions.java @@ -0,0 +1,118 @@ +package dev.marcospaulo.canalhandia; + +import net.kyori.adventure.bossbar.BossBar; +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 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 message. + * + *

Chat cannot be edited after sending, so the counts inside the clickable + * buttons are frozen at send time. Three other surfaces carry the live numbers: + * a boss bar while the window is open, an action bar shown to whoever just + * reacted, and a final tally line broadcast when the window closes. Reactions + * keep being accepted after the boss bar disappears, because people scroll back + * and click late. + */ +final class Reactions { + + private final int id; + private final Map> votes = new LinkedHashMap<>(); + private final Map labels; + private final BossBar bar; + private final long createdAt = System.currentTimeMillis(); + private boolean barVisible; + + Reactions(int id, Map labels) { + this.id = id; + this.labels = labels; + labels.keySet().forEach(key -> votes.put(key, new LinkedHashSet<>())); + this.bar = BossBar.bossBar(tally(), 1.0f, BossBar.Color.PURPLE, BossBar.Overlay.PROGRESS); + } + + int id() { + return id; + } + + long ageMillis() { + return System.currentTimeMillis() - createdAt; + } + + boolean hasAnyVote() { + return votes.values().stream().anyMatch(set -> !set.isEmpty()); + } + + /** + * Records a reaction. One per player per message; reacting again with a + * different key moves the 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()); + if (barVisible) { + bar.name(tally()); + } + player.sendActionBar(tally()); + return true; + } + + int count(String key) { + Set set = votes.get(key); + return set == null ? 0 : set.size(); + } + + /** The clickable row. Counts are the values at send time and never change. */ + Component buttons(String commandBase) { + Component row = Component.text(" "); + for (Map.Entry entry : labels.entrySet()) { + String text = entry.getValue() + " " + count(entry.getKey()) + " "; + row = row.append(Component.text(text, NamedTextColor.YELLOW) + .clickEvent(ClickEvent.runCommand(commandBase + " " + id + " " + entry.getKey())) + .hoverEvent(HoverEvent.showText(Component.text( + "Clique para reagir com " + entry.getValue(), NamedTextColor.GRAY)))); + } + return row; + } + + /** Live counts, used for the boss bar, action bar and closing line. */ + Component tally() { + Component text = Component.text("Reações: ", NamedTextColor.WHITE); + for (Map.Entry entry : labels.entrySet()) { + text = text.append(Component.text(entry.getValue() + " ", NamedTextColor.YELLOW)) + .append(Component.text(count(entry.getKey()) + " ", NamedTextColor.AQUA)); + } + return text; + } + + void show() { + barVisible = true; + Bukkit.getOnlinePlayers().forEach(p -> p.showBossBar(bar)); + } + + void hide() { + barVisible = false; + Bukkit.getOnlinePlayers().forEach(p -> p.hideBossBar(bar)); + } + + /** Shows the bar to someone who joined while the window was still open. */ + void showTo(Player player) { + if (barVisible) { + player.showBossBar(bar); + } + } +} diff --git a/src/main/java/dev/marcospaulo/curiosidades/Settings.java b/src/main/java/dev/marcospaulo/canalhandia/Settings.java similarity index 66% rename from src/main/java/dev/marcospaulo/curiosidades/Settings.java rename to src/main/java/dev/marcospaulo/canalhandia/Settings.java index 3fbd22d..02b9011 100644 --- a/src/main/java/dev/marcospaulo/curiosidades/Settings.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Settings.java @@ -1,4 +1,4 @@ -package dev.marcospaulo.curiosidades; +package dev.marcospaulo.canalhandia; import org.bukkit.configuration.ConfigurationSection; @@ -13,13 +13,23 @@ import java.util.Map; */ final class Settings { - private final Curiosidades plugin; + private final Canalhandia plugin; - Settings(Curiosidades plugin) { + Settings(Canalhandia plugin) { this.plugin = plugin; } - // --- triggering --------------------------------------------------------- + // --- modules ------------------------------------------------------------ + + boolean moduleEnabled(Module module) { + return plugin.getConfig().getBoolean("modulos." + module.key(), true); + } + + void moduleEnabled(Module module, boolean enabled) { + set("modulos." + module.key(), enabled); + } + + // --- curiosity triggering ----------------------------------------------- Mode mode() { Mode mode = Mode.byKey(plugin.getConfig().getString("modo", "ENTRADA")); @@ -65,7 +75,7 @@ final class Settings { set("evitar-repetir", Math.max(0, count)); } - // --- presentation ------------------------------------------------------- + // --- reactions ---------------------------------------------------------- boolean reactionsEnabled() { return plugin.getConfig().getBoolean("reacoes-ativas", true); @@ -75,6 +85,7 @@ final class Settings { set("reacoes-ativas", enabled); } + /** How long the boss bar stays up. */ int reactionWindowSeconds() { return Math.max(5, plugin.getConfig().getInt("janela-reacao-segundos", 90)); } @@ -83,6 +94,18 @@ final class Settings { set("janela-reacao-segundos", Math.max(5, seconds)); } + /** + * How long clicks keep counting after the boss bar is gone. People scroll + * back and click late, and silently dropping those looks broken. + */ + int reactionValidityMinutes() { + return Math.max(1, plugin.getConfig().getInt("reacao-validade-minutos", 15)); + } + + void reactionValidityMinutes(int minutes) { + set("reacao-validade-minutos", Math.max(1, minutes)); + } + Map reactions() { Map reactions = new LinkedHashMap<>(); ConfigurationSection section = plugin.getConfig().getConfigurationSection("reacoes"); @@ -105,6 +128,32 @@ final class Settings { set("reacoes." + key, null); } + // --- games -------------------------------------------------------------- + + int guessSeconds() { + return Math.max(10, plugin.getConfig().getInt("adivinha-segundos", 45)); + } + + void guessSeconds(int seconds) { + set("adivinha-segundos", Math.max(10, seconds)); + } + + int pollMinutes() { + return Math.max(1, plugin.getConfig().getInt("enquete-minutos", 5)); + } + + void pollMinutes(int minutes) { + set("enquete-minutos", Math.max(1, minutes)); + } + + int rankingSize() { + return Math.max(3, plugin.getConfig().getInt("ranking-tamanho", 5)); + } + + void rankingSize(int size) { + set("ranking-tamanho", Math.max(3, size)); + } + // --- content ------------------------------------------------------------ boolean categoryEnabled(Category category) { diff --git a/src/main/java/dev/marcospaulo/curiosidades/Stats.java b/src/main/java/dev/marcospaulo/canalhandia/Stats.java similarity index 98% rename from src/main/java/dev/marcospaulo/curiosidades/Stats.java rename to src/main/java/dev/marcospaulo/canalhandia/Stats.java index cca9a64..4e11aeb 100644 --- a/src/main/java/dev/marcospaulo/curiosidades/Stats.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Stats.java @@ -1,4 +1,4 @@ -package dev.marcospaulo.curiosidades; +package dev.marcospaulo.canalhandia; import org.bukkit.Material; import org.bukkit.Statistic; diff --git a/src/main/java/dev/marcospaulo/curiosidades/CuriosidadeCommand.java b/src/main/java/dev/marcospaulo/curiosidades/CuriosidadeCommand.java deleted file mode 100644 index 5e0cf92..0000000 --- a/src/main/java/dev/marcospaulo/curiosidades/CuriosidadeCommand.java +++ /dev/null @@ -1,479 +0,0 @@ -package dev.marcospaulo.curiosidades; - -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 org.bukkit.Bukkit; -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 java.util.ArrayList; -import java.util.List; -import java.util.Map; - -/** The whole /curiosidade command tree. */ -final class CuriosidadeCommand implements CommandExecutor, TabCompleter { - - private static final String ADMIN = "curiosidades.admin"; - - private final Curiosidades plugin; - - CuriosidadeCommand(Curiosidades plugin) { - this.plugin = plugin; - } - - @Override - public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { - Settings settings = plugin.settings(); - - if (args.length == 0) { - return force(sender, null); - } - - String sub = args[0].toLowerCase(); - String[] rest = java.util.Arrays.copyOfRange(args, 1, args.length); - - switch (sub) { - case "ajuda", "help" -> help(sender); - case "reagir" -> react(sender, rest); - case "toggle" -> toggle(sender); - case "status" -> status(sender); - case "listar" -> list(sender, rest); - case "ver", "preview" -> preview(sender, rest); - case "modo" -> mode(sender, rest); - case "intervalo" -> intervalMinutes(sender, rest); - case "atraso" -> joinDelay(sender, rest); - case "cooldown" -> cooldown(sender, rest); - case "repetir" -> noRepeat(sender, rest); - case "janela" -> window(sender, rest); - case "reacoes" -> reactionsToggle(sender, rest); - case "reacao" -> reactionEdit(sender, rest); - case "categoria" -> categoryToggle(sender, rest); - case "categorias" -> categories(sender); - case "limpar" -> clear(sender, rest); - case "reload" -> reload(sender); - default -> { - // Bare player name: /curiosidade - Player target = Bukkit.getPlayerExact(args[0]); - if (target != null) { - return force(sender, target); - } - error(sender, "Subcomando desconhecido. Use /curiosidade ajuda"); - } - } - return true; - } - - // --- announcing --------------------------------------------------------- - - private boolean force(CommandSender sender, Player target) { - if (!sender.hasPermission("curiosidades.forcar")) { - return denied(sender); - } - if (!plugin.announceRandom(target)) { - error(sender, target == null - ? "Ninguém elegível online (ou sem estatísticas suficientes)." - : target.getName() + " ainda não tem estatísticas suficientes."); - } - return true; - } - - /** Shows a curiosity only to the sender, without broadcasting or touching cooldowns. */ - private void preview(CommandSender sender, String[] args) { - if (!sender.hasPermission("curiosidades.ver")) { - denied(sender); - return; - } - Player target = resolveTarget(sender, args); - if (target == null) { - return; - } - List facts = CuriosityFactory.facts(target, plugin.settings()); - if (facts.isEmpty()) { - error(sender, "Nenhuma curiosidade disponível para " + target.getName() + "."); - return; - } - Fact fact = facts.get((int) (Math.random() * facts.size())); - sender.sendMessage(plugin.render(target, fact.text())); - } - - /** Dumps every available fact for a player, privately. Handy for tuning thresholds. */ - private void list(CommandSender sender, String[] args) { - if (!sender.hasPermission("curiosidades.ver")) { - denied(sender); - return; - } - Player target = resolveTarget(sender, args); - if (target == null) { - return; - } - List facts = CuriosityFactory.facts(target, plugin.settings()); - header(sender, "Curiosidades de " + target.getName() + " (" + facts.size() + ")"); - for (Fact fact : facts) { - sender.sendMessage(Component.text(" [" + fact.category().label() + "] ", NamedTextColor.DARK_AQUA) - .append(fact.text())); - } - if (facts.isEmpty()) { - sender.sendMessage(Component.text(" (nada ainda)", NamedTextColor.GRAY)); - } - } - - private Player resolveTarget(CommandSender sender, String[] args) { - if (args.length > 0) { - Player target = Bukkit.getPlayerExact(args[0]); - if (target == null) { - error(sender, "Jogador '" + args[0] + "' não está online."); - } - return target; - } - if (sender instanceof Player player) { - return player; - } - error(sender, "Informe um jogador: /curiosidade listar "); - return null; - } - - private void react(CommandSender sender, String[] args) { - if (!(sender instanceof Player player) || args.length < 2) { - return; - } - if (!player.hasPermission("curiosidades.reagir")) { - denied(sender); - return; - } - ReactionSession active = plugin.activeSession(); - int id; - try { - id = Integer.parseInt(args[0]); - } catch (NumberFormatException e) { - return; - } - if (active == null || active.id() != id) { - player.sendActionBar(Component.text("Essa curiosidade já expirou.", NamedTextColor.RED)); - return; - } - if (active.react(player, args[1])) { - player.sendActionBar(Component.text("Você reagiu!", NamedTextColor.GREEN)); - } - } - - private void toggle(CommandSender sender) { - if (!(sender instanceof Player player)) { - error(sender, "Só jogadores podem usar isso."); - return; - } - boolean optedOut = !plugin.isOptedOut(player); - plugin.setOptedOut(player, optedOut); - sender.sendMessage(optedOut - ? Component.text("Você não aparecerá mais nas curiosidades.", NamedTextColor.YELLOW) - : Component.text("Você voltou a aparecer nas curiosidades.", NamedTextColor.GREEN)); - } - - // --- admin settings ----------------------------------------------------- - - private void mode(CommandSender sender, String[] args) { - if (!admin(sender)) { - return; - } - if (args.length == 0) { - error(sender, "Uso: /curiosidade modo "); - return; - } - Mode mode = Mode.byKey(args[0]); - if (mode == null) { - error(sender, "Modo inválido. Use entrada, intervalo, ambos ou manual."); - return; - } - plugin.settings().mode(mode); - plugin.rescheduleTimer(); - ok(sender, "Modo alterado para " + mode + "."); - } - - private void intervalMinutes(CommandSender sender, String[] args) { - Integer value = adminNumber(sender, args, "/curiosidade intervalo "); - if (value == null) { - return; - } - plugin.settings().intervalMinutes(value); - plugin.rescheduleTimer(); - ok(sender, "Intervalo alterado para " + plugin.settings().intervalMinutes() + " minutos."); - } - - private void joinDelay(CommandSender sender, String[] args) { - Integer value = adminNumber(sender, args, "/curiosidade atraso "); - if (value == null) { - return; - } - plugin.settings().joinDelaySeconds(value); - ok(sender, "Atraso após entrar: " + plugin.settings().joinDelaySeconds() + "s."); - } - - private void cooldown(CommandSender sender, String[] args) { - Integer value = adminNumber(sender, args, "/curiosidade cooldown "); - if (value == null) { - return; - } - plugin.settings().cooldownMinutes(value); - ok(sender, "Cooldown por jogador: " + plugin.settings().cooldownMinutes() + " minutos."); - } - - private void noRepeat(CommandSender sender, String[] args) { - Integer value = adminNumber(sender, args, "/curiosidade repetir "); - if (value == null) { - return; - } - plugin.settings().noRepeat(value); - ok(sender, "Evitando repetir as últimas " + plugin.settings().noRepeat() + " curiosidades."); - } - - private void window(CommandSender sender, String[] args) { - Integer value = adminNumber(sender, args, "/curiosidade janela "); - if (value == null) { - return; - } - plugin.settings().reactionWindowSeconds(value); - ok(sender, "Janela de reação: " + plugin.settings().reactionWindowSeconds() + "s."); - } - - private void reactionsToggle(CommandSender sender, String[] args) { - if (!admin(sender)) { - return; - } - if (args.length == 0) { - error(sender, "Uso: /curiosidade reacoes "); - return; - } - boolean on = args[0].equalsIgnoreCase("on"); - plugin.settings().reactionsEnabled(on); - ok(sender, "Reações " + (on ? "ativadas" : "desativadas") + "."); - } - - private void reactionEdit(CommandSender sender, String[] args) { - if (!admin(sender)) { - return; - } - if (args.length >= 3 && args[0].equalsIgnoreCase("add")) { - String label = String.join(" ", java.util.Arrays.copyOfRange(args, 2, args.length)); - plugin.settings().reaction(args[1].toLowerCase(), label); - ok(sender, "Reação '" + args[1] + "' definida como " + label + "."); - } else if (args.length >= 2 && args[0].equalsIgnoreCase("remover")) { - plugin.settings().removeReaction(args[1].toLowerCase()); - ok(sender, "Reação '" + args[1] + "' removida."); - } else { - error(sender, "Uso: /curiosidade reacao add | remover "); - } - } - - private void categoryToggle(CommandSender sender, String[] args) { - if (!admin(sender)) { - return; - } - if (args.length < 2) { - error(sender, "Uso: /curiosidade categoria "); - return; - } - Category category = Category.byKey(args[0]); - if (category == null) { - error(sender, "Categoria desconhecida. Veja /curiosidade categorias"); - return; - } - boolean on = args[1].equalsIgnoreCase("on"); - plugin.settings().categoryEnabled(category, on); - ok(sender, "Categoria " + category.label() + " " + (on ? "ativada" : "desativada") + "."); - } - - private void categories(CommandSender sender) { - header(sender, "Categorias"); - for (Category category : Category.values()) { - boolean on = plugin.settings().categoryEnabled(category); - sender.sendMessage(Component.text(" " + category.key() + " ", NamedTextColor.WHITE) - .append(Component.text(on ? "ativada" : "desativada", - on ? NamedTextColor.GREEN : NamedTextColor.RED)) - .append(Component.text(" [alternar]", NamedTextColor.DARK_AQUA) - .clickEvent(ClickEvent.runCommand( - "/curiosidade categoria " + category.key() + (on ? " off" : " on"))))); - } - } - - private void clear(CommandSender sender, String[] args) { - if (!admin(sender)) { - return; - } - String what = args.length > 0 ? args[0].toLowerCase() : "tudo"; - if (what.equals("cooldown") || what.equals("tudo")) { - plugin.clearCooldowns(); - } - if (what.equals("historico") || what.equals("tudo")) { - plugin.clearHistory(); - } - ok(sender, "Limpo: " + what + "."); - } - - private void reload(CommandSender sender) { - if (!admin(sender)) { - return; - } - plugin.reloadConfig(); - plugin.rescheduleTimer(); - ok(sender, "Configuração recarregada."); - } - - private void status(CommandSender sender) { - Settings settings = plugin.settings(); - header(sender, "Curiosidades — status"); - line(sender, "modo", settings.mode().name().toLowerCase()); - line(sender, "intervalo", settings.intervalMinutes() + " min" - + (settings.mode().firesOnTimer() ? "" : " (inativo neste modo)")); - line(sender, "atraso após entrar", settings.joinDelaySeconds() + "s" - + (settings.mode().firesOnJoin() ? "" : " (inativo neste modo)")); - line(sender, "cooldown por jogador", settings.cooldownMinutes() + " min"); - line(sender, "evitar repetir", settings.noRepeat() + " últimas"); - line(sender, "reações", settings.reactionsEnabled() ? "ativas" : "desativadas"); - line(sender, "janela de reação", settings.reactionWindowSeconds() + "s"); - line(sender, "reações definidas", String.join(" ", settings.reactions().values())); - List enabled = new ArrayList<>(); - for (Category category : Category.values()) { - if (settings.categoryEnabled(category)) { - enabled.add(category.key()); - } - } - line(sender, "categorias ativas", enabled.isEmpty() ? "nenhuma" : String.join(", ", enabled)); - } - - private void help(CommandSender sender) { - header(sender, "Curiosidades — comandos"); - Map commands = new java.util.LinkedHashMap<>(); - commands.put("/curiosidade", "anuncia uma curiosidade agora"); - commands.put("/curiosidade ", "anuncia sobre um jogador específico"); - commands.put("/curiosidade ver [jogador]", "mostra uma curiosidade só para você"); - commands.put("/curiosidade listar [jogador]", "lista todas as curiosidades disponíveis"); - commands.put("/curiosidade toggle", "entra/sai da lista de jogadores sorteados"); - commands.put("/curiosidade status", "mostra a configuração atual"); - commands.put("/curiosidade categorias", "lista as categorias e seu estado"); - commands.put("/curiosidade modo ", "entrada | intervalo | ambos | manual"); - commands.put("/curiosidade intervalo ", "intervalo do modo temporizado"); - commands.put("/curiosidade atraso ", "espera após o jogador entrar"); - commands.put("/curiosidade cooldown ", "tempo mínimo entre citar o mesmo jogador"); - commands.put("/curiosidade repetir ", "quantas curiosidades recentes evitar repetir"); - commands.put("/curiosidade janela ", "duração da barra de reações"); - commands.put("/curiosidade reacoes ", "liga/desliga as reações"); - commands.put("/curiosidade reacao add ", "cria ou muda uma reação"); - commands.put("/curiosidade reacao remover ", "remove uma reação"); - commands.put("/curiosidade categoria ", "liga/desliga uma categoria"); - commands.put("/curiosidade limpar [cooldown|historico|tudo]", "zera cooldowns/histórico"); - commands.put("/curiosidade reload", "recarrega o config.yml"); - commands.forEach((cmd, description) -> sender.sendMessage( - Component.text(" " + cmd, NamedTextColor.AQUA) - .append(Component.text(" — " + description, NamedTextColor.GRAY)))); - } - - // --- helpers ------------------------------------------------------------ - - private boolean admin(CommandSender sender) { - if (sender.hasPermission(ADMIN)) { - return true; - } - denied(sender); - return false; - } - - /** Parses a positive integer argument for an admin-only setting. */ - private Integer adminNumber(CommandSender sender, String[] args, String usage) { - if (!admin(sender)) { - return null; - } - if (args.length == 0) { - error(sender, "Uso: " + usage); - return null; - } - try { - return Integer.parseInt(args[0]); - } catch (NumberFormatException e) { - error(sender, "'" + args[0] + "' não é um número."); - return null; - } - } - - private boolean denied(CommandSender sender) { - error(sender, "Você não tem permissão para isso."); - return true; - } - - private void ok(CommandSender sender, String text) { - sender.sendMessage(Component.text("[Curiosidades] ", NamedTextColor.GOLD) - .append(Component.text(text, NamedTextColor.GREEN))); - } - - private void error(CommandSender sender, String text) { - sender.sendMessage(Component.text("[Curiosidades] ", NamedTextColor.GOLD) - .append(Component.text(text, NamedTextColor.RED))); - } - - private void header(CommandSender sender, String text) { - sender.sendMessage(Component.text("— " + text + " —", NamedTextColor.GOLD, TextDecoration.BOLD)); - } - - private void line(CommandSender sender, String key, String value) { - sender.sendMessage(Component.text(" " + key + ": ", NamedTextColor.GRAY) - .append(Component.text(value, NamedTextColor.AQUA))); - } - - // --- tab completion ----------------------------------------------------- - - @Override - public List onTabComplete(CommandSender sender, Command command, String label, String[] args) { - if (args.length == 1) { - List options = new ArrayList<>(List.of("ver", "listar", "toggle", "status", - "categorias", "ajuda")); - if (sender.hasPermission(ADMIN)) { - options.addAll(List.of("modo", "intervalo", "atraso", "cooldown", "repetir", - "janela", "reacoes", "reacao", "categoria", "limpar", "reload")); - } - Bukkit.getOnlinePlayers().forEach(p -> options.add(p.getName())); - return filter(options, args[0]); - } - if (args.length == 2) { - return switch (args[0].toLowerCase()) { - case "modo" -> filter(List.of("entrada", "intervalo", "ambos", "manual"), args[1]); - case "reacoes" -> filter(List.of("on", "off"), args[1]); - case "reacao" -> filter(List.of("add", "remover"), args[1]); - case "limpar" -> filter(List.of("cooldown", "historico", "tudo"), args[1]); - case "categoria" -> filter(categoryKeys(), args[1]); - case "ver", "listar" -> filter(onlineNames(), args[1]); - default -> List.of(); - }; - } - if (args.length == 3 && args[0].equalsIgnoreCase("categoria")) { - return filter(List.of("on", "off"), args[2]); - } - return List.of(); - } - - private List categoryKeys() { - List keys = new ArrayList<>(); - for (Category category : Category.values()) { - keys.add(category.key()); - } - return keys; - } - - private List onlineNames() { - List names = new ArrayList<>(); - Bukkit.getOnlinePlayers().forEach(p -> names.add(p.getName())); - return names; - } - - private static List filter(List options, String prefix) { - List matches = new ArrayList<>(); - for (String option : options) { - if (option.toLowerCase().startsWith(prefix.toLowerCase())) { - matches.add(option); - } - } - return matches; - } -} diff --git a/src/main/java/dev/marcospaulo/curiosidades/Curiosidades.java b/src/main/java/dev/marcospaulo/curiosidades/Curiosidades.java deleted file mode 100644 index b40224c..0000000 --- a/src/main/java/dev/marcospaulo/curiosidades/Curiosidades.java +++ /dev/null @@ -1,236 +0,0 @@ -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 lastFeatured = new HashMap<>(); - private final Deque 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 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 facts = CuriosityFactory.facts(subject, settings); - if (facts.isEmpty()) { - return false; - } - - // Prefer a fact that has not been announced recently; fall back to any. - List 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 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(); - } -} diff --git a/src/main/java/dev/marcospaulo/curiosidades/ReactionSession.java b/src/main/java/dev/marcospaulo/curiosidades/ReactionSession.java deleted file mode 100644 index 9bec213..0000000 --- a/src/main/java/dev/marcospaulo/curiosidades/ReactionSession.java +++ /dev/null @@ -1,84 +0,0 @@ -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. - * - *

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 can be updated in place and is visible to - * everyone until the window closes. - */ -final class ReactionSession { - - private final int id; - private final Map> votes = new LinkedHashMap<>(); - private final Map labels; - private final BossBar bar; - - ReactionSession(int id, Map 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 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 entry : labels.entrySet()) { - tally = tally - .append(Component.text(entry.getValue() + " ", NamedTextColor.YELLOW)) - .append(Component.text(count(entry.getKey()) + " ", NamedTextColor.AQUA)); - } - return tally; - } -} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index e0e7644..c80947a 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -1,7 +1,21 @@ -# Curiosidades — anúncios automáticos sobre os jogadores. +# Canalhandia — interações de chat do servidor. # -# Tudo aqui também pode ser mudado em jogo com /curiosidade, e os comandos -# gravam neste arquivo na hora. Veja /curiosidade ajuda. +# Tudo aqui também pode ser mudado em jogo, e os comandos gravam neste arquivo +# na hora. Veja /canalhandia status e /curiosidade ajuda. +# +# Nada neste plugin afeta a jogabilidade: só mensagens de chat, barras de +# progresso e botões clicáveis. + +# Cada módulo pode ser ligado/desligado com /canalhandia modulo +modulos: + curiosidades: true # "Sabia que o Fulano já minerou 5.966 blocos de Pedra?" + adivinha: true # curiosidade anônima + botões com os nomes dos jogadores + luto: true # botão [F] embaixo das mensagens de morte + enquete: true # /enquete com votação clicável + ranking: true # /ranking com placares do servidor + marcos: true # avisos automáticos ao passar de 100 km, 24 horas, etc. + +# --- Curiosidades ------------------------------------------------------------ # Quando as curiosidades disparam sozinhas: # entrada - só quando um jogador entra no mundo (padrão) @@ -17,27 +31,12 @@ atraso-entrada-segundos: 6 # Intervalo do modo temporizado, em minutos. Ignorado nos modos entrada/manual. intervalo-minutos: 20 -# Tempo mínimo antes do mesmo jogador ser citado de novo. Evita spam quando -# alguém fica entrando e saindo. +# Tempo mínimo antes do mesmo jogador ser citado de novo. cooldown-minutos: 30 # Quantas curiosidades recentes lembrar para não repetir. evitar-repetir: 15 -# Reações clicáveis embaixo de cada curiosidade. -reacoes-ativas: true - -# Por quanto tempo a barra de reações fica visível, em segundos. -janela-reacao-segundos: 90 - -# As reações disponíveis. A chave é usada no comando; o valor 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 se você tem muitos jogadores Bedrock. -reacoes: - joia: "[👍]" - uau: "[😮]" - fogo: "[🔥]" - # Tipos de curiosidade que podem ser sorteados. categorias: mineracao: true @@ -47,3 +46,34 @@ categorias: distancia: true tempo: true diversos: true + +# --- Reações ----------------------------------------------------------------- + +reacoes-ativas: true + +# Por quanto tempo a barra de reações fica visível, em segundos. +janela-reacao-segundos: 90 + +# Por quanto tempo um clique ainda conta depois que a barra some. As pessoas +# rolam o chat e clicam minutos depois; descartar esses cliques em silêncio +# parece defeito. +reacao-validade-minutos: 15 + +# As reações disponíveis. A chave é usada no comando; o valor 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 se você tem muitos jogadores Bedrock. +reacoes: + joia: "[👍]" + uau: "[😮]" + fogo: "[🔥]" + +# --- Jogos ------------------------------------------------------------------- + +# Quanto tempo cada rodada de /adivinha fica aberta, em segundos. +adivinha-segundos: 45 + +# Quanto tempo cada enquete fica aberta, em minutos. +enquete-minutos: 5 + +# Quantas posições mostrar em cada /ranking. +ranking-tamanho: 5 diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 4fbf407..f45eab5 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -1,32 +1,50 @@ -name: Curiosidades +name: Canalhandia version: "${project.version}" -main: dev.marcospaulo.curiosidades.Curiosidades +main: dev.marcospaulo.canalhandia.Canalhandia api-version: "26.1.1" -description: Anuncia curiosidades sobre os jogadores com base nas estatísticas do servidor. +description: Interações sociais de chat para o servidor Canalhandia — curiosidades, adivinhas, enquetes, rankings e marcos. author: Canalhandia folia-supported: false commands: + canalhandia: + description: Raiz de administração e destino dos botões clicáveis. + usage: /canalhandia + aliases: [cnh] curiosidade: - description: Anuncia curiosidades e ajusta como elas funcionam. + description: Curiosidades sobre os jogadores. usage: /curiosidade ajuda - aliases: [curiosidades, cur] + aliases: [curiosidades] + adivinha: + description: Inicia uma rodada de "adivinhe de quem é". + usage: /adivinha + enquete: + description: Abre uma enquete com opções clicáveis. + usage: /enquete Pergunta | Opção 1 | Opção 2 + aliases: [poll] + ranking: + description: Placares do servidor. + usage: /ranking [categoria] + aliases: [rankings, placar] permissions: # Declared explicitly: an undeclared Bukkit permission falls back to op-only, - # which would silently stop normal players reacting or opting out. - curiosidades.reagir: - description: Permite reagir às curiosidades. + # which would silently stop normal players reacting, voting or guessing. + canalhandia.reagir: + description: Permite reagir e prestar luto. default: true - curiosidades.ver: - description: Permite usar /curiosidade ver e /curiosidade listar. + canalhandia.ver: + description: Permite /curiosidade ver, listar e /ranking. default: true - curiosidades.forcar: - description: Permite disparar um anúncio manualmente. + canalhandia.enquete: + description: Permite abrir enquetes. + default: true + canalhandia.forcar: + description: Permite disparar curiosidades e adivinhas manualmente. default: op - curiosidades.admin: - description: Permite mudar modo, intervalo, categorias e reações. + canalhandia.admin: + description: Permite mudar módulos, modo, intervalo, categorias e reações. default: op - curiosidades.isento: + canalhandia.isento: description: Quem tem isto nunca é sorteado como assunto. default: false