diff --git a/README.md b/README.md index af07c90..d2919d3 100644 --- a/README.md +++ b/README.md @@ -59,14 +59,34 @@ change. The live numbers appear on three other surfaces instead: An earlier version only had the boss bar, and it read as broken — the buttons showed no number at all. -### 2. Clicks arrive late +### 2. Who reacted, without filling the screen + +Naming every reactor on its own chat line does not scale — five people +reacting to five reactions is a wall of text. So the names live in three +progressively larger places: + +- **hover tooltip** on each button (Java only — Bedrock cannot hover) +- **one-line closing summary** naming the first `resumo-nomes` (default 3) and + collapsing the rest into `+N` +- **`/reacoes`** for the full breakdown, sent privately so chat stays clean + +The same cap applies to the mourning line, so a popular death is still one line. + +Relatedly, a curiosity costs **one** chat message, not two — the button row is +appended to the headline rather than broadcast separately. + +And automatic curiosities are rate-limited by `intervalo-minimo-segundos` +(default 120). Without it, five people joining together produced five +curiosities back to back. A typed `/curiosidade` bypasses the limit. + +### 3. 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. Bedrock cannot click, and cannot show emoji +### 4. Bedrock cannot click, and cannot show emoji Geyser cannot deliver a chat `clickEvent` to a Bedrock client, and most emoji render as tofu boxes there. So every clickable interaction has a typed @@ -95,7 +115,7 @@ Changing a `comando` to a new name also requires adding that command to runtime dependency rather than a compile-time one. `/canalhandia plataformas` lists who is online and on which platform. -### 4. Names are translated by the client, not by us +### 5. 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 diff --git a/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java b/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java index 9ba2665..a75d620 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java @@ -48,6 +48,7 @@ public final class Canalhandia extends JavaPlugin implements Listener { private BukkitTask timerTask; private BukkitTask milestoneTask; + private long lastAnnouncement; private int nextId = 1; private Reactions liveReactions; private GuessRound guessRound; @@ -63,7 +64,7 @@ public final class Canalhandia extends JavaPlugin implements Listener { CanalhandiaCommand root = new CanalhandiaCommand(this); for (String name : List.of("canalhandia", "curiosidade", "adivinha", "enquete", "ranking", - "reagir", "palpite", "votar", "legal", "wow", "top", "f")) { + "reagir", "reacoes", "palpite", "votar", "legal", "wow", "top", "f")) { register(name, root); } @@ -153,14 +154,33 @@ public final class Canalhandia extends JavaPlugin implements Listener { * @return false if nobody was eligible or there was nothing notable to say */ boolean announceCuriosity(Player subject) { + return announceCuriosity(subject, true); + } + + /** + * Announces one curiosity. + * + * @param subject who to talk about, or null to pick a random eligible player + * @param automatic true for join/timer triggers, which are rate-limited so a + * wave of simultaneous joins produces one curiosity instead + * of one per player; false for an explicit command + * @return false if nothing was announced + */ + boolean announceCuriosity(Player subject, boolean automatic) { if (!settings.moduleEnabled(Module.CURIOSIDADES)) { return false; } + if (automatic && System.currentTimeMillis() - lastAnnouncement + < settings.minGapSeconds() * 1000L) { + return false; + } Chosen chosen = pickFact(subject == null ? pickSubject() : subject); if (chosen == null) { return false; } - Bukkit.broadcast(Msg.tag("Curiosidade", NamedTextColor.GOLD) + lastAnnouncement = System.currentTimeMillis(); + + Component headline = Msg.tag("Curiosidade", NamedTextColor.GOLD) .append(Component.text("Sabia que o ", NamedTextColor.WHITE) .decoration(TextDecoration.BOLD, false)) .append(Component.text(chosen.subject().getName(), NamedTextColor.GREEN) @@ -169,8 +189,17 @@ public final class Canalhandia extends JavaPlugin implements Listener { .decoration(TextDecoration.BOLD, false)) .append(chosen.fact().text().decoration(TextDecoration.BOLD, false)) .append(Component.text("?", NamedTextColor.WHITE) - .decoration(TextDecoration.BOLD, false))); - openReactions(); + .decoration(TextDecoration.BOLD, false)); + + if (!settings.reactionsEnabled()) { + Bukkit.broadcast(headline); + return true; + } + // One message, not two: the buttons ride along on the same broadcast so + // a curiosity costs a single chat entry. + Reactions reactions = openReactions(); + broadcastPerPlatform(bedrock -> headline.append(Component.newline()) + .append(reactions.buttons(bedrock))); return true; } @@ -238,16 +267,15 @@ public final class Canalhandia extends JavaPlugin implements Listener { } } - /** Attaches a fresh reaction row to the message just broadcast. */ - private void openReactions() { - if (!settings.reactionsEnabled()) { - return; - } + /** + * Creates a reaction set and schedules its close. The caller is responsible + * for broadcasting {@link Reactions#buttons}, so the buttons can share a + * message with whatever they belong to. + */ + private Reactions openReactions() { Reactions reactions = new Reactions(nextId++, settings.reactions()); liveReactions = reactions; remember(reactions); - - broadcastPerPlatform(reactions::buttons); reactions.show(); getServer().getScheduler().runTaskLater(this, () -> { @@ -255,12 +283,14 @@ public final class Canalhandia extends JavaPlugin implements Listener { if (liveReactions == reactions) { liveReactions = null; } - // Chat cannot be edited, so post the final count as its own line. + // Chat cannot be edited, so the result gets one closing line — and + // only if anyone actually reacted, to avoid noise. if (reactions.hasAnyVote()) { - broadcastPerPlatform(bedrock -> - Component.text(" ").append(reactions.tally(bedrock))); + broadcastPerPlatform(bedrock -> Component.text(" ") + .append(reactions.summary(bedrock, settings.summaryNames()))); } }, settings.reactionWindowSeconds() * 20L); + return reactions; } /** @@ -389,7 +419,7 @@ public final class Canalhandia extends JavaPlugin implements Listener { // Delayed so the curiosity lands after the join message rather than racing it. getServer().getScheduler().runTaskLater(this, () -> { if (player.isOnline()) { - announceCuriosity(player); + announceCuriosity(player, true); } }, settings.joinDelaySeconds() * 20L); } @@ -404,6 +434,7 @@ public final class Canalhandia extends JavaPlugin implements Listener { List.of(new ReactionDef("f", "[F]", "[F]", "f"))); liveReactions = mourning; remember(mourning); + mourning.show(); String name = event.getEntity().getName(); // One tick later so it prints under the vanilla death message. @@ -421,10 +452,16 @@ public final class Canalhandia extends JavaPlugin implements Listener { }), 2L); getServer().getScheduler().runTaskLater(this, () -> { + mourning.hide(); if (mourning.hasAnyVote()) { + // One line, names truncated, so a busy death does not fill the screen. + List who = mourning.names("f"); + int shown = Math.min(who.size(), settings.summaryNames()); + String text = String.join(", ", who.subList(0, shown)) + + (who.size() > shown ? " +" + (who.size() - shown) : ""); Bukkit.broadcast(Component.text(" ") - .append(Component.text(mourning.count("f") - + " pessoa(s) prestaram luto por " + name + ".", NamedTextColor.GRAY))); + .append(Component.text(text + " prestaram luto por " + name + ".", + NamedTextColor.GRAY))); } if (liveReactions == mourning) { liveReactions = null; diff --git a/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java index 1a1433f..54b2e4f 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java +++ b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java @@ -45,6 +45,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { case "reagir" -> reactLatest(sender, args.length > 0 ? args[0] : null); case "palpite" -> guessLatest(sender, args); case "votar" -> voteLatest(sender, args); + case "reacoes" -> whoReacted(sender); default -> { String reaction = plugin.settings().reactionForCommand(command.getName()); if (reaction != null) { @@ -141,6 +142,18 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { plugin.settings().reactionWindowSeconds(v); return "Janela de reação: " + plugin.settings().reactionWindowSeconds() + "s."; }); + case "resumo" -> setNumber(sender, rest, "resumo ", + v -> { + plugin.settings().summaryNames(v); + return "Mostrando até " + plugin.settings().summaryNames() + + " nomes no resumo."; + }); + case "intervalo-minimo", "minimo" -> setNumber(sender, rest, "minimo ", + v -> { + plugin.settings().minGapSeconds(v); + return "Intervalo mínimo entre curiosidades automáticas: " + + plugin.settings().minGapSeconds() + "s."; + }); case "validade" -> setNumber(sender, rest, "validade ", v -> { plugin.settings().reactionValidityMinutes(v); @@ -422,6 +435,19 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { return true; } + /** Who reacted to the last message — sent privately so chat stays clean. */ + private boolean whoReacted(CommandSender sender) { + Reactions reactions = plugin.latestReactions(); + if (reactions == null || !reactions.hasAnyVote()) { + Msg.error(sender, "Ninguém reagiu à última mensagem ainda."); + return true; + } + boolean bedrock = sender instanceof Player player && Platform.isBedrock(player); + Msg.header(sender, "Quem reagiu (" + reactions.total() + ")"); + reactions.breakdown(bedrock).forEach(sender::sendMessage); + return true; + } + private List reactionKeys() { List keys = new ArrayList<>(); for (ReactionDef def : plugin.settings().reactions()) { @@ -609,6 +635,9 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { 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, "nomes no resumo", settings.summaryNames() + " (resto vira +N)"); + Msg.line(sender, "intervalo mínimo", settings.minGapSeconds() + + "s entre curiosidades automáticas"); StringBuilder labels = new StringBuilder(); for (ReactionDef def : settings.reactions()) { labels.append(def.java()).append(' ').append(def.texto()) @@ -653,6 +682,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { commands.put("/canalhandia modulos", "lista os módulos e seu estado"); commands.put("/canalhandia plataformas", "quem está online e se é Java ou Bedrock"); commands.put("/reagir ", "reage à última mensagem (funciona no Bedrock)"); + commands.put("/reacoes", "mostra quem reagiu à última mensagem"); commands.put("/palpite ", "palpita na adivinha sem clicar"); commands.put("/votar ", "vota na enquete sem clicar"); if (sender.hasPermission(ADMIN)) { @@ -667,6 +697,8 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { 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 resumo ", "quantos nomes mostrar no resumo de reações"); + commands.put("/curiosidade minimo ", "intervalo mínimo entre curiosidades automáticas"); 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"); @@ -762,7 +794,8 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { "status", "categorias", "ajuda")); if (sender.hasPermission(ADMIN)) { options.addAll(List.of("modo", "intervalo", "atraso", "cooldown", "repetir", - "janela", "validade", "reacoes", "reacao", "categoria")); + "janela", "validade", "resumo", "minimo", "reacoes", "reacao", + "categoria")); } options.addAll(onlineNames()); return filter(options, args[0]); diff --git a/src/main/java/dev/marcospaulo/canalhandia/CuriosityFactory.java b/src/main/java/dev/marcospaulo/canalhandia/CuriosityFactory.java index 981f6e4..bf8652c 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/CuriosityFactory.java +++ b/src/main/java/dev/marcospaulo/canalhandia/CuriosityFactory.java @@ -137,15 +137,12 @@ final class CuriosityFactory { 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) { + if (ticks / 20L / 3600L < 1) { return; } - String text = hours >= 24 - ? String.format(PT_BR, "%d dias e %d horas", hours / 24, hours % 24) - : hours + " horas"; + // Msg.duration handles the pt-BR plural agreement ("1 hora", "2 horas"). out.add(new Fact(Category.TEMPO, Component.text(prefix, NamedTextColor.WHITE) - .append(Component.text(text, NamedTextColor.AQUA)) + .append(Component.text(Msg.duration(ticks), NamedTextColor.AQUA)) .append(Component.text(suffix, NamedTextColor.WHITE)))); } diff --git a/src/main/java/dev/marcospaulo/canalhandia/Reactions.java b/src/main/java/dev/marcospaulo/canalhandia/Reactions.java index 8cbdf0b..40bf12c 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Reactions.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Reactions.java @@ -8,31 +8,34 @@ import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Bukkit; import org.bukkit.entity.Player; +import java.util.ArrayList; import java.util.LinkedHashMap; -import java.util.LinkedHashSet; import java.util.List; 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 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 when the window closes. Reactions keep being accepted after - * the boss bar disappears, because people scroll back and click late. + * frozen at send time. Live numbers appear on the boss bar, on the reactor's + * action bar, and in a one-line summary when the window closes. * - *

Bedrock clients get a different rendering: ASCII labels instead of emoji, - * and a typed-command hint instead of clickable text, because Geyser cannot - * deliver a chat {@code clickEvent} to Bedrock. + *

Who reacted is deliberately not given its own chat line — with + * several reactions and several players that would fill the screen. Instead the + * names live in a hover tooltip on Java, and behind {@code /reacoes} for + * everyone (Bedrock cannot hover either). The closing summary shows the first + * couple of names inline and abbreviates the rest as "+N". + * + *

Bedrock also cannot run a chat {@code clickEvent} and renders most emoji + * as tofu, so it gets ASCII labels and typed commands. */ final class Reactions { private final int id; private final List defs; - private final Map> votes = new LinkedHashMap<>(); + /** Reaction key to reactors, preserving both order and display name. */ + private final Map> votes = new LinkedHashMap<>(); private final BossBar bar; private final long createdAt = System.currentTimeMillis(); private boolean barVisible; @@ -40,7 +43,7 @@ final class Reactions { Reactions(int id, List defs) { this.id = id; this.defs = defs; - defs.forEach(def -> votes.put(def.key(), new LinkedHashSet<>())); + defs.forEach(def -> votes.put(def.key(), new LinkedHashMap<>())); this.bar = BossBar.bossBar(tally(false), 1.0f, BossBar.Color.PURPLE, BossBar.Overlay.PROGRESS); } @@ -53,11 +56,7 @@ final class Reactions { } boolean hasAnyVote() { - return votes.values().stream().anyMatch(set -> !set.isEmpty()); - } - - boolean knows(String key) { - return votes.containsKey(key); + return votes.values().stream().anyMatch(map -> !map.isEmpty()); } /** @@ -70,8 +69,9 @@ final class Reactions { if (!votes.containsKey(key)) { return false; } - votes.values().forEach(set -> set.remove(player.getUniqueId())); - votes.get(key).add(player.getUniqueId()); + votes.values().forEach(map -> map.remove(player.getUniqueId())); + // Names are captured now so the summary still works if someone logs off. + votes.get(key).put(player.getUniqueId(), player.getName()); if (barVisible) { bar.name(tally(false)); } @@ -80,25 +80,32 @@ final class Reactions { } int count(String key) { - Set set = votes.get(key); - return set == null ? 0 : set.size(); + Map map = votes.get(key); + return map == null ? 0 : map.size(); + } + + List names(String key) { + Map map = votes.get(key); + return map == null ? List.of() : new ArrayList<>(map.values()); + } + + int total() { + return votes.values().stream().mapToInt(Map::size).sum(); } /** - * The reaction row. On Java the labels are clickable; on Bedrock they are - * plain text followed by the commands to type, since clicking does nothing - * there. + * The reaction row. Java gets clickable labels whose tooltip lists who + * reacted; Bedrock gets plain labels plus the commands to type. */ Component buttons(boolean bedrock) { Component row = Component.text(" "); for (ReactionDef def : defs) { - String text = def.label(bedrock) + " " + count(def.key()) + " "; - Component piece = Component.text(text, NamedTextColor.YELLOW); + Component piece = Component.text(def.label(bedrock) + " " + count(def.key()) + " ", + NamedTextColor.YELLOW); if (!bedrock) { piece = piece .clickEvent(ClickEvent.runCommand("/canalhandia reagir " + id + " " + def.key())) - .hoverEvent(HoverEvent.showText(Component.text( - "Clique para reagir com " + def.java(), NamedTextColor.GRAY))); + .hoverEvent(HoverEvent.showText(hover(def))); } row = row.append(piece); } @@ -108,6 +115,14 @@ final class Reactions { return row; } + private Component hover(ReactionDef def) { + List names = names(def.key()); + if (names.isEmpty()) { + return Component.text("Clique para reagir com " + def.java(), NamedTextColor.GRAY); + } + return Component.text(String.join(", ", names), NamedTextColor.YELLOW); + } + /** Comma-joined typed shortcuts, e.g. "/legal, /wow, /top". */ String shortcuts() { StringBuilder text = new StringBuilder(); @@ -120,7 +135,7 @@ final class Reactions { return text.toString(); } - /** Live counts, used for the boss bar, action bar and closing line. */ + /** Compact live counts, for the boss bar and action bar. */ Component tally(boolean bedrock) { Component text = Component.text("Reações: ", NamedTextColor.WHITE); for (ReactionDef def : defs) { @@ -130,6 +145,62 @@ final class Reactions { return text; } + /** + * One-line closing summary naming the reactors, e.g. + * {@code Reações: [+1] NegoncioZ, Guzada · [UAU] Larieus2 +2}. + * + *

Only reactions that actually got votes are listed, and at most + * {@code maxNames} names appear in total, so this never grows past a line + * however many people react. + */ + Component summary(boolean bedrock, int maxNames) { + Component text = Component.text("Reações: ", NamedTextColor.WHITE); + int shown = 0; + boolean first = true; + for (ReactionDef def : defs) { + List names = names(def.key()); + if (names.isEmpty()) { + continue; + } + if (!first) { + text = text.append(Component.text(" · ", NamedTextColor.DARK_GRAY)); + } + first = false; + text = text.append(Component.text(def.label(bedrock) + " ", NamedTextColor.YELLOW)); + + List visible = new ArrayList<>(); + int hidden = 0; + for (String name : names) { + if (shown < maxNames) { + visible.add(name); + shown++; + } else { + hidden++; + } + } + text = text.append(Component.text(String.join(", ", visible), NamedTextColor.GREEN)); + if (hidden > 0) { + text = text.append(Component.text(" +" + hidden, NamedTextColor.GRAY)); + } + } + return text; + } + + /** Full per-reaction breakdown, sent privately by /reacoes so chat stays clean. */ + List breakdown(boolean bedrock) { + List lines = new ArrayList<>(); + for (ReactionDef def : defs) { + List names = names(def.key()); + if (names.isEmpty()) { + continue; + } + lines.add(Component.text(" " + def.label(bedrock) + " ", NamedTextColor.YELLOW) + .append(Component.text(names.size() + " ", NamedTextColor.AQUA)) + .append(Component.text(String.join(", ", names), NamedTextColor.GREEN))); + } + return lines; + } + void show() { barVisible = true; Bukkit.getOnlinePlayers().forEach(p -> p.showBossBar(bar)); diff --git a/src/main/java/dev/marcospaulo/canalhandia/Settings.java b/src/main/java/dev/marcospaulo/canalhandia/Settings.java index 691bf05..070e89d 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Settings.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Settings.java @@ -106,6 +106,31 @@ final class Settings { set("reacao-validade-minutos", Math.max(1, minutes)); } + /** + * How many reactor names fit in the one-line closing summary before the + * rest collapse into "+N". Keeps the summary to a single line no matter how + * many people react. + */ + int summaryNames() { + return Math.max(0, plugin.getConfig().getInt("resumo-nomes", 3)); + } + + void summaryNames(int count) { + set("resumo-nomes", Math.max(0, count)); + } + + /** + * Minimum gap between automatic curiosities. Without this, five people + * joining together produce five curiosities and flood the chat. + */ + int minGapSeconds() { + return Math.max(0, plugin.getConfig().getInt("intervalo-minimo-segundos", 120)); + } + + void minGapSeconds(int seconds) { + set("intervalo-minimo-segundos", Math.max(0, seconds)); + } + /** * Configured reactions. * diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index a4e9a40..88b9080 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -34,6 +34,11 @@ intervalo-minutos: 20 # Tempo mínimo antes do mesmo jogador ser citado de novo. cooldown-minutos: 30 +# Intervalo mínimo entre curiosidades automáticas, em segundos. Sem isso, +# cinco pessoas entrando juntas geram cinco curiosidades e enchem a tela. +# Não afeta /curiosidade digitado à mão. +intervalo-minimo-segundos: 120 + # Quantas curiosidades recentes lembrar para não repetir. evitar-repetir: 15 @@ -54,6 +59,11 @@ reacoes-ativas: true # Por quanto tempo a barra de reações fica visível, em segundos. janela-reacao-segundos: 90 +# Quantos nomes cabem no resumo de reações antes do resto virar "+N". +# O resumo é sempre UMA linha, por mais gente que reaja; para ver a lista +# completa use /reacoes (privado, não polui o chat). +resumo-nomes: 3 + # 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. diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 497071e..54adf24 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -33,6 +33,10 @@ commands: reagir: description: Reage à última mensagem. usage: /reagir + reacoes: + description: Mostra quem reagiu à última mensagem. + usage: /reacoes + aliases: [quemreagiu] palpite: description: Palpita na adivinha aberta. usage: /palpite