diff --git a/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java b/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java index 48e1a40..0e998a6 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java @@ -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); } diff --git a/src/main/java/dev/marcospaulo/canalhandia/DeathGift.java b/src/main/java/dev/marcospaulo/canalhandia/DeathGift.java new file mode 100644 index 0000000..86fb738 --- /dev/null +++ b/src/main/java/dev/marcospaulo/canalhandia/DeathGift.java @@ -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. + * + *

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 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 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 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 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 gifts, Random random) { + return gifts.isEmpty() ? null : gifts.get(random.nextInt(gifts.size())); + } + + /** Parses {@code "MATERIAL | Nome | mensagem"} lines, skipping bad ones. */ + static List parse(List raw, Logger log) { + List 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); + } +} diff --git a/src/main/java/dev/marcospaulo/canalhandia/TitleChatListener.java b/src/main/java/dev/marcospaulo/canalhandia/TitleChatListener.java index af1842c..bcb5b09 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/TitleChatListener.java +++ b/src/main/java/dev/marcospaulo/canalhandia/TitleChatListener.java @@ -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. + * + *

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); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 0f54fab..5d47b64 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -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." diff --git a/src/test/java/dev/marcospaulo/canalhandia/DeathGiftTest.java b/src/test/java/dev/marcospaulo/canalhandia/DeathGiftTest.java new file mode 100644 index 0000000..30d22c6 --- /dev/null +++ b/src/test/java/dev/marcospaulo/canalhandia/DeathGiftTest.java @@ -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())); + } +} diff --git a/src/test/java/dev/marcospaulo/canalhandia/TitlesTest.java b/src/test/java/dev/marcospaulo/canalhandia/TitlesTest.java index 6b8bfe9..89a39c9 100644 --- a/src/test/java/dev/marcospaulo/canalhandia/TitlesTest.java +++ b/src/test/java/dev/marcospaulo/canalhandia/TitlesTest.java @@ -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()); + } }