i18n: per-player EN/PT via Adventure GlobalTranslator #1
@@ -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) {
|
||||||
|
|||||||
@@ -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("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