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
10 changed files with 383 additions and 1 deletions
Showing only changes of commit 68db3a7079 - Show all commits
+9 -1
View File
@@ -20,6 +20,7 @@ All player-facing text is Portuguese (pt-BR).
| `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. |
| `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>`
@@ -246,6 +247,12 @@ no model round trip.
- **Server context**: the `contexto:` list in `config.yml` is facts the model - **Server context**: the `contexto:` list in `config.yml` is facts the model
would never know (server name, Bedrock prefix, installed mods). Sent on every would never know (server name, Bedrock prefix, installed mods). Sent on every
question. question.
- **Asker's stats**: when `estatisticas-jogador` is on (default), the asking
player's headline numbers (blocks mined, time played, distance walked, deaths,
mob kills) are read from their vanilla stats JSON and injected as context, so
"quantos blocos eu minerei?" gets a real answer instead of "não tenho
acesso". The stats file lags by under a minute. Gated off with
`/canalhandia` settings or `ia.estatisticas-jogador: false`.
- **Recipes**: `RecipeBook` snapshots `Bukkit.recipeIterator()` at enable (main - **Recipes**: `RecipeBook` snapshots `Bukkit.recipeIterator()` at enable (main
thread) and answers recipe questions from that snapshot — `explaintext` drops thread) and answers recipe questions from that snapshot — `explaintext` drops
tables, so the wiki cannot supply them. tables, so the wiki cannot supply them.
@@ -313,8 +320,9 @@ microk8s kubectl cp target/Canalhandia-1.0.0.jar minecraft/${POD#pod/}:$SRV/plug
| `GuessRound.java` | "Adivinhe de quem é" round state | | `GuessRound.java` | "Adivinhe de quem é" round state |
| `Poll.java` | Poll state, voting, results | | `Poll.java` | Poll state, voting, results |
| `Milestones.java` | Threshold tracking, persisted to `marcos.yml` | | `Milestones.java` | Threshold tracking, persisted to `marcos.yml` |
| `OfflineStats.java` | Reads stats JSON for offline players | | `OfflineStats.java` | Reads stats JSON for offline players (rankings + the asker's stat summary for the IA) |
| `RankingMetric.java` | Leaderboard columns and their formatting | | `RankingMetric.java` | Leaderboard columns and their formatting |
| `DeathFlavor.java` | Comic pt-BR verb phrases for each death cause (used by the `mortes` module) |
| `Msg.java` | Shared chat formatting and pt-BR number/duration formatting | | `Msg.java` | Shared chat formatting and pt-BR number/duration formatting |
### Adding a curiosity ### Adding a curiosity
@@ -313,6 +313,20 @@ final class Ai {
messages.add(new MiniMax.Turn("system", "Sobre este servidor: " + serverContext)); messages.add(new MiniMax.Turn("system", "Sobre este servidor: " + serverContext));
} }
// The asker's own stats, so "quantos blocos eu minerei?" gets a real
// number instead of "não tenho acesso ao servidor". ~30 tokens; gated
// by ia.estatisticas-jogador so an operator can turn it off. Stale by
// under a minute (the server writes the stats JSON periodically).
if (settings.aiPlayerStats()) {
String stats = plugin.offlineStats().summary(asker.getUniqueId());
if (stats != null && !stats.isBlank()) {
messages.add(new MiniMax.Turn("system",
"Estatísticas do jogador que fez a pergunta — " + stats
+ ". Use estes números para responder perguntas sobre as estatísticas dele "
+ "(blocos minerados, tempo jogado, distância, mortes, monstros)."));
}
}
for (Corrections.Entry entry : Corrections.matching(corrections.all(), question)) { for (Corrections.Entry entry : Corrections.matching(corrections.all(), question)) {
messages.add(new MiniMax.Turn("system", messages.add(new MiniMax.Turn("system",
"Correção registrada por um operador. Pergunta parecida: \"" "Correção registrada por um operador. Pergunta parecida: \""
@@ -6,10 +6,15 @@ 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.Location;
import org.bukkit.NamespacedKey; import org.bukkit.NamespacedKey;
import org.bukkit.Statistic;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler; import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener; import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
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.persistence.PersistentDataType; import org.bukkit.persistence.PersistentDataType;
@@ -513,6 +518,60 @@ public final class Canalhandia extends JavaPlugin implements Listener {
}, settings.reactionWindowSeconds() * 20L); }, settings.reactionWindowSeconds() * 20L);
} }
/**
* Comic death broadcast + private coordinates, gated by the {@code mortes}
* module. Replaces the vanilla translatable death message with a pt-BR
* flavor line ("Fulano foi achatado como panqueca (47ª morte)") and sends
* the death location only to the dead player, so they can run back to their
* dropped items. Java players get a click-to-copy coordinate; Bedrock gets
* plain text (no chat clickEvent on Geyser).
*
* <p>Coexists with {@link #onDeath}: that handler only broadcasts the
* {@code [F]} mourning row and never touches {@code deathMessage}, so both
* fire on the same event without conflict.
*/
@EventHandler
public void onDeathComic(PlayerDeathEvent event) {
if (!settings.moduleEnabled(Module.MORTES)) {
return;
}
Player player = event.getEntity();
EntityDamageEvent damage = player.getLastDamageCause();
EntityDamageEvent.DamageCause cause = damage == null ? null : damage.getCause();
Entity killer = player.getKiller();
if (killer == null && damage instanceof EntityDamageByEntityEvent byEntity) {
killer = byEntity.getDamager();
}
String flavor = DeathFlavor.flavor(cause, killer);
// Paper fires PlayerDeathEvent inside LivingEntity.die, before the
// minecraft:deaths stat is awarded, so +1 makes this death count. The
// log line lets an operator confirm on the first real death and drop
// the +1 if their server increments the stat before the event.
long deaths = player.getStatistic(Statistic.DEATHS);
long shown = deaths + 1;
getLogger().info("[mortes] " + player.getName() + " stat=" + deaths + " mostrando=" + shown);
event.deathMessage(Component.text(player.getName() + " " + flavor + " ("
+ 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.
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));
}
player.sendMessage(coordsMsg);
}
/** /**
* Drops a player's short-term AI memory on quit, so a rejoin does not * Drops a player's short-term AI memory on quit, so a rejoin does not
* answer a fresh question with an old one (carry-forward #6). * answer a fresh question with an old one (carry-forward #6).
@@ -0,0 +1,101 @@
package dev.marcospaulo.canalhandia;
import org.bukkit.entity.Entity;
import org.bukkit.entity.EntityType;
import org.bukkit.entity.Player;
import org.bukkit.event.entity.EntityDamageEvent;
/**
* Comic Portuguese verb phrases for each way a player can die, so the death
* broadcast reads like "Fulano foi achatado como panqueca" instead of the bland
* vanilla translatable.
*
* <p>Pure: given a damage cause and the killer (nullable), returns the verb
* phrase only — the caller prepends the player's name. No Bukkit state beyond
* the two arguments, so it is unit-testable without a server.
*/
final class DeathFlavor {
private DeathFlavor() {
}
/**
* A comic pt-BR verb phrase for how the player died.
*
* @param cause the damage cause, or null if there was no last damage event
* @param killer the entity that landed the killing blow, or null
*/
static String flavor(EntityDamageEvent.DamageCause cause, Entity killer) {
if (cause == null) {
return "bateu as botas";
}
return switch (cause) {
case FALL -> "foi achatado como panqueca";
case LAVA -> "virou churrasco no lava";
case FIRE, FIRE_TICK -> "virou fritanga";
case DROWNING -> "esqueceu como se respira";
case VOID -> "sumiu no void";
case STARVATION -> "morreu de fome, coitado";
case SUFFOCATION -> "engatou num bloco";
case FREEZE -> "virou picolé";
case LIGHTNING -> "levou um raio";
case HOT_FLOOR -> "pisou em magma";
case CONTACT, FALLING_BLOCK -> "foi espetado";
case BLOCK_EXPLOSION, ENTITY_EXPLOSION -> mobFlavor(killer, "voou em pedacinhos");
case PROJECTILE -> mobFlavor(killer, "levou um projetil");
case ENTITY_ATTACK, ENTITY_SWEEP_ATTACK -> mobFlavor(killer, "bateu as botas");
case WITHER -> "foi definhado pelo wither";
case POISON -> "engoliu veneno";
case MAGIC -> "levou um feitiço";
case CRAMMING -> "foi espremidinho";
case FLY_INTO_WALL -> "bateu de frente na parede";
case DRYOUT -> "ficou ressecado demais";
default -> "bateu as botas";
};
}
/**
* Refines a generic phrase when the killer is a known mob, and names the
* killer when it is another player.
*/
private static String mobFlavor(Entity killer, String fallback) {
if (killer == null) {
return fallback;
}
if (killer instanceof Player pvp) {
return "levou uma pedrada de " + pvp.getName();
}
EntityType type = killer.getType();
return switch (type) {
case CREEPER -> "deu um abraço apertado num creeper";
case ZOMBIE, HUSK, DROWNED, ZOMBIE_VILLAGER -> "virou lanche de zumbi";
case SKELETON, STRAY -> "levou uma flechada do esqueleto";
case SPIDER, CAVE_SPIDER -> "virou jantar de aranha";
case ENDERMAN -> "olhou nos olhos errados do enderman";
case WITCH -> "levou uma poção da bruxa";
case BLAZE -> "virou alvo do blaze";
case GHAST -> "levou uma bola de fogo do ghast";
case PHANTOM -> "foi abocanhado por um phantom";
case SLIME, MAGMA_CUBE -> "foi engolido por uma geleia";
case WITHER, WITHER_SKELETON -> "foi definhado pelo wither";
case ENDER_DRAGON -> "desafiou o dragão do End";
case WARDEN -> "fazia barulho perto do warden";
case IRON_GOLEM -> "provocou um golem de ferro";
case WOLF -> "foi atacado por um lobo";
case BEE -> "incomodou uma abelha";
case HOGLIN, ZOGLIN -> "enfezou um hoglin";
case PIGLIN, PIGLIN_BRUTE -> "fez besteira com piglin";
case PILLAGER -> "levou uma flechada de saqueador";
case VINDICATOR, EVOKER, RAVAGER -> "invadiu uma invasão";
default -> fallback;
};
}
/**
* Portuguese feminine ordinal for the death counter ("1ª", "47ª"). "Morte"
* is feminine, so every number takes "ª".
*/
static String ordinal(long n) {
return n + "ª";
}
}
@@ -9,6 +9,7 @@ enum Module {
ENQUETE("enquete", "Enquetes"), ENQUETE("enquete", "Enquetes"),
RANKING("ranking", "Rankings"), RANKING("ranking", "Rankings"),
MARCOS("marcos", "Marcos e conquistas"), MARCOS("marcos", "Marcos e conquistas"),
MORTES("mortes", "Mortes com humor e coordenadas"),
IA("ia", "Perguntas para a IA"); IA("ia", "Perguntas para a IA");
private final String key; private final String key;
@@ -13,6 +13,7 @@ import java.util.ArrayList;
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.UUID;
/** /**
* Reads statistics straight off disk so rankings can include players who are * Reads statistics straight off disk so rankings can include players who are
@@ -64,6 +65,51 @@ final class OfflineStats {
return rows.size() > limit ? rows.subList(0, limit) : rows; return rows.size() > limit ? rows.subList(0, limit) : rows;
} }
/**
* One player's headline stats as a compact pt-BR line, for the IA module to
* answer "quantos blocos eu minerei?" with the asker's own numbers.
*
* <p>Returns null if there is no stats directory or no file for this player.
* The stats JSON is written by the server every few seconds and on quit, so
* the current session may lag by under a minute — acceptable for chat.
*
* @see #formatSummary
*/
String summary(UUID uuid) {
File dir = statsDirectory();
if (dir == null) {
return null;
}
File file = new File(dir, uuid + ".json");
if (!file.isFile()) {
return null;
}
long mined = read(file, RankingMetric.MINERACAO);
long playTime = read(file, RankingMetric.TEMPO);
long distance = read(file, RankingMetric.DISTANCIA);
long deaths = read(file, RankingMetric.MORTES);
long mobKills = read(file, RankingMetric.COMBATE);
String name = names().getOrDefault(uuid.toString(),
uuid.toString().substring(0, Math.min(8, uuid.toString().length())));
return formatSummary(name, mined, playTime, distance, deaths, mobKills);
}
/**
* Formats the five headline stats into one pt-BR line. Pure, so it can be
* tested without a server; {@link #summary} reads the numbers off disk and
* delegates here. Each piece reuses {@link RankingMetric#format} so the
* units (ticks→duration, cm→km) stay consistent with the rankings.
*/
static String formatSummary(String name, long mined, long playTimeTicks,
long distanceCm, long deaths, long mobKills) {
return name + ": "
+ RankingMetric.MINERACAO.format(mined) + " minerados, "
+ RankingMetric.TEMPO.format(playTimeTicks) + " jogado, "
+ RankingMetric.DISTANCIA.format(distanceCm) + " a pé, "
+ RankingMetric.MORTES.format(deaths) + ", "
+ RankingMetric.COMBATE.format(mobKills) + " derrotados.";
}
private long read(File file, RankingMetric metric) { private long read(File file, RankingMetric metric) {
try (Reader reader = new FileReader(file)) { try (Reader reader = new FileReader(file)) {
JsonElement root = JsonParser.parseReader(reader); JsonElement root = JsonParser.parseReader(reader);
@@ -322,6 +322,19 @@ final class Settings {
return String.join(" ", plugin.getConfig().getStringList("ia.contexto")); return String.join(" ", plugin.getConfig().getStringList("ia.contexto"));
} }
/**
* Whether the asker's own stats are injected into the AI context, so it can
* answer "quantos blocos eu minerei?" with real numbers instead of saying it
* has no access to the server.
*/
boolean aiPlayerStats() {
return plugin.getConfig().getBoolean("ia.estatisticas-jogador", true);
}
void aiPlayerStats(boolean value) {
set("ia.estatisticas-jogador", value);
}
// --- content ------------------------------------------------------------ // --- content ------------------------------------------------------------
boolean categoryEnabled(Category category) { boolean categoryEnabled(Category category) {
+7
View File
@@ -14,6 +14,9 @@ modulos:
enquete: true # /enquete com votação clicável enquete: true # /enquete com votação clicável
ranking: true # /ranking com placares do servidor ranking: true # /ranking com placares do servidor
marcos: true # avisos automáticos ao passar de 100 km, 24 horas, etc. marcos: true # avisos automáticos ao passar de 100 km, 24 horas, etc.
# Mensagem de morte engraçada + coordenadas da morte mandadas em privado
# só para quem morreu (para correr buscar os itens).
mortes: true
ia: true # /ia <pergunta> — só para quem tem canalhandia.ia ia: true # /ia <pergunta> — só para quem tem canalhandia.ia
# --- Curiosidades ------------------------------------------------------------ # --- Curiosidades ------------------------------------------------------------
@@ -181,6 +184,10 @@ ia:
memoria-perguntas: 3 memoria-perguntas: 3
memoria-minutos: 10 memoria-minutos: 10
# true: injeta as estatísticas do jogador que perguntou no contexto da IA,
# para responder "quantos blocos eu minerei?" com números reais.
estatisticas-jogador: true
# Fatos do servidor que a IA nunca teria como saber. Uma linha por fato. # Fatos do servidor que a IA nunca teria como saber. Uma linha por fato.
contexto: contexto:
- "O servidor se chama Canalhandia e roda Minecraft 26.2 (Paper)." - "O servidor se chama Canalhandia e roda Minecraft 26.2 (Paper)."
@@ -0,0 +1,92 @@
package dev.marcospaulo.canalhandia;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import org.bukkit.event.entity.EntityDamageEvent;
class DeathFlavorTest {
@Test
void nullCauseFallsBack() {
assertEquals("bateu as botas", DeathFlavor.flavor(null, null));
}
@Test
void fallIsPancake() {
assertEquals("foi achatado como panqueca",
DeathFlavor.flavor(EntityDamageEvent.DamageCause.FALL, null));
}
@Test
void lavaIsChurrasco() {
assertEquals("virou churrasco no lava",
DeathFlavor.flavor(EntityDamageEvent.DamageCause.LAVA, null));
}
@Test
void fireAndFireTickAreFritanga() {
assertEquals("virou fritanga",
DeathFlavor.flavor(EntityDamageEvent.DamageCause.FIRE, null));
assertEquals("virou fritanga",
DeathFlavor.flavor(EntityDamageEvent.DamageCause.FIRE_TICK, null));
}
@Test
void drownForgetsToBreathe() {
assertEquals("esqueceu como se respira",
DeathFlavor.flavor(EntityDamageEvent.DamageCause.DROWNING, null));
}
@Test
void voidIsVoid() {
assertEquals("sumiu no void",
DeathFlavor.flavor(EntityDamageEvent.DamageCause.VOID, null));
}
@Test
void starvationIsHunger() {
assertEquals("morreu de fome, coitado",
DeathFlavor.flavor(EntityDamageEvent.DamageCause.STARVATION, null));
}
@Test
void freezeIsPicole() {
assertEquals("virou picolé",
DeathFlavor.flavor(EntityDamageEvent.DamageCause.FREEZE, null));
}
@Test
void entityAttackWithoutKillerFallsBack() {
assertEquals("bateu as botas",
DeathFlavor.flavor(EntityDamageEvent.DamageCause.ENTITY_ATTACK, null));
}
@Test
void explosionWithoutKillerIsPieces() {
assertEquals("voou em pedacinhos",
DeathFlavor.flavor(EntityDamageEvent.DamageCause.ENTITY_EXPLOSION, null));
assertEquals("voou em pedacinhos",
DeathFlavor.flavor(EntityDamageEvent.DamageCause.BLOCK_EXPLOSION, null));
}
@Test
void unknownCauseFallsBack() {
// Custom/unusual causes degrade to the generic phrase rather than crash.
assertEquals("bateu as botas",
DeathFlavor.flavor(EntityDamageEvent.DamageCause.SONIC_BOOM, null));
}
@Test
void contactIsEspetado() {
assertEquals("foi espetado",
DeathFlavor.flavor(EntityDamageEvent.DamageCause.CONTACT, null));
}
@Test
void ordinalIsFeminine() {
assertEquals("", DeathFlavor.ordinal(1));
assertEquals("47ª", DeathFlavor.ordinal(47));
assertEquals("", DeathFlavor.ordinal(0));
}
}
@@ -0,0 +1,41 @@
package dev.marcospaulo.canalhandia;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class OfflineStatsSummaryTest {
@Test
void formatsHeadlineStatsInPtBr() {
// 12.530 blocks, 1 day (1.728.000 ticks), 63,9 km (6.390.000 cm),
// 47 deaths, 230 mob kills — the same shape as a real NegoncioZ line.
String summary = OfflineStats.formatSummary(
"NegoncioZ", 12530L, 1_728_000L, 6_390_000L, 47L, 230L);
assertEquals(
"NegoncioZ: 12.530 blocos minerados, 1 dia jogado, 63,9 km a pé, "
+ "47 mortes, 230 monstros derrotados.",
summary);
}
@Test
void zerosStillReadCleanly() {
// A brand-new player has no stats; the line should still make sense and
// not throw or print "null".
String summary = OfflineStats.formatSummary("Novato", 0L, 0L, 0L, 0L, 0L);
assertEquals(
"Novato: 0 blocos minerados, 0 minutos jogado, 0,0 km a pé, "
+ "0 mortes, 0 monstros derrotados.",
summary);
}
@Test
void hoursAndMinutesRenderWithUnits() {
// 2 hours = 144.000 ticks; 30 minutes = 36.000 ticks.
assertEquals("2 horas jogado",
OfflineStats.formatSummary("X", 0L, 144_000L, 0L, 0L, 0L)
.split(", ")[1]);
assertEquals("30 minutos jogado",
OfflineStats.formatSummary("X", 0L, 36_000L, 0L, 0L, 0L)
.split(", ")[1]);
}
}