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).
**Nothing here touches gameplay.** No items, no world edits, no attributes, no
economy. Everything is chat messages, boss bars and clickable buttons, and every
module can be switched off independently.
**Almost nothing here touches gameplay** no world edits, no attributes, no
economy, and no items anywhere except one: the `luto` tribute. Pressing F to pay
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).
@@ -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. |
| `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. |
| `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. |
| `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. |
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
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
- a **final tally line** broadcast when the window closes
An earlier version only had the boss bar, and it read as broken — the buttons
showed no number at all.
(A boss bar spanning the whole reaction window was tried and removed — it sat
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
@@ -6,7 +6,9 @@ import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextDecoration;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import org.bukkit.Bukkit;
import org.bukkit.GameRule;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.Statistic;
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.PlayerDeathEvent;
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.plugin.java.JavaPlugin;
import org.bukkit.scheduler.BukkitTask;
@@ -28,6 +33,8 @@ import java.util.Deque;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.Random;
import java.util.UUID;
@@ -35,8 +42,11 @@ import java.util.UUID;
* Chat-only social features for the Canalhandia server: curiosities, a guess
* game, polls, mourning reactions, milestones and rankings.
*
* <p>Nothing here touches gameplay — no items, no world edits, no attributes.
* Every module can be switched off independently.
* <p>Almost nothing here touches gameplay — no items, no world edits, no
* 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 {
@@ -45,6 +55,10 @@ public final class Canalhandia extends JavaPlugin implements Listener {
private final Deque<String> recentFacts = new ArrayDeque<>();
/** Recent reaction sets, newest last, so late clicks still land. */
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 OfflineStats offlineStats;
@@ -110,9 +124,6 @@ public final class Canalhandia extends JavaPlugin implements Listener {
@Override
public void onDisable() {
if (liveReactions != null) {
liveReactions.hide();
}
if (poll != null) {
poll.hide();
}
@@ -293,10 +304,8 @@ public final class Canalhandia extends JavaPlugin implements Listener {
Reactions reactions = new Reactions(nextId++, settings.reactions());
liveReactions = reactions;
remember(reactions);
reactions.show();
getServer().getScheduler().runTaskLater(this, () -> {
reactions.hide();
if (liveReactions == reactions) {
liveReactions = null;
}
@@ -327,9 +336,7 @@ public final class Canalhandia extends JavaPlugin implements Listener {
new ReactionDef("errado", "[❌]", "[ERRADO]", "errado")));
liveReactions = reactions;
remember(reactions);
reactions.show();
getServer().getScheduler().runTaskLater(this, () -> {
reactions.hide();
if (liveReactions == reactions) {
liveReactions = null;
}
@@ -360,14 +367,15 @@ public final class Canalhandia extends JavaPlugin implements Listener {
private void remember(Reactions reactions) {
reactionHistory.addLast(reactions);
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
* lasts {@code janela-reacao-segundos}, but people scroll back and click
* minutes later, so clicks stay valid for {@code reacao-validade-minutos}.
* Finds a reaction set that is still accepting clicks. The reaction window
* closes after {@code janela-reacao-segundos}, but people scroll back and
* click minutes later, so clicks stay valid for {@code reacao-validade-minutos}.
*/
Reactions findReactions(int id) {
long limit = settings.reactionValidityMinutes() * 60_000L;
@@ -454,9 +462,6 @@ public final class Canalhandia extends JavaPlugin implements Listener {
@EventHandler
public void onJoin(PlayerJoinEvent event) {
Player player = event.getPlayer();
if (liveReactions != null) {
liveReactions.showTo(player);
}
if (poll != null) {
poll.showTo(player);
}
@@ -483,8 +488,10 @@ public final class Canalhandia extends JavaPlugin implements Listener {
List.of(new ReactionDef("f", "[F]", "[F]", "f")));
liveReactions = mourning;
remember(mourning);
mourning.show();
String name = event.getEntity().getName();
if (settings.lutoHeadReward()) {
tributes.put(mourning.id(), new Tribute(event.getEntity().getUniqueId(), name));
}
// One tick later so it prints under the vanilla death message.
getServer().getScheduler().runTaskLater(this, () -> broadcastPerPlatform(bedrock -> {
@@ -501,7 +508,6 @@ public final class Canalhandia extends JavaPlugin implements Listener {
}), 2L);
getServer().getScheduler().runTaskLater(this, () -> {
mourning.hide();
if (mourning.hasAnyVote()) {
// One line, names truncated, so a busy death does not fill the screen.
List<String> who = mourning.names("f");
@@ -555,21 +561,90 @@ public final class Canalhandia extends JavaPlugin implements Listener {
+ DeathFlavor.ordinal(shown) + " morte)", NamedTextColor.YELLOW));
// 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();
String coords = loc.getBlockX() + " " + loc.getBlockY() + " " + loc.getBlockZ()
+ " (" + loc.getWorld().getName() + ")";
Component coordsMsg;
if (Platform.isBedrock(player)) {
coordsMsg = Component.text("Você morreu em " + coords + ". Corre buscar seus itens!",
NamedTextColor.AQUA);
} else {
coordsMsg = Component.text("Você morreu em ", NamedTextColor.AQUA)
.append(Component.text(coords, NamedTextColor.WHITE)
.clickEvent(ClickEvent.copyToClipboard(coords)))
.append(Component.text(". Corre buscar seus itens!", NamedTextColor.AQUA));
boolean keepInventory = Boolean.TRUE.equals(
loc.getWorld().getGameRuleValue(GameRule.KEEP_INVENTORY));
pendingDeathCoords.put(player.getUniqueId(), new DeathCoords(coords, keepInventory));
}
player.sendMessage(coordsMsg);
/**
* Sends the death coordinates once the player has actually respawned and
* can act on them. The death screen ate the message when it was sent
* synchronously during {@link PlayerDeathEvent}.
*/
@EventHandler
public void onRespawn(PlayerRespawnEvent event) {
Player player = event.getPlayer();
DeathCoords dc = pendingDeathCoords.remove(player.getUniqueId());
if (dc == null) {
return;
}
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) {
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 -------------------------------------------------
@@ -49,6 +49,11 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
case "reacoes" -> whoReacted(sender);
case "ia" -> ia(sender, args, false);
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 -> {
String reaction = plugin.settings().reactionForCommand(command.getName());
if (reaction != null) {
@@ -397,6 +402,8 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
}
if (!reactions.react(player, key)) {
Msg.error(sender, "Essa reação não vale para a última mensagem.");
} else {
plugin.afterReact(player, reactions.id(), key);
}
return true;
}
@@ -489,6 +496,8 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
}
if (!reactions.react(player, args[1])) {
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;
import net.kyori.adventure.bossbar.BossBar;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.event.HoverEvent;
import net.kyori.adventure.text.format.NamedTextColor;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import java.util.ArrayList;
@@ -18,8 +16,9 @@ import java.util.UUID;
* Reaction state for one announced message.
*
* <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
* action bar, and in a one-line summary when the window closes.
* frozen at send time. Live numbers appear on the reactor's action bar, and in
* 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
* several reactions and several players that would fill the screen. Instead the
@@ -36,15 +35,12 @@ final class Reactions {
private final List<ReactionDef> defs;
/** 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 long createdAt = System.currentTimeMillis();
private boolean barVisible;
Reactions(int id, List<ReactionDef> defs) {
this.id = id;
this.defs = defs;
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() {
@@ -72,9 +68,6 @@ final class Reactions {
votes.values().forEach(map -> map.remove(player.getUniqueId()));
// Names are captured now so the summary still works if someone logs off.
votes.get(key).put(player.getUniqueId(), player.getName());
if (barVisible) {
bar.name(tally(false));
}
player.sendActionBar(tally(Platform.isBedrock(player)));
return true;
}
@@ -135,7 +128,7 @@ final class Reactions {
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 text = Component.text("Reações: ", NamedTextColor.WHITE);
for (ReactionDef def : defs) {
@@ -200,21 +193,4 @@ final class Reactions {
}
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);
}
/** 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. */
int reactionWindowSeconds() {
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.
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".
# O resumo é sempre UMA linha, por mais gente que reaja; para ver a lista
# completa use /reacoes (privado, não polui o chat).