feat: show who reacted, and stop curiosities flooding chat
Reactions only ever showed counts, so with several reactors nobody could tell who had reacted. Naming everyone on its own line does not scale, so names now appear in three progressively larger places: a hover tooltip per button (Java only), a one-line closing summary naming the first resumo-nomes (3) and collapsing the rest to "+N", and /reacoes for the full breakdown sent privately. Reactors' names are captured at react time so the summary survives them logging off. Two separate causes of chat flooding: - every join fired its own curiosity, so a wave of joins produced one each. Automatic triggers are now rate-limited by intervalo-minimo-segundos (120); a typed /curiosidade still bypasses it. - a curiosity cost two messages. The button row is now appended to the headline instead of being broadcast separately. Also fixes "1 horas" — CuriosityFactory had its own duration formatting that missed the earlier plural fix; it now uses Msg.duration. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ActvLGJApdxEAd2yfKPwqv
This commit is contained in:
@@ -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
|
An earlier version only had the boss bar, and it read as broken — the buttons
|
||||||
showed no number at all.
|
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
|
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
|
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
|
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.
|
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
|
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
|
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`
|
runtime dependency rather than a compile-time one. `/canalhandia plataformas`
|
||||||
lists who is online and on which platform.
|
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**
|
Block, item and mob names are emitted as **translatable components**
|
||||||
(`Component.translatable(material.translationKey())`), so a pt-BR client renders
|
(`Component.translatable(material.translationKey())`), so a pt-BR client renders
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
|||||||
private BukkitTask timerTask;
|
private BukkitTask timerTask;
|
||||||
private BukkitTask milestoneTask;
|
private BukkitTask milestoneTask;
|
||||||
|
|
||||||
|
private long lastAnnouncement;
|
||||||
private int nextId = 1;
|
private int nextId = 1;
|
||||||
private Reactions liveReactions;
|
private Reactions liveReactions;
|
||||||
private GuessRound guessRound;
|
private GuessRound guessRound;
|
||||||
@@ -63,7 +64,7 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
|||||||
|
|
||||||
CanalhandiaCommand root = new CanalhandiaCommand(this);
|
CanalhandiaCommand root = new CanalhandiaCommand(this);
|
||||||
for (String name : List.of("canalhandia", "curiosidade", "adivinha", "enquete", "ranking",
|
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);
|
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
|
* @return false if nobody was eligible or there was nothing notable to say
|
||||||
*/
|
*/
|
||||||
boolean announceCuriosity(Player subject) {
|
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)) {
|
if (!settings.moduleEnabled(Module.CURIOSIDADES)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (automatic && System.currentTimeMillis() - lastAnnouncement
|
||||||
|
< settings.minGapSeconds() * 1000L) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
Chosen chosen = pickFact(subject == null ? pickSubject() : subject);
|
Chosen chosen = pickFact(subject == null ? pickSubject() : subject);
|
||||||
if (chosen == null) {
|
if (chosen == null) {
|
||||||
return false;
|
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)
|
.append(Component.text("Sabia que o ", NamedTextColor.WHITE)
|
||||||
.decoration(TextDecoration.BOLD, false))
|
.decoration(TextDecoration.BOLD, false))
|
||||||
.append(Component.text(chosen.subject().getName(), NamedTextColor.GREEN)
|
.append(Component.text(chosen.subject().getName(), NamedTextColor.GREEN)
|
||||||
@@ -169,8 +189,17 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
|||||||
.decoration(TextDecoration.BOLD, false))
|
.decoration(TextDecoration.BOLD, false))
|
||||||
.append(chosen.fact().text().decoration(TextDecoration.BOLD, false))
|
.append(chosen.fact().text().decoration(TextDecoration.BOLD, false))
|
||||||
.append(Component.text("?", NamedTextColor.WHITE)
|
.append(Component.text("?", NamedTextColor.WHITE)
|
||||||
.decoration(TextDecoration.BOLD, false)));
|
.decoration(TextDecoration.BOLD, false));
|
||||||
openReactions();
|
|
||||||
|
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;
|
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() {
|
* Creates a reaction set and schedules its close. The caller is responsible
|
||||||
if (!settings.reactionsEnabled()) {
|
* for broadcasting {@link Reactions#buttons}, so the buttons can share a
|
||||||
return;
|
* message with whatever they belong to.
|
||||||
}
|
*/
|
||||||
|
private Reactions openReactions() {
|
||||||
Reactions reactions = new Reactions(nextId++, settings.reactions());
|
Reactions reactions = new Reactions(nextId++, settings.reactions());
|
||||||
liveReactions = reactions;
|
liveReactions = reactions;
|
||||||
remember(reactions);
|
remember(reactions);
|
||||||
|
|
||||||
broadcastPerPlatform(reactions::buttons);
|
|
||||||
reactions.show();
|
reactions.show();
|
||||||
|
|
||||||
getServer().getScheduler().runTaskLater(this, () -> {
|
getServer().getScheduler().runTaskLater(this, () -> {
|
||||||
@@ -255,12 +283,14 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
|||||||
if (liveReactions == reactions) {
|
if (liveReactions == reactions) {
|
||||||
liveReactions = null;
|
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()) {
|
if (reactions.hasAnyVote()) {
|
||||||
broadcastPerPlatform(bedrock ->
|
broadcastPerPlatform(bedrock -> Component.text(" ")
|
||||||
Component.text(" ").append(reactions.tally(bedrock)));
|
.append(reactions.summary(bedrock, settings.summaryNames())));
|
||||||
}
|
}
|
||||||
}, settings.reactionWindowSeconds() * 20L);
|
}, 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.
|
// Delayed so the curiosity lands after the join message rather than racing it.
|
||||||
getServer().getScheduler().runTaskLater(this, () -> {
|
getServer().getScheduler().runTaskLater(this, () -> {
|
||||||
if (player.isOnline()) {
|
if (player.isOnline()) {
|
||||||
announceCuriosity(player);
|
announceCuriosity(player, true);
|
||||||
}
|
}
|
||||||
}, settings.joinDelaySeconds() * 20L);
|
}, settings.joinDelaySeconds() * 20L);
|
||||||
}
|
}
|
||||||
@@ -404,6 +434,7 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
|||||||
List.of(new ReactionDef("f", "[F]", "[F]", "f")));
|
List.of(new ReactionDef("f", "[F]", "[F]", "f")));
|
||||||
liveReactions = mourning;
|
liveReactions = mourning;
|
||||||
remember(mourning);
|
remember(mourning);
|
||||||
|
mourning.show();
|
||||||
String name = event.getEntity().getName();
|
String name = event.getEntity().getName();
|
||||||
|
|
||||||
// One tick later so it prints under the vanilla death message.
|
// One tick later so it prints under the vanilla death message.
|
||||||
@@ -421,10 +452,16 @@ public final class Canalhandia extends JavaPlugin implements Listener {
|
|||||||
}), 2L);
|
}), 2L);
|
||||||
|
|
||||||
getServer().getScheduler().runTaskLater(this, () -> {
|
getServer().getScheduler().runTaskLater(this, () -> {
|
||||||
|
mourning.hide();
|
||||||
if (mourning.hasAnyVote()) {
|
if (mourning.hasAnyVote()) {
|
||||||
|
// One line, names truncated, so a busy death does not fill the screen.
|
||||||
|
List<String> who = mourning.names("f");
|
||||||
|
int shown = Math.min(who.size(), settings.summaryNames());
|
||||||
|
String text = String.join(", ", who.subList(0, shown))
|
||||||
|
+ (who.size() > shown ? " +" + (who.size() - shown) : "");
|
||||||
Bukkit.broadcast(Component.text(" ")
|
Bukkit.broadcast(Component.text(" ")
|
||||||
.append(Component.text(mourning.count("f")
|
.append(Component.text(text + " prestaram luto por " + name + ".",
|
||||||
+ " pessoa(s) prestaram luto por " + name + ".", NamedTextColor.GRAY)));
|
NamedTextColor.GRAY)));
|
||||||
}
|
}
|
||||||
if (liveReactions == mourning) {
|
if (liveReactions == mourning) {
|
||||||
liveReactions = null;
|
liveReactions = null;
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
|||||||
case "reagir" -> reactLatest(sender, args.length > 0 ? args[0] : null);
|
case "reagir" -> reactLatest(sender, args.length > 0 ? args[0] : null);
|
||||||
case "palpite" -> guessLatest(sender, args);
|
case "palpite" -> guessLatest(sender, args);
|
||||||
case "votar" -> voteLatest(sender, args);
|
case "votar" -> voteLatest(sender, args);
|
||||||
|
case "reacoes" -> whoReacted(sender);
|
||||||
default -> {
|
default -> {
|
||||||
String reaction = plugin.settings().reactionForCommand(command.getName());
|
String reaction = plugin.settings().reactionForCommand(command.getName());
|
||||||
if (reaction != null) {
|
if (reaction != null) {
|
||||||
@@ -141,6 +142,18 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
|||||||
plugin.settings().reactionWindowSeconds(v);
|
plugin.settings().reactionWindowSeconds(v);
|
||||||
return "Janela de reação: " + plugin.settings().reactionWindowSeconds() + "s.";
|
return "Janela de reação: " + plugin.settings().reactionWindowSeconds() + "s.";
|
||||||
});
|
});
|
||||||
|
case "resumo" -> setNumber(sender, rest, "resumo <quantidade>",
|
||||||
|
v -> {
|
||||||
|
plugin.settings().summaryNames(v);
|
||||||
|
return "Mostrando até " + plugin.settings().summaryNames()
|
||||||
|
+ " nomes no resumo.";
|
||||||
|
});
|
||||||
|
case "intervalo-minimo", "minimo" -> setNumber(sender, rest, "minimo <segundos>",
|
||||||
|
v -> {
|
||||||
|
plugin.settings().minGapSeconds(v);
|
||||||
|
return "Intervalo mínimo entre curiosidades automáticas: "
|
||||||
|
+ plugin.settings().minGapSeconds() + "s.";
|
||||||
|
});
|
||||||
case "validade" -> setNumber(sender, rest, "validade <minutos>",
|
case "validade" -> setNumber(sender, rest, "validade <minutos>",
|
||||||
v -> {
|
v -> {
|
||||||
plugin.settings().reactionValidityMinutes(v);
|
plugin.settings().reactionValidityMinutes(v);
|
||||||
@@ -422,6 +435,19 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
|||||||
return true;
|
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<String> reactionKeys() {
|
private List<String> reactionKeys() {
|
||||||
List<String> keys = new ArrayList<>();
|
List<String> keys = new ArrayList<>();
|
||||||
for (ReactionDef def : plugin.settings().reactions()) {
|
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, "reações", settings.reactionsEnabled() ? "ativas" : "desativadas");
|
||||||
Msg.line(sender, "barra de reação", settings.reactionWindowSeconds() + "s");
|
Msg.line(sender, "barra de reação", settings.reactionWindowSeconds() + "s");
|
||||||
Msg.line(sender, "cliques válidos por", settings.reactionValidityMinutes() + " min");
|
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();
|
StringBuilder labels = new StringBuilder();
|
||||||
for (ReactionDef def : settings.reactions()) {
|
for (ReactionDef def : settings.reactions()) {
|
||||||
labels.append(def.java()).append(' ').append(def.texto())
|
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 modulos", "lista os módulos e seu estado");
|
||||||
commands.put("/canalhandia plataformas", "quem está online e se é Java ou Bedrock");
|
commands.put("/canalhandia plataformas", "quem está online e se é Java ou Bedrock");
|
||||||
commands.put("/reagir <chave>", "reage à última mensagem (funciona no Bedrock)");
|
commands.put("/reagir <chave>", "reage à última mensagem (funciona no Bedrock)");
|
||||||
|
commands.put("/reacoes", "mostra quem reagiu à última mensagem");
|
||||||
commands.put("/palpite <nome>", "palpita na adivinha sem clicar");
|
commands.put("/palpite <nome>", "palpita na adivinha sem clicar");
|
||||||
commands.put("/votar <número>", "vota na enquete sem clicar");
|
commands.put("/votar <número>", "vota na enquete sem clicar");
|
||||||
if (sender.hasPermission(ADMIN)) {
|
if (sender.hasPermission(ADMIN)) {
|
||||||
@@ -667,6 +697,8 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
|||||||
commands.put("/curiosidade repetir <n>", "quantas recentes evitar repetir");
|
commands.put("/curiosidade repetir <n>", "quantas recentes evitar repetir");
|
||||||
commands.put("/curiosidade janela <seg>", "duração da barra de reações");
|
commands.put("/curiosidade janela <seg>", "duração da barra de reações");
|
||||||
commands.put("/curiosidade validade <min>", "por quanto tempo cliques ainda contam");
|
commands.put("/curiosidade validade <min>", "por quanto tempo cliques ainda contam");
|
||||||
|
commands.put("/curiosidade resumo <n>", "quantos nomes mostrar no resumo de reações");
|
||||||
|
commands.put("/curiosidade minimo <seg>", "intervalo mínimo entre curiosidades automáticas");
|
||||||
commands.put("/curiosidade reacoes <on|off>", "liga/desliga reações");
|
commands.put("/curiosidade reacoes <on|off>", "liga/desliga reações");
|
||||||
commands.put("/curiosidade reacao add <chave> <rótulo>", "cria ou muda uma reação");
|
commands.put("/curiosidade reacao add <chave> <rótulo>", "cria ou muda uma reação");
|
||||||
commands.put("/curiosidade reacao remover <chave>", "remove uma reação");
|
commands.put("/curiosidade reacao remover <chave>", "remove uma reação");
|
||||||
@@ -762,7 +794,8 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
|||||||
"status", "categorias", "ajuda"));
|
"status", "categorias", "ajuda"));
|
||||||
if (sender.hasPermission(ADMIN)) {
|
if (sender.hasPermission(ADMIN)) {
|
||||||
options.addAll(List.of("modo", "intervalo", "atraso", "cooldown", "repetir",
|
options.addAll(List.of("modo", "intervalo", "atraso", "cooldown", "repetir",
|
||||||
"janela", "validade", "reacoes", "reacao", "categoria"));
|
"janela", "validade", "resumo", "minimo", "reacoes", "reacao",
|
||||||
|
"categoria"));
|
||||||
}
|
}
|
||||||
options.addAll(onlineNames());
|
options.addAll(onlineNames());
|
||||||
return filter(options, args[0]);
|
return filter(options, args[0]);
|
||||||
|
|||||||
@@ -137,15 +137,12 @@ final class CuriosityFactory {
|
|||||||
private static void time(List<Fact> out, Player player, Statistic statistic,
|
private static void time(List<Fact> out, Player player, Statistic statistic,
|
||||||
String prefix, String suffix) {
|
String prefix, String suffix) {
|
||||||
int ticks = Stats.untyped(player, statistic);
|
int ticks = Stats.untyped(player, statistic);
|
||||||
long hours = ticks / 20L / 3600L;
|
if (ticks / 20L / 3600L < 1) {
|
||||||
if (hours < 1) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String text = hours >= 24
|
// Msg.duration handles the pt-BR plural agreement ("1 hora", "2 horas").
|
||||||
? String.format(PT_BR, "%d dias e %d horas", hours / 24, hours % 24)
|
|
||||||
: hours + " horas";
|
|
||||||
out.add(new Fact(Category.TEMPO, 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(Msg.duration(ticks), NamedTextColor.AQUA))
|
||||||
.append(Component.text(suffix, NamedTextColor.WHITE))));
|
.append(Component.text(suffix, NamedTextColor.WHITE))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,31 +8,34 @@ import net.kyori.adventure.text.format.NamedTextColor;
|
|||||||
import org.bukkit.Bukkit;
|
import org.bukkit.Bukkit;
|
||||||
import org.bukkit.entity.Player;
|
import org.bukkit.entity.Player;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.LinkedHashSet;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reaction state for one announced message.
|
* Reaction state for one announced message.
|
||||||
*
|
*
|
||||||
* <p>Chat cannot be edited after sending, so the counts inside the buttons are
|
* <p>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
|
* frozen at send time. Live numbers appear on the boss bar, on the reactor's
|
||||||
* while the window is open, an action bar shown to whoever just reacted, and a
|
* action bar, and in a one-line summary when the window closes.
|
||||||
* final tally line when the window closes. Reactions keep being accepted after
|
|
||||||
* the boss bar disappears, because people scroll back and click late.
|
|
||||||
*
|
*
|
||||||
* <p>Bedrock clients get a different rendering: ASCII labels instead of emoji,
|
* <p>Who reacted is deliberately <em>not</em> given its own chat line — with
|
||||||
* and a typed-command hint instead of clickable text, because Geyser cannot
|
* several reactions and several players that would fill the screen. Instead the
|
||||||
* deliver a chat {@code clickEvent} to Bedrock.
|
* 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".
|
||||||
|
*
|
||||||
|
* <p>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 {
|
final class Reactions {
|
||||||
|
|
||||||
private final int id;
|
private final int id;
|
||||||
private final List<ReactionDef> defs;
|
private final List<ReactionDef> defs;
|
||||||
private final Map<String, Set<UUID>> votes = new LinkedHashMap<>();
|
/** Reaction key to reactors, preserving both order and display name. */
|
||||||
|
private final Map<String, LinkedHashMap<UUID, String>> votes = new LinkedHashMap<>();
|
||||||
private final BossBar bar;
|
private final BossBar bar;
|
||||||
private final long createdAt = System.currentTimeMillis();
|
private final long createdAt = System.currentTimeMillis();
|
||||||
private boolean barVisible;
|
private boolean barVisible;
|
||||||
@@ -40,7 +43,7 @@ final class Reactions {
|
|||||||
Reactions(int id, List<ReactionDef> defs) {
|
Reactions(int id, List<ReactionDef> defs) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.defs = defs;
|
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);
|
this.bar = BossBar.bossBar(tally(false), 1.0f, BossBar.Color.PURPLE, BossBar.Overlay.PROGRESS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,11 +56,7 @@ final class Reactions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
boolean hasAnyVote() {
|
boolean hasAnyVote() {
|
||||||
return votes.values().stream().anyMatch(set -> !set.isEmpty());
|
return votes.values().stream().anyMatch(map -> !map.isEmpty());
|
||||||
}
|
|
||||||
|
|
||||||
boolean knows(String key) {
|
|
||||||
return votes.containsKey(key);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,8 +69,9 @@ final class Reactions {
|
|||||||
if (!votes.containsKey(key)) {
|
if (!votes.containsKey(key)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
votes.values().forEach(set -> set.remove(player.getUniqueId()));
|
votes.values().forEach(map -> map.remove(player.getUniqueId()));
|
||||||
votes.get(key).add(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) {
|
if (barVisible) {
|
||||||
bar.name(tally(false));
|
bar.name(tally(false));
|
||||||
}
|
}
|
||||||
@@ -80,25 +80,32 @@ final class Reactions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
int count(String key) {
|
int count(String key) {
|
||||||
Set<UUID> set = votes.get(key);
|
Map<UUID, String> map = votes.get(key);
|
||||||
return set == null ? 0 : set.size();
|
return map == null ? 0 : map.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> names(String key) {
|
||||||
|
Map<UUID, String> 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
|
* The reaction row. Java gets clickable labels whose tooltip lists who
|
||||||
* plain text followed by the commands to type, since clicking does nothing
|
* reacted; Bedrock gets plain labels plus the commands to type.
|
||||||
* there.
|
|
||||||
*/
|
*/
|
||||||
Component buttons(boolean bedrock) {
|
Component buttons(boolean bedrock) {
|
||||||
Component row = Component.text(" ");
|
Component row = Component.text(" ");
|
||||||
for (ReactionDef def : defs) {
|
for (ReactionDef def : defs) {
|
||||||
String text = def.label(bedrock) + " " + count(def.key()) + " ";
|
Component piece = Component.text(def.label(bedrock) + " " + count(def.key()) + " ",
|
||||||
Component piece = Component.text(text, NamedTextColor.YELLOW);
|
NamedTextColor.YELLOW);
|
||||||
if (!bedrock) {
|
if (!bedrock) {
|
||||||
piece = piece
|
piece = piece
|
||||||
.clickEvent(ClickEvent.runCommand("/canalhandia reagir " + id + " " + def.key()))
|
.clickEvent(ClickEvent.runCommand("/canalhandia reagir " + id + " " + def.key()))
|
||||||
.hoverEvent(HoverEvent.showText(Component.text(
|
.hoverEvent(HoverEvent.showText(hover(def)));
|
||||||
"Clique para reagir com " + def.java(), NamedTextColor.GRAY)));
|
|
||||||
}
|
}
|
||||||
row = row.append(piece);
|
row = row.append(piece);
|
||||||
}
|
}
|
||||||
@@ -108,6 +115,14 @@ final class Reactions {
|
|||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Component hover(ReactionDef def) {
|
||||||
|
List<String> 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". */
|
/** Comma-joined typed shortcuts, e.g. "/legal, /wow, /top". */
|
||||||
String shortcuts() {
|
String shortcuts() {
|
||||||
StringBuilder text = new StringBuilder();
|
StringBuilder text = new StringBuilder();
|
||||||
@@ -120,7 +135,7 @@ final class Reactions {
|
|||||||
return text.toString();
|
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 tally(boolean bedrock) {
|
||||||
Component text = Component.text("Reações: ", NamedTextColor.WHITE);
|
Component text = Component.text("Reações: ", NamedTextColor.WHITE);
|
||||||
for (ReactionDef def : defs) {
|
for (ReactionDef def : defs) {
|
||||||
@@ -130,6 +145,62 @@ final class Reactions {
|
|||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-line closing summary naming the reactors, e.g.
|
||||||
|
* {@code Reações: [+1] NegoncioZ, Guzada · [UAU] Larieus2 +2}.
|
||||||
|
*
|
||||||
|
* <p>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<String> 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<String> 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<Component> breakdown(boolean bedrock) {
|
||||||
|
List<Component> lines = new ArrayList<>();
|
||||||
|
for (ReactionDef def : defs) {
|
||||||
|
List<String> 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() {
|
void show() {
|
||||||
barVisible = true;
|
barVisible = true;
|
||||||
Bukkit.getOnlinePlayers().forEach(p -> p.showBossBar(bar));
|
Bukkit.getOnlinePlayers().forEach(p -> p.showBossBar(bar));
|
||||||
|
|||||||
@@ -106,6 +106,31 @@ final class Settings {
|
|||||||
set("reacao-validade-minutos", Math.max(1, minutes));
|
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.
|
* Configured reactions.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ intervalo-minutos: 20
|
|||||||
# Tempo mínimo antes do mesmo jogador ser citado de novo.
|
# Tempo mínimo antes do mesmo jogador ser citado de novo.
|
||||||
cooldown-minutos: 30
|
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.
|
# Quantas curiosidades recentes lembrar para não repetir.
|
||||||
evitar-repetir: 15
|
evitar-repetir: 15
|
||||||
|
|
||||||
@@ -54,6 +59,11 @@ reacoes-ativas: true
|
|||||||
# Por quanto tempo a barra de reações fica visível, em segundos.
|
# Por quanto tempo a barra de reações fica visível, em segundos.
|
||||||
janela-reacao-segundos: 90
|
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
|
# 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
|
# rolam o chat e clicam minutos depois; descartar esses cliques em silêncio
|
||||||
# parece defeito.
|
# parece defeito.
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ commands:
|
|||||||
reagir:
|
reagir:
|
||||||
description: Reage à última mensagem.
|
description: Reage à última mensagem.
|
||||||
usage: /reagir <chave>
|
usage: /reagir <chave>
|
||||||
|
reacoes:
|
||||||
|
description: Mostra quem reagiu à última mensagem.
|
||||||
|
usage: /reacoes
|
||||||
|
aliases: [quemreagiu]
|
||||||
palpite:
|
palpite:
|
||||||
description: Palpita na adivinha aberta.
|
description: Palpita na adivinha aberta.
|
||||||
usage: /palpite <nome>
|
usage: /palpite <nome>
|
||||||
|
|||||||
Reference in New Issue
Block a user