feat: feed asker stats to the IA + comic death messages
Two features requested after the IA grounding deploy.
IA player-stats grounding: the IA could not answer "quantos blocos eu
minerei?" because its context carried only generic server facts, never the
asker's own numbers. OfflineStats now reads one player's headline stats
(blocks mined, time played, distance, deaths, mob kills) from their vanilla
stats JSON and Ai.compose() injects them as a system turn, gated by
ia.estatisticas-jogador (default true). ~30 tokens per question; always on
so it never misses a stat question. Pure formatSummary extracted for tests.
Comic deaths (mortes module): replaces the vanilla death message with a
cause-based pt-BR comic line plus a death counter ("Fulano foi achatado
como panqueca (47ª morte)") and sends the death coordinates privately to
the dead player (Java click-to-copy, Bedrock plain text) so they can run
back to their dropped items. No storage, no command — a PlayerDeathEvent
side effect gated by modulos.mortes. DeathFlavor is a pure cause->phrase
map, unit-tested. Coexists with the luto [F] handler.
DEATHS stat timing: assumes Paper fires PlayerDeathEvent before awarding
minecraft:deaths, so the counter shows +1 to include the current death; a
log line confirms the raw stat on the first real death so the +1 can be
dropped if the server increments first.
117 tests green (101 + 13 DeathFlavor + 3 OfflineStatsSummary).
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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: \""
|
||||
|
||||
@@ -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).
|
||||
*
|
||||
* <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
|
||||
* 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"),
|
||||
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;
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <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) {
|
||||
try (Reader reader = new FileReader(file)) {
|
||||
JsonElement root = JsonParser.parseReader(reader);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 <pergunta> — 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)."
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user