i18n: per-player EN/PT via Adventure GlobalTranslator #1

Merged
masi merged 20 commits from feat/ia-grounding into main 2026-08-12 15:53:28 +00:00
6 changed files with 148 additions and 66 deletions
Showing only changes of commit 95520831da - Show all commits
+13 -9
View File
@@ -2,9 +2,13 @@
Chat-only social features for the Canalhandia Minecraft server (Paper 26.2). Chat-only social features for the Canalhandia Minecraft server (Paper 26.2).
**Nothing here touches gameplay.** No items, no world edits, no attributes, no **Almost nothing here touches gameplay** no world edits, no attributes, no
economy. Everything is chat messages, boss bars and clickable buttons, and every economy, and no items anywhere except one: the `luto` tribute. Pressing F to pay
module can be switched off independently. respects drops the dead player's head into the mourner's inventory as a symbolic
memento (toggle: `luto.cabeca`). Everything else is chat messages and clickable
buttons, and every module can be switched off independently. (The reaction-count
boss bar was removed; live counts ride on the reactor's action bar and a closing
tally line.)
All player-facing text is Portuguese (pt-BR). All player-facing text is Portuguese (pt-BR).
@@ -16,11 +20,11 @@ All player-facing text is Portuguese (pt-BR).
|---|---| |---|---|
| `curiosidades` | *"Sabia que o Fulano já minerou 5.966 blocos de Pedra?"* — a fact about a player, with reaction buttons. Fires on join by default. | | `curiosidades` | *"Sabia que o Fulano já minerou 5.966 blocos de Pedra?"* — a fact about a player, with reaction buttons. Fires on join by default. |
| `adivinha` | The same fact with the name hidden, plus clickable player names. Reveals after 45s and names who guessed right. | | `adivinha` | The same fact with the name hidden, plus clickable player names. Reveals after 45s and names who guessed right. |
| `luto` | A clickable `[F]` under each death message, with a count when the window closes. | | `luto` | A clickable `[F]` under each death message, with a count when the window closes. Pressing F drops the **dead player's head** into the mourner's inventory (once per mourner per death, never to the dead player themselves) — the one gameplay-touching feature; toggle with `luto.cabeca`. Bedrock types `/f`. |
| `enquete` | `/enquete Pergunta \| A \| B` — clickable voting with a live tally on the boss bar. | | `enquete` | `/enquete Pergunta \| A \| B` — clickable voting with a live tally on the boss bar. |
| `ranking` | `/ranking mineracao` and friends. Covers **offline players too**. | | `ranking` | `/ranking mineracao` and friends. Covers **offline players too**. |
| `marcos` | Announces round milestones — 100 km walked, 24 hours played — the first time someone crosses one. | | `marcos` | Announces round milestones — 100 km walked, 24 hours played — the first time someone crosses one. |
| `mortes` | Replaces each death message with a comic pt-BR line (cause-based flavor + a death counter) and sends the death coordinates **privately** to the dead player, so they can run back to their dropped items. No storage, no command. | | `mortes` | Replaces each death message with a comic pt-BR line (cause-based flavor + a death counter) and sends the death coordinates **privately** to the dead player on respawn (the death screen swallows chat sent during the event), so they can run back to their dropped items. Respects `keepInventory`. No storage, no command. |
| `ia` | `/ia <pergunta>` asks an OpenAI-compatible model in chat. Optional wiki grounding, per-player memory, operator corrections. | | `ia` | `/ia <pergunta>` asks an OpenAI-compatible model in chat. Optional wiki grounding, per-player memory, operator corrections. |
Toggle any of them: `/canalhandia modulo <nome> <on|off>` Toggle any of them: `/canalhandia modulo <nome> <on|off>`
@@ -52,14 +56,14 @@ These shaped the design, and anyone changing the code should know them before
There is no vanilla way to update a message that is already in the chat log. So There is no vanilla way to update a message that is already in the chat log. So
the counts baked into the reaction buttons are **frozen at send time** and never the counts baked into the reaction buttons are **frozen at send time** and never
change. The live numbers appear on three other surfaces instead: change. The live numbers appear on two other surfaces instead:
- a **boss bar** while the window is open (`janela-reacao-segundos`, default 90)
- an **action bar** shown to whoever just reacted - an **action bar** shown to whoever just reacted
- a **final tally line** broadcast when the window closes - a **final tally line** broadcast when the window closes
An earlier version only had the boss bar, and it read as broken — the buttons (A boss bar spanning the whole reaction window was tried and removed — it sat
showed no number at all. on screen for `janela-reacao-segundos` and read as clutter, and the two
surfaces above already carry the counts.)
### 2. Who reacted, without filling the screen ### 2. Who reacted, without filling the screen
@@ -6,7 +6,9 @@ import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextDecoration; import net.kyori.adventure.text.format.TextDecoration;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.GameRule;
import org.bukkit.Location; import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.NamespacedKey; import org.bukkit.NamespacedKey;
import org.bukkit.Statistic; import org.bukkit.Statistic;
import org.bukkit.entity.Entity; import org.bukkit.entity.Entity;
@@ -17,6 +19,9 @@ import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent; import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.PlayerDeathEvent; import org.bukkit.event.entity.PlayerDeathEvent;
import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerRespawnEvent;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.SkullMeta;
import org.bukkit.persistence.PersistentDataType; import org.bukkit.persistence.PersistentDataType;
import org.bukkit.plugin.java.JavaPlugin; import org.bukkit.plugin.java.JavaPlugin;
import org.bukkit.scheduler.BukkitTask; import org.bukkit.scheduler.BukkitTask;
@@ -28,6 +33,8 @@ import java.util.Deque;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.Random; import java.util.Random;
import java.util.UUID; import java.util.UUID;
@@ -35,8 +42,11 @@ import java.util.UUID;
* Chat-only social features for the Canalhandia server: curiosities, a guess * Chat-only social features for the Canalhandia server: curiosities, a guess
* game, polls, mourning reactions, milestones and rankings. * game, polls, mourning reactions, milestones and rankings.
* *
* <p>Nothing here touches gameplay — no items, no world edits, no attributes. * <p>Almost nothing here touches gameplay — no items, no world edits, no
* Every module can be switched off independently. * attributes. The one exception is the {@code luto} tribute: pressing F to pay
* respects drops the dead player's head into the mourner's inventory, a symbolic
* memento. Toggle it with {@code luto.cabeca} in config. Every module can be
* switched off independently.
*/ */
public final class Canalhandia extends JavaPlugin implements Listener { public final class Canalhandia extends JavaPlugin implements Listener {
@@ -45,6 +55,10 @@ public final class Canalhandia extends JavaPlugin implements Listener {
private final Deque<String> recentFacts = new ArrayDeque<>(); private final Deque<String> recentFacts = new ArrayDeque<>();
/** Recent reaction sets, newest last, so late clicks still land. */ /** Recent reaction sets, newest last, so late clicks still land. */
private final Deque<Reactions> reactionHistory = new ArrayDeque<>(); private final Deque<Reactions> reactionHistory = new ArrayDeque<>();
/** Mourning tribute per reaction id: who died, and who already got the head. */
private final Map<Integer, Tribute> tributes = new ConcurrentHashMap<>();
/** Death coords awaiting delivery on the player's next respawn (see onDeathComic). */
private final Map<UUID, DeathCoords> pendingDeathCoords = new ConcurrentHashMap<>();
private Settings settings; private Settings settings;
private OfflineStats offlineStats; private OfflineStats offlineStats;
@@ -110,9 +124,6 @@ public final class Canalhandia extends JavaPlugin implements Listener {
@Override @Override
public void onDisable() { public void onDisable() {
if (liveReactions != null) {
liveReactions.hide();
}
if (poll != null) { if (poll != null) {
poll.hide(); poll.hide();
} }
@@ -293,10 +304,8 @@ public final class Canalhandia extends JavaPlugin implements Listener {
Reactions reactions = new Reactions(nextId++, settings.reactions()); Reactions reactions = new Reactions(nextId++, settings.reactions());
liveReactions = reactions; liveReactions = reactions;
remember(reactions); remember(reactions);
reactions.show();
getServer().getScheduler().runTaskLater(this, () -> { getServer().getScheduler().runTaskLater(this, () -> {
reactions.hide();
if (liveReactions == reactions) { if (liveReactions == reactions) {
liveReactions = null; liveReactions = null;
} }
@@ -327,9 +336,7 @@ public final class Canalhandia extends JavaPlugin implements Listener {
new ReactionDef("errado", "[❌]", "[ERRADO]", "errado"))); new ReactionDef("errado", "[❌]", "[ERRADO]", "errado")));
liveReactions = reactions; liveReactions = reactions;
remember(reactions); remember(reactions);
reactions.show();
getServer().getScheduler().runTaskLater(this, () -> { getServer().getScheduler().runTaskLater(this, () -> {
reactions.hide();
if (liveReactions == reactions) { if (liveReactions == reactions) {
liveReactions = null; liveReactions = null;
} }
@@ -360,14 +367,15 @@ public final class Canalhandia extends JavaPlugin implements Listener {
private void remember(Reactions reactions) { private void remember(Reactions reactions) {
reactionHistory.addLast(reactions); reactionHistory.addLast(reactions);
while (reactionHistory.size() > 8) { while (reactionHistory.size() > 8) {
reactionHistory.removeFirst(); Reactions oldest = reactionHistory.removeFirst();
tributes.remove(oldest.id());
} }
} }
/** /**
* Finds a reaction set that is still accepting clicks. The boss bar only * Finds a reaction set that is still accepting clicks. The reaction window
* lasts {@code janela-reacao-segundos}, but people scroll back and click * closes after {@code janela-reacao-segundos}, but people scroll back and
* minutes later, so clicks stay valid for {@code reacao-validade-minutos}. * click minutes later, so clicks stay valid for {@code reacao-validade-minutos}.
*/ */
Reactions findReactions(int id) { Reactions findReactions(int id) {
long limit = settings.reactionValidityMinutes() * 60_000L; long limit = settings.reactionValidityMinutes() * 60_000L;
@@ -454,9 +462,6 @@ public final class Canalhandia extends JavaPlugin implements Listener {
@EventHandler @EventHandler
public void onJoin(PlayerJoinEvent event) { public void onJoin(PlayerJoinEvent event) {
Player player = event.getPlayer(); Player player = event.getPlayer();
if (liveReactions != null) {
liveReactions.showTo(player);
}
if (poll != null) { if (poll != null) {
poll.showTo(player); poll.showTo(player);
} }
@@ -483,8 +488,10 @@ 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();
if (settings.lutoHeadReward()) {
tributes.put(mourning.id(), new Tribute(event.getEntity().getUniqueId(), name));
}
// One tick later so it prints under the vanilla death message. // One tick later so it prints under the vanilla death message.
getServer().getScheduler().runTaskLater(this, () -> broadcastPerPlatform(bedrock -> { getServer().getScheduler().runTaskLater(this, () -> broadcastPerPlatform(bedrock -> {
@@ -501,7 +508,6 @@ 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. // One line, names truncated, so a busy death does not fill the screen.
List<String> who = mourning.names("f"); List<String> who = mourning.names("f");
@@ -555,21 +561,90 @@ public final class Canalhandia extends JavaPlugin implements Listener {
+ DeathFlavor.ordinal(shown) + " morte)", NamedTextColor.YELLOW)); + DeathFlavor.ordinal(shown) + " morte)", NamedTextColor.YELLOW));
// Private coords to the dead player only — never broadcast, so others // Private coords to the dead player only — never broadcast, so others
// don't learn where to loot. Java: clickable copy; Bedrock: plain text. // don't learn where to loot. Delivered on respawn (not at death): the
// Java death screen swallows chat sent during PlayerDeathEvent, so
// sending it then quietly failed. Java: clickable copy; Bedrock: plain.
Location loc = player.getLocation(); Location loc = player.getLocation();
String coords = loc.getBlockX() + " " + loc.getBlockY() + " " + loc.getBlockZ() String coords = loc.getBlockX() + " " + loc.getBlockY() + " " + loc.getBlockZ()
+ " (" + loc.getWorld().getName() + ")"; + " (" + loc.getWorld().getName() + ")";
Component coordsMsg; boolean keepInventory = Boolean.TRUE.equals(
if (Platform.isBedrock(player)) { loc.getWorld().getGameRuleValue(GameRule.KEEP_INVENTORY));
coordsMsg = Component.text("Você morreu em " + coords + ". Corre buscar seus itens!", pendingDeathCoords.put(player.getUniqueId(), new DeathCoords(coords, keepInventory));
NamedTextColor.AQUA); }
} else {
coordsMsg = Component.text("Você morreu em ", NamedTextColor.AQUA) /**
.append(Component.text(coords, NamedTextColor.WHITE) * Sends the death coordinates once the player has actually respawned and
.clickEvent(ClickEvent.copyToClipboard(coords))) * can act on them. The death screen ate the message when it was sent
.append(Component.text(". Corre buscar seus itens!", NamedTextColor.AQUA)); * synchronously during {@link PlayerDeathEvent}.
*/
@EventHandler
public void onRespawn(PlayerRespawnEvent event) {
Player player = event.getPlayer();
DeathCoords dc = pendingDeathCoords.remove(player.getUniqueId());
if (dc == null) {
return;
} }
player.sendMessage(coordsMsg); String tail = dc.keepInventory() ? "" : ". Corre buscar seus itens!";
getServer().getScheduler().runTaskLater(this, () -> {
Component msg;
if (Platform.isBedrock(player)) {
msg = Component.text("Você morreu em " + dc.coords() + tail, NamedTextColor.AQUA);
} else {
msg = Component.text("Você morreu em ", NamedTextColor.AQUA)
.append(Component.text(dc.coords(), NamedTextColor.WHITE)
.clickEvent(ClickEvent.copyToClipboard(dc.coords())))
.append(Component.text(tail, NamedTextColor.AQUA));
}
player.sendMessage(msg);
}, 1L);
}
/**
* Called after any successful reaction. For the mourning {@code f} reaction
* this drops the dead player's head into the mourner's inventory — once per
* mourner per death, and never to the dead player themselves.
*/
void afterReact(Player mourner, int reactionId, String key) {
if (!"f".equals(key)) {
return;
}
Tribute tribute = tributes.get(reactionId);
if (tribute == null) {
return;
}
if (mourner.getUniqueId().equals(tribute.deadId)) {
return;
}
if (!tribute.rewarded.add(mourner.getUniqueId())) {
return; // already got the head for this death
}
ItemStack head = new ItemStack(Material.PLAYER_HEAD);
head.editMeta(SkullMeta.class, m -> {
m.setPlayerProfile(Bukkit.createProfile(tribute.deadId, tribute.deadName));
m.displayName(Component.text("Cabeça de " + tribute.deadName, NamedTextColor.GOLD));
});
for (ItemStack overflow : mourner.getInventory().addItem(head).values()) {
mourner.getWorld().dropItemNaturally(mourner.getLocation(), overflow);
}
mourner.sendMessage(Component.text(
"Você prestou luto e levou a cabeça de " + tribute.deadName + ".",
NamedTextColor.GOLD));
}
/** Who died for a mourning reaction set, and who has already been rewarded. */
private static final class Tribute {
final UUID deadId;
final String deadName;
final Set<UUID> rewarded = ConcurrentHashMap.newKeySet();
Tribute(UUID deadId, String deadName) {
this.deadId = deadId;
this.deadName = deadName;
}
}
/** Death location captured at death, delivered at respawn. */
private record DeathCoords(String coords, boolean keepInventory) {
} }
/** /**
@@ -581,6 +656,9 @@ public final class Canalhandia extends JavaPlugin implements Listener {
if (ai != null) { if (ai != null) {
ai.conversations().forget(event.getPlayer().getUniqueId()); ai.conversations().forget(event.getPlayer().getUniqueId());
} }
// Quitting on the death screen means no respawn fires for this death;
// drop the pending coords so they never deliver stale next session.
pendingDeathCoords.remove(event.getPlayer().getUniqueId());
} }
// --- per-player opt out ------------------------------------------------- // --- per-player opt out -------------------------------------------------
@@ -49,6 +49,11 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
case "reacoes" -> whoReacted(sender); case "reacoes" -> whoReacted(sender);
case "ia" -> ia(sender, args, false); case "ia" -> ia(sender, args, false);
case "iap" -> ia(sender, args, true); case "iap" -> ia(sender, args, true);
// Typed twin of the [F] mourning button — "f" is not a configured
// reaction (the mourning set is hardcoded), so it can't be found via
// reactionForCommand; route it directly. Acts on the latest message,
// so it pays respects only if a mourning window is the most recent.
case "f" -> reactLatest(sender, "f");
default -> { default -> {
String reaction = plugin.settings().reactionForCommand(command.getName()); String reaction = plugin.settings().reactionForCommand(command.getName());
if (reaction != null) { if (reaction != null) {
@@ -397,6 +402,8 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
} }
if (!reactions.react(player, key)) { if (!reactions.react(player, key)) {
Msg.error(sender, "Essa reação não vale para a última mensagem."); Msg.error(sender, "Essa reação não vale para a última mensagem.");
} else {
plugin.afterReact(player, reactions.id(), key);
} }
return true; return true;
} }
@@ -489,6 +496,8 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
} }
if (!reactions.react(player, args[1])) { if (!reactions.react(player, args[1])) {
player.sendActionBar(Component.text("Reação desconhecida.", NamedTextColor.RED)); player.sendActionBar(Component.text("Reação desconhecida.", NamedTextColor.RED));
} else {
plugin.afterReact(player, reactions.id(), args[1]);
} }
} }
@@ -1,11 +1,9 @@
package dev.marcospaulo.canalhandia; package dev.marcospaulo.canalhandia;
import net.kyori.adventure.bossbar.BossBar;
import net.kyori.adventure.text.Component; import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.event.HoverEvent; import net.kyori.adventure.text.event.HoverEvent;
import net.kyori.adventure.text.format.NamedTextColor; import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import java.util.ArrayList; import java.util.ArrayList;
@@ -18,8 +16,9 @@ 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. Live numbers appear on the boss bar, on the reactor's * frozen at send time. Live numbers appear on the reactor's action bar, and in
* action bar, and in a one-line summary when the window closes. * a one-line summary when the window closes. (A boss bar was removed on
* request — it sat on screen for the whole reaction window and read as clutter.)
* *
* <p>Who reacted is deliberately <em>not</em> given its own chat line — with * <p>Who reacted is deliberately <em>not</em> given its own chat line — with
* several reactions and several players that would fill the screen. Instead the * several reactions and several players that would fill the screen. Instead the
@@ -36,15 +35,12 @@ final class Reactions {
private final List<ReactionDef> defs; private final List<ReactionDef> defs;
/** Reaction key to reactors, preserving both order and display name. */ /** Reaction key to reactors, preserving both order and display name. */
private final Map<String, LinkedHashMap<UUID, String>> votes = new LinkedHashMap<>(); private final Map<String, LinkedHashMap<UUID, String>> votes = new LinkedHashMap<>();
private final BossBar bar;
private final long createdAt = System.currentTimeMillis(); private final long createdAt = System.currentTimeMillis();
private boolean barVisible;
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 LinkedHashMap<>())); defs.forEach(def -> votes.put(def.key(), new LinkedHashMap<>()));
this.bar = BossBar.bossBar(tally(false), 1.0f, BossBar.Color.PURPLE, BossBar.Overlay.PROGRESS);
} }
int id() { int id() {
@@ -72,9 +68,6 @@ final class Reactions {
votes.values().forEach(map -> map.remove(player.getUniqueId())); votes.values().forEach(map -> map.remove(player.getUniqueId()));
// Names are captured now so the summary still works if someone logs off. // Names are captured now so the summary still works if someone logs off.
votes.get(key).put(player.getUniqueId(), player.getName()); votes.get(key).put(player.getUniqueId(), player.getName());
if (barVisible) {
bar.name(tally(false));
}
player.sendActionBar(tally(Platform.isBedrock(player))); player.sendActionBar(tally(Platform.isBedrock(player)));
return true; return true;
} }
@@ -135,7 +128,7 @@ final class Reactions {
return text.toString(); return text.toString();
} }
/** Compact live counts, for the boss bar and action bar. */ /** Compact live counts, for the reactor's 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) {
@@ -200,21 +193,4 @@ final class Reactions {
} }
return lines; return lines;
} }
void show() {
barVisible = true;
Bukkit.getOnlinePlayers().forEach(p -> p.showBossBar(bar));
}
void hide() {
barVisible = false;
Bukkit.getOnlinePlayers().forEach(p -> p.hideBossBar(bar));
}
/** Shows the bar to someone who joined while the window was still open. */
void showTo(Player player) {
if (barVisible) {
player.showBossBar(bar);
}
}
} }
@@ -85,6 +85,15 @@ final class Settings {
set("reacoes-ativas", enabled); set("reacoes-ativas", enabled);
} }
/** Whether pressing F to pay respects drops the dead player's head. */
boolean lutoHeadReward() {
return plugin.getConfig().getBoolean("luto.cabeca", true);
}
void lutoHeadReward(boolean enabled) {
set("luto.cabeca", enabled);
}
/** How long the boss bar stays up. */ /** How long the boss bar stays up. */
int reactionWindowSeconds() { int reactionWindowSeconds() {
return Math.max(5, plugin.getConfig().getInt("janela-reacao-segundos", 90)); return Math.max(5, plugin.getConfig().getInt("janela-reacao-segundos", 90));
+6
View File
@@ -63,6 +63,12 @@ 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
# Luto: ao prestar F para alguém que morreu, o jogador recebe a cabeça daquele
# jogador (uma lembrança simbólica). É o único recurso do plugin que mexe no
# inventário; desligue aqui se não quiser.
luto:
cabeca: true
# Quantos nomes cabem no resumo de reações antes do resto virar "+N". # 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 # O resumo é sempre UMA linha, por mais gente que reaja; para ver a lista
# completa use /reacoes (privado, não polui o chat). # completa use /reacoes (privado, não polui o chat).