From 987ee13d7488b7e18ba000fef47517681ab3d3f9 Mon Sep 17 00:00:00 2001 From: marcos Date: Tue, 4 Aug 2026 21:31:24 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Bedrock=20support=20=E2=80=94=20typed?= =?UTF-8?q?=20commands=20and=20ASCII=20labels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Geyser cannot deliver a chat clickEvent to Bedrock, and most emoji render as tofu boxes there, so Bedrock players could see the reaction row but neither read nor use it. Messages carrying buttons are now built twice and sent per player rather than via Bukkit.broadcast. Each reaction gains a per-platform label plus a typed shortcut: uau: { java: "[😮]", texto: "[UAU]", comando: "wow" } Typed fallbacks for every clickable interaction: /reagir, /legal, /wow, /top, /f, /palpite and /votar, all acting on the most recent message so no message id is needed. Bedrock detection uses Floodgate's UUID scheme (high 64 bits zero) rather than the Floodgate API, keeping it an optional runtime dependency. /canalhandia plataformas lists who is online and on which platform. Reaction config moves from a flat key->label map to a section per reaction; the old flat form is still accepted and gets an ASCII fallback derived from the key. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ActvLGJApdxEAd2yfKPwqv --- README.md | 31 ++++- .../marcospaulo/canalhandia/Canalhandia.java | 69 +++++++--- .../canalhandia/CanalhandiaCommand.java | 128 ++++++++++++++++-- .../marcospaulo/canalhandia/GuessRound.java | 21 ++- .../dev/marcospaulo/canalhandia/Platform.java | 33 +++++ .../dev/marcospaulo/canalhandia/Poll.java | 22 ++- .../marcospaulo/canalhandia/ReactionDef.java | 17 +++ .../marcospaulo/canalhandia/Reactions.java | 81 +++++++---- .../dev/marcospaulo/canalhandia/Settings.java | 47 +++++-- src/main/resources/config.yml | 28 +++- src/main/resources/plugin.yml | 26 ++++ 11 files changed, 431 insertions(+), 72 deletions(-) create mode 100644 src/main/java/dev/marcospaulo/canalhandia/Platform.java create mode 100644 src/main/java/dev/marcospaulo/canalhandia/ReactionDef.java diff --git a/README.md b/README.md index 664713d..af07c90 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,36 @@ 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 +### 3. 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 +equivalent, and every reaction carries two labels: + +```yaml +reacoes: + uau: + java: "[😮]" # Java clients + texto: "[UAU]" # Bedrock clients — ASCII only + comando: "wow" # what a Bedrock player types +``` + +Messages with buttons are therefore built twice and sent per player +(`broadcastPerPlatform`), not via `Bukkit.broadcast`. Typed fallbacks: +`/reagir `, `/legal`, `/wow`, `/top`, `/f`, `/palpite `, +`/votar ` — all of which act on the most recent message, so the player +never needs a message id. + +Changing a `comando` to a new name also requires adding that command to +`plugin.yml` and restarting; Bukkit commands are static. + +**Detecting Bedrock**: Floodgate mints UUIDs whose high 64 bits are zero +(`00000000-0000-0000-0009-…`), so `Platform.isBedrock` checks +`getUniqueId().getMostSignificantBits() == 0`. That keeps Floodgate an optional +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 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 4d178cc..9ba2665 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java @@ -62,11 +62,10 @@ public final class Canalhandia extends JavaPlugin implements Listener { 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); + for (String name : List.of("canalhandia", "curiosidade", "adivinha", "enquete", "ranking", + "reagir", "palpite", "votar", "legal", "wow", "top", "f")) { + register(name, root); + } getServer().getPluginManager().registerEvents(this, this); rescheduleTimer(); @@ -224,6 +223,21 @@ public final class Canalhandia extends JavaPlugin implements Listener { // --- reactions ---------------------------------------------------------- + /** + * Sends a message that renders differently per platform. + * + *

Bedrock cannot show emoji or run a chat clickEvent, so anything with + * buttons has to be built twice. The console gets the Java rendering. + */ + void broadcastPerPlatform(java.util.function.Function builder) { + Component javaVersion = builder.apply(false); + Component bedrockVersion = builder.apply(true); + Bukkit.getConsoleSender().sendMessage(javaVersion); + for (Player player : Bukkit.getOnlinePlayers()) { + player.sendMessage(Platform.isBedrock(player) ? bedrockVersion : javaVersion); + } + } + /** Attaches a fresh reaction row to the message just broadcast. */ private void openReactions() { if (!settings.reactionsEnabled()) { @@ -233,7 +247,7 @@ public final class Canalhandia extends JavaPlugin implements Listener { liveReactions = reactions; remember(reactions); - Bukkit.broadcast(reactions.buttons("/canalhandia reagir")); + broadcastPerPlatform(reactions::buttons); reactions.show(); getServer().getScheduler().runTaskLater(this, () -> { @@ -243,11 +257,27 @@ public final class Canalhandia extends JavaPlugin implements Listener { } // Chat cannot be edited, so post the final count as its own line. if (reactions.hasAnyVote()) { - Bukkit.broadcast(Component.text(" ").append(reactions.tally())); + broadcastPerPlatform(bedrock -> + Component.text(" ").append(reactions.tally(bedrock))); } }, settings.reactionWindowSeconds() * 20L); } + /** + * The newest reaction set still accepting clicks, for typed shortcuts like + * {@code /wow} where the player never sees an id. + */ + Reactions latestReactions() { + long limit = settings.reactionValidityMinutes() * 60_000L; + Reactions best = null; + for (Reactions reactions : reactionHistory) { + if (reactions.ageMillis() <= limit) { + best = reactions; + } + } + return best; + } + private void remember(Reactions reactions) { reactionHistory.addLast(reactions); while (reactionHistory.size() > 8) { @@ -302,7 +332,7 @@ public final class Canalhandia extends JavaPlugin implements Listener { GuessRound round = new GuessRound(nextId++, chosen.subject(), chosen.fact().text(), candidates); guessRound = round; - Bukkit.broadcast(round.question("/canalhandia palpite")); + broadcastPerPlatform(bedrock -> round.question("/canalhandia palpite", bedrock)); getServer().getScheduler().runTaskLater(this, () -> { if (guessRound == round && !round.finished()) { @@ -330,7 +360,7 @@ public final class Canalhandia extends JavaPlugin implements Listener { } Poll started = new Poll(nextId++, question, options, author); poll = started; - Bukkit.broadcast(started.announcement("/canalhandia votar")); + broadcastPerPlatform(bedrock -> started.announcement("/canalhandia votar", bedrock)); started.show(); getServer().getScheduler().runTaskLater(this, () -> { @@ -370,18 +400,25 @@ public final class Canalhandia extends JavaPlugin implements Listener { if (!settings.moduleEnabled(Module.LUTO)) { return; } - Reactions mourning = new Reactions(nextId++, Map.of("f", "[F]")); + Reactions mourning = new Reactions(nextId++, + List.of(new ReactionDef("f", "[F]", "[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, () -> broadcastPerPlatform(bedrock -> { + Component button = Component.text("[F] ", NamedTextColor.YELLOW); + if (!bedrock) { + button = button.clickEvent(ClickEvent.runCommand( + "/canalhandia reagir " + mourning.id() + " f")); + } + return Component.text(" ").append(button) + .append(Component.text(bedrock + ? "digite /f para prestar luto por " + name + : "prestar luto por " + name, + NamedTextColor.GRAY)); + }), 2L); getServer().getScheduler().runTaskLater(this, () -> { if (mourning.hasAnyVote()) { diff --git a/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java index 783d58d..1a1433f 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java +++ b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java @@ -40,7 +40,18 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { case "adivinha" -> adivinha(sender); case "enquete" -> enquete(sender, args); case "ranking" -> ranking(sender, args); - default -> root(sender, args); + // Typed fallbacks. Bedrock cannot run a chat clickEvent, so every + // clickable interaction needs a command a player can type. + case "reagir" -> reactLatest(sender, args.length > 0 ? args[0] : null); + case "palpite" -> guessLatest(sender, args); + case "votar" -> voteLatest(sender, args); + default -> { + String reaction = plugin.settings().reactionForCommand(command.getName()); + if (reaction != null) { + yield reactLatest(sender, reaction); + } + yield root(sender, args); + } }; } @@ -60,6 +71,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { // Admin. case "modulo" -> moduleToggle(sender, rest); case "modulos" -> modules(sender); + case "plataformas", "quem" -> platforms(sender); case "status" -> status(sender); case "marcos" -> { if (admin(sender)) { @@ -346,6 +358,93 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { // --- reactions ---------------------------------------------------------- + /** + * Reacts to the most recent still-valid message, for players typing a + * shortcut like {@code /wow} who never see a message id. + */ + private boolean reactLatest(CommandSender sender, String key) { + if (!(sender instanceof Player player)) { + Msg.error(sender, "Só jogadores podem reagir."); + return true; + } + if (!player.hasPermission("canalhandia.reagir")) { + return denied(sender); + } + Reactions reactions = plugin.latestReactions(); + if (reactions == null) { + Msg.error(sender, "Nada para reagir agora."); + return true; + } + if (key == null) { + Msg.error(sender, "Uso: /reagir <" + String.join("|", reactionKeys()) + ">"); + return true; + } + if (!reactions.react(player, key)) { + Msg.error(sender, "Essa reação não vale para a última mensagem."); + } + return true; + } + + private boolean guessLatest(CommandSender sender, String[] args) { + if (!(sender instanceof Player player)) { + return true; + } + GuessRound round = plugin.guessRound(); + if (round == null || round.finished()) { + Msg.error(sender, "Nenhuma adivinha aberta."); + return true; + } + if (args.length == 0) { + Msg.error(sender, "Uso: /palpite "); + return true; + } + player.sendMessage(round.guess(player, args[0])); + return true; + } + + private boolean voteLatest(CommandSender sender, String[] args) { + if (!(sender instanceof Player player)) { + return true; + } + Poll poll = plugin.poll(); + if (poll == null || poll.closed()) { + Msg.error(sender, "Nenhuma enquete aberta."); + return true; + } + if (args.length == 0) { + Msg.error(sender, "Uso: /votar "); + return true; + } + Component result = poll.vote(player, parse(args[0], -1)); + player.sendMessage(result == null + ? Component.text("Essa opção não existe.", NamedTextColor.RED) + : result); + return true; + } + + private List reactionKeys() { + List keys = new ArrayList<>(); + for (ReactionDef def : plugin.settings().reactions()) { + keys.add(def.key()); + } + return keys; + } + + /** Who is online and on which platform. */ + private void platforms(CommandSender sender) { + Msg.header(sender, "Jogadores online"); + if (Bukkit.getOnlinePlayers().isEmpty()) { + sender.sendMessage(Component.text(" (ninguém online)", NamedTextColor.GRAY)); + return; + } + for (Player player : Bukkit.getOnlinePlayers()) { + boolean bedrock = Platform.isBedrock(player); + sender.sendMessage(Component.text(" " + player.getName() + " ", NamedTextColor.GREEN) + .append(Component.text(bedrock ? "[Bedrock]" : "[Java]", + bedrock ? NamedTextColor.AQUA : NamedTextColor.GOLD))); + } + } + private void react(CommandSender sender, String[] args) { if (!(sender instanceof Player player) || args.length < 2) { return; @@ -466,15 +565,19 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { 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 + "."); + if (args.length >= 4 && args[0].equalsIgnoreCase("add")) { + // add [comando] + String key = args[1].toLowerCase(); + String command = args.length >= 5 ? args[4].toLowerCase() : key; + plugin.settings().reaction(key, args[2], args[3], command); + Msg.ok(sender, "Reação '" + key + "': " + args[2] + " / " + args[3] + + " (/" + command + "). Reinicie para registrar o comando novo."); } 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 "); + Msg.error(sender, "Uso: /curiosidade reacao add [comando]" + + " | remover "); } } @@ -506,7 +609,12 @@ 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, "reações definidas", String.join(" ", settings.reactions().values())); + StringBuilder labels = new StringBuilder(); + for (ReactionDef def : settings.reactions()) { + labels.append(def.java()).append(' ').append(def.texto()) + .append(" (/").append(def.comando()).append(") "); + } + Msg.line(sender, "reações definidas", labels.toString().trim()); 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()); @@ -543,6 +651,10 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { 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"); + commands.put("/canalhandia plataformas", "quem está online e se é Java ou Bedrock"); + commands.put("/reagir ", "reage à última mensagem (funciona no Bedrock)"); + commands.put("/palpite ", "palpita na adivinha sem clicar"); + commands.put("/votar ", "vota na enquete sem clicar"); if (sender.hasPermission(ADMIN)) { commands.put("/canalhandia modulo ", "liga/desliga um módulo"); commands.put("/canalhandia marcos", "força uma verificação de marcos"); @@ -623,7 +735,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { } if (name.equals("canalhandia")) { if (args.length == 1) { - List options = new ArrayList<>(List.of("status", "modulos")); + List options = new ArrayList<>(List.of("status", "modulos", "plataformas")); if (sender.hasPermission(ADMIN)) { options.addAll(List.of("modulo", "marcos", "limpar", "reload")); } diff --git a/src/main/java/dev/marcospaulo/canalhandia/GuessRound.java b/src/main/java/dev/marcospaulo/canalhandia/GuessRound.java index 4e97d5d..2c21c3d 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/GuessRound.java +++ b/src/main/java/dev/marcospaulo/canalhandia/GuessRound.java @@ -70,8 +70,13 @@ final class GuessRound { return Component.text("Palpite registrado: " + name, NamedTextColor.GREEN); } - /** The question, with one clickable button per candidate. */ - Component question(String commandBase) { + /** + * The question with the candidates listed. + * + *

Java clients get clickable names; Bedrock cannot run a chat clickEvent, + * so it gets the same names plus the command to type instead. + */ + Component question(String commandBase, boolean bedrock) { Component message = Msg.tag("Adivinha", NamedTextColor.LIGHT_PURPLE) .append(Component.text("Alguém aqui ", NamedTextColor.WHITE) .decoration(TextDecoration.BOLD, false)) @@ -81,8 +86,16 @@ final class GuessRound { Component row = Component.text(" "); for (String candidate : candidates) { - row = row.append(Component.text("[" + candidate + "] ", NamedTextColor.AQUA) - .clickEvent(ClickEvent.runCommand(commandBase + " " + id + " " + candidate))); + Component piece = Component.text("[" + candidate + "] ", NamedTextColor.AQUA); + if (!bedrock) { + piece = piece.clickEvent( + ClickEvent.runCommand(commandBase + " " + id + " " + candidate)); + } + row = row.append(piece); + } + if (bedrock) { + row = row.append(Component.newline()) + .append(Component.text(" digite /palpite ", NamedTextColor.GRAY)); } return message.append(Component.newline()).append(row); } diff --git a/src/main/java/dev/marcospaulo/canalhandia/Platform.java b/src/main/java/dev/marcospaulo/canalhandia/Platform.java new file mode 100644 index 0000000..2bd2308 --- /dev/null +++ b/src/main/java/dev/marcospaulo/canalhandia/Platform.java @@ -0,0 +1,33 @@ +package dev.marcospaulo.canalhandia; + +import org.bukkit.entity.Player; + +/** + * Tells Bedrock players apart from Java players. + * + *

Floodgate mints UUIDs whose high 64 bits are all zero — every Bedrock + * player on this server looks like {@code 00000000-0000-0000-0009-01f584c722c4}. + * Checking that costs nothing and keeps Floodgate an optional runtime + * dependency rather than a compile-time one, so the plugin still loads on a + * server without it. + * + *

This matters because Bedrock cannot do two things Java can: + *

    + *
  • render most emoji — they come out as tofu boxes
  • + *
  • run a chat {@code clickEvent} — the text simply is not clickable
  • + *
+ * So Bedrock players get plain-text labels and typed commands instead. + */ +final class Platform { + + private Platform() { + } + + static boolean isBedrock(Player player) { + return player.getUniqueId().getMostSignificantBits() == 0L; + } + + static String label(Player player) { + return isBedrock(player) ? "Bedrock" : "Java"; + } +} diff --git a/src/main/java/dev/marcospaulo/canalhandia/Poll.java b/src/main/java/dev/marcospaulo/canalhandia/Poll.java index e405a54..edfa639 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Poll.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Poll.java @@ -69,7 +69,13 @@ final class Poll { return total; } - Component announcement(String commandBase) { + /** + * The poll with its options. + * + *

Java clients get clickable options; Bedrock cannot run a chat + * clickEvent, so it gets the command to type instead. + */ + Component announcement(String commandBase, boolean bedrock) { Component message = Msg.tag("Enquete", NamedTextColor.BLUE) .append(Component.text(question, NamedTextColor.WHITE) .decoration(TextDecoration.BOLD, false)) @@ -78,9 +84,17 @@ final class Poll { 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)))); + Component piece = Component.text("[" + (i + 1) + ". " + options.get(i) + "] ", + NamedTextColor.AQUA); + if (!bedrock) { + piece = piece.clickEvent( + ClickEvent.runCommand(commandBase + " " + id + " " + (i + 1))); + } + row = row.append(piece); + } + if (bedrock) { + row = row.append(Component.newline()) + .append(Component.text(" digite /votar ", NamedTextColor.GRAY)); } return message.append(Component.newline()).append(row); } diff --git a/src/main/java/dev/marcospaulo/canalhandia/ReactionDef.java b/src/main/java/dev/marcospaulo/canalhandia/ReactionDef.java new file mode 100644 index 0000000..74878e0 --- /dev/null +++ b/src/main/java/dev/marcospaulo/canalhandia/ReactionDef.java @@ -0,0 +1,17 @@ +package dev.marcospaulo.canalhandia; + +/** + * One configured reaction, with a label per platform and a typed shortcut. + * + * @param key internal name, used in {@code /reagir } + * @param java label for Java clients — emoji are fine here + * @param texto label for Bedrock clients — ASCII only, emoji render as tofu + * @param comando typed shortcut such as {@code wow}, for players who cannot click + */ +record ReactionDef(String key, String java, String texto, String comando) { + + /** The label to show to a given platform. */ + String label(boolean bedrock) { + return bedrock ? texto : java; + } +} diff --git a/src/main/java/dev/marcospaulo/canalhandia/Reactions.java b/src/main/java/dev/marcospaulo/canalhandia/Reactions.java index a84dc59..8cbdf0b 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Reactions.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Reactions.java @@ -10,6 +10,7 @@ import org.bukkit.entity.Player; import java.util.LinkedHashMap; import java.util.LinkedHashSet; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.UUID; @@ -17,27 +18,30 @@ 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. + *

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. + * + *

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. */ final class Reactions { private final int id; + private final List defs; 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) { + Reactions(int id, List defs) { 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); + this.defs = defs; + defs.forEach(def -> votes.put(def.key(), new LinkedHashSet<>())); + this.bar = BossBar.bossBar(tally(false), 1.0f, BossBar.Color.PURPLE, BossBar.Overlay.PROGRESS); } int id() { @@ -52,6 +56,10 @@ final class Reactions { return votes.values().stream().anyMatch(set -> !set.isEmpty()); } + boolean knows(String key) { + return votes.containsKey(key); + } + /** * Records a reaction. One per player per message; reacting again with a * different key moves the vote rather than double-counting it. @@ -65,9 +73,9 @@ final class Reactions { votes.values().forEach(set -> set.remove(player.getUniqueId())); votes.get(key).add(player.getUniqueId()); if (barVisible) { - bar.name(tally()); + bar.name(tally(false)); } - player.sendActionBar(tally()); + player.sendActionBar(tally(Platform.isBedrock(player))); return true; } @@ -76,25 +84,48 @@ final class Reactions { return set == null ? 0 : set.size(); } - /** The clickable row. Counts are the values at send time and never change. */ - Component buttons(String commandBase) { + /** + * 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. + */ + Component buttons(boolean bedrock) { 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)))); + for (ReactionDef def : defs) { + String text = def.label(bedrock) + " " + count(def.key()) + " "; + Component piece = Component.text(text, 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))); + } + row = row.append(piece); + } + if (bedrock) { + row = row.append(Component.text("— digite " + shortcuts(), NamedTextColor.GRAY)); } return row; } + /** Comma-joined typed shortcuts, e.g. "/legal, /wow, /top". */ + String shortcuts() { + StringBuilder text = new StringBuilder(); + for (ReactionDef def : defs) { + if (text.length() > 0) { + text.append(", "); + } + text.append('/').append(def.comando()); + } + return text.toString(); + } + /** Live counts, used for the boss bar, action bar and closing line. */ - Component tally() { + Component tally(boolean bedrock) { 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)); + for (ReactionDef def : defs) { + text = text.append(Component.text(def.label(bedrock) + " ", NamedTextColor.YELLOW)) + .append(Component.text(count(def.key()) + " ", NamedTextColor.AQUA)); } return text; } diff --git a/src/main/java/dev/marcospaulo/canalhandia/Settings.java b/src/main/java/dev/marcospaulo/canalhandia/Settings.java index 02b9011..691bf05 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Settings.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Settings.java @@ -2,8 +2,8 @@ package dev.marcospaulo.canalhandia; import org.bukkit.configuration.ConfigurationSection; -import java.util.LinkedHashMap; -import java.util.Map; +import java.util.ArrayList; +import java.util.List; /** * Typed view over config.yml. @@ -106,22 +106,53 @@ final class Settings { set("reacao-validade-minutos", Math.max(1, minutes)); } - Map reactions() { - Map reactions = new LinkedHashMap<>(); + /** + * Configured reactions. + * + *

Accepts both the rich form (a section with {@code java}, {@code texto} + * and {@code comando}) and the old flat form where the value was just a + * label string, so an existing config keeps working — the flat form gets an + * ASCII fallback derived from the key. + */ + List reactions() { + List reactions = new ArrayList<>(); ConfigurationSection section = plugin.getConfig().getConfigurationSection("reacoes"); if (section != null) { for (String key : section.getKeys(false)) { - reactions.put(key, section.getString(key, key)); + ConfigurationSection entry = section.getConfigurationSection(key); + if (entry != null) { + reactions.add(new ReactionDef(key, + entry.getString("java", "[" + key + "]"), + entry.getString("texto", "[" + key.toUpperCase() + "]"), + entry.getString("comando", key))); + } else { + String label = section.getString(key, key); + reactions.add(new ReactionDef(key, label, + "[" + key.toUpperCase() + "]", key)); + } } } if (reactions.isEmpty()) { - reactions.put("joia", "[+1]"); + reactions.add(new ReactionDef("joia", "[+1]", "[+1]", "legal")); } return reactions; } - void reaction(String key, String label) { - set("reacoes." + key, label); + /** Maps a typed shortcut such as "wow" to its reaction key, or null. */ + String reactionForCommand(String command) { + for (ReactionDef def : reactions()) { + if (def.comando().equalsIgnoreCase(command)) { + return def.key(); + } + } + return null; + } + + void reaction(String key, String javaLabel, String textLabel, String command) { + plugin.getConfig().set("reacoes." + key + ".java", javaLabel); + plugin.getConfig().set("reacoes." + key + ".texto", textLabel); + plugin.getConfig().set("reacoes." + key + ".comando", command); + plugin.saveConfig(); } void removeReaction(String key) { diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index c80947a..a4e9a40 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -59,13 +59,29 @@ janela-reacao-segundos: 90 # 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. +# As reações disponíveis. +# +# java - rótulo para jogadores Java (emoji funcionam) +# texto - rótulo para jogadores Bedrock (SÓ ASCII: emoji viram quadradinhos) +# comando - atalho digitável, para quem não consegue clicar +# +# O Bedrock não consegue executar clique no chat, então todo jogador Bedrock vê +# os rótulos "texto" e a dica de digitar o comando. Os comandos abaixo precisam +# existir no plugin.yml para serem registrados — mudar "comando" para um nome +# novo exige adicionar o comando lá e reiniciar. reacoes: - joia: "[👍]" - uau: "[😮]" - fogo: "[🔥]" + joia: + java: "[👍]" + texto: "[+1]" + comando: "legal" + uau: + java: "[😮]" + texto: "[UAU]" + comando: "wow" + fogo: + java: "[🔥]" + texto: "[TOP]" + comando: "top" # --- Jogos ------------------------------------------------------------------- diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index f45eab5..497071e 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -26,6 +26,32 @@ commands: description: Placares do servidor. usage: /ranking [categoria] aliases: [rankings, placar] + # Typed fallbacks. Bedrock cannot run a chat clickEvent, so every clickable + # interaction needs a command that can be typed instead. The shortcut names + # below must exist here to be registered; config.yml maps each one to a + # reaction key. + reagir: + description: Reage à última mensagem. + usage: /reagir + palpite: + description: Palpita na adivinha aberta. + usage: /palpite + votar: + description: Vota na enquete aberta. + usage: /votar + legal: + description: Reage com "legal" à última mensagem. + usage: /legal + wow: + description: Reage com "uau" à última mensagem. + usage: /wow + aliases: [uau] + top: + description: Reage com "top" à última mensagem. + usage: /top + f: + description: Presta luto pela última morte. + usage: /f permissions: # Declared explicitly: an undeclared Bukkit permission falls back to op-only,