feat(mortes): comic consolation gift on respawn + fix grey title chat

- On respawn a dead player privately gets a harmless comic item (funeral
  poppy, wilted bush, ...) with a pt-BR jab. Config-driven under
  mortes.presente ("MATERIAL | Nome | mensagem"), defaults baked in,
  hot-reloadable via /canalhandia reload. Overflow drops at their feet.
- Fix: a title-holder's chat text rendered grey. The title chip was
  rooted on a DARK_GRAY component and the message, appended to it,
  inherited that colour. Root on Component.empty() so the message falls
  back to white.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Marcos (via Claude)
2026-08-12 11:14:25 -03:00
parent 34a0b44a03
commit cee0b020a1
6 changed files with 187 additions and 2 deletions
@@ -98,6 +98,7 @@ public final class Canalhandia extends JavaPlugin implements Listener {
private GuessRound guessRound;
private Poll poll;
private Titles titles;
private DeathGift deathGift;
@Override
public void onEnable() {
@@ -113,6 +114,7 @@ public final class Canalhandia extends JavaPlugin implements Listener {
milestones = new Milestones(this);
achievements = new Achievements(this);
titles = new Titles(this);
deathGift = new DeathGift(this);
// Load the achievement catalogue from config, then silently bank any
// history the current definitions already imply (both here and for
// milestones), so an expanded catalogue never spams returning players.
@@ -247,6 +249,7 @@ public final class Canalhandia extends JavaPlugin implements Listener {
Achievement.load(Achievement.loadFrom(conquistasCatalogo(), getLogger()));
milestones.reload();
achievements.syncCatalogue();
deathGift.reload();
}
/** The weekly ranking baseline. Never null. */
@@ -895,6 +898,11 @@ public final class Canalhandia extends JavaPlugin implements Listener {
.append(Component.text(tail, NamedTextColor.AQUA));
}
player.sendMessage(msg);
// A comic consolation item, given once they can actually hold it.
// Gameplay-neutral by design (a poppy, a wilted bush) — just a laugh.
if (deathGift.active()) {
deathGift.give(player);
}
}, 1L);
}
@@ -0,0 +1,111 @@
package dev.marcospaulo.canalhandia;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextDecoration;
import org.bukkit.Material;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Random;
import java.util.logging.Logger;
/**
* A small consolation prize handed to a player when they respawn — a funeral
* poppy, a wilted bush, whatever. Comic, never useful: the point is a laugh at
* the death, not a leg up, so gifts are cosmetic-tier items given one at a time.
*
* <p>Config-driven like the achievement catalogue. If {@code mortes.presente} is
* absent the built-in list is used, so it works the moment the plugin loads;
* operators expand or mute it under that key and {@code /canalhandia reload}
* picks it up. Each line is {@code "MATERIAL | Nome | mensagem"}.
*/
final class DeathGift {
/** Baked-in default so a fresh server has something without editing config. */
private static final List<String> DEFAULTS = List.of(
"POPPY | Flor do Velório | Uma florzinha pro seu velório. Sentimos muito.",
"DEAD_BUSH | Buquê Murcho | Um buquê à altura do seu último desempenho.",
"WET_SPONGE | Esponja das Lágrimas | Toma, pra enxugar as lágrimas.",
"BONE | Osso da Sorte | Um ossinho pra você, campeão.",
"COOKIE | Cookie de Consolação | Cookie de consolação. Vai que melhora.",
"ROTTEN_FLESH | Carne Podre | É o que tinha sobrado na despensa.");
/** One gift: an item, the name it wears, and the line shown when it is given. */
record Gift(Material material, String name, String message) {
}
private final Canalhandia plugin;
private final Random random = new Random();
private volatile boolean active;
private volatile List<Gift> gifts = List.of();
DeathGift(Canalhandia plugin) {
this.plugin = plugin;
reload();
}
/** Re-reads the gift list from config (or the defaults). Driven by reload. */
void reload() {
ConfigurationSection section = plugin.getConfig().getConfigurationSection("mortes.presente");
boolean on = section == null || section.getBoolean("ativo", true);
List<String> raw = section == null ? DEFAULTS : section.getStringList("itens");
if (raw.isEmpty()) {
raw = DEFAULTS;
}
gifts = parse(raw, plugin.getLogger());
active = on && !gifts.isEmpty();
}
/** True when a gift should be handed out on respawn. */
boolean active() {
return active;
}
/** Hands the player a random gift and a private comic line. Overflow is dropped
* at their feet rather than lost, so a full inventory never eats the joke. */
void give(Player player) {
Gift gift = pick(gifts, random);
if (gift == null) {
return;
}
ItemStack item = new ItemStack(gift.material());
item.editMeta(meta -> meta.displayName(Component.text(gift.name(), NamedTextColor.LIGHT_PURPLE)
.decoration(TextDecoration.ITALIC, false)));
Map<Integer, ItemStack> overflow = player.getInventory().addItem(item);
for (ItemStack leftover : overflow.values()) {
player.getWorld().dropItemNaturally(player.getLocation(), leftover);
}
player.sendMessage(Msg.tag("Consolação", NamedTextColor.LIGHT_PURPLE)
.append(Component.text(gift.message(), NamedTextColor.GRAY)));
}
/** Picks one gift at random, or null if the list is empty. Pure, for tests. */
static Gift pick(List<Gift> gifts, Random random) {
return gifts.isEmpty() ? null : gifts.get(random.nextInt(gifts.size()));
}
/** Parses {@code "MATERIAL | Nome | mensagem"} lines, skipping bad ones. */
static List<Gift> parse(List<String> raw, Logger log) {
List<Gift> out = new ArrayList<>();
for (String line : raw) {
String[] parts = line.split("\\|", 3);
if (parts.length != 3) {
log.warning("Presente de morte ignorado (formato 'ITEM | Nome | mensagem'): " + line);
continue;
}
Material material = Material.matchMaterial(parts[0].trim().toUpperCase(Locale.ROOT));
if (material == null || !material.isItem()) {
log.warning("Presente de morte ignorado (item inválido): " + parts[0].trim());
continue;
}
out.add(new Gift(material, parts[1].trim(), parts[2].trim()));
}
return List.copyOf(out);
}
}
@@ -45,9 +45,15 @@ final class TitleChatListener implements Listener {
}
/** The bracketed title chip that sits before the name, drawn in the title's
* own tier colour so a legendary reads gold and a rare aqua. Pure, so testable. */
* own tier colour so a legendary reads gold and a rare aqua. Pure, so testable.
*
* <p>Rooted on an empty, colourless component on purpose: the chat message is
* appended to this in {@link #onChat}, and a coloured root would bleed its
* colour into any unstyled message text — which turned title-holders' chat
* grey. Empty root → the message falls back to the client default (white). */
static Component tag(Achievement achievement) {
return Component.text("[", NamedTextColor.DARK_GRAY)
return Component.empty()
.append(Component.text("[", NamedTextColor.DARK_GRAY))
.append(Component.text(achievement.title(), achievement.color()))
.append(Component.text("] ", NamedTextColor.DARK_GRAY))
.decoration(TextDecoration.BOLD, false);
+15
View File
@@ -329,3 +329,18 @@ ia:
- "O servidor se chama Canalhandia e roda Minecraft 26.2 (Paper)."
- "Jogadores de Bedrock entram pelo Geyser e o nome deles começa com ponto."
- "O servidor tem BlueMap, voice chat e Distant Horizons."
# Presente de consolação: quando um jogador renasce, ganha um item cômico e
# inofensivo (uma flor de velório, um arbusto murcho). Parte do módulo "mortes".
# Sem esta seção, uma lista padrão embutida é usada. Rode /canalhandia reload
# depois de editar. Cada item é "MATERIAL | Nome | mensagem".
mortes:
presente:
ativo: true
itens:
- "POPPY | Flor do Velório | Uma florzinha pro seu velório. Sentimos muito."
- "DEAD_BUSH | Buquê Murcho | Um buquê à altura do seu último desempenho."
- "WET_SPONGE | Esponja das Lágrimas | Toma, pra enxugar as lágrimas."
- "BONE | Osso da Sorte | Um ossinho pra você, campeão."
- "COOKIE | Cookie de Consolação | Cookie de consolação. Vai que melhora."
- "ROTTEN_FLESH | Carne Podre | É o que tinha sobrado na despensa."
@@ -0,0 +1,38 @@
package dev.marcospaulo.canalhandia;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import java.util.Random;
import java.util.logging.Logger;
import org.bukkit.Material;
import org.junit.jupiter.api.Test;
/**
* The consolation-gift parsing and pick. Deliberately avoids the happy path of
* {@link DeathGift#parse}, which calls {@code Material.isItem()} — that throws in
* a unit JVM without a bootstrapped registry (same limit RecipeBookTest notes),
* so the item-resolution branch is verified live instead.
*/
class DeathGiftTest {
private static final Logger LOG = Logger.getAnonymousLogger();
@Test
void skipsMalformedAndUnknownLines() {
assertTrue(DeathGift.parse(List.of("sem as barras certas"), LOG).isEmpty());
assertTrue(DeathGift.parse(List.of("SÓ | DUAS_PARTES"), LOG).isEmpty());
// Unknown material name is rejected at matchMaterial, before isItem().
assertTrue(DeathGift.parse(List.of("ITEM_QUE_NAO_EXISTE_XYZ | Nome | msg"), LOG).isEmpty());
}
@Test
void pickIsNullOnEmptyAndAMemberOtherwise() {
assertNull(DeathGift.pick(List.of(), new Random()));
DeathGift.Gift only = new DeathGift.Gift(Material.POPPY, "Flor do Velório", "oi");
assertSame(only, DeathGift.pick(List.of(only), new Random()));
}
}
@@ -54,4 +54,11 @@ class TitlesTest {
void tagCarriesTheTitle() {
assertNotNull(TitleChatListener.tag(Achievement.byKey("pedreiro")));
}
@Test
void tagRootIsColourlessSoMessageStaysWhite() {
// The chat message is appended to this tag; a coloured root would bleed
// into unstyled message text and grey it out. Root must carry no colour.
assertNull(TitleChatListener.tag(Achievement.byKey("pedreiro")).color());
}
}