diff --git a/src/main/java/dev/marcospaulo/curiosidades/Category.java b/src/main/java/dev/marcospaulo/curiosidades/Category.java new file mode 100644 index 0000000..2c418ed --- /dev/null +++ b/src/main/java/dev/marcospaulo/curiosidades/Category.java @@ -0,0 +1,40 @@ +package dev.marcospaulo.curiosidades; + +/** Groups of curiosities that can be switched on and off independently. */ +enum Category { + + MINERACAO("mineracao", "Mineração"), + ITENS("itens", "Itens"), + MORTES("mortes", "Mortes"), + COMBATE("combate", "Combate"), + DISTANCIA("distancia", "Distância"), + TEMPO("tempo", "Tempo"), + DIVERSOS("diversos", "Diversos"); + + private final String key; + private final String label; + + Category(String key, String label) { + this.key = key; + this.label = label; + } + + /** Lowercase ASCII key used in commands and config. */ + String key() { + return key; + } + + /** Accented name shown to players. */ + String label() { + return label; + } + + static Category byKey(String key) { + for (Category category : values()) { + if (category.key.equalsIgnoreCase(key)) { + return category; + } + } + return null; + } +} diff --git a/src/main/java/dev/marcospaulo/curiosidades/CuriosidadeCommand.java b/src/main/java/dev/marcospaulo/curiosidades/CuriosidadeCommand.java new file mode 100644 index 0000000..5e0cf92 --- /dev/null +++ b/src/main/java/dev/marcospaulo/curiosidades/CuriosidadeCommand.java @@ -0,0 +1,479 @@ +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 index 621d086..b40224c 100644 --- a/src/main/java/dev/marcospaulo/curiosidades/Curiosidades.java +++ b/src/main/java/dev/marcospaulo/curiosidades/Curiosidades.java @@ -5,94 +5,147 @@ 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.command.Command; -import org.bukkit.command.CommandExecutor; -import org.bukkit.command.CommandSender; -import org.bukkit.command.TabCompleter; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.persistence.PersistentDataType; import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitTask; +import java.util.ArrayDeque; import java.util.ArrayList; -import java.util.LinkedHashMap; +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 CommandExecutor, TabCompleter, Listener { +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 Map reactionLabels; - private int reactionWindowSeconds; + 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"); - loadReactionSettings(); - getCommand("curiosidade").setExecutor(this); - getCommand("curiosidade").setTabCompleter(this); + CuriosidadeCommand command = new CuriosidadeCommand(this); + if (getCommand("curiosidade") != null) { + getCommand("curiosidade").setExecutor(command); + getCommand("curiosidade").setTabCompleter(command); + } getServer().getPluginManager().registerEvents(this, this); + rescheduleTimer(); - long periodTicks = Math.max(1, getConfig().getLong("intervalo-minutos", 20)) * 60L * 20L; - getServer().getScheduler().runTaskTimer(this, this::announceRandom, periodTicks, periodTicks); - - getLogger().info("Curiosidades ativo — anúncio a cada " - + getConfig().getLong("intervalo-minutos", 20) + " minutos."); + getLogger().info("Curiosidades ativo — modo " + settings.mode() + + (settings.mode().firesOnTimer() + ? " (intervalo de " + settings.intervalMinutes() + " min)" : "")); } - private void loadReactionSettings() { - reactionWindowSeconds = getConfig().getInt("janela-reacao-segundos", 90); - reactionLabels = new LinkedHashMap<>(); - var section = getConfig().getConfigurationSection("reacoes"); - if (section != null) { - for (String key : section.getKeys(false)) { - reactionLabels.put(key, section.getString(key, key)); - } + @Override + public void onDisable() { + if (active != null) { + active.hide(); } - if (reactionLabels.isEmpty()) { - reactionLabels.put("joia", "[+1]"); + } + + 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 --------------------------------------------------------- - /** Picks a random eligible online player and broadcasts one fact about them. */ - void announceRandom() { - List candidates = new ArrayList<>(); - for (Player player : Bukkit.getOnlinePlayers()) { - if (!isOptedOut(player)) { - candidates.add(player); + /** + * 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())); } - if (candidates.isEmpty()) { - return; + + List facts = CuriosityFactory.facts(subject, settings); + if (facts.isEmpty()) { + return false; } - Player subject = candidates.get(random.nextInt(candidates.size())); - Component fact = CuriosityFactory.random(subject, random); - if (fact == null) { - return; // Nothing notable about this player yet. - } - broadcast(subject, fact); + + // 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 broadcast(Player subject, Component fact) { - if (active != null) { - active.hide(); + private void remember(Fact fact) { + recentFacts.addLast(plain(fact.text())); + while (recentFacts.size() > settings.noRepeat()) { + recentFacts.removeFirst(); } - ReactionSession session = new ReactionSession(nextSessionId++, reactionLabels); - active = session; + } - Component message = Component.text("[Curiosidade] ", NamedTextColor.GOLD, TextDecoration.BOLD) + 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) @@ -100,23 +153,34 @@ public final class Curiosidades extends JavaPlugin implements CommandExecutor, T .append(Component.text(" ", NamedTextColor.WHITE).decoration(TextDecoration.BOLD, false)) .append(fact.decoration(TextDecoration.BOLD, false)) .append(Component.text("?", NamedTextColor.WHITE).decoration(TextDecoration.BOLD, false)); + } - Bukkit.broadcast(message); + 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; } - }, reactionWindowSeconds * 20L); + }, 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 : reactionLabels.entrySet()) { + 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())) @@ -127,87 +191,46 @@ public final class Curiosidades extends JavaPlugin implements CommandExecutor, T return row; } - // --- commands ----------------------------------------------------------- - - @Override - public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { - if (args.length == 0) { - if (!sender.hasPermission("curiosidades.forcar")) { - sender.sendMessage(Component.text("Sem permissão.", NamedTextColor.RED)); - return true; - } - announceRandom(); - return true; - } - - switch (args[0].toLowerCase()) { - case "reagir" -> { - if (!(sender instanceof Player player)) { - return true; - } - if (args.length < 3 || active == null) { - return true; - } - int id; - try { - id = Integer.parseInt(args[1]); - } catch (NumberFormatException e) { - return true; - } - if (id != active.id()) { - player.sendActionBar(Component.text("Essa curiosidade já expirou.", NamedTextColor.RED)); - return true; - } - if (active.react(player, args[2])) { - player.sendActionBar(Component.text("Você reagiu!", NamedTextColor.GREEN)); - } - } - case "toggle" -> { - if (!(sender instanceof Player player)) { - return true; - } - boolean nowOptedOut = !isOptedOut(player); - player.getPersistentDataContainer() - .set(optOutKey, PersistentDataType.BYTE, (byte) (nowOptedOut ? 1 : 0)); - player.sendMessage(nowOptedOut - ? Component.text("Você não aparecerá mais nas curiosidades.", NamedTextColor.YELLOW) - : Component.text("Você voltou a aparecer nas curiosidades.", NamedTextColor.GREEN)); - } - case "reload" -> { - if (!sender.hasPermission("curiosidades.admin")) { - sender.sendMessage(Component.text("Sem permissão.", NamedTextColor.RED)); - return true; - } - reloadConfig(); - loadReactionSettings(); - sender.sendMessage(Component.text("Configuração recarregada.", NamedTextColor.GREEN)); - } - default -> sender.sendMessage( - Component.text("Uso: /curiosidade [toggle|reload]", NamedTextColor.GRAY)); - } - return true; - } - - @Override - public List onTabComplete(CommandSender sender, Command command, String label, String[] args) { - if (args.length == 1) { - return List.of("toggle", "reload"); - } - return List.of(); + ReactionSession activeSession() { + return active; } // --- events ------------------------------------------------------------- @EventHandler public void onJoin(PlayerJoinEvent event) { + Player player = event.getPlayer(); if (active != null) { - active.showTo(event.getPlayer()); + 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); } - private boolean isOptedOut(Player player) { - Byte value = player.getPersistentDataContainer() - .get(optOutKey, PersistentDataType.BYTE); + // --- 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/CuriosityFactory.java b/src/main/java/dev/marcospaulo/curiosidades/CuriosityFactory.java index f23a07a..81fea68 100644 --- a/src/main/java/dev/marcospaulo/curiosidades/CuriosityFactory.java +++ b/src/main/java/dev/marcospaulo/curiosidades/CuriosityFactory.java @@ -36,108 +36,106 @@ final class CuriosityFactory { } /** - * Every sentence that currently applies to {@code player}. May be empty for a - * brand-new player who has not done anything worth mentioning yet. + * Every sentence that currently applies to {@code player}, restricted to the + * enabled categories. May be empty for a brand-new player. */ - static List facts(Player player) { - List facts = new ArrayList<>(); + static List facts(Player player, Settings settings) { + List facts = new ArrayList<>(); - materialFact(facts, player, Stats.resolve("MINE_BLOCK"), MIN_COUNT, + material(facts, player, Category.MINERACAO, Stats.resolve("MINE_BLOCK"), MIN_COUNT, "já minerou ", " blocos de "); - materialFact(facts, player, Stats.resolve("BREAK_ITEM"), 3, + material(facts, player, Category.ITENS, Stats.resolve("BREAK_ITEM"), 3, "já quebrou ", " unidades de "); - materialFact(facts, player, Stats.resolve("CRAFT_ITEM"), MIN_COUNT, + material(facts, player, Category.ITENS, Stats.resolve("CRAFT_ITEM"), MIN_COUNT, "já fabricou ", " unidades de "); - materialFact(facts, player, Stats.resolve("USE_ITEM"), 200, + material(facts, player, Category.ITENS, Stats.resolve("USE_ITEM"), 200, "já usou ", " vezes o item "); - materialFact(facts, player, Stats.resolve("PICKUP", "PICKED_UP"), 500, + material(facts, player, Category.ITENS, Stats.resolve("PICKUP", "PICKED_UP"), 500, "já coletou ", " unidades de "); - entityFacts(facts, player, Stats.resolve("ENTITY_KILLED_BY"), MIN_DEATHS, + entities(facts, player, Category.MORTES, Stats.resolve("ENTITY_KILLED_BY"), MIN_DEATHS, "já morreu ", " vezes para "); - entityFacts(facts, player, Stats.resolve("KILL_ENTITY"), MIN_COUNT, + entities(facts, player, Category.COMBATE, Stats.resolve("KILL_ENTITY"), MIN_COUNT, "já derrotou ", " inimigos do tipo "); - distanceFact(facts, player, Stats.resolve("WALK_ONE_CM"), "já caminhou ", " a pé"); - distanceFact(facts, player, Stats.resolve("SPRINT_ONE_CM"), "já correu ", ""); - distanceFact(facts, player, Stats.resolve("FLY_ONE_CM"), "já voou ", ""); - distanceFact(facts, player, Stats.resolve("BOAT_ONE_CM"), "já navegou ", " de barco"); - distanceFact(facts, player, Stats.resolve("HORSE_ONE_CM"), "já cavalgou ", ""); - distanceFact(facts, player, Stats.resolve("SWIM_ONE_CM"), "já nadou ", ""); - distanceFact(facts, player, Stats.resolve("MINECART_ONE_CM"), "já andou ", " de carrinho"); + distance(facts, player, Stats.resolve("WALK_ONE_CM"), "já caminhou ", " a pé"); + distance(facts, player, Stats.resolve("SPRINT_ONE_CM"), "já correu ", ""); + distance(facts, player, Stats.resolve("FLY_ONE_CM"), "já voou ", ""); + distance(facts, player, Stats.resolve("BOAT_ONE_CM"), "já navegou ", " de barco"); + distance(facts, player, Stats.resolve("HORSE_ONE_CM"), "já cavalgou ", ""); + distance(facts, player, Stats.resolve("SWIM_ONE_CM"), "já nadou ", ""); + distance(facts, player, Stats.resolve("MINECART_ONE_CM"), "já andou ", " de carrinho"); - timeFact(facts, player, Stats.resolve("PLAY_TIME", "PLAY_ONE_MINUTE"), + time(facts, player, Stats.resolve("PLAY_TIME", "PLAY_ONE_MINUTE"), "já passou ", " dentro do servidor"); - timeFact(facts, player, Stats.resolve("TIME_SINCE_DEATH"), - "está há ", " sem morrer"); - timeFact(facts, player, Stats.resolve("TIME_SINCE_REST"), - "está há ", " sem dormir"); + time(facts, player, Stats.resolve("TIME_SINCE_DEATH"), "está há ", " sem morrer"); + time(facts, player, Stats.resolve("TIME_SINCE_REST"), "está há ", " sem dormir"); - countFact(facts, player, Stats.resolve("JUMP"), 500, "já pulou ", " vezes"); - countFact(facts, player, Stats.resolve("DEATHS"), MIN_DEATHS, "já morreu ", " vezes no total"); - countFact(facts, player, Stats.resolve("MOB_KILLS"), MIN_COUNT, "já derrotou ", " monstros"); - countFact(facts, player, Stats.resolve("DAMAGE_DEALT"), 1000, "já causou ", " de dano"); - countFact(facts, player, Stats.resolve("DAMAGE_TAKEN"), 1000, "já levou ", " de dano"); - countFact(facts, player, Stats.resolve("FISH_CAUGHT"), 5, "já pescou ", " peixes"); - countFact(facts, player, Stats.resolve("ANIMALS_BRED"), 5, "já acasalou ", " animais"); - countFact(facts, player, Stats.resolve("ITEM_ENCHANTED"), 3, "já encantou ", " itens"); - countFact(facts, player, Stats.resolve("TRADED_WITH_VILLAGER"), 5, + count(facts, player, Category.MORTES, Stats.resolve("DEATHS"), MIN_DEATHS, + "já morreu ", " vezes no total"); + count(facts, player, Category.COMBATE, Stats.resolve("MOB_KILLS"), MIN_COUNT, + "já derrotou ", " monstros"); + count(facts, player, Category.COMBATE, Stats.resolve("DAMAGE_DEALT"), 1000, + "já causou ", " de dano"); + count(facts, player, Category.COMBATE, Stats.resolve("DAMAGE_TAKEN"), 1000, + "já levou ", " de dano"); + count(facts, player, Category.DIVERSOS, Stats.resolve("JUMP"), 500, "já pulou ", " vezes"); + count(facts, player, Category.DIVERSOS, Stats.resolve("FISH_CAUGHT"), 5, + "já pescou ", " peixes"); + count(facts, player, Category.DIVERSOS, Stats.resolve("ANIMALS_BRED"), 5, + "já acasalou ", " animais"); + count(facts, player, Category.DIVERSOS, Stats.resolve("ITEM_ENCHANTED"), 3, + "já encantou ", " itens"); + count(facts, player, Category.DIVERSOS, Stats.resolve("TRADED_WITH_VILLAGER"), 5, "já negociou ", " vezes com aldeões"); - countFact(facts, player, Stats.resolve("RAID_WIN"), 1, "já venceu ", " invasões"); + count(facts, player, Category.DIVERSOS, Stats.resolve("RAID_WIN"), 1, + "já venceu ", " invasões"); + facts.removeIf(fact -> !settings.categoryEnabled(fact.category())); return facts; } - /** One random applicable sentence, or null if the player has nothing notable yet. */ - static Component random(Player player, java.util.Random random) { - List facts = facts(player); - if (facts.isEmpty()) { - return null; - } - return facts.get(random.nextInt(facts.size())); - } - // --- builders ----------------------------------------------------------- - private static void materialFact(List out, Player player, Statistic statistic, - int minimum, String prefix, String middle) { + private static void material(List out, Player player, Category category, + Statistic statistic, int minimum, String prefix, String middle) { Stats.Entry top = Stats.topMaterial(player, statistic, minimum); if (top == null) { return; } - out.add(Component.text(prefix, NamedTextColor.WHITE) + out.add(new Fact(category, Component.text(prefix, NamedTextColor.WHITE) .append(number(top.value())) .append(Component.text(middle, NamedTextColor.WHITE)) - .append(name(top.subject()))); + .append(name(top.subject())))); } - private static void entityFacts(List out, Player player, Statistic statistic, - int minimum, String prefix, String middle) { + private static void entities(List out, Player player, Category category, + Statistic statistic, int minimum, String prefix, String middle) { List> entries = Stats.entities(player, statistic, minimum); // Deaths-by-mob are interesting per mob, not just for the worst offender. Collections.shuffle(entries); for (Stats.Entry entry : entries.subList(0, Math.min(3, entries.size()))) { - out.add(Component.text(prefix, NamedTextColor.WHITE) + out.add(new Fact(category, Component.text(prefix, NamedTextColor.WHITE) .append(number(entry.value())) .append(Component.text(middle, NamedTextColor.WHITE)) - .append(name(entry.subject()))); + .append(name(entry.subject())))); } } - private static void distanceFact(List out, Player player, Statistic statistic, - String prefix, String suffix) { + private static void distance(List out, Player player, Statistic statistic, + String prefix, String suffix) { int centimetres = Stats.untyped(player, statistic); if (centimetres < MIN_CENTIMETRES) { return; } double km = centimetres / 100_000.0; - out.add(Component.text(prefix, NamedTextColor.WHITE) + out.add(new Fact(Category.DISTANCIA, Component.text(prefix, NamedTextColor.WHITE) .append(Component.text(String.format(PT_BR, "%,.1f km", km), NamedTextColor.AQUA)) - .append(Component.text(suffix, NamedTextColor.WHITE))); + .append(Component.text(suffix, NamedTextColor.WHITE)))); } - private static void timeFact(List out, Player player, Statistic statistic, - String prefix, String suffix) { + private static void time(List out, Player player, Statistic statistic, + String prefix, String suffix) { int ticks = Stats.untyped(player, statistic); long hours = ticks / 20L / 3600L; if (hours < 1) { @@ -146,13 +144,13 @@ final class CuriosityFactory { String text = hours >= 24 ? String.format(PT_BR, "%d dias e %d horas", hours / 24, hours % 24) : hours + " horas"; - out.add(Component.text(prefix, NamedTextColor.WHITE) + out.add(new Fact(Category.TEMPO, Component.text(prefix, NamedTextColor.WHITE) .append(Component.text(text, NamedTextColor.AQUA)) - .append(Component.text(suffix, NamedTextColor.WHITE))); + .append(Component.text(suffix, NamedTextColor.WHITE)))); } - private static void countFact(List out, Player player, Statistic statistic, - int minimum, String prefix, String suffix) { + private static void count(List out, Player player, Category category, + Statistic statistic, int minimum, String prefix, String suffix) { int value = Stats.untyped(player, statistic); if (value < minimum) { return; @@ -161,9 +159,9 @@ final class CuriosityFactory { if (statistic != null && statistic.name().startsWith("DAMAGE_")) { value = value / 10; } - out.add(Component.text(prefix, NamedTextColor.WHITE) + out.add(new Fact(category, Component.text(prefix, NamedTextColor.WHITE) .append(number(value)) - .append(Component.text(suffix, NamedTextColor.WHITE))); + .append(Component.text(suffix, NamedTextColor.WHITE)))); } // --- pieces ------------------------------------------------------------- diff --git a/src/main/java/dev/marcospaulo/curiosidades/Fact.java b/src/main/java/dev/marcospaulo/curiosidades/Fact.java new file mode 100644 index 0000000..54b8da1 --- /dev/null +++ b/src/main/java/dev/marcospaulo/curiosidades/Fact.java @@ -0,0 +1,7 @@ +package dev.marcospaulo.curiosidades; + +import net.kyori.adventure.text.Component; + +/** One generated curiosity sentence, tagged with the category it came from. */ +record Fact(Category category, Component text) { +} diff --git a/src/main/java/dev/marcospaulo/curiosidades/Mode.java b/src/main/java/dev/marcospaulo/curiosidades/Mode.java new file mode 100644 index 0000000..d5de416 --- /dev/null +++ b/src/main/java/dev/marcospaulo/curiosidades/Mode.java @@ -0,0 +1,31 @@ +package dev.marcospaulo.curiosidades; + +/** When curiosities fire on their own. */ +enum Mode { + + /** Only when a player joins the world. This is the default. */ + ENTRADA, + /** Only on a repeating timer. */ + INTERVALO, + /** Both triggers at once. */ + AMBOS, + /** Never automatically — only via /curiosidade. */ + MANUAL; + + static Mode byKey(String key) { + for (Mode mode : values()) { + if (mode.name().equalsIgnoreCase(key)) { + return mode; + } + } + return null; + } + + boolean firesOnJoin() { + return this == ENTRADA || this == AMBOS; + } + + boolean firesOnTimer() { + return this == INTERVALO || this == AMBOS; + } +} diff --git a/src/main/java/dev/marcospaulo/curiosidades/Settings.java b/src/main/java/dev/marcospaulo/curiosidades/Settings.java new file mode 100644 index 0000000..3fbd22d --- /dev/null +++ b/src/main/java/dev/marcospaulo/curiosidades/Settings.java @@ -0,0 +1,124 @@ +package dev.marcospaulo.curiosidades; + +import org.bukkit.configuration.ConfigurationSection; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Typed view over config.yml. + * + *

Every setter writes through to disk immediately, so a command that changes + * behaviour survives a restart without anyone having to remember to save. + */ +final class Settings { + + private final Curiosidades plugin; + + Settings(Curiosidades plugin) { + this.plugin = plugin; + } + + // --- triggering --------------------------------------------------------- + + Mode mode() { + Mode mode = Mode.byKey(plugin.getConfig().getString("modo", "ENTRADA")); + return mode == null ? Mode.ENTRADA : mode; + } + + void mode(Mode mode) { + set("modo", mode.name()); + } + + int intervalMinutes() { + return Math.max(1, plugin.getConfig().getInt("intervalo-minutos", 20)); + } + + void intervalMinutes(int minutes) { + set("intervalo-minutos", Math.max(1, minutes)); + } + + /** Seconds to wait after a join before announcing, so it lands after the join message. */ + int joinDelaySeconds() { + return Math.max(0, plugin.getConfig().getInt("atraso-entrada-segundos", 6)); + } + + void joinDelaySeconds(int seconds) { + set("atraso-entrada-segundos", Math.max(0, seconds)); + } + + /** Minutes before the same player can be the subject again. */ + int cooldownMinutes() { + return Math.max(0, plugin.getConfig().getInt("cooldown-minutos", 30)); + } + + void cooldownMinutes(int minutes) { + set("cooldown-minutos", Math.max(0, minutes)); + } + + /** How many recent facts to remember and avoid repeating. */ + int noRepeat() { + return Math.max(0, plugin.getConfig().getInt("evitar-repetir", 15)); + } + + void noRepeat(int count) { + set("evitar-repetir", Math.max(0, count)); + } + + // --- presentation ------------------------------------------------------- + + boolean reactionsEnabled() { + return plugin.getConfig().getBoolean("reacoes-ativas", true); + } + + void reactionsEnabled(boolean enabled) { + set("reacoes-ativas", enabled); + } + + int reactionWindowSeconds() { + return Math.max(5, plugin.getConfig().getInt("janela-reacao-segundos", 90)); + } + + void reactionWindowSeconds(int seconds) { + set("janela-reacao-segundos", Math.max(5, seconds)); + } + + Map reactions() { + Map reactions = new LinkedHashMap<>(); + ConfigurationSection section = plugin.getConfig().getConfigurationSection("reacoes"); + if (section != null) { + for (String key : section.getKeys(false)) { + reactions.put(key, section.getString(key, key)); + } + } + if (reactions.isEmpty()) { + reactions.put("joia", "[+1]"); + } + return reactions; + } + + void reaction(String key, String label) { + set("reacoes." + key, label); + } + + void removeReaction(String key) { + set("reacoes." + key, null); + } + + // --- content ------------------------------------------------------------ + + boolean categoryEnabled(Category category) { + return plugin.getConfig().getBoolean("categorias." + category.key(), true); + } + + void categoryEnabled(Category category, boolean enabled) { + set("categorias." + category.key(), enabled); + } + + // --- plumbing ----------------------------------------------------------- + + private void set(String path, Object value) { + plugin.getConfig().set(path, value); + plugin.saveConfig(); + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 43f2b64..e0e7644 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -1,16 +1,49 @@ -# Curiosidades — anúncios automáticos sobre os jogadores online. +# Curiosidades — anúncios automáticos sobre os jogadores. +# +# Tudo aqui também pode ser mudado em jogo com /curiosidade, e os comandos +# gravam neste arquivo na hora. Veja /curiosidade ajuda. -# Intervalo entre anúncios, em minutos. +# Quando as curiosidades disparam sozinhas: +# entrada - só quando um jogador entra no mundo (padrão) +# intervalo - só num temporizador +# ambos - os dois +# manual - nunca sozinho, só com /curiosidade +modo: ENTRADA + +# Segundos de espera depois que o jogador entra, para a curiosidade aparecer +# depois da mensagem de entrada em vez de disputar com ela. +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. +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 -# Reações disponíveis. A chave é usada no comando; o valor é o que aparece no chat. +# 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 para servidores com muitos jogadores -# de Bedrock. +# 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 + itens: true + mortes: true + combate: true + distancia: true + tempo: true + diversos: true diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index cf9938d..4fbf407 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -8,19 +8,25 @@ folia-supported: false commands: curiosidade: - description: Anuncia uma curiosidade agora, ou gerencia suas preferências. - usage: /curiosidade [toggle|reload] - aliases: [curiosidades] + description: Anuncia curiosidades e ajusta como elas funcionam. + usage: /curiosidade ajuda + aliases: [curiosidades, cur] permissions: # Declared explicitly: an undeclared Bukkit permission falls back to op-only, - # which would silently stop normal players from being able to react. + # which would silently stop normal players reacting or opting out. curiosidades.reagir: description: Permite reagir às curiosidades. default: true + curiosidades.ver: + description: Permite usar /curiosidade ver e /curiosidade listar. + default: true curiosidades.forcar: - description: Permite forçar um anúncio com /curiosidade. + description: Permite disparar um anúncio manualmente. default: op curiosidades.admin: - description: Permite recarregar a configuração. + description: Permite mudar modo, intervalo, categorias e reações. default: op + curiosidades.isento: + description: Quem tem isto nunca é sorteado como assunto. + default: false