diff --git a/README.md b/README.md index db29467..8d03db0 100644 --- a/README.md +++ b/README.md @@ -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. | | `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. | | `ia` | `/ia ` asks an OpenAI-compatible model in chat. Optional wiki grounding, per-player memory, operator corrections. | Toggle any of them: `/canalhandia modulo ` @@ -246,6 +247,12 @@ no model round trip. - **Server context**: the `contexto:` list in `config.yml` is facts the model would never know (server name, Bedrock prefix, installed mods). Sent on every 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 thread) and answers recipe questions from that snapshot — `explaintext` drops 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 | | `Poll.java` | Poll state, voting, results | | `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 | +| `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 | ### Adding a curiosity diff --git a/src/main/java/dev/marcospaulo/canalhandia/Ai.java b/src/main/java/dev/marcospaulo/canalhandia/Ai.java index 89d8f1e..f5f4719 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Ai.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Ai.java @@ -313,6 +313,20 @@ final class Ai { 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)) { messages.add(new MiniMax.Turn("system", "Correção registrada por um operador. Pergunta parecida: \"" diff --git a/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java b/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java index a0bfa4e..1982006 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java @@ -6,10 +6,15 @@ 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.Location; import org.bukkit.NamespacedKey; +import org.bukkit.Statistic; +import org.bukkit.entity.Entity; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; 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.player.PlayerJoinEvent; import org.bukkit.persistence.PersistentDataType; @@ -513,6 +518,60 @@ public final class Canalhandia extends JavaPlugin implements Listener { }, 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). + * + *

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 * answer a fresh question with an old one (carry-forward #6). diff --git a/src/main/java/dev/marcospaulo/canalhandia/DeathFlavor.java b/src/main/java/dev/marcospaulo/canalhandia/DeathFlavor.java new file mode 100644 index 0000000..258c4b6 --- /dev/null +++ b/src/main/java/dev/marcospaulo/canalhandia/DeathFlavor.java @@ -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. + * + *

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 + "ª"; + } +} \ No newline at end of file diff --git a/src/main/java/dev/marcospaulo/canalhandia/Module.java b/src/main/java/dev/marcospaulo/canalhandia/Module.java index c4144a0..606ac14 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Module.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Module.java @@ -9,6 +9,7 @@ enum Module { ENQUETE("enquete", "Enquetes"), RANKING("ranking", "Rankings"), MARCOS("marcos", "Marcos e conquistas"), + MORTES("mortes", "Mortes com humor e coordenadas"), IA("ia", "Perguntas para a IA"); private final String key; diff --git a/src/main/java/dev/marcospaulo/canalhandia/OfflineStats.java b/src/main/java/dev/marcospaulo/canalhandia/OfflineStats.java index d80427c..029722d 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/OfflineStats.java +++ b/src/main/java/dev/marcospaulo/canalhandia/OfflineStats.java @@ -13,6 +13,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.UUID; /** * 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; } + /** + * 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. + * + *

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) { try (Reader reader = new FileReader(file)) { JsonElement root = JsonParser.parseReader(reader); diff --git a/src/main/java/dev/marcospaulo/canalhandia/Settings.java b/src/main/java/dev/marcospaulo/canalhandia/Settings.java index 78b5811..65e4647 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Settings.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Settings.java @@ -322,6 +322,19 @@ final class Settings { 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 ------------------------------------------------------------ boolean categoryEnabled(Category category) { diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index c267706..3aedcf5 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -14,6 +14,9 @@ modulos: enquete: true # /enquete com votação clicável ranking: true # /ranking com placares do servidor 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 — só para quem tem canalhandia.ia # --- Curiosidades ------------------------------------------------------------ @@ -181,6 +184,10 @@ ia: memoria-perguntas: 3 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. contexto: - "O servidor se chama Canalhandia e roda Minecraft 26.2 (Paper)." diff --git a/src/test/java/dev/marcospaulo/canalhandia/DeathFlavorTest.java b/src/test/java/dev/marcospaulo/canalhandia/DeathFlavorTest.java new file mode 100644 index 0000000..98c9dd3 --- /dev/null +++ b/src/test/java/dev/marcospaulo/canalhandia/DeathFlavorTest.java @@ -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("1ª", DeathFlavor.ordinal(1)); + assertEquals("47ª", DeathFlavor.ordinal(47)); + assertEquals("0ª", DeathFlavor.ordinal(0)); + } +} \ No newline at end of file diff --git a/src/test/java/dev/marcospaulo/canalhandia/OfflineStatsSummaryTest.java b/src/test/java/dev/marcospaulo/canalhandia/OfflineStatsSummaryTest.java new file mode 100644 index 0000000..a1c3787 --- /dev/null +++ b/src/test/java/dev/marcospaulo/canalhandia/OfflineStatsSummaryTest.java @@ -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]); + } +} \ No newline at end of file