diff --git a/README.md b/README.md index 8d03db0..b72575e 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,13 @@ Chat-only social features for the Canalhandia Minecraft server (Paper 26.2). -**Nothing here touches gameplay.** No items, no world edits, no attributes, no -economy. Everything is chat messages, boss bars and clickable buttons, and every -module can be switched off independently. +**Almost nothing here touches gameplay** — no world edits, no attributes, no +economy, and no items anywhere except one: the `luto` tribute. Pressing F to pay +respects drops the dead player's head into the mourner's inventory as a symbolic +memento (toggle: `luto.cabeca`). Everything else is chat messages and clickable +buttons, and every module can be switched off independently. (The reaction-count +boss bar was removed; live counts ride on the reactor's action bar and a closing +tally line.) All player-facing text is Portuguese (pt-BR). @@ -16,11 +20,11 @@ All player-facing text is Portuguese (pt-BR). |---|---| | `curiosidades` | *"Sabia que o Fulano já minerou 5.966 blocos de Pedra?"* — a fact about a player, with reaction buttons. Fires on join by default. | | `adivinha` | The same fact with the name hidden, plus clickable player names. Reveals after 45s and names who guessed right. | -| `luto` | A clickable `[F]` under each death message, with a count when the window closes. | +| `luto` | A clickable `[F]` under each death message, with a count when the window closes. Pressing F drops the **dead player's head** into the mourner's inventory (once per mourner per death, never to the dead player themselves) — the one gameplay-touching feature; toggle with `luto.cabeca`. Bedrock types `/f`. | | `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. | +| `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 on respawn (the death screen swallows chat sent during the event), so they can run back to their dropped items. Respects `keepInventory`. 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 ` @@ -52,14 +56,14 @@ These shaped the design, and anyone changing the code should know them before There is no vanilla way to update a message that is already in the chat log. So the counts baked into the reaction buttons are **frozen at send time** and never -change. The live numbers appear on three other surfaces instead: +change. The live numbers appear on two other surfaces instead: -- a **boss bar** while the window is open (`janela-reacao-segundos`, default 90) - an **action bar** shown to whoever just reacted - a **final tally line** broadcast when the window closes -An earlier version only had the boss bar, and it read as broken — the buttons -showed no number at all. +(A boss bar spanning the whole reaction window was tried and removed — it sat +on screen for `janela-reacao-segundos` and read as clutter, and the two +surfaces above already carry the counts.) ### 2. Who reacted, without filling the screen diff --git a/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java b/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java index 1982006..e0184a0 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Canalhandia.java @@ -6,7 +6,9 @@ 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.GameRule; import org.bukkit.Location; +import org.bukkit.Material; import org.bukkit.NamespacedKey; import org.bukkit.Statistic; import org.bukkit.entity.Entity; @@ -17,6 +19,9 @@ 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.event.player.PlayerRespawnEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.SkullMeta; import org.bukkit.persistence.PersistentDataType; import org.bukkit.plugin.java.JavaPlugin; import org.bukkit.scheduler.BukkitTask; @@ -28,6 +33,8 @@ import java.util.Deque; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.Random; import java.util.UUID; @@ -35,8 +42,11 @@ import java.util.UUID; * Chat-only social features for the Canalhandia server: curiosities, a guess * game, polls, mourning reactions, milestones and rankings. * - *

Nothing here touches gameplay — no items, no world edits, no attributes. - * Every module can be switched off independently. + *

Almost nothing here touches gameplay — no items, no world edits, no + * attributes. The one exception is the {@code luto} tribute: pressing F to pay + * respects drops the dead player's head into the mourner's inventory, a symbolic + * memento. Toggle it with {@code luto.cabeca} in config. Every module can be + * switched off independently. */ public final class Canalhandia extends JavaPlugin implements Listener { @@ -45,6 +55,10 @@ public final class Canalhandia extends JavaPlugin implements Listener { private final Deque recentFacts = new ArrayDeque<>(); /** Recent reaction sets, newest last, so late clicks still land. */ private final Deque reactionHistory = new ArrayDeque<>(); + /** Mourning tribute per reaction id: who died, and who already got the head. */ + private final Map tributes = new ConcurrentHashMap<>(); + /** Death coords awaiting delivery on the player's next respawn (see onDeathComic). */ + private final Map pendingDeathCoords = new ConcurrentHashMap<>(); private Settings settings; private OfflineStats offlineStats; @@ -110,9 +124,6 @@ public final class Canalhandia extends JavaPlugin implements Listener { @Override public void onDisable() { - if (liveReactions != null) { - liveReactions.hide(); - } if (poll != null) { poll.hide(); } @@ -293,10 +304,8 @@ public final class Canalhandia extends JavaPlugin implements Listener { Reactions reactions = new Reactions(nextId++, settings.reactions()); liveReactions = reactions; remember(reactions); - reactions.show(); getServer().getScheduler().runTaskLater(this, () -> { - reactions.hide(); if (liveReactions == reactions) { liveReactions = null; } @@ -327,9 +336,7 @@ public final class Canalhandia extends JavaPlugin implements Listener { new ReactionDef("errado", "[❌]", "[ERRADO]", "errado"))); liveReactions = reactions; remember(reactions); - reactions.show(); getServer().getScheduler().runTaskLater(this, () -> { - reactions.hide(); if (liveReactions == reactions) { liveReactions = null; } @@ -360,14 +367,15 @@ public final class Canalhandia extends JavaPlugin implements Listener { private void remember(Reactions reactions) { reactionHistory.addLast(reactions); while (reactionHistory.size() > 8) { - reactionHistory.removeFirst(); + Reactions oldest = reactionHistory.removeFirst(); + tributes.remove(oldest.id()); } } /** - * Finds a reaction set that is still accepting clicks. The boss bar only - * lasts {@code janela-reacao-segundos}, but people scroll back and click - * minutes later, so clicks stay valid for {@code reacao-validade-minutos}. + * Finds a reaction set that is still accepting clicks. The reaction window + * closes after {@code janela-reacao-segundos}, but people scroll back and + * click minutes later, so clicks stay valid for {@code reacao-validade-minutos}. */ Reactions findReactions(int id) { long limit = settings.reactionValidityMinutes() * 60_000L; @@ -454,9 +462,6 @@ public final class Canalhandia extends JavaPlugin implements Listener { @EventHandler public void onJoin(PlayerJoinEvent event) { Player player = event.getPlayer(); - if (liveReactions != null) { - liveReactions.showTo(player); - } if (poll != null) { poll.showTo(player); } @@ -483,8 +488,10 @@ public final class Canalhandia extends JavaPlugin implements Listener { List.of(new ReactionDef("f", "[F]", "[F]", "f"))); liveReactions = mourning; remember(mourning); - mourning.show(); String name = event.getEntity().getName(); + if (settings.lutoHeadReward()) { + tributes.put(mourning.id(), new Tribute(event.getEntity().getUniqueId(), name)); + } // One tick later so it prints under the vanilla death message. getServer().getScheduler().runTaskLater(this, () -> broadcastPerPlatform(bedrock -> { @@ -501,7 +508,6 @@ public final class Canalhandia extends JavaPlugin implements Listener { }), 2L); getServer().getScheduler().runTaskLater(this, () -> { - mourning.hide(); if (mourning.hasAnyVote()) { // One line, names truncated, so a busy death does not fill the screen. List who = mourning.names("f"); @@ -555,21 +561,90 @@ public final class Canalhandia extends JavaPlugin implements Listener { + 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. + // don't learn where to loot. Delivered on respawn (not at death): the + // Java death screen swallows chat sent during PlayerDeathEvent, so + // sending it then quietly failed. Java: clickable copy; Bedrock: plain. 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)); + boolean keepInventory = Boolean.TRUE.equals( + loc.getWorld().getGameRuleValue(GameRule.KEEP_INVENTORY)); + pendingDeathCoords.put(player.getUniqueId(), new DeathCoords(coords, keepInventory)); + } + + /** + * Sends the death coordinates once the player has actually respawned and + * can act on them. The death screen ate the message when it was sent + * synchronously during {@link PlayerDeathEvent}. + */ + @EventHandler + public void onRespawn(PlayerRespawnEvent event) { + Player player = event.getPlayer(); + DeathCoords dc = pendingDeathCoords.remove(player.getUniqueId()); + if (dc == null) { + return; } - player.sendMessage(coordsMsg); + String tail = dc.keepInventory() ? "" : ". Corre buscar seus itens!"; + getServer().getScheduler().runTaskLater(this, () -> { + Component msg; + if (Platform.isBedrock(player)) { + msg = Component.text("Você morreu em " + dc.coords() + tail, NamedTextColor.AQUA); + } else { + msg = Component.text("Você morreu em ", NamedTextColor.AQUA) + .append(Component.text(dc.coords(), NamedTextColor.WHITE) + .clickEvent(ClickEvent.copyToClipboard(dc.coords()))) + .append(Component.text(tail, NamedTextColor.AQUA)); + } + player.sendMessage(msg); + }, 1L); + } + + /** + * Called after any successful reaction. For the mourning {@code f} reaction + * this drops the dead player's head into the mourner's inventory — once per + * mourner per death, and never to the dead player themselves. + */ + void afterReact(Player mourner, int reactionId, String key) { + if (!"f".equals(key)) { + return; + } + Tribute tribute = tributes.get(reactionId); + if (tribute == null) { + return; + } + if (mourner.getUniqueId().equals(tribute.deadId)) { + return; + } + if (!tribute.rewarded.add(mourner.getUniqueId())) { + return; // already got the head for this death + } + ItemStack head = new ItemStack(Material.PLAYER_HEAD); + head.editMeta(SkullMeta.class, m -> { + m.setPlayerProfile(Bukkit.createProfile(tribute.deadId, tribute.deadName)); + m.displayName(Component.text("Cabeça de " + tribute.deadName, NamedTextColor.GOLD)); + }); + for (ItemStack overflow : mourner.getInventory().addItem(head).values()) { + mourner.getWorld().dropItemNaturally(mourner.getLocation(), overflow); + } + mourner.sendMessage(Component.text( + "Você prestou luto e levou a cabeça de " + tribute.deadName + ".", + NamedTextColor.GOLD)); + } + + /** Who died for a mourning reaction set, and who has already been rewarded. */ + private static final class Tribute { + final UUID deadId; + final String deadName; + final Set rewarded = ConcurrentHashMap.newKeySet(); + + Tribute(UUID deadId, String deadName) { + this.deadId = deadId; + this.deadName = deadName; + } + } + + /** Death location captured at death, delivered at respawn. */ + private record DeathCoords(String coords, boolean keepInventory) { } /** @@ -581,6 +656,9 @@ public final class Canalhandia extends JavaPlugin implements Listener { if (ai != null) { ai.conversations().forget(event.getPlayer().getUniqueId()); } + // Quitting on the death screen means no respawn fires for this death; + // drop the pending coords so they never deliver stale next session. + pendingDeathCoords.remove(event.getPlayer().getUniqueId()); } // --- per-player opt out ------------------------------------------------- diff --git a/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java index d09255c..061d36e 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java +++ b/src/main/java/dev/marcospaulo/canalhandia/CanalhandiaCommand.java @@ -49,6 +49,11 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { case "reacoes" -> whoReacted(sender); case "ia" -> ia(sender, args, false); case "iap" -> ia(sender, args, true); + // Typed twin of the [F] mourning button — "f" is not a configured + // reaction (the mourning set is hardcoded), so it can't be found via + // reactionForCommand; route it directly. Acts on the latest message, + // so it pays respects only if a mourning window is the most recent. + case "f" -> reactLatest(sender, "f"); default -> { String reaction = plugin.settings().reactionForCommand(command.getName()); if (reaction != null) { @@ -397,6 +402,8 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { } if (!reactions.react(player, key)) { Msg.error(sender, "Essa reação não vale para a última mensagem."); + } else { + plugin.afterReact(player, reactions.id(), key); } return true; } @@ -489,6 +496,8 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter { } if (!reactions.react(player, args[1])) { player.sendActionBar(Component.text("Reação desconhecida.", NamedTextColor.RED)); + } else { + plugin.afterReact(player, reactions.id(), args[1]); } } diff --git a/src/main/java/dev/marcospaulo/canalhandia/Reactions.java b/src/main/java/dev/marcospaulo/canalhandia/Reactions.java index 40bf12c..1158bcc 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Reactions.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Reactions.java @@ -1,11 +1,9 @@ package dev.marcospaulo.canalhandia; -import net.kyori.adventure.bossbar.BossBar; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.event.ClickEvent; import net.kyori.adventure.text.event.HoverEvent; import net.kyori.adventure.text.format.NamedTextColor; -import org.bukkit.Bukkit; import org.bukkit.entity.Player; import java.util.ArrayList; @@ -18,8 +16,9 @@ import java.util.UUID; * Reaction state for one announced message. * *

Chat cannot be edited after sending, so the counts inside the buttons are - * frozen at send time. Live numbers appear on the boss bar, on the reactor's - * action bar, and in a one-line summary when the window closes. + * frozen at send time. Live numbers appear on the reactor's action bar, and in + * a one-line summary when the window closes. (A boss bar was removed on + * request — it sat on screen for the whole reaction window and read as clutter.) * *

Who reacted is deliberately not given its own chat line — with * several reactions and several players that would fill the screen. Instead the @@ -36,15 +35,12 @@ final class Reactions { private final List defs; /** Reaction key to reactors, preserving both order and display name. */ private final Map> votes = new LinkedHashMap<>(); - private final BossBar bar; private final long createdAt = System.currentTimeMillis(); - private boolean barVisible; Reactions(int id, List defs) { this.id = id; this.defs = defs; defs.forEach(def -> votes.put(def.key(), new LinkedHashMap<>())); - this.bar = BossBar.bossBar(tally(false), 1.0f, BossBar.Color.PURPLE, BossBar.Overlay.PROGRESS); } int id() { @@ -72,9 +68,6 @@ final class Reactions { votes.values().forEach(map -> map.remove(player.getUniqueId())); // Names are captured now so the summary still works if someone logs off. votes.get(key).put(player.getUniqueId(), player.getName()); - if (barVisible) { - bar.name(tally(false)); - } player.sendActionBar(tally(Platform.isBedrock(player))); return true; } @@ -135,7 +128,7 @@ final class Reactions { return text.toString(); } - /** Compact live counts, for the boss bar and action bar. */ + /** Compact live counts, for the reactor's action bar. */ Component tally(boolean bedrock) { Component text = Component.text("Reações: ", NamedTextColor.WHITE); for (ReactionDef def : defs) { @@ -200,21 +193,4 @@ final class Reactions { } return lines; } - - void show() { - barVisible = true; - Bukkit.getOnlinePlayers().forEach(p -> p.showBossBar(bar)); - } - - void hide() { - barVisible = false; - Bukkit.getOnlinePlayers().forEach(p -> p.hideBossBar(bar)); - } - - /** Shows the bar to someone who joined while the window was still open. */ - void showTo(Player player) { - if (barVisible) { - player.showBossBar(bar); - } - } } diff --git a/src/main/java/dev/marcospaulo/canalhandia/Settings.java b/src/main/java/dev/marcospaulo/canalhandia/Settings.java index 65e4647..220230e 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Settings.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Settings.java @@ -85,6 +85,15 @@ final class Settings { set("reacoes-ativas", enabled); } + /** Whether pressing F to pay respects drops the dead player's head. */ + boolean lutoHeadReward() { + return plugin.getConfig().getBoolean("luto.cabeca", true); + } + + void lutoHeadReward(boolean enabled) { + set("luto.cabeca", enabled); + } + /** How long the boss bar stays up. */ int reactionWindowSeconds() { return Math.max(5, plugin.getConfig().getInt("janela-reacao-segundos", 90)); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 3aedcf5..c68ad79 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -63,6 +63,12 @@ reacoes-ativas: true # Por quanto tempo a barra de reações fica visível, em segundos. janela-reacao-segundos: 90 +# Luto: ao prestar F para alguém que morreu, o jogador recebe a cabeça daquele +# jogador (uma lembrança simbólica). É o único recurso do plugin que mexe no +# inventário; desligue aqui se não quiser. +luto: + cabeca: true + # Quantos nomes cabem no resumo de reações antes do resto virar "+N". # O resumo é sempre UMA linha, por mais gente que reaja; para ver a lista # completa use /reacoes (privado, não polui o chat).