feat!: rename to Canalhandia and add four chat games
Renames the plugin from Curiosidades to Canalhandia (the server name) now that it covers more than curiosities, and adds: - adivinha: the curiosity with the name hidden and clickable player names, revealing who guessed right. Curiosity sentences never contained the subject's name, so the same generator feeds both with no redaction step. - luto: a clickable [F] under death messages with a closing count. - enquete: polls with clickable options and a live boss-bar tally. - ranking + marcos: leaderboards covering offline players, and one-time milestone announcements persisted to marcos.yml. Fixes the reaction counter being invisible. Chat cannot be edited after sending, so the button counts are necessarily frozen; previously the only live surface was the boss bar and the buttons showed no number at all. Counts now appear in the buttons at send time, on the boss bar, on the reactor's action bar, and as a final tally line when the window closes. Late clicks used to be dropped silently once the 90s window passed. The last 8 reaction sets now stay live for reacao-validade-minutos (15). Rankings read the stats JSON directly because Bukkit only exposes statistics for online players. Adds README.md documenting the design constraints that are easy to mistake for bugs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ActvLGJApdxEAd2yfKPwqv
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
# Canalhandia
|
||||
|
||||
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.
|
||||
|
||||
All player-facing text is Portuguese (pt-BR).
|
||||
|
||||
---
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | What it does |
|
||||
|---|---|
|
||||
| `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. |
|
||||
| `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. |
|
||||
|
||||
Toggle any of them: `/canalhandia modulo <nome> <on|off>`
|
||||
|
||||
---
|
||||
|
||||
## Where the data comes from
|
||||
|
||||
Everything is derived from **vanilla statistics**. No database, no tracking code,
|
||||
no extra writes — the server was already recording all of it.
|
||||
|
||||
- **Online players** use the Bukkit API (`Player#getStatistic`).
|
||||
- **Rankings** read `<world>/players/stats/<uuid>.json` directly, because Bukkit
|
||||
only exposes statistics for players who are online. `usercache.json` maps those
|
||||
UUIDs back to names, including Floodgate/Bedrock players whose UUIDs start with
|
||||
`00000000-0000-0000-0009`.
|
||||
|
||||
Note the path: Paper writes to `<world>/players/stats`, **not** `<world>/stats`.
|
||||
`OfflineStats` checks both.
|
||||
|
||||
---
|
||||
|
||||
## Two constraints worth knowing
|
||||
|
||||
These shaped the design, and anyone changing the code should know them before
|
||||
"fixing" what looks odd.
|
||||
|
||||
### 1. Chat messages cannot be edited after sending
|
||||
|
||||
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:
|
||||
|
||||
- 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.
|
||||
|
||||
### 2. Clicks arrive late
|
||||
|
||||
People scroll back and click minutes after a message. Reactions therefore keep
|
||||
counting for `reacao-validade-minutos` (default 15) even after the boss bar is
|
||||
gone, and the last 8 reaction sets stay in memory for that reason. Silently
|
||||
dropping a late click looks like a bug to the player.
|
||||
|
||||
### 3. Names are translated by the client, not by us
|
||||
|
||||
Block, item and mob names are emitted as **translatable components**
|
||||
(`Component.translatable(material.translationKey())`), so a pt-BR client renders
|
||||
"Pedra" and an en-US client renders "Stone" from the same broadcast. There is no
|
||||
translation table to maintain.
|
||||
|
||||
The consequence: the client only supplies the **singular** form. Every sentence
|
||||
is therefore phrased so the number never has to agree with the noun —
|
||||
*"5.966 blocos de Pedra"*, never *"5.966 Pedras"*. Keep that rule when adding
|
||||
sentences to `CuriosityFactory`.
|
||||
|
||||
Note that the **server console** renders translatable components in English, so
|
||||
`[Curiosidade] ... 16 unidades de Copper Pickaxe` in `latest.log` does not mean
|
||||
players saw English.
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
Player-facing:
|
||||
|
||||
```
|
||||
/curiosidade anuncia uma curiosidade agora
|
||||
/curiosidade <jogador> anuncia sobre alguém específico
|
||||
/curiosidade ver [jogador] mostra só para você
|
||||
/curiosidade listar [jogador] lista todas as curiosidades disponíveis
|
||||
/curiosidade toggle entra/sai do sorteio
|
||||
/adivinha inicia uma rodada de "adivinhe de quem é"
|
||||
/enquete Pergunta | A | B abre uma enquete
|
||||
/enquete encerrar encerra a enquete aberta
|
||||
/ranking [categoria] placares do servidor
|
||||
/canalhandia status mostra toda a configuração
|
||||
/canalhandia modulos lista os módulos e seu estado
|
||||
```
|
||||
|
||||
Admin (`canalhandia.admin`):
|
||||
|
||||
```
|
||||
/canalhandia modulo <nome> <on|off> liga/desliga um módulo
|
||||
/canalhandia marcos força uma verificação de marcos
|
||||
/canalhandia limpar [cooldown|historico|tudo]
|
||||
/canalhandia reload
|
||||
/curiosidade modo <entrada|intervalo|ambos|manual>
|
||||
/curiosidade intervalo <min> intervalo do modo temporizado
|
||||
/curiosidade atraso <seg> espera após o jogador entrar
|
||||
/curiosidade cooldown <min> mínimo entre citar o mesmo jogador
|
||||
/curiosidade repetir <n> quantas recentes evitar repetir
|
||||
/curiosidade janela <seg> duração da barra de reações
|
||||
/curiosidade validade <min> por quanto tempo cliques ainda contam
|
||||
/curiosidade reacoes <on|off>
|
||||
/curiosidade reacao add <chave> <rótulo>
|
||||
/curiosidade reacao remover <chave>
|
||||
/curiosidade categoria <nome> <on|off>
|
||||
```
|
||||
|
||||
Every setter **writes through to `config.yml` immediately**, so in-game changes
|
||||
survive a restart.
|
||||
|
||||
### Permissions
|
||||
|
||||
| Permission | Default | Grants |
|
||||
|---|---|---|
|
||||
| `canalhandia.reagir` | everyone | react, press F |
|
||||
| `canalhandia.ver` | everyone | `ver`, `listar`, `/ranking` |
|
||||
| `canalhandia.enquete` | everyone | open polls |
|
||||
| `canalhandia.forcar` | op | trigger curiosities and guess rounds |
|
||||
| `canalhandia.admin` | op | change modules and all settings |
|
||||
| `canalhandia.isento` | nobody | never be the subject |
|
||||
|
||||
Permissions are **declared explicitly** in `plugin.yml`. An undeclared Bukkit
|
||||
permission falls back to op-only, which would silently stop normal players from
|
||||
reacting.
|
||||
|
||||
---
|
||||
|
||||
## Building
|
||||
|
||||
Requires **JDK 25**. Paper 26.2's API ships Java 25 class files, and JDK 21 fails
|
||||
with a misleading `cannot access org.bukkit.Bukkit` — that phrasing means the
|
||||
class-file version is too new, not that the dependency is missing.
|
||||
|
||||
```bash
|
||||
docker run --rm -v "$PWD":/work -v "$HOME/.m2":/root/.m2 -w /work \
|
||||
maven:3.9-eclipse-temurin-25 mvn -B package
|
||||
```
|
||||
|
||||
Output: `target/Canalhandia-1.0.0.jar`
|
||||
|
||||
The dependency uses Paper's newer coordinate scheme:
|
||||
`io.papermc.paper:paper-api:26.2.build.92-stable`.
|
||||
|
||||
### Deploying
|
||||
|
||||
Copy the jar into the server's `plugins/` and restart. There is no hot-reload
|
||||
path for a new jar — `/canalhandia reload` only re-reads `config.yml`.
|
||||
|
||||
```bash
|
||||
POD=$(microk8s kubectl get pod -n minecraft -l app=crafty-controller -o name | head -1)
|
||||
SRV=/crafty/servers/6e39a8b2-300b-42d6-8139-f397c23e461b
|
||||
microk8s kubectl cp target/Canalhandia-1.0.0.jar minecraft/${POD#pod/}:$SRV/plugins/Canalhandia-1.0.0.jar
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Source layout
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `Canalhandia.java` | Plugin entry point, scheduling, broadcasting, listeners |
|
||||
| `CanalhandiaCommand.java` | Every command and all click callbacks |
|
||||
| `Settings.java` | Typed config access; all setters persist immediately |
|
||||
| `Module.java` / `Category.java` / `Mode.java` | Toggleable feature, fact group, trigger mode |
|
||||
| `CuriosityFactory.java` | Builds the Portuguese sentences from statistics |
|
||||
| `Stats.java` | Defensive Bukkit statistics access |
|
||||
| `Fact.java` | One sentence plus its category |
|
||||
| `Reactions.java` | Reaction state, buttons, boss bar, tally |
|
||||
| `GuessRound.java` | "Adivinhe de quem é" round state |
|
||||
| `Poll.java` | Poll state, voting, results |
|
||||
| `Milestones.java` | Threshold tracking, persisted to `marcos.yml` |
|
||||
| `OfflineStats.java` | Reads stats JSON for offline players |
|
||||
| `RankingMetric.java` | Leaderboard columns and their formatting |
|
||||
| `Msg.java` | Shared chat formatting and pt-BR number/duration formatting |
|
||||
|
||||
### Adding a curiosity
|
||||
|
||||
Add one line to `CuriosityFactory.facts(...)` using the existing helpers
|
||||
(`material`, `entities`, `distance`, `time`, `count`), pick a `Category`, and
|
||||
phrase it so the count never has to agree with a translated noun.
|
||||
|
||||
Statistic constants get renamed between Minecraft releases, so resolve them via
|
||||
`Stats.resolve("NEW_NAME", "OLD_NAME")` — a rename then degrades one curiosity
|
||||
instead of breaking the whole announcement.
|
||||
@@ -5,7 +5,7 @@
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<groupId>dev.marcospaulo</groupId>
|
||||
<artifactId>curiosidades</artifactId>
|
||||
<artifactId>canalhandia</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<finalName>Curiosidades-${project.version}</finalName>
|
||||
<finalName>Canalhandia-${project.version}</finalName>
|
||||
<resources>
|
||||
<resource>
|
||||
<directory>src/main/resources</directory>
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
package dev.marcospaulo.canalhandia;
|
||||
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.event.ClickEvent;
|
||||
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.NamespacedKey;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.PlayerDeathEvent;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.bukkit.scheduler.BukkitTask;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Deque;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Chat-only social features for the Canalhandia server: curiosities, a guess
|
||||
* game, polls, mourning reactions, milestones and rankings.
|
||||
*
|
||||
* <p>Nothing here touches gameplay — no items, no world edits, no attributes.
|
||||
* Every module can be switched off independently.
|
||||
*/
|
||||
public final class Canalhandia extends JavaPlugin implements Listener {
|
||||
|
||||
private final Random random = new Random();
|
||||
private final Map<UUID, Long> lastFeatured = new HashMap<>();
|
||||
private final Deque<String> recentFacts = new ArrayDeque<>();
|
||||
/** Recent reaction sets, newest last, so late clicks still land. */
|
||||
private final Deque<Reactions> reactionHistory = new ArrayDeque<>();
|
||||
|
||||
private Settings settings;
|
||||
private OfflineStats offlineStats;
|
||||
private Milestones milestones;
|
||||
private NamespacedKey optOutKey;
|
||||
private BukkitTask timerTask;
|
||||
private BukkitTask milestoneTask;
|
||||
|
||||
private int nextId = 1;
|
||||
private Reactions liveReactions;
|
||||
private GuessRound guessRound;
|
||||
private Poll poll;
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
saveDefaultConfig();
|
||||
settings = new Settings(this);
|
||||
offlineStats = new OfflineStats(this);
|
||||
milestones = new Milestones(this);
|
||||
optOutKey = new NamespacedKey(this, "opt_out");
|
||||
|
||||
CanalhandiaCommand root = new CanalhandiaCommand(this);
|
||||
register("canalhandia", root);
|
||||
register("curiosidade", root);
|
||||
register("adivinha", root);
|
||||
register("enquete", root);
|
||||
register("ranking", root);
|
||||
|
||||
getServer().getPluginManager().registerEvents(this, this);
|
||||
rescheduleTimer();
|
||||
rescheduleMilestones();
|
||||
|
||||
getLogger().info("Canalhandia ativo — curiosidades em modo " + settings.mode()
|
||||
+ ", módulos: " + enabledModules());
|
||||
}
|
||||
|
||||
private void register(String name, CanalhandiaCommand handler) {
|
||||
if (getCommand(name) != null) {
|
||||
getCommand(name).setExecutor(handler);
|
||||
getCommand(name).setTabCompleter(handler);
|
||||
} else {
|
||||
getLogger().warning("Comando /" + name + " não está no plugin.yml");
|
||||
}
|
||||
}
|
||||
|
||||
private String enabledModules() {
|
||||
List<String> on = new ArrayList<>();
|
||||
for (Module module : Module.values()) {
|
||||
if (settings.moduleEnabled(module)) {
|
||||
on.add(module.key());
|
||||
}
|
||||
}
|
||||
return on.isEmpty() ? "nenhum" : String.join(", ", on);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
if (liveReactions != null) {
|
||||
liveReactions.hide();
|
||||
}
|
||||
if (poll != null) {
|
||||
poll.hide();
|
||||
}
|
||||
}
|
||||
|
||||
Settings settings() {
|
||||
return settings;
|
||||
}
|
||||
|
||||
OfflineStats offlineStats() {
|
||||
return offlineStats;
|
||||
}
|
||||
|
||||
// --- scheduling ---------------------------------------------------------
|
||||
|
||||
/** Starts, stops or restarts the repeating curiosity task to match the mode. */
|
||||
void rescheduleTimer() {
|
||||
if (timerTask != null) {
|
||||
timerTask.cancel();
|
||||
timerTask = null;
|
||||
}
|
||||
if (!settings.moduleEnabled(Module.CURIOSIDADES) || !settings.mode().firesOnTimer()) {
|
||||
return;
|
||||
}
|
||||
long ticks = settings.intervalMinutes() * 60L * 20L;
|
||||
timerTask = getServer().getScheduler()
|
||||
.runTaskTimer(this, () -> announceCuriosity(null), ticks, ticks);
|
||||
}
|
||||
|
||||
void rescheduleMilestones() {
|
||||
if (milestoneTask != null) {
|
||||
milestoneTask.cancel();
|
||||
milestoneTask = null;
|
||||
}
|
||||
if (!settings.moduleEnabled(Module.MARCOS)) {
|
||||
return;
|
||||
}
|
||||
long ticks = 5L * 60L * 20L;
|
||||
milestoneTask = getServer().getScheduler().runTaskTimer(this, milestones::check, ticks, ticks);
|
||||
}
|
||||
|
||||
// --- curiosities --------------------------------------------------------
|
||||
|
||||
/** A fact plus who it is about, so callers do not have to re-resolve the player. */
|
||||
private record Chosen(Player subject, Fact fact) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Announces one curiosity.
|
||||
*
|
||||
* @param subject who to talk about, or null to pick a random eligible player
|
||||
* @return false if nobody was eligible or there was nothing notable to say
|
||||
*/
|
||||
boolean announceCuriosity(Player subject) {
|
||||
if (!settings.moduleEnabled(Module.CURIOSIDADES)) {
|
||||
return false;
|
||||
}
|
||||
Chosen chosen = pickFact(subject == null ? pickSubject() : subject);
|
||||
if (chosen == null) {
|
||||
return false;
|
||||
}
|
||||
Bukkit.broadcast(Msg.tag("Curiosidade", NamedTextColor.GOLD)
|
||||
.append(Component.text("Sabia que o ", NamedTextColor.WHITE)
|
||||
.decoration(TextDecoration.BOLD, false))
|
||||
.append(Component.text(chosen.subject().getName(), NamedTextColor.GREEN)
|
||||
.decoration(TextDecoration.BOLD, false))
|
||||
.append(Component.text(" ", NamedTextColor.WHITE)
|
||||
.decoration(TextDecoration.BOLD, false))
|
||||
.append(chosen.fact().text().decoration(TextDecoration.BOLD, false))
|
||||
.append(Component.text("?", NamedTextColor.WHITE)
|
||||
.decoration(TextDecoration.BOLD, false)));
|
||||
openReactions();
|
||||
return true;
|
||||
}
|
||||
|
||||
private Chosen pickFact(Player subject) {
|
||||
if (subject == null) {
|
||||
return null;
|
||||
}
|
||||
List<Fact> facts = CuriosityFactory.facts(subject, settings);
|
||||
if (facts.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
List<Fact> fresh = new ArrayList<>(facts);
|
||||
fresh.removeIf(fact -> recentFacts.contains(plain(fact.text())));
|
||||
List<Fact> pool = fresh.isEmpty() ? facts : fresh;
|
||||
Fact fact = pool.get(random.nextInt(pool.size()));
|
||||
|
||||
recentFacts.addLast(plain(fact.text()));
|
||||
while (recentFacts.size() > settings.noRepeat()) {
|
||||
recentFacts.removeFirst();
|
||||
}
|
||||
lastFeatured.put(subject.getUniqueId(), System.currentTimeMillis());
|
||||
return new Chosen(subject, fact);
|
||||
}
|
||||
|
||||
private Player pickSubject() {
|
||||
List<Player> candidates = new ArrayList<>();
|
||||
for (Player player : Bukkit.getOnlinePlayers()) {
|
||||
if (isEligible(player)) {
|
||||
candidates.add(player);
|
||||
}
|
||||
}
|
||||
return candidates.isEmpty() ? null : candidates.get(random.nextInt(candidates.size()));
|
||||
}
|
||||
|
||||
private static String plain(Component component) {
|
||||
return PlainTextComponentSerializer.plainText().serialize(component);
|
||||
}
|
||||
|
||||
/** True if the player can currently be the subject of an announcement. */
|
||||
boolean isEligible(Player player) {
|
||||
if (isOptedOut(player) || player.hasPermission("canalhandia.isento")) {
|
||||
return false;
|
||||
}
|
||||
Long last = lastFeatured.get(player.getUniqueId());
|
||||
if (last == null) {
|
||||
return true;
|
||||
}
|
||||
return System.currentTimeMillis() - last >= settings.cooldownMinutes() * 60_000L;
|
||||
}
|
||||
|
||||
// --- reactions ----------------------------------------------------------
|
||||
|
||||
/** Attaches a fresh reaction row to the message just broadcast. */
|
||||
private void openReactions() {
|
||||
if (!settings.reactionsEnabled()) {
|
||||
return;
|
||||
}
|
||||
Reactions reactions = new Reactions(nextId++, settings.reactions());
|
||||
liveReactions = reactions;
|
||||
remember(reactions);
|
||||
|
||||
Bukkit.broadcast(reactions.buttons("/canalhandia reagir"));
|
||||
reactions.show();
|
||||
|
||||
getServer().getScheduler().runTaskLater(this, () -> {
|
||||
reactions.hide();
|
||||
if (liveReactions == reactions) {
|
||||
liveReactions = null;
|
||||
}
|
||||
// Chat cannot be edited, so post the final count as its own line.
|
||||
if (reactions.hasAnyVote()) {
|
||||
Bukkit.broadcast(Component.text(" ").append(reactions.tally()));
|
||||
}
|
||||
}, settings.reactionWindowSeconds() * 20L);
|
||||
}
|
||||
|
||||
private void remember(Reactions reactions) {
|
||||
reactionHistory.addLast(reactions);
|
||||
while (reactionHistory.size() > 8) {
|
||||
reactionHistory.removeFirst();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}.
|
||||
*/
|
||||
Reactions findReactions(int id) {
|
||||
long limit = settings.reactionValidityMinutes() * 60_000L;
|
||||
for (Reactions reactions : reactionHistory) {
|
||||
if (reactions.id() == id) {
|
||||
return reactions.ageMillis() <= limit ? reactions : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- guess game ---------------------------------------------------------
|
||||
|
||||
/** Starts a round of "adivinhe de quem é". Needs at least two players online. */
|
||||
boolean startGuess() {
|
||||
if (!settings.moduleEnabled(Module.ADIVINHA)) {
|
||||
return false;
|
||||
}
|
||||
List<Player> online = new ArrayList<>(Bukkit.getOnlinePlayers());
|
||||
if (online.size() < 2) {
|
||||
return false;
|
||||
}
|
||||
Chosen chosen = pickFact(pickSubject());
|
||||
if (chosen == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
List<String> candidates = new ArrayList<>();
|
||||
for (Player player : online) {
|
||||
candidates.add(player.getName());
|
||||
}
|
||||
Collections.shuffle(candidates);
|
||||
// Trim to a readable row, but never drop the right answer.
|
||||
while (candidates.size() > 6) {
|
||||
int last = candidates.size() - 1;
|
||||
if (candidates.get(last).equals(chosen.subject().getName())) {
|
||||
Collections.swap(candidates, 0, last);
|
||||
}
|
||||
candidates.remove(candidates.size() - 1);
|
||||
}
|
||||
|
||||
GuessRound round = new GuessRound(nextId++, chosen.subject(), chosen.fact().text(), candidates);
|
||||
guessRound = round;
|
||||
Bukkit.broadcast(round.question("/canalhandia palpite"));
|
||||
|
||||
getServer().getScheduler().runTaskLater(this, () -> {
|
||||
if (guessRound == round && !round.finished()) {
|
||||
Bukkit.broadcast(round.reveal());
|
||||
guessRound = null;
|
||||
}
|
||||
}, settings.guessSeconds() * 20L);
|
||||
return true;
|
||||
}
|
||||
|
||||
GuessRound guessRound() {
|
||||
return guessRound;
|
||||
}
|
||||
|
||||
// --- polls --------------------------------------------------------------
|
||||
|
||||
Poll poll() {
|
||||
return poll;
|
||||
}
|
||||
|
||||
/** Opens a poll, closing any poll already running. */
|
||||
void startPoll(String question, List<String> options, String author) {
|
||||
if (poll != null && !poll.closed()) {
|
||||
Bukkit.broadcast(poll.close());
|
||||
}
|
||||
Poll started = new Poll(nextId++, question, options, author);
|
||||
poll = started;
|
||||
Bukkit.broadcast(started.announcement("/canalhandia votar"));
|
||||
started.show();
|
||||
|
||||
getServer().getScheduler().runTaskLater(this, () -> {
|
||||
if (poll == started && !started.closed()) {
|
||||
Bukkit.broadcast(started.close());
|
||||
}
|
||||
}, settings.pollMinutes() * 60L * 20L);
|
||||
}
|
||||
|
||||
// --- events -------------------------------------------------------------
|
||||
|
||||
@EventHandler
|
||||
public void onJoin(PlayerJoinEvent event) {
|
||||
Player player = event.getPlayer();
|
||||
if (liveReactions != null) {
|
||||
liveReactions.showTo(player);
|
||||
}
|
||||
if (poll != null) {
|
||||
poll.showTo(player);
|
||||
}
|
||||
if (!settings.moduleEnabled(Module.CURIOSIDADES)
|
||||
|| !settings.mode().firesOnJoin()
|
||||
|| !isEligible(player)) {
|
||||
return;
|
||||
}
|
||||
// Delayed so the curiosity lands after the join message rather than racing it.
|
||||
getServer().getScheduler().runTaskLater(this, () -> {
|
||||
if (player.isOnline()) {
|
||||
announceCuriosity(player);
|
||||
}
|
||||
}, settings.joinDelaySeconds() * 20L);
|
||||
}
|
||||
|
||||
/** "Press F" — a mourning button under each death message. */
|
||||
@EventHandler
|
||||
public void onDeath(PlayerDeathEvent event) {
|
||||
if (!settings.moduleEnabled(Module.LUTO)) {
|
||||
return;
|
||||
}
|
||||
Reactions mourning = new Reactions(nextId++, Map.of("f", "[F]"));
|
||||
liveReactions = mourning;
|
||||
remember(mourning);
|
||||
String name = event.getEntity().getName();
|
||||
|
||||
// One tick later so it prints under the vanilla death message.
|
||||
getServer().getScheduler().runTaskLater(this, () -> Bukkit.broadcast(
|
||||
Component.text(" ")
|
||||
.append(Component.text("[F] ", NamedTextColor.YELLOW)
|
||||
.clickEvent(ClickEvent.runCommand(
|
||||
"/canalhandia reagir " + mourning.id() + " f")))
|
||||
.append(Component.text("prestar luto por " + name, NamedTextColor.GRAY))), 2L);
|
||||
|
||||
getServer().getScheduler().runTaskLater(this, () -> {
|
||||
if (mourning.hasAnyVote()) {
|
||||
Bukkit.broadcast(Component.text(" ")
|
||||
.append(Component.text(mourning.count("f")
|
||||
+ " pessoa(s) prestaram luto por " + name + ".", NamedTextColor.GRAY)));
|
||||
}
|
||||
if (liveReactions == mourning) {
|
||||
liveReactions = null;
|
||||
}
|
||||
}, settings.reactionWindowSeconds() * 20L);
|
||||
}
|
||||
|
||||
// --- per-player opt out -------------------------------------------------
|
||||
|
||||
boolean isOptedOut(Player player) {
|
||||
Byte value = player.getPersistentDataContainer().get(optOutKey, PersistentDataType.BYTE);
|
||||
return value != null && value == 1;
|
||||
}
|
||||
|
||||
void setOptedOut(Player player, boolean optedOut) {
|
||||
player.getPersistentDataContainer()
|
||||
.set(optOutKey, PersistentDataType.BYTE, (byte) (optedOut ? 1 : 0));
|
||||
}
|
||||
|
||||
void clearCooldowns() {
|
||||
lastFeatured.clear();
|
||||
}
|
||||
|
||||
void clearHistory() {
|
||||
recentFacts.clear();
|
||||
}
|
||||
|
||||
void runMilestoneCheck() {
|
||||
milestones.check();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,696 @@
|
||||
package dev.marcospaulo.canalhandia;
|
||||
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.event.ClickEvent;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandExecutor;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.command.TabCompleter;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Every command the plugin owns.
|
||||
*
|
||||
* <p>{@code /curiosidade}, {@code /adivinha}, {@code /enquete} and
|
||||
* {@code /ranking} are player-facing shortcuts; {@code /canalhandia} is the
|
||||
* admin root and also carries the click callbacks that the chat buttons invoke.
|
||||
*/
|
||||
final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
|
||||
|
||||
private static final String ADMIN = "canalhandia.admin";
|
||||
|
||||
private final Canalhandia plugin;
|
||||
|
||||
CanalhandiaCommand(Canalhandia plugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||
return switch (command.getName().toLowerCase()) {
|
||||
case "curiosidade" -> curiosidade(sender, args);
|
||||
case "adivinha" -> adivinha(sender);
|
||||
case "enquete" -> enquete(sender, args);
|
||||
case "ranking" -> ranking(sender, args);
|
||||
default -> root(sender, args);
|
||||
};
|
||||
}
|
||||
|
||||
// --- /canalhandia -------------------------------------------------------
|
||||
|
||||
private boolean root(CommandSender sender, String[] args) {
|
||||
if (args.length == 0) {
|
||||
help(sender);
|
||||
return true;
|
||||
}
|
||||
String[] rest = Arrays.copyOfRange(args, 1, args.length);
|
||||
switch (args[0].toLowerCase()) {
|
||||
// Click callbacks.
|
||||
case "reagir" -> react(sender, rest);
|
||||
case "palpite" -> guess(sender, rest);
|
||||
case "votar" -> vote(sender, rest);
|
||||
// Admin.
|
||||
case "modulo" -> moduleToggle(sender, rest);
|
||||
case "modulos" -> modules(sender);
|
||||
case "status" -> status(sender);
|
||||
case "marcos" -> {
|
||||
if (admin(sender)) {
|
||||
plugin.runMilestoneCheck();
|
||||
Msg.ok(sender, "Verificação de marcos executada.");
|
||||
}
|
||||
}
|
||||
case "limpar" -> clear(sender, rest);
|
||||
case "reload" -> {
|
||||
if (admin(sender)) {
|
||||
plugin.reloadConfig();
|
||||
plugin.rescheduleTimer();
|
||||
plugin.rescheduleMilestones();
|
||||
Msg.ok(sender, "Configuração recarregada.");
|
||||
}
|
||||
}
|
||||
default -> help(sender);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- /curiosidade -------------------------------------------------------
|
||||
|
||||
private boolean curiosidade(CommandSender sender, String[] args) {
|
||||
if (args.length == 0) {
|
||||
if (!sender.hasPermission("canalhandia.forcar")) {
|
||||
return denied(sender);
|
||||
}
|
||||
if (!plugin.announceCuriosity(null)) {
|
||||
Msg.error(sender, "Ninguém elegível online (ou sem estatísticas suficientes).");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
String[] rest = Arrays.copyOfRange(args, 1, args.length);
|
||||
switch (args[0].toLowerCase()) {
|
||||
case "ver" -> preview(sender, rest);
|
||||
case "listar" -> list(sender, rest);
|
||||
case "toggle" -> toggle(sender);
|
||||
case "status" -> status(sender);
|
||||
case "categorias" -> categories(sender);
|
||||
case "categoria" -> categoryToggle(sender, rest);
|
||||
case "modo" -> mode(sender, rest);
|
||||
case "intervalo" -> setNumber(sender, rest, "intervalo <minutos>",
|
||||
v -> {
|
||||
plugin.settings().intervalMinutes(v);
|
||||
plugin.rescheduleTimer();
|
||||
return "Intervalo: " + plugin.settings().intervalMinutes() + " min.";
|
||||
});
|
||||
case "atraso" -> setNumber(sender, rest, "atraso <segundos>",
|
||||
v -> {
|
||||
plugin.settings().joinDelaySeconds(v);
|
||||
return "Atraso após entrar: " + plugin.settings().joinDelaySeconds() + "s.";
|
||||
});
|
||||
case "cooldown" -> setNumber(sender, rest, "cooldown <minutos>",
|
||||
v -> {
|
||||
plugin.settings().cooldownMinutes(v);
|
||||
return "Cooldown: " + plugin.settings().cooldownMinutes() + " min.";
|
||||
});
|
||||
case "repetir" -> setNumber(sender, rest, "repetir <quantidade>",
|
||||
v -> {
|
||||
plugin.settings().noRepeat(v);
|
||||
return "Evitando repetir as últimas " + plugin.settings().noRepeat() + ".";
|
||||
});
|
||||
case "janela" -> setNumber(sender, rest, "janela <segundos>",
|
||||
v -> {
|
||||
plugin.settings().reactionWindowSeconds(v);
|
||||
return "Janela de reação: " + plugin.settings().reactionWindowSeconds() + "s.";
|
||||
});
|
||||
case "validade" -> setNumber(sender, rest, "validade <minutos>",
|
||||
v -> {
|
||||
plugin.settings().reactionValidityMinutes(v);
|
||||
return "Cliques válidos por "
|
||||
+ plugin.settings().reactionValidityMinutes() + " min.";
|
||||
});
|
||||
case "reacoes" -> reactionsToggle(sender, rest);
|
||||
case "reacao" -> reactionEdit(sender, rest);
|
||||
case "ajuda" -> help(sender);
|
||||
default -> {
|
||||
Player target = Bukkit.getPlayerExact(args[0]);
|
||||
if (target == null) {
|
||||
Msg.error(sender, "Subcomando ou jogador desconhecido. Use /curiosidade ajuda");
|
||||
} else if (!sender.hasPermission("canalhandia.forcar")) {
|
||||
denied(sender);
|
||||
} else if (!plugin.announceCuriosity(target)) {
|
||||
Msg.error(sender, target.getName() + " ainda não tem estatísticas suficientes.");
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void preview(CommandSender sender, String[] args) {
|
||||
Player target = resolveTarget(sender, args);
|
||||
if (target == null) {
|
||||
return;
|
||||
}
|
||||
List<Fact> facts = CuriosityFactory.facts(target, plugin.settings());
|
||||
if (facts.isEmpty()) {
|
||||
Msg.error(sender, "Nenhuma curiosidade disponível para " + target.getName() + ".");
|
||||
return;
|
||||
}
|
||||
Fact fact = facts.get((int) (Math.random() * facts.size()));
|
||||
sender.sendMessage(Msg.tag("Curiosidade", NamedTextColor.GOLD)
|
||||
.append(Component.text(target.getName() + " ", NamedTextColor.GREEN))
|
||||
.append(fact.text()));
|
||||
}
|
||||
|
||||
/** Dumps every available fact for a player, privately. Handy for tuning thresholds. */
|
||||
private void list(CommandSender sender, String[] args) {
|
||||
Player target = resolveTarget(sender, args);
|
||||
if (target == null) {
|
||||
return;
|
||||
}
|
||||
List<Fact> facts = CuriosityFactory.facts(target, plugin.settings());
|
||||
Msg.header(sender, "Curiosidades de " + target.getName() + " (" + facts.size() + ")");
|
||||
for (Fact fact : facts) {
|
||||
sender.sendMessage(Component.text(" [" + fact.category().label() + "] ",
|
||||
NamedTextColor.DARK_AQUA).append(fact.text()));
|
||||
}
|
||||
if (facts.isEmpty()) {
|
||||
sender.sendMessage(Component.text(" (nada ainda)", NamedTextColor.GRAY));
|
||||
}
|
||||
}
|
||||
|
||||
private Player resolveTarget(CommandSender sender, String[] args) {
|
||||
if (!sender.hasPermission("canalhandia.ver")) {
|
||||
denied(sender);
|
||||
return null;
|
||||
}
|
||||
if (args.length > 0) {
|
||||
Player target = Bukkit.getPlayerExact(args[0]);
|
||||
if (target == null) {
|
||||
Msg.error(sender, "Jogador '" + args[0] + "' não está online.");
|
||||
}
|
||||
return target;
|
||||
}
|
||||
if (sender instanceof Player player) {
|
||||
return player;
|
||||
}
|
||||
Msg.error(sender, "Informe um jogador.");
|
||||
return null;
|
||||
}
|
||||
|
||||
private void toggle(CommandSender sender) {
|
||||
if (!(sender instanceof Player player)) {
|
||||
Msg.error(sender, "Só jogadores podem usar isso.");
|
||||
return;
|
||||
}
|
||||
boolean optedOut = !plugin.isOptedOut(player);
|
||||
plugin.setOptedOut(player, optedOut);
|
||||
sender.sendMessage(optedOut
|
||||
? Component.text("Você não aparecerá mais nas curiosidades.", NamedTextColor.YELLOW)
|
||||
: Component.text("Você voltou a aparecer nas curiosidades.", NamedTextColor.GREEN));
|
||||
}
|
||||
|
||||
// --- /adivinha ----------------------------------------------------------
|
||||
|
||||
private boolean adivinha(CommandSender sender) {
|
||||
if (!sender.hasPermission("canalhandia.forcar")) {
|
||||
return denied(sender);
|
||||
}
|
||||
if (!plugin.settings().moduleEnabled(Module.ADIVINHA)) {
|
||||
Msg.error(sender, "O módulo adivinha está desativado.");
|
||||
} else if (!plugin.startGuess()) {
|
||||
Msg.error(sender, "Precisa de pelo menos 2 jogadores online com estatísticas.");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private void guess(CommandSender sender, String[] args) {
|
||||
if (!(sender instanceof Player player) || args.length < 2) {
|
||||
return;
|
||||
}
|
||||
GuessRound round = plugin.guessRound();
|
||||
if (round == null || round.id() != parse(args[0], -1)) {
|
||||
player.sendActionBar(Component.text("Essa rodada já acabou.", NamedTextColor.RED));
|
||||
return;
|
||||
}
|
||||
player.sendActionBar(round.guess(player, args[1]));
|
||||
}
|
||||
|
||||
// --- /enquete -----------------------------------------------------------
|
||||
|
||||
private boolean enquete(CommandSender sender, String[] args) {
|
||||
if (!sender.hasPermission("canalhandia.enquete")) {
|
||||
return denied(sender);
|
||||
}
|
||||
if (!plugin.settings().moduleEnabled(Module.ENQUETE)) {
|
||||
Msg.error(sender, "O módulo enquete está desativado.");
|
||||
return true;
|
||||
}
|
||||
if (args.length == 1 && args[0].equalsIgnoreCase("encerrar")) {
|
||||
Poll poll = plugin.poll();
|
||||
if (poll == null || poll.closed()) {
|
||||
Msg.error(sender, "Nenhuma enquete aberta.");
|
||||
} else {
|
||||
Bukkit.broadcast(poll.close());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Everything is one string split on "|": question | option | option ...
|
||||
String[] parts = String.join(" ", args).split("\\|");
|
||||
if (parts.length < 3) {
|
||||
Msg.error(sender, "Uso: /enquete Pergunta | Opção 1 | Opção 2 [| Opção 3 ...]");
|
||||
return true;
|
||||
}
|
||||
List<String> options = new ArrayList<>();
|
||||
for (int i = 1; i < parts.length && options.size() < 5; i++) {
|
||||
String option = parts[i].trim();
|
||||
if (!option.isEmpty()) {
|
||||
options.add(option);
|
||||
}
|
||||
}
|
||||
if (options.size() < 2) {
|
||||
Msg.error(sender, "Informe pelo menos duas opções.");
|
||||
return true;
|
||||
}
|
||||
plugin.startPoll(parts[0].trim(), options, sender.getName());
|
||||
return true;
|
||||
}
|
||||
|
||||
private void vote(CommandSender sender, String[] args) {
|
||||
if (!(sender instanceof Player player) || args.length < 2) {
|
||||
return;
|
||||
}
|
||||
Poll poll = plugin.poll();
|
||||
if (poll == null || poll.id() != parse(args[0], -1)) {
|
||||
player.sendActionBar(Component.text("Essa enquete já foi encerrada.", NamedTextColor.RED));
|
||||
return;
|
||||
}
|
||||
Component result = poll.vote(player, parse(args[1], -1));
|
||||
if (result != null) {
|
||||
player.sendActionBar(result);
|
||||
}
|
||||
}
|
||||
|
||||
// --- /ranking -----------------------------------------------------------
|
||||
|
||||
private boolean ranking(CommandSender sender, String[] args) {
|
||||
if (!sender.hasPermission("canalhandia.ver")) {
|
||||
return denied(sender);
|
||||
}
|
||||
if (!plugin.settings().moduleEnabled(Module.RANKING)) {
|
||||
Msg.error(sender, "O módulo ranking está desativado.");
|
||||
return true;
|
||||
}
|
||||
if (args.length == 0) {
|
||||
Msg.header(sender, "Rankings disponíveis");
|
||||
for (RankingMetric metric : RankingMetric.values()) {
|
||||
sender.sendMessage(Component.text(" " + metric.commandKey(), NamedTextColor.AQUA)
|
||||
.clickEvent(ClickEvent.runCommand("/ranking " + metric.commandKey()))
|
||||
.append(Component.text(" — " + metric.label(), NamedTextColor.GRAY)));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
RankingMetric metric = RankingMetric.byKey(args[0]);
|
||||
if (metric == null) {
|
||||
Msg.error(sender, "Ranking desconhecido. Use /ranking para ver a lista.");
|
||||
return true;
|
||||
}
|
||||
List<OfflineStats.Row> rows =
|
||||
plugin.offlineStats().ranking(metric, plugin.settings().rankingSize());
|
||||
Msg.header(sender, "Ranking: " + metric.label());
|
||||
if (rows.isEmpty()) {
|
||||
sender.sendMessage(Component.text(" (sem dados ainda)", NamedTextColor.GRAY));
|
||||
return true;
|
||||
}
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
OfflineStats.Row row = rows.get(i);
|
||||
NamedTextColor color = switch (i) {
|
||||
case 0 -> NamedTextColor.GOLD;
|
||||
case 1 -> NamedTextColor.GRAY;
|
||||
case 2 -> NamedTextColor.DARK_RED;
|
||||
default -> NamedTextColor.WHITE;
|
||||
};
|
||||
sender.sendMessage(Component.text(" " + (i + 1) + ". ", color)
|
||||
.append(Component.text(row.name() + " ", NamedTextColor.GREEN))
|
||||
.append(Component.text(metric.format(row.value()), NamedTextColor.AQUA)));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- reactions ----------------------------------------------------------
|
||||
|
||||
private void react(CommandSender sender, String[] args) {
|
||||
if (!(sender instanceof Player player) || args.length < 2) {
|
||||
return;
|
||||
}
|
||||
if (!player.hasPermission("canalhandia.reagir")) {
|
||||
denied(sender);
|
||||
return;
|
||||
}
|
||||
Reactions reactions = plugin.findReactions(parse(args[0], -1));
|
||||
if (reactions == null) {
|
||||
player.sendActionBar(Component.text("Essa mensagem já expirou.", NamedTextColor.RED));
|
||||
return;
|
||||
}
|
||||
if (!reactions.react(player, args[1])) {
|
||||
player.sendActionBar(Component.text("Reação desconhecida.", NamedTextColor.RED));
|
||||
}
|
||||
}
|
||||
|
||||
// --- admin --------------------------------------------------------------
|
||||
|
||||
private void moduleToggle(CommandSender sender, String[] args) {
|
||||
if (!admin(sender)) {
|
||||
return;
|
||||
}
|
||||
if (args.length < 2) {
|
||||
Msg.error(sender, "Uso: /canalhandia modulo <nome> <on|off>");
|
||||
return;
|
||||
}
|
||||
Module module = Module.byKey(args[0]);
|
||||
if (module == null) {
|
||||
Msg.error(sender, "Módulo desconhecido. Veja /canalhandia modulos");
|
||||
return;
|
||||
}
|
||||
boolean on = args[1].equalsIgnoreCase("on");
|
||||
plugin.settings().moduleEnabled(module, on);
|
||||
plugin.rescheduleTimer();
|
||||
plugin.rescheduleMilestones();
|
||||
Msg.ok(sender, "Módulo " + module.label() + " " + (on ? "ativado" : "desativado") + ".");
|
||||
}
|
||||
|
||||
private void modules(CommandSender sender) {
|
||||
Msg.header(sender, "Módulos");
|
||||
for (Module module : Module.values()) {
|
||||
boolean on = plugin.settings().moduleEnabled(module);
|
||||
sender.sendMessage(Component.text(" " + module.key() + " ", NamedTextColor.WHITE)
|
||||
.append(Component.text(on ? "ativado" : "desativado",
|
||||
on ? NamedTextColor.GREEN : NamedTextColor.RED))
|
||||
.append(Component.text(" [alternar]", NamedTextColor.DARK_AQUA)
|
||||
.clickEvent(ClickEvent.runCommand("/canalhandia modulo "
|
||||
+ module.key() + (on ? " off" : " on"))))
|
||||
.append(Component.text(" " + module.label(), NamedTextColor.DARK_GRAY)));
|
||||
}
|
||||
}
|
||||
|
||||
private void mode(CommandSender sender, String[] args) {
|
||||
if (!admin(sender)) {
|
||||
return;
|
||||
}
|
||||
if (args.length == 0) {
|
||||
Msg.error(sender, "Uso: /curiosidade modo <entrada|intervalo|ambos|manual>");
|
||||
return;
|
||||
}
|
||||
Mode mode = Mode.byKey(args[0]);
|
||||
if (mode == null) {
|
||||
Msg.error(sender, "Modo inválido. Use entrada, intervalo, ambos ou manual.");
|
||||
return;
|
||||
}
|
||||
plugin.settings().mode(mode);
|
||||
plugin.rescheduleTimer();
|
||||
Msg.ok(sender, "Modo alterado para " + mode + ".");
|
||||
}
|
||||
|
||||
private void categoryToggle(CommandSender sender, String[] args) {
|
||||
if (!admin(sender)) {
|
||||
return;
|
||||
}
|
||||
if (args.length < 2) {
|
||||
Msg.error(sender, "Uso: /curiosidade categoria <nome> <on|off>");
|
||||
return;
|
||||
}
|
||||
Category category = Category.byKey(args[0]);
|
||||
if (category == null) {
|
||||
Msg.error(sender, "Categoria desconhecida. Veja /curiosidade categorias");
|
||||
return;
|
||||
}
|
||||
boolean on = args[1].equalsIgnoreCase("on");
|
||||
plugin.settings().categoryEnabled(category, on);
|
||||
Msg.ok(sender, "Categoria " + category.label() + " " + (on ? "ativada" : "desativada") + ".");
|
||||
}
|
||||
|
||||
private void categories(CommandSender sender) {
|
||||
Msg.header(sender, "Categorias");
|
||||
for (Category category : Category.values()) {
|
||||
boolean on = plugin.settings().categoryEnabled(category);
|
||||
sender.sendMessage(Component.text(" " + category.key() + " ", NamedTextColor.WHITE)
|
||||
.append(Component.text(on ? "ativada" : "desativada",
|
||||
on ? NamedTextColor.GREEN : NamedTextColor.RED))
|
||||
.append(Component.text(" [alternar]", NamedTextColor.DARK_AQUA)
|
||||
.clickEvent(ClickEvent.runCommand("/curiosidade categoria "
|
||||
+ category.key() + (on ? " off" : " on")))));
|
||||
}
|
||||
}
|
||||
|
||||
private void reactionsToggle(CommandSender sender, String[] args) {
|
||||
if (!admin(sender)) {
|
||||
return;
|
||||
}
|
||||
if (args.length == 0) {
|
||||
Msg.error(sender, "Uso: /curiosidade reacoes <on|off>");
|
||||
return;
|
||||
}
|
||||
boolean on = args[0].equalsIgnoreCase("on");
|
||||
plugin.settings().reactionsEnabled(on);
|
||||
Msg.ok(sender, "Reações " + (on ? "ativadas" : "desativadas") + ".");
|
||||
}
|
||||
|
||||
private void reactionEdit(CommandSender sender, String[] args) {
|
||||
if (!admin(sender)) {
|
||||
return;
|
||||
}
|
||||
if (args.length >= 3 && args[0].equalsIgnoreCase("add")) {
|
||||
String label = String.join(" ", Arrays.copyOfRange(args, 2, args.length));
|
||||
plugin.settings().reaction(args[1].toLowerCase(), label);
|
||||
Msg.ok(sender, "Reação '" + args[1] + "' definida como " + label + ".");
|
||||
} else if (args.length >= 2 && args[0].equalsIgnoreCase("remover")) {
|
||||
plugin.settings().removeReaction(args[1].toLowerCase());
|
||||
Msg.ok(sender, "Reação '" + args[1] + "' removida.");
|
||||
} else {
|
||||
Msg.error(sender, "Uso: /curiosidade reacao add <chave> <rótulo> | remover <chave>");
|
||||
}
|
||||
}
|
||||
|
||||
private void clear(CommandSender sender, String[] args) {
|
||||
if (!admin(sender)) {
|
||||
return;
|
||||
}
|
||||
String what = args.length > 0 ? args[0].toLowerCase() : "tudo";
|
||||
if (what.equals("cooldown") || what.equals("tudo")) {
|
||||
plugin.clearCooldowns();
|
||||
}
|
||||
if (what.equals("historico") || what.equals("tudo")) {
|
||||
plugin.clearHistory();
|
||||
}
|
||||
Msg.ok(sender, "Limpo: " + what + ".");
|
||||
}
|
||||
|
||||
private void status(CommandSender sender) {
|
||||
Settings settings = plugin.settings();
|
||||
Msg.header(sender, "Canalhandia — status");
|
||||
Msg.line(sender, "módulos ativos", enabledModules());
|
||||
Msg.line(sender, "modo", settings.mode().name().toLowerCase());
|
||||
Msg.line(sender, "intervalo", settings.intervalMinutes() + " min"
|
||||
+ (settings.mode().firesOnTimer() ? "" : " (inativo neste modo)"));
|
||||
Msg.line(sender, "atraso após entrar", settings.joinDelaySeconds() + "s"
|
||||
+ (settings.mode().firesOnJoin() ? "" : " (inativo neste modo)"));
|
||||
Msg.line(sender, "cooldown por jogador", settings.cooldownMinutes() + " min");
|
||||
Msg.line(sender, "evitar repetir", settings.noRepeat() + " últimas");
|
||||
Msg.line(sender, "reações", settings.reactionsEnabled() ? "ativas" : "desativadas");
|
||||
Msg.line(sender, "barra de reação", settings.reactionWindowSeconds() + "s");
|
||||
Msg.line(sender, "cliques válidos por", settings.reactionValidityMinutes() + " min");
|
||||
Msg.line(sender, "reações definidas", String.join(" ", settings.reactions().values()));
|
||||
Msg.line(sender, "adivinha", settings.guessSeconds() + "s por rodada");
|
||||
Msg.line(sender, "enquete", settings.pollMinutes() + " min por enquete");
|
||||
Msg.line(sender, "ranking", "top " + settings.rankingSize());
|
||||
List<String> enabled = new ArrayList<>();
|
||||
for (Category category : Category.values()) {
|
||||
if (settings.categoryEnabled(category)) {
|
||||
enabled.add(category.key());
|
||||
}
|
||||
}
|
||||
Msg.line(sender, "categorias ativas", enabled.isEmpty() ? "nenhuma" : String.join(", ", enabled));
|
||||
}
|
||||
|
||||
private String enabledModules() {
|
||||
List<String> on = new ArrayList<>();
|
||||
for (Module module : Module.values()) {
|
||||
if (plugin.settings().moduleEnabled(module)) {
|
||||
on.add(module.key());
|
||||
}
|
||||
}
|
||||
return on.isEmpty() ? "nenhum" : String.join(", ", on);
|
||||
}
|
||||
|
||||
private void help(CommandSender sender) {
|
||||
Msg.header(sender, "Canalhandia — comandos");
|
||||
Map<String, String> commands = new LinkedHashMap<>();
|
||||
commands.put("/curiosidade", "anuncia uma curiosidade agora");
|
||||
commands.put("/curiosidade <jogador>", "anuncia sobre alguém específico");
|
||||
commands.put("/curiosidade ver [jogador]", "mostra só para você");
|
||||
commands.put("/curiosidade listar [jogador]", "lista todas as curiosidades disponíveis");
|
||||
commands.put("/curiosidade toggle", "entra/sai do sorteio");
|
||||
commands.put("/adivinha", "inicia uma rodada de 'adivinhe de quem é'");
|
||||
commands.put("/enquete P | A | B", "abre uma enquete com opções clicáveis");
|
||||
commands.put("/enquete encerrar", "encerra a enquete aberta");
|
||||
commands.put("/ranking [categoria]", "mostra os placares do servidor");
|
||||
commands.put("/canalhandia status", "mostra toda a configuração");
|
||||
commands.put("/canalhandia modulos", "lista os módulos e seu estado");
|
||||
if (sender.hasPermission(ADMIN)) {
|
||||
commands.put("/canalhandia modulo <nome> <on|off>", "liga/desliga um módulo");
|
||||
commands.put("/canalhandia marcos", "força uma verificação de marcos");
|
||||
commands.put("/canalhandia limpar [cooldown|historico|tudo]", "zera estado temporário");
|
||||
commands.put("/canalhandia reload", "recarrega o config.yml");
|
||||
commands.put("/curiosidade modo <m>", "entrada | intervalo | ambos | manual");
|
||||
commands.put("/curiosidade intervalo <min>", "intervalo do modo temporizado");
|
||||
commands.put("/curiosidade atraso <seg>", "espera após o jogador entrar");
|
||||
commands.put("/curiosidade cooldown <min>", "mínimo entre citar o mesmo jogador");
|
||||
commands.put("/curiosidade repetir <n>", "quantas recentes evitar repetir");
|
||||
commands.put("/curiosidade janela <seg>", "duração da barra de reações");
|
||||
commands.put("/curiosidade validade <min>", "por quanto tempo cliques ainda contam");
|
||||
commands.put("/curiosidade reacoes <on|off>", "liga/desliga reações");
|
||||
commands.put("/curiosidade reacao add <chave> <rótulo>", "cria ou muda uma reação");
|
||||
commands.put("/curiosidade reacao remover <chave>", "remove uma reação");
|
||||
commands.put("/curiosidade categoria <nome> <on|off>", "liga/desliga uma categoria");
|
||||
}
|
||||
commands.forEach((cmd, description) -> sender.sendMessage(
|
||||
Component.text(" " + cmd, NamedTextColor.AQUA)
|
||||
.append(Component.text(" — " + description, NamedTextColor.GRAY))));
|
||||
}
|
||||
|
||||
// --- helpers ------------------------------------------------------------
|
||||
|
||||
/** Applies a validated positive-integer setting and reports the result. */
|
||||
private void setNumber(CommandSender sender, String[] args, String usage,
|
||||
java.util.function.IntFunction<String> apply) {
|
||||
if (!admin(sender)) {
|
||||
return;
|
||||
}
|
||||
if (args.length == 0) {
|
||||
Msg.error(sender, "Uso: /curiosidade " + usage);
|
||||
return;
|
||||
}
|
||||
int value = parse(args[0], Integer.MIN_VALUE);
|
||||
if (value == Integer.MIN_VALUE) {
|
||||
Msg.error(sender, "'" + args[0] + "' não é um número.");
|
||||
return;
|
||||
}
|
||||
Msg.ok(sender, apply.apply(value));
|
||||
}
|
||||
|
||||
private static int parse(String text, int fallback) {
|
||||
try {
|
||||
return Integer.parseInt(text);
|
||||
} catch (NumberFormatException e) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean admin(CommandSender sender) {
|
||||
if (sender.hasPermission(ADMIN)) {
|
||||
return true;
|
||||
}
|
||||
denied(sender);
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean denied(CommandSender sender) {
|
||||
Msg.error(sender, "Você não tem permissão para isso.");
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- tab completion -----------------------------------------------------
|
||||
|
||||
@Override
|
||||
public List<String> onTabComplete(CommandSender sender, Command command, String label, String[] args) {
|
||||
String name = command.getName().toLowerCase();
|
||||
if (name.equals("ranking") && args.length == 1) {
|
||||
List<String> keys = new ArrayList<>();
|
||||
for (RankingMetric metric : RankingMetric.values()) {
|
||||
keys.add(metric.commandKey());
|
||||
}
|
||||
return filter(keys, args[0]);
|
||||
}
|
||||
if (name.equals("enquete") && args.length == 1) {
|
||||
return filter(List.of("encerrar"), args[0]);
|
||||
}
|
||||
if (name.equals("canalhandia")) {
|
||||
if (args.length == 1) {
|
||||
List<String> options = new ArrayList<>(List.of("status", "modulos"));
|
||||
if (sender.hasPermission(ADMIN)) {
|
||||
options.addAll(List.of("modulo", "marcos", "limpar", "reload"));
|
||||
}
|
||||
return filter(options, args[0]);
|
||||
}
|
||||
if (args.length == 2 && args[0].equalsIgnoreCase("modulo")) {
|
||||
List<String> keys = new ArrayList<>();
|
||||
for (Module module : Module.values()) {
|
||||
keys.add(module.key());
|
||||
}
|
||||
return filter(keys, args[1]);
|
||||
}
|
||||
if (args.length == 2 && args[0].equalsIgnoreCase("limpar")) {
|
||||
return filter(List.of("cooldown", "historico", "tudo"), args[1]);
|
||||
}
|
||||
if (args.length == 3 && args[0].equalsIgnoreCase("modulo")) {
|
||||
return filter(List.of("on", "off"), args[2]);
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
if (name.equals("curiosidade")) {
|
||||
if (args.length == 1) {
|
||||
List<String> options = new ArrayList<>(List.of("ver", "listar", "toggle",
|
||||
"status", "categorias", "ajuda"));
|
||||
if (sender.hasPermission(ADMIN)) {
|
||||
options.addAll(List.of("modo", "intervalo", "atraso", "cooldown", "repetir",
|
||||
"janela", "validade", "reacoes", "reacao", "categoria"));
|
||||
}
|
||||
options.addAll(onlineNames());
|
||||
return filter(options, args[0]);
|
||||
}
|
||||
if (args.length == 2) {
|
||||
return switch (args[0].toLowerCase()) {
|
||||
case "modo" -> filter(List.of("entrada", "intervalo", "ambos", "manual"), args[1]);
|
||||
case "reacoes" -> filter(List.of("on", "off"), args[1]);
|
||||
case "reacao" -> filter(List.of("add", "remover"), args[1]);
|
||||
case "categoria" -> {
|
||||
List<String> keys = new ArrayList<>();
|
||||
for (Category category : Category.values()) {
|
||||
keys.add(category.key());
|
||||
}
|
||||
yield filter(keys, args[1]);
|
||||
}
|
||||
case "ver", "listar" -> filter(onlineNames(), args[1]);
|
||||
default -> List.of();
|
||||
};
|
||||
}
|
||||
if (args.length == 3 && args[0].equalsIgnoreCase("categoria")) {
|
||||
return filter(List.of("on", "off"), args[2]);
|
||||
}
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private List<String> onlineNames() {
|
||||
List<String> names = new ArrayList<>();
|
||||
Bukkit.getOnlinePlayers().forEach(p -> names.add(p.getName()));
|
||||
return names;
|
||||
}
|
||||
|
||||
private static List<String> filter(List<String> options, String prefix) {
|
||||
List<String> matches = new ArrayList<>();
|
||||
for (String option : options) {
|
||||
if (option.toLowerCase().startsWith(prefix.toLowerCase())) {
|
||||
matches.add(option);
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package dev.marcospaulo.curiosidades;
|
||||
package dev.marcospaulo.canalhandia;
|
||||
|
||||
/** Groups of curiosities that can be switched on and off independently. */
|
||||
enum Category {
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package dev.marcospaulo.curiosidades;
|
||||
package dev.marcospaulo.canalhandia;
|
||||
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package dev.marcospaulo.curiosidades;
|
||||
package dev.marcospaulo.canalhandia;
|
||||
|
||||
import net.kyori.adventure.text.Component;
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package dev.marcospaulo.canalhandia;
|
||||
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.event.ClickEvent;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import net.kyori.adventure.text.format.TextDecoration;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* One round of "adivinhe de quem é": a curiosity with the subject's name
|
||||
* removed, and the online players offered as clickable answers.
|
||||
*
|
||||
* <p>Curiosity sentences never contain the subject's name — the name lives in
|
||||
* the wrapper, not the fact — so the same generator feeds both features
|
||||
* without any redaction step.
|
||||
*/
|
||||
final class GuessRound {
|
||||
|
||||
private final int id;
|
||||
private final UUID subject;
|
||||
private final String subjectName;
|
||||
private final Component fact;
|
||||
private final List<String> candidates;
|
||||
private final Map<UUID, String> guesses = new LinkedHashMap<>();
|
||||
private boolean finished;
|
||||
|
||||
GuessRound(int id, Player subject, Component fact, List<String> candidates) {
|
||||
this.id = id;
|
||||
this.subject = subject.getUniqueId();
|
||||
this.subjectName = subject.getName();
|
||||
this.fact = fact;
|
||||
this.candidates = candidates;
|
||||
}
|
||||
|
||||
int id() {
|
||||
return id;
|
||||
}
|
||||
|
||||
boolean finished() {
|
||||
return finished;
|
||||
}
|
||||
|
||||
String subjectName() {
|
||||
return subjectName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a guess. The subject is barred from guessing about themselves,
|
||||
* and nobody gets a second attempt.
|
||||
*
|
||||
* @return a message explaining the outcome for the guesser
|
||||
*/
|
||||
Component guess(Player player, String name) {
|
||||
if (finished) {
|
||||
return Component.text("Essa rodada já acabou.", NamedTextColor.RED);
|
||||
}
|
||||
if (player.getUniqueId().equals(subject)) {
|
||||
return Component.text("Você não pode adivinhar sobre você mesmo!", NamedTextColor.RED);
|
||||
}
|
||||
if (guesses.containsKey(player.getUniqueId())) {
|
||||
return Component.text("Você já palpitou nessa rodada.", NamedTextColor.RED);
|
||||
}
|
||||
guesses.put(player.getUniqueId(), name);
|
||||
return Component.text("Palpite registrado: " + name, NamedTextColor.GREEN);
|
||||
}
|
||||
|
||||
/** The question, with one clickable button per candidate. */
|
||||
Component question(String commandBase) {
|
||||
Component message = Msg.tag("Adivinha", NamedTextColor.LIGHT_PURPLE)
|
||||
.append(Component.text("Alguém aqui ", NamedTextColor.WHITE)
|
||||
.decoration(TextDecoration.BOLD, false))
|
||||
.append(fact.decoration(TextDecoration.BOLD, false))
|
||||
.append(Component.text(". Quem será?", NamedTextColor.WHITE)
|
||||
.decoration(TextDecoration.BOLD, false));
|
||||
|
||||
Component row = Component.text(" ");
|
||||
for (String candidate : candidates) {
|
||||
row = row.append(Component.text("[" + candidate + "] ", NamedTextColor.AQUA)
|
||||
.clickEvent(ClickEvent.runCommand(commandBase + " " + id + " " + candidate)));
|
||||
}
|
||||
return message.append(Component.newline()).append(row);
|
||||
}
|
||||
|
||||
/** Marks the round over and builds the reveal message. */
|
||||
Component reveal() {
|
||||
finished = true;
|
||||
List<String> correct = new ArrayList<>();
|
||||
for (Map.Entry<UUID, String> entry : guesses.entrySet()) {
|
||||
if (entry.getValue().equalsIgnoreCase(subjectName)) {
|
||||
Player guesser = org.bukkit.Bukkit.getPlayer(entry.getKey());
|
||||
correct.add(guesser != null ? guesser.getName() : entry.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
Component message = Msg.tag("Adivinha", NamedTextColor.LIGHT_PURPLE)
|
||||
.append(Component.text("Era o ", NamedTextColor.WHITE)
|
||||
.decoration(TextDecoration.BOLD, false))
|
||||
.append(Component.text(subjectName, NamedTextColor.GREEN)
|
||||
.decoration(TextDecoration.BOLD, false))
|
||||
.append(Component.text("!", NamedTextColor.WHITE).decoration(TextDecoration.BOLD, false));
|
||||
|
||||
if (guesses.isEmpty()) {
|
||||
return message.append(Component.text(" Ninguém palpitou.", NamedTextColor.GRAY));
|
||||
}
|
||||
if (correct.isEmpty()) {
|
||||
return message.append(Component.text(" Ninguém acertou!", NamedTextColor.GRAY));
|
||||
}
|
||||
return message.append(Component.text(" Acertaram: ", NamedTextColor.GRAY))
|
||||
.append(Component.text(String.join(", ", correct), NamedTextColor.YELLOW));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
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.Bukkit;
|
||||
import org.bukkit.Statistic;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Announces round-number milestones — 100 km walked, 24 hours played — the
|
||||
* first time a player crosses one.
|
||||
*
|
||||
* <p>The highest already-announced threshold is stored per player per metric in
|
||||
* {@code marcos.yml}, so a restart never replays old milestones and a player who
|
||||
* joins already past a threshold does not trigger a burst of announcements: the
|
||||
* first check records where they are without announcing anything.
|
||||
*/
|
||||
final class Milestones {
|
||||
|
||||
/** A metric and the values worth celebrating. */
|
||||
private record Track(String key, String statistic, String verb, Unit unit, long[] thresholds) {
|
||||
}
|
||||
|
||||
private enum Unit { COUNT, HOURS, KILOMETRES }
|
||||
|
||||
private static final List<Track> TRACKS = List.of(
|
||||
new Track("distancia", "WALK_ONE_CM", "caminhados", Unit.KILOMETRES,
|
||||
new long[]{50, 100, 250, 500, 1000, 2500}),
|
||||
new Track("tempo", "PLAY_TIME", "jogadas", Unit.HOURS,
|
||||
new long[]{10, 24, 50, 100, 250, 500, 1000}),
|
||||
new Track("mortes", "DEATHS", "mortes", Unit.COUNT,
|
||||
new long[]{10, 25, 50, 100, 250, 500}),
|
||||
new Track("combate", "MOB_KILLS", "monstros derrotados", Unit.COUNT,
|
||||
new long[]{100, 500, 1000, 5000, 10000}),
|
||||
new Track("pulos", "JUMP", "pulos", Unit.COUNT,
|
||||
new long[]{1000, 5000, 10000, 50000}),
|
||||
new Track("pesca", "FISH_CAUGHT", "peixes pescados", Unit.COUNT,
|
||||
new long[]{10, 50, 100, 500}));
|
||||
|
||||
private final Canalhandia plugin;
|
||||
private final File file;
|
||||
private final YamlConfiguration data;
|
||||
|
||||
Milestones(Canalhandia plugin) {
|
||||
this.plugin = plugin;
|
||||
this.file = new File(plugin.getDataFolder(), "marcos.yml");
|
||||
this.data = YamlConfiguration.loadConfiguration(file);
|
||||
}
|
||||
|
||||
/** Checks every online player and announces any newly crossed threshold. */
|
||||
void check() {
|
||||
for (Player player : Bukkit.getOnlinePlayers()) {
|
||||
for (Track track : TRACKS) {
|
||||
check(player, track);
|
||||
}
|
||||
}
|
||||
save();
|
||||
}
|
||||
|
||||
private void check(Player player, Track track) {
|
||||
Statistic statistic = Stats.resolve(track.statistic());
|
||||
long raw = Stats.untyped(player, statistic);
|
||||
if (raw <= 0) {
|
||||
return;
|
||||
}
|
||||
long value = switch (track.unit()) {
|
||||
case COUNT -> raw;
|
||||
case HOURS -> raw / 20L / 3600L;
|
||||
case KILOMETRES -> raw / 100_000L;
|
||||
};
|
||||
|
||||
long reached = 0;
|
||||
for (long threshold : track.thresholds()) {
|
||||
if (value >= threshold) {
|
||||
reached = threshold;
|
||||
}
|
||||
}
|
||||
if (reached == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
String path = player.getUniqueId() + "." + track.key();
|
||||
long announced = data.getLong(path, -1);
|
||||
data.set(path, reached);
|
||||
|
||||
// -1 means we have never seen this player on this metric. Record where
|
||||
// they already are instead of announcing history they earned earlier.
|
||||
if (announced < 0 || reached <= announced) {
|
||||
return;
|
||||
}
|
||||
|
||||
String amount = switch (track.unit()) {
|
||||
case COUNT -> Msg.number(reached);
|
||||
case HOURS -> Msg.number(reached) + " horas";
|
||||
case KILOMETRES -> Msg.number(reached) + " km";
|
||||
};
|
||||
Bukkit.broadcast(Msg.tag("Marco", NamedTextColor.GOLD)
|
||||
.append(Component.text(player.getName(), NamedTextColor.GREEN)
|
||||
.decoration(TextDecoration.BOLD, false))
|
||||
.append(Component.text(" acabou de passar de ", NamedTextColor.WHITE)
|
||||
.decoration(TextDecoration.BOLD, false))
|
||||
.append(Component.text(amount + " " + track.verb(), NamedTextColor.AQUA)
|
||||
.decoration(TextDecoration.BOLD, false))
|
||||
.append(Component.text("!", NamedTextColor.WHITE)
|
||||
.decoration(TextDecoration.BOLD, false)));
|
||||
}
|
||||
|
||||
private void save() {
|
||||
try {
|
||||
data.save(file);
|
||||
} catch (IOException e) {
|
||||
plugin.getLogger().warning("Não consegui salvar marcos.yml: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package dev.marcospaulo.curiosidades;
|
||||
package dev.marcospaulo.canalhandia;
|
||||
|
||||
/** When curiosities fire on their own. */
|
||||
enum Mode {
|
||||
@@ -0,0 +1,37 @@
|
||||
package dev.marcospaulo.canalhandia;
|
||||
|
||||
/** A feature that can be switched on or off on its own. */
|
||||
enum Module {
|
||||
|
||||
CURIOSIDADES("curiosidades", "Curiosidades automáticas"),
|
||||
ADIVINHA("adivinha", "Adivinhe de quem é"),
|
||||
LUTO("luto", "Botão [F] nas mortes"),
|
||||
ENQUETE("enquete", "Enquetes"),
|
||||
RANKING("ranking", "Rankings"),
|
||||
MARCOS("marcos", "Marcos e conquistas");
|
||||
|
||||
private final String key;
|
||||
private final String label;
|
||||
|
||||
Module(String key, String label) {
|
||||
this.key = key;
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
String key() {
|
||||
return key;
|
||||
}
|
||||
|
||||
String label() {
|
||||
return label;
|
||||
}
|
||||
|
||||
static Module byKey(String key) {
|
||||
for (Module module : values()) {
|
||||
if (module.key.equalsIgnoreCase(key)) {
|
||||
return module;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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.command.CommandSender;
|
||||
|
||||
import java.text.NumberFormat;
|
||||
import java.util.Locale;
|
||||
|
||||
/** Shared chat formatting so every module looks like the same plugin. */
|
||||
final class Msg {
|
||||
|
||||
static final Locale PT_BR = Locale.of("pt", "BR");
|
||||
private static final NumberFormat NUMBERS = NumberFormat.getInstance(PT_BR);
|
||||
|
||||
private Msg() {
|
||||
}
|
||||
|
||||
/** Thousands-separated in pt-BR ("5.966"). */
|
||||
static String number(long value) {
|
||||
return NUMBERS.format(value);
|
||||
}
|
||||
|
||||
static Component tag(String label, NamedTextColor color) {
|
||||
return Component.text("[" + label + "] ", color, TextDecoration.BOLD);
|
||||
}
|
||||
|
||||
static void ok(CommandSender sender, String text) {
|
||||
sender.sendMessage(tag("Canalhandia", NamedTextColor.GOLD)
|
||||
.append(Component.text(text, NamedTextColor.GREEN).decoration(TextDecoration.BOLD, false)));
|
||||
}
|
||||
|
||||
static void error(CommandSender sender, String text) {
|
||||
sender.sendMessage(tag("Canalhandia", NamedTextColor.GOLD)
|
||||
.append(Component.text(text, NamedTextColor.RED).decoration(TextDecoration.BOLD, false)));
|
||||
}
|
||||
|
||||
static void header(CommandSender sender, String text) {
|
||||
sender.sendMessage(Component.text("— " + text + " —", NamedTextColor.GOLD, TextDecoration.BOLD));
|
||||
}
|
||||
|
||||
static void line(CommandSender sender, String key, String value) {
|
||||
sender.sendMessage(Component.text(" " + key + ": ", NamedTextColor.GRAY)
|
||||
.append(Component.text(value, NamedTextColor.AQUA)));
|
||||
}
|
||||
|
||||
/** Renders a duration in ticks as "3 dias e 4 horas" / "5 horas" / "12 minutos". */
|
||||
static String duration(long ticks) {
|
||||
long minutes = ticks / 20L / 60L;
|
||||
if (minutes < 60) {
|
||||
return minutes + " minutos";
|
||||
}
|
||||
long hours = minutes / 60;
|
||||
if (hours < 24) {
|
||||
return hours + " horas";
|
||||
}
|
||||
return hours / 24 + " dias e " + hours % 24 + " horas";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package dev.marcospaulo.canalhandia;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileReader;
|
||||
import java.io.Reader;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Reads statistics straight off disk so rankings can include players who are
|
||||
* not online.
|
||||
*
|
||||
* <p>Bukkit only exposes {@code getStatistic} for online players, which is fine
|
||||
* for a curiosity about whoever is playing but useless for a leaderboard. The
|
||||
* server already writes everything to {@code <world>/players/stats/<uuid>.json},
|
||||
* and {@code usercache.json} maps those UUIDs back to names — including
|
||||
* Floodgate/Bedrock players, whose UUIDs start with {@code 00000000-0000-0000-0009}.
|
||||
*/
|
||||
final class OfflineStats {
|
||||
|
||||
/** One player's entry in a ranking. */
|
||||
record Row(String name, long value) {
|
||||
}
|
||||
|
||||
private final Plugin plugin;
|
||||
|
||||
OfflineStats(Plugin plugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ranking rows for a metric, highest first, excluding zero values.
|
||||
*
|
||||
* @param limit maximum rows to return
|
||||
*/
|
||||
List<Row> ranking(RankingMetric metric, int limit) {
|
||||
Map<String, String> names = names();
|
||||
List<Row> rows = new ArrayList<>();
|
||||
|
||||
File dir = statsDirectory();
|
||||
File[] files = dir == null ? null : dir.listFiles((d, n) -> n.endsWith(".json"));
|
||||
if (files == null) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
for (File file : files) {
|
||||
String uuid = file.getName().substring(0, file.getName().length() - ".json".length());
|
||||
long value = read(file, metric);
|
||||
if (value <= 0) {
|
||||
continue;
|
||||
}
|
||||
rows.add(new Row(names.getOrDefault(uuid, uuid.substring(0, 8)), value));
|
||||
}
|
||||
|
||||
rows.sort((a, b) -> Long.compare(b.value(), a.value()));
|
||||
return rows.size() > limit ? rows.subList(0, limit) : rows;
|
||||
}
|
||||
|
||||
private long read(File file, RankingMetric metric) {
|
||||
try (Reader reader = new FileReader(file)) {
|
||||
JsonElement root = JsonParser.parseReader(reader);
|
||||
if (!root.isJsonObject()) {
|
||||
return 0;
|
||||
}
|
||||
JsonElement stats = root.getAsJsonObject().get("stats");
|
||||
if (stats == null || !stats.isJsonObject()) {
|
||||
return 0;
|
||||
}
|
||||
JsonElement section = stats.getAsJsonObject().get(metric.section());
|
||||
if (section == null || !section.isJsonObject()) {
|
||||
return 0;
|
||||
}
|
||||
JsonObject object = section.getAsJsonObject();
|
||||
if (metric.statKey() == null) {
|
||||
// Sum the whole section, e.g. every block ever mined.
|
||||
long total = 0;
|
||||
for (String key : object.keySet()) {
|
||||
total += object.get(key).getAsLong();
|
||||
}
|
||||
return total;
|
||||
}
|
||||
JsonElement value = object.get(metric.statKey());
|
||||
return value == null ? 0 : value.getAsLong();
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().warning("Não consegui ler " + file.getName() + ": " + e.getMessage());
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** UUID to last known name, from usercache.json. */
|
||||
private Map<String, String> names() {
|
||||
Map<String, String> names = new HashMap<>();
|
||||
File cache = new File(Bukkit.getWorldContainer(), "usercache.json");
|
||||
if (!cache.isFile()) {
|
||||
return names;
|
||||
}
|
||||
try (Reader reader = new FileReader(cache)) {
|
||||
JsonElement root = JsonParser.parseReader(reader);
|
||||
if (!root.isJsonArray()) {
|
||||
return names;
|
||||
}
|
||||
root.getAsJsonArray().forEach(element -> {
|
||||
JsonObject entry = element.getAsJsonObject();
|
||||
if (entry.has("uuid") && entry.has("name")) {
|
||||
names.put(entry.get("uuid").getAsString(), entry.get("name").getAsString());
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
plugin.getLogger().warning("Não consegui ler usercache.json: " + e.getMessage());
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paper writes to {@code <world>/players/stats}; older layouts used
|
||||
* {@code <world>/stats}. Try both rather than assume.
|
||||
*/
|
||||
private File statsDirectory() {
|
||||
if (Bukkit.getWorlds().isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
File world = Bukkit.getWorlds().get(0).getWorldFolder();
|
||||
File modern = new File(world, "players/stats");
|
||||
if (modern.isDirectory()) {
|
||||
return modern;
|
||||
}
|
||||
File legacy = new File(world, "stats");
|
||||
return legacy.isDirectory() ? legacy : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
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.format.NamedTextColor;
|
||||
import net.kyori.adventure.text.format.TextDecoration;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/** A chat poll with clickable options and a live tally on the boss bar. */
|
||||
final class Poll {
|
||||
|
||||
private final int id;
|
||||
private final String question;
|
||||
private final List<String> options;
|
||||
private final String author;
|
||||
private final Map<UUID, Integer> votes = new HashMap<>();
|
||||
private final BossBar bar;
|
||||
private boolean closed;
|
||||
|
||||
Poll(int id, String question, List<String> options, String author) {
|
||||
this.id = id;
|
||||
this.question = question;
|
||||
this.options = options;
|
||||
this.author = author;
|
||||
this.bar = BossBar.bossBar(tally(), 1.0f, BossBar.Color.BLUE, BossBar.Overlay.PROGRESS);
|
||||
}
|
||||
|
||||
int id() {
|
||||
return id;
|
||||
}
|
||||
|
||||
boolean closed() {
|
||||
return closed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a vote. Voting again changes the existing vote rather than adding
|
||||
* a second one.
|
||||
*
|
||||
* @param option 1-based option number as shown in chat
|
||||
* @return a message for the voter, or null if the option does not exist
|
||||
*/
|
||||
Component vote(Player player, int option) {
|
||||
if (closed) {
|
||||
return Component.text("Essa enquete já foi encerrada.", NamedTextColor.RED);
|
||||
}
|
||||
if (option < 1 || option > options.size()) {
|
||||
return null;
|
||||
}
|
||||
votes.put(player.getUniqueId(), option - 1);
|
||||
bar.name(tally());
|
||||
return Component.text("Voto registrado: " + options.get(option - 1), NamedTextColor.GREEN);
|
||||
}
|
||||
|
||||
int count(int index) {
|
||||
int total = 0;
|
||||
for (int vote : votes.values()) {
|
||||
if (vote == index) {
|
||||
total++;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
Component announcement(String commandBase) {
|
||||
Component message = Msg.tag("Enquete", NamedTextColor.BLUE)
|
||||
.append(Component.text(question, NamedTextColor.WHITE)
|
||||
.decoration(TextDecoration.BOLD, false))
|
||||
.append(Component.text(" (por " + author + ")", NamedTextColor.DARK_GRAY)
|
||||
.decoration(TextDecoration.BOLD, false));
|
||||
|
||||
Component row = Component.text(" ");
|
||||
for (int i = 0; i < options.size(); i++) {
|
||||
row = row.append(Component.text("[" + (i + 1) + ". " + options.get(i) + "] ",
|
||||
NamedTextColor.AQUA)
|
||||
.clickEvent(ClickEvent.runCommand(commandBase + " " + id + " " + (i + 1))));
|
||||
}
|
||||
return message.append(Component.newline()).append(row);
|
||||
}
|
||||
|
||||
Component tally() {
|
||||
Component text = Component.text("Enquete: ", NamedTextColor.WHITE);
|
||||
for (int i = 0; i < options.size(); i++) {
|
||||
text = text.append(Component.text(options.get(i) + " ", NamedTextColor.YELLOW))
|
||||
.append(Component.text(count(i) + " ", NamedTextColor.AQUA));
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
/** Closes the poll and builds the result message, naming the winner. */
|
||||
Component close() {
|
||||
closed = true;
|
||||
hide();
|
||||
|
||||
int best = -1;
|
||||
int bestCount = -1;
|
||||
boolean tie = false;
|
||||
for (int i = 0; i < options.size(); i++) {
|
||||
int count = count(i);
|
||||
if (count > bestCount) {
|
||||
best = i;
|
||||
bestCount = count;
|
||||
tie = false;
|
||||
} else if (count == bestCount) {
|
||||
tie = true;
|
||||
}
|
||||
}
|
||||
|
||||
Component header = Msg.tag("Enquete", NamedTextColor.BLUE)
|
||||
.append(Component.text("Resultado de \"" + question + "\": ", NamedTextColor.WHITE)
|
||||
.decoration(TextDecoration.BOLD, false));
|
||||
|
||||
if (votes.isEmpty()) {
|
||||
return header.append(Component.text("ninguém votou.", NamedTextColor.GRAY));
|
||||
}
|
||||
if (tie) {
|
||||
return header.append(Component.text("empate!", NamedTextColor.YELLOW))
|
||||
.append(Component.newline()).append(tally());
|
||||
}
|
||||
return header.append(Component.text(options.get(best), NamedTextColor.GREEN))
|
||||
.append(Component.text(" com " + bestCount + " voto(s).", NamedTextColor.GRAY))
|
||||
.append(Component.newline()).append(tally());
|
||||
}
|
||||
|
||||
void show() {
|
||||
Bukkit.getOnlinePlayers().forEach(p -> p.showBossBar(bar));
|
||||
}
|
||||
|
||||
void hide() {
|
||||
Bukkit.getOnlinePlayers().forEach(p -> p.hideBossBar(bar));
|
||||
}
|
||||
|
||||
void showTo(Player player) {
|
||||
if (!closed) {
|
||||
player.showBossBar(bar);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package dev.marcospaulo.canalhandia;
|
||||
|
||||
/**
|
||||
* A leaderboard column, expressed as a path into the stats JSON.
|
||||
*
|
||||
* <p>A null {@link #statKey()} means "sum every entry in the section", which is
|
||||
* how total blocks mined is computed — vanilla has no single total-mined counter.
|
||||
*/
|
||||
enum RankingMetric {
|
||||
|
||||
MINERACAO("mineracao", "Mineração", "minecraft:mined", null, Unit.COUNT, "blocos"),
|
||||
MORTES("mortes", "Mortes", "minecraft:custom", "minecraft:deaths", Unit.COUNT, "mortes"),
|
||||
TEMPO("tempo", "Tempo jogado", "minecraft:custom", "minecraft:play_time", Unit.TICKS, ""),
|
||||
DISTANCIA("distancia", "Distância a pé", "minecraft:custom", "minecraft:walk_one_cm",
|
||||
Unit.CENTIMETRES, ""),
|
||||
COMBATE("combate", "Monstros derrotados", "minecraft:custom", "minecraft:mob_kills",
|
||||
Unit.COUNT, "monstros"),
|
||||
PESCA("pesca", "Peixes pescados", "minecraft:custom", "minecraft:fish_caught",
|
||||
Unit.COUNT, "peixes"),
|
||||
PULOS("pulos", "Pulos", "minecraft:custom", "minecraft:jump", Unit.COUNT, "pulos");
|
||||
|
||||
enum Unit { COUNT, TICKS, CENTIMETRES }
|
||||
|
||||
private final String commandKey;
|
||||
private final String label;
|
||||
private final String section;
|
||||
private final String statKey;
|
||||
private final Unit unit;
|
||||
private final String suffix;
|
||||
|
||||
RankingMetric(String commandKey, String label, String section, String statKey,
|
||||
Unit unit, String suffix) {
|
||||
this.commandKey = commandKey;
|
||||
this.label = label;
|
||||
this.section = section;
|
||||
this.statKey = statKey;
|
||||
this.unit = unit;
|
||||
this.suffix = suffix;
|
||||
}
|
||||
|
||||
/** What players type: /ranking mineracao. */
|
||||
String commandKey() {
|
||||
return commandKey;
|
||||
}
|
||||
|
||||
/** Accented display name. */
|
||||
String label() {
|
||||
return label;
|
||||
}
|
||||
|
||||
/** Top-level section of the stats JSON, e.g. "minecraft:custom". */
|
||||
String section() {
|
||||
return section;
|
||||
}
|
||||
|
||||
/** Entry within the section, or null to sum the whole section. */
|
||||
String statKey() {
|
||||
return statKey;
|
||||
}
|
||||
|
||||
/** Formats a raw value for display, including its unit. */
|
||||
String format(long value) {
|
||||
return switch (unit) {
|
||||
case COUNT -> Msg.number(value) + (suffix.isEmpty() ? "" : " " + suffix);
|
||||
case TICKS -> Msg.duration(value);
|
||||
case CENTIMETRES -> String.format(Msg.PT_BR, "%,.1f km", value / 100_000.0);
|
||||
};
|
||||
}
|
||||
|
||||
static RankingMetric byKey(String key) {
|
||||
for (RankingMetric metric : values()) {
|
||||
if (metric.commandKey.equalsIgnoreCase(key)) {
|
||||
return metric;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
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.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Reaction state for one announced message.
|
||||
*
|
||||
* <p>Chat cannot be edited after sending, so the counts inside the clickable
|
||||
* buttons are frozen at send time. Three other surfaces carry the live numbers:
|
||||
* a boss bar while the window is open, an action bar shown to whoever just
|
||||
* reacted, and a final tally line broadcast when the window closes. Reactions
|
||||
* keep being accepted after the boss bar disappears, because people scroll back
|
||||
* and click late.
|
||||
*/
|
||||
final class Reactions {
|
||||
|
||||
private final int id;
|
||||
private final Map<String, Set<UUID>> votes = new LinkedHashMap<>();
|
||||
private final Map<String, String> labels;
|
||||
private final BossBar bar;
|
||||
private final long createdAt = System.currentTimeMillis();
|
||||
private boolean barVisible;
|
||||
|
||||
Reactions(int id, Map<String, String> labels) {
|
||||
this.id = id;
|
||||
this.labels = labels;
|
||||
labels.keySet().forEach(key -> votes.put(key, new LinkedHashSet<>()));
|
||||
this.bar = BossBar.bossBar(tally(), 1.0f, BossBar.Color.PURPLE, BossBar.Overlay.PROGRESS);
|
||||
}
|
||||
|
||||
int id() {
|
||||
return id;
|
||||
}
|
||||
|
||||
long ageMillis() {
|
||||
return System.currentTimeMillis() - createdAt;
|
||||
}
|
||||
|
||||
boolean hasAnyVote() {
|
||||
return votes.values().stream().anyMatch(set -> !set.isEmpty());
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a reaction. One per player per message; reacting again with a
|
||||
* different key moves the vote rather than double-counting it.
|
||||
*
|
||||
* @return false if the key is not a configured reaction
|
||||
*/
|
||||
boolean react(Player player, String key) {
|
||||
if (!votes.containsKey(key)) {
|
||||
return false;
|
||||
}
|
||||
votes.values().forEach(set -> set.remove(player.getUniqueId()));
|
||||
votes.get(key).add(player.getUniqueId());
|
||||
if (barVisible) {
|
||||
bar.name(tally());
|
||||
}
|
||||
player.sendActionBar(tally());
|
||||
return true;
|
||||
}
|
||||
|
||||
int count(String key) {
|
||||
Set<UUID> set = votes.get(key);
|
||||
return set == null ? 0 : set.size();
|
||||
}
|
||||
|
||||
/** The clickable row. Counts are the values at send time and never change. */
|
||||
Component buttons(String commandBase) {
|
||||
Component row = Component.text(" ");
|
||||
for (Map.Entry<String, String> entry : labels.entrySet()) {
|
||||
String text = entry.getValue() + " " + count(entry.getKey()) + " ";
|
||||
row = row.append(Component.text(text, NamedTextColor.YELLOW)
|
||||
.clickEvent(ClickEvent.runCommand(commandBase + " " + id + " " + entry.getKey()))
|
||||
.hoverEvent(HoverEvent.showText(Component.text(
|
||||
"Clique para reagir com " + entry.getValue(), NamedTextColor.GRAY))));
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Live counts, used for the boss bar, action bar and closing line. */
|
||||
Component tally() {
|
||||
Component text = Component.text("Reações: ", NamedTextColor.WHITE);
|
||||
for (Map.Entry<String, String> entry : labels.entrySet()) {
|
||||
text = text.append(Component.text(entry.getValue() + " ", NamedTextColor.YELLOW))
|
||||
.append(Component.text(count(entry.getKey()) + " ", NamedTextColor.AQUA));
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+54
-5
@@ -1,4 +1,4 @@
|
||||
package dev.marcospaulo.curiosidades;
|
||||
package dev.marcospaulo.canalhandia;
|
||||
|
||||
import org.bukkit.configuration.ConfigurationSection;
|
||||
|
||||
@@ -13,13 +13,23 @@ import java.util.Map;
|
||||
*/
|
||||
final class Settings {
|
||||
|
||||
private final Curiosidades plugin;
|
||||
private final Canalhandia plugin;
|
||||
|
||||
Settings(Curiosidades plugin) {
|
||||
Settings(Canalhandia plugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
// --- triggering ---------------------------------------------------------
|
||||
// --- modules ------------------------------------------------------------
|
||||
|
||||
boolean moduleEnabled(Module module) {
|
||||
return plugin.getConfig().getBoolean("modulos." + module.key(), true);
|
||||
}
|
||||
|
||||
void moduleEnabled(Module module, boolean enabled) {
|
||||
set("modulos." + module.key(), enabled);
|
||||
}
|
||||
|
||||
// --- curiosity triggering -----------------------------------------------
|
||||
|
||||
Mode mode() {
|
||||
Mode mode = Mode.byKey(plugin.getConfig().getString("modo", "ENTRADA"));
|
||||
@@ -65,7 +75,7 @@ final class Settings {
|
||||
set("evitar-repetir", Math.max(0, count));
|
||||
}
|
||||
|
||||
// --- presentation -------------------------------------------------------
|
||||
// --- reactions ----------------------------------------------------------
|
||||
|
||||
boolean reactionsEnabled() {
|
||||
return plugin.getConfig().getBoolean("reacoes-ativas", true);
|
||||
@@ -75,6 +85,7 @@ final class Settings {
|
||||
set("reacoes-ativas", enabled);
|
||||
}
|
||||
|
||||
/** How long the boss bar stays up. */
|
||||
int reactionWindowSeconds() {
|
||||
return Math.max(5, plugin.getConfig().getInt("janela-reacao-segundos", 90));
|
||||
}
|
||||
@@ -83,6 +94,18 @@ final class Settings {
|
||||
set("janela-reacao-segundos", Math.max(5, seconds));
|
||||
}
|
||||
|
||||
/**
|
||||
* How long clicks keep counting after the boss bar is gone. People scroll
|
||||
* back and click late, and silently dropping those looks broken.
|
||||
*/
|
||||
int reactionValidityMinutes() {
|
||||
return Math.max(1, plugin.getConfig().getInt("reacao-validade-minutos", 15));
|
||||
}
|
||||
|
||||
void reactionValidityMinutes(int minutes) {
|
||||
set("reacao-validade-minutos", Math.max(1, minutes));
|
||||
}
|
||||
|
||||
Map<String, String> reactions() {
|
||||
Map<String, String> reactions = new LinkedHashMap<>();
|
||||
ConfigurationSection section = plugin.getConfig().getConfigurationSection("reacoes");
|
||||
@@ -105,6 +128,32 @@ final class Settings {
|
||||
set("reacoes." + key, null);
|
||||
}
|
||||
|
||||
// --- games --------------------------------------------------------------
|
||||
|
||||
int guessSeconds() {
|
||||
return Math.max(10, plugin.getConfig().getInt("adivinha-segundos", 45));
|
||||
}
|
||||
|
||||
void guessSeconds(int seconds) {
|
||||
set("adivinha-segundos", Math.max(10, seconds));
|
||||
}
|
||||
|
||||
int pollMinutes() {
|
||||
return Math.max(1, plugin.getConfig().getInt("enquete-minutos", 5));
|
||||
}
|
||||
|
||||
void pollMinutes(int minutes) {
|
||||
set("enquete-minutos", Math.max(1, minutes));
|
||||
}
|
||||
|
||||
int rankingSize() {
|
||||
return Math.max(3, plugin.getConfig().getInt("ranking-tamanho", 5));
|
||||
}
|
||||
|
||||
void rankingSize(int size) {
|
||||
set("ranking-tamanho", Math.max(3, size));
|
||||
}
|
||||
|
||||
// --- content ------------------------------------------------------------
|
||||
|
||||
boolean categoryEnabled(Category category) {
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package dev.marcospaulo.curiosidades;
|
||||
package dev.marcospaulo.canalhandia;
|
||||
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.Statistic;
|
||||
@@ -1,479 +0,0 @@
|
||||
package dev.marcospaulo.curiosidades;
|
||||
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.event.ClickEvent;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import net.kyori.adventure.text.format.TextDecoration;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandExecutor;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.command.TabCompleter;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** The whole /curiosidade command tree. */
|
||||
final class CuriosidadeCommand implements CommandExecutor, TabCompleter {
|
||||
|
||||
private static final String ADMIN = "curiosidades.admin";
|
||||
|
||||
private final Curiosidades plugin;
|
||||
|
||||
CuriosidadeCommand(Curiosidades plugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
|
||||
Settings settings = plugin.settings();
|
||||
|
||||
if (args.length == 0) {
|
||||
return force(sender, null);
|
||||
}
|
||||
|
||||
String sub = args[0].toLowerCase();
|
||||
String[] rest = java.util.Arrays.copyOfRange(args, 1, args.length);
|
||||
|
||||
switch (sub) {
|
||||
case "ajuda", "help" -> help(sender);
|
||||
case "reagir" -> react(sender, rest);
|
||||
case "toggle" -> toggle(sender);
|
||||
case "status" -> status(sender);
|
||||
case "listar" -> list(sender, rest);
|
||||
case "ver", "preview" -> preview(sender, rest);
|
||||
case "modo" -> mode(sender, rest);
|
||||
case "intervalo" -> intervalMinutes(sender, rest);
|
||||
case "atraso" -> joinDelay(sender, rest);
|
||||
case "cooldown" -> cooldown(sender, rest);
|
||||
case "repetir" -> noRepeat(sender, rest);
|
||||
case "janela" -> window(sender, rest);
|
||||
case "reacoes" -> reactionsToggle(sender, rest);
|
||||
case "reacao" -> reactionEdit(sender, rest);
|
||||
case "categoria" -> categoryToggle(sender, rest);
|
||||
case "categorias" -> categories(sender);
|
||||
case "limpar" -> clear(sender, rest);
|
||||
case "reload" -> reload(sender);
|
||||
default -> {
|
||||
// Bare player name: /curiosidade <jogador>
|
||||
Player target = Bukkit.getPlayerExact(args[0]);
|
||||
if (target != null) {
|
||||
return force(sender, target);
|
||||
}
|
||||
error(sender, "Subcomando desconhecido. Use /curiosidade ajuda");
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- announcing ---------------------------------------------------------
|
||||
|
||||
private boolean force(CommandSender sender, Player target) {
|
||||
if (!sender.hasPermission("curiosidades.forcar")) {
|
||||
return denied(sender);
|
||||
}
|
||||
if (!plugin.announceRandom(target)) {
|
||||
error(sender, target == null
|
||||
? "Ninguém elegível online (ou sem estatísticas suficientes)."
|
||||
: target.getName() + " ainda não tem estatísticas suficientes.");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Shows a curiosity only to the sender, without broadcasting or touching cooldowns. */
|
||||
private void preview(CommandSender sender, String[] args) {
|
||||
if (!sender.hasPermission("curiosidades.ver")) {
|
||||
denied(sender);
|
||||
return;
|
||||
}
|
||||
Player target = resolveTarget(sender, args);
|
||||
if (target == null) {
|
||||
return;
|
||||
}
|
||||
List<Fact> facts = CuriosityFactory.facts(target, plugin.settings());
|
||||
if (facts.isEmpty()) {
|
||||
error(sender, "Nenhuma curiosidade disponível para " + target.getName() + ".");
|
||||
return;
|
||||
}
|
||||
Fact fact = facts.get((int) (Math.random() * facts.size()));
|
||||
sender.sendMessage(plugin.render(target, fact.text()));
|
||||
}
|
||||
|
||||
/** Dumps every available fact for a player, privately. Handy for tuning thresholds. */
|
||||
private void list(CommandSender sender, String[] args) {
|
||||
if (!sender.hasPermission("curiosidades.ver")) {
|
||||
denied(sender);
|
||||
return;
|
||||
}
|
||||
Player target = resolveTarget(sender, args);
|
||||
if (target == null) {
|
||||
return;
|
||||
}
|
||||
List<Fact> facts = CuriosityFactory.facts(target, plugin.settings());
|
||||
header(sender, "Curiosidades de " + target.getName() + " (" + facts.size() + ")");
|
||||
for (Fact fact : facts) {
|
||||
sender.sendMessage(Component.text(" [" + fact.category().label() + "] ", NamedTextColor.DARK_AQUA)
|
||||
.append(fact.text()));
|
||||
}
|
||||
if (facts.isEmpty()) {
|
||||
sender.sendMessage(Component.text(" (nada ainda)", NamedTextColor.GRAY));
|
||||
}
|
||||
}
|
||||
|
||||
private Player resolveTarget(CommandSender sender, String[] args) {
|
||||
if (args.length > 0) {
|
||||
Player target = Bukkit.getPlayerExact(args[0]);
|
||||
if (target == null) {
|
||||
error(sender, "Jogador '" + args[0] + "' não está online.");
|
||||
}
|
||||
return target;
|
||||
}
|
||||
if (sender instanceof Player player) {
|
||||
return player;
|
||||
}
|
||||
error(sender, "Informe um jogador: /curiosidade listar <jogador>");
|
||||
return null;
|
||||
}
|
||||
|
||||
private void react(CommandSender sender, String[] args) {
|
||||
if (!(sender instanceof Player player) || args.length < 2) {
|
||||
return;
|
||||
}
|
||||
if (!player.hasPermission("curiosidades.reagir")) {
|
||||
denied(sender);
|
||||
return;
|
||||
}
|
||||
ReactionSession active = plugin.activeSession();
|
||||
int id;
|
||||
try {
|
||||
id = Integer.parseInt(args[0]);
|
||||
} catch (NumberFormatException e) {
|
||||
return;
|
||||
}
|
||||
if (active == null || active.id() != id) {
|
||||
player.sendActionBar(Component.text("Essa curiosidade já expirou.", NamedTextColor.RED));
|
||||
return;
|
||||
}
|
||||
if (active.react(player, args[1])) {
|
||||
player.sendActionBar(Component.text("Você reagiu!", NamedTextColor.GREEN));
|
||||
}
|
||||
}
|
||||
|
||||
private void toggle(CommandSender sender) {
|
||||
if (!(sender instanceof Player player)) {
|
||||
error(sender, "Só jogadores podem usar isso.");
|
||||
return;
|
||||
}
|
||||
boolean optedOut = !plugin.isOptedOut(player);
|
||||
plugin.setOptedOut(player, optedOut);
|
||||
sender.sendMessage(optedOut
|
||||
? Component.text("Você não aparecerá mais nas curiosidades.", NamedTextColor.YELLOW)
|
||||
: Component.text("Você voltou a aparecer nas curiosidades.", NamedTextColor.GREEN));
|
||||
}
|
||||
|
||||
// --- admin settings -----------------------------------------------------
|
||||
|
||||
private void mode(CommandSender sender, String[] args) {
|
||||
if (!admin(sender)) {
|
||||
return;
|
||||
}
|
||||
if (args.length == 0) {
|
||||
error(sender, "Uso: /curiosidade modo <entrada|intervalo|ambos|manual>");
|
||||
return;
|
||||
}
|
||||
Mode mode = Mode.byKey(args[0]);
|
||||
if (mode == null) {
|
||||
error(sender, "Modo inválido. Use entrada, intervalo, ambos ou manual.");
|
||||
return;
|
||||
}
|
||||
plugin.settings().mode(mode);
|
||||
plugin.rescheduleTimer();
|
||||
ok(sender, "Modo alterado para " + mode + ".");
|
||||
}
|
||||
|
||||
private void intervalMinutes(CommandSender sender, String[] args) {
|
||||
Integer value = adminNumber(sender, args, "/curiosidade intervalo <minutos>");
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
plugin.settings().intervalMinutes(value);
|
||||
plugin.rescheduleTimer();
|
||||
ok(sender, "Intervalo alterado para " + plugin.settings().intervalMinutes() + " minutos.");
|
||||
}
|
||||
|
||||
private void joinDelay(CommandSender sender, String[] args) {
|
||||
Integer value = adminNumber(sender, args, "/curiosidade atraso <segundos>");
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
plugin.settings().joinDelaySeconds(value);
|
||||
ok(sender, "Atraso após entrar: " + plugin.settings().joinDelaySeconds() + "s.");
|
||||
}
|
||||
|
||||
private void cooldown(CommandSender sender, String[] args) {
|
||||
Integer value = adminNumber(sender, args, "/curiosidade cooldown <minutos>");
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
plugin.settings().cooldownMinutes(value);
|
||||
ok(sender, "Cooldown por jogador: " + plugin.settings().cooldownMinutes() + " minutos.");
|
||||
}
|
||||
|
||||
private void noRepeat(CommandSender sender, String[] args) {
|
||||
Integer value = adminNumber(sender, args, "/curiosidade repetir <quantidade>");
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
plugin.settings().noRepeat(value);
|
||||
ok(sender, "Evitando repetir as últimas " + plugin.settings().noRepeat() + " curiosidades.");
|
||||
}
|
||||
|
||||
private void window(CommandSender sender, String[] args) {
|
||||
Integer value = adminNumber(sender, args, "/curiosidade janela <segundos>");
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
plugin.settings().reactionWindowSeconds(value);
|
||||
ok(sender, "Janela de reação: " + plugin.settings().reactionWindowSeconds() + "s.");
|
||||
}
|
||||
|
||||
private void reactionsToggle(CommandSender sender, String[] args) {
|
||||
if (!admin(sender)) {
|
||||
return;
|
||||
}
|
||||
if (args.length == 0) {
|
||||
error(sender, "Uso: /curiosidade reacoes <on|off>");
|
||||
return;
|
||||
}
|
||||
boolean on = args[0].equalsIgnoreCase("on");
|
||||
plugin.settings().reactionsEnabled(on);
|
||||
ok(sender, "Reações " + (on ? "ativadas" : "desativadas") + ".");
|
||||
}
|
||||
|
||||
private void reactionEdit(CommandSender sender, String[] args) {
|
||||
if (!admin(sender)) {
|
||||
return;
|
||||
}
|
||||
if (args.length >= 3 && args[0].equalsIgnoreCase("add")) {
|
||||
String label = String.join(" ", java.util.Arrays.copyOfRange(args, 2, args.length));
|
||||
plugin.settings().reaction(args[1].toLowerCase(), label);
|
||||
ok(sender, "Reação '" + args[1] + "' definida como " + label + ".");
|
||||
} else if (args.length >= 2 && args[0].equalsIgnoreCase("remover")) {
|
||||
plugin.settings().removeReaction(args[1].toLowerCase());
|
||||
ok(sender, "Reação '" + args[1] + "' removida.");
|
||||
} else {
|
||||
error(sender, "Uso: /curiosidade reacao add <chave> <rótulo> | remover <chave>");
|
||||
}
|
||||
}
|
||||
|
||||
private void categoryToggle(CommandSender sender, String[] args) {
|
||||
if (!admin(sender)) {
|
||||
return;
|
||||
}
|
||||
if (args.length < 2) {
|
||||
error(sender, "Uso: /curiosidade categoria <nome> <on|off>");
|
||||
return;
|
||||
}
|
||||
Category category = Category.byKey(args[0]);
|
||||
if (category == null) {
|
||||
error(sender, "Categoria desconhecida. Veja /curiosidade categorias");
|
||||
return;
|
||||
}
|
||||
boolean on = args[1].equalsIgnoreCase("on");
|
||||
plugin.settings().categoryEnabled(category, on);
|
||||
ok(sender, "Categoria " + category.label() + " " + (on ? "ativada" : "desativada") + ".");
|
||||
}
|
||||
|
||||
private void categories(CommandSender sender) {
|
||||
header(sender, "Categorias");
|
||||
for (Category category : Category.values()) {
|
||||
boolean on = plugin.settings().categoryEnabled(category);
|
||||
sender.sendMessage(Component.text(" " + category.key() + " ", NamedTextColor.WHITE)
|
||||
.append(Component.text(on ? "ativada" : "desativada",
|
||||
on ? NamedTextColor.GREEN : NamedTextColor.RED))
|
||||
.append(Component.text(" [alternar]", NamedTextColor.DARK_AQUA)
|
||||
.clickEvent(ClickEvent.runCommand(
|
||||
"/curiosidade categoria " + category.key() + (on ? " off" : " on")))));
|
||||
}
|
||||
}
|
||||
|
||||
private void clear(CommandSender sender, String[] args) {
|
||||
if (!admin(sender)) {
|
||||
return;
|
||||
}
|
||||
String what = args.length > 0 ? args[0].toLowerCase() : "tudo";
|
||||
if (what.equals("cooldown") || what.equals("tudo")) {
|
||||
plugin.clearCooldowns();
|
||||
}
|
||||
if (what.equals("historico") || what.equals("tudo")) {
|
||||
plugin.clearHistory();
|
||||
}
|
||||
ok(sender, "Limpo: " + what + ".");
|
||||
}
|
||||
|
||||
private void reload(CommandSender sender) {
|
||||
if (!admin(sender)) {
|
||||
return;
|
||||
}
|
||||
plugin.reloadConfig();
|
||||
plugin.rescheduleTimer();
|
||||
ok(sender, "Configuração recarregada.");
|
||||
}
|
||||
|
||||
private void status(CommandSender sender) {
|
||||
Settings settings = plugin.settings();
|
||||
header(sender, "Curiosidades — status");
|
||||
line(sender, "modo", settings.mode().name().toLowerCase());
|
||||
line(sender, "intervalo", settings.intervalMinutes() + " min"
|
||||
+ (settings.mode().firesOnTimer() ? "" : " (inativo neste modo)"));
|
||||
line(sender, "atraso após entrar", settings.joinDelaySeconds() + "s"
|
||||
+ (settings.mode().firesOnJoin() ? "" : " (inativo neste modo)"));
|
||||
line(sender, "cooldown por jogador", settings.cooldownMinutes() + " min");
|
||||
line(sender, "evitar repetir", settings.noRepeat() + " últimas");
|
||||
line(sender, "reações", settings.reactionsEnabled() ? "ativas" : "desativadas");
|
||||
line(sender, "janela de reação", settings.reactionWindowSeconds() + "s");
|
||||
line(sender, "reações definidas", String.join(" ", settings.reactions().values()));
|
||||
List<String> enabled = new ArrayList<>();
|
||||
for (Category category : Category.values()) {
|
||||
if (settings.categoryEnabled(category)) {
|
||||
enabled.add(category.key());
|
||||
}
|
||||
}
|
||||
line(sender, "categorias ativas", enabled.isEmpty() ? "nenhuma" : String.join(", ", enabled));
|
||||
}
|
||||
|
||||
private void help(CommandSender sender) {
|
||||
header(sender, "Curiosidades — comandos");
|
||||
Map<String, String> commands = new java.util.LinkedHashMap<>();
|
||||
commands.put("/curiosidade", "anuncia uma curiosidade agora");
|
||||
commands.put("/curiosidade <jogador>", "anuncia sobre um jogador específico");
|
||||
commands.put("/curiosidade ver [jogador]", "mostra uma curiosidade só para você");
|
||||
commands.put("/curiosidade listar [jogador]", "lista todas as curiosidades disponíveis");
|
||||
commands.put("/curiosidade toggle", "entra/sai da lista de jogadores sorteados");
|
||||
commands.put("/curiosidade status", "mostra a configuração atual");
|
||||
commands.put("/curiosidade categorias", "lista as categorias e seu estado");
|
||||
commands.put("/curiosidade modo <m>", "entrada | intervalo | ambos | manual");
|
||||
commands.put("/curiosidade intervalo <min>", "intervalo do modo temporizado");
|
||||
commands.put("/curiosidade atraso <seg>", "espera após o jogador entrar");
|
||||
commands.put("/curiosidade cooldown <min>", "tempo mínimo entre citar o mesmo jogador");
|
||||
commands.put("/curiosidade repetir <n>", "quantas curiosidades recentes evitar repetir");
|
||||
commands.put("/curiosidade janela <seg>", "duração da barra de reações");
|
||||
commands.put("/curiosidade reacoes <on|off>", "liga/desliga as reações");
|
||||
commands.put("/curiosidade reacao add <chave> <rótulo>", "cria ou muda uma reação");
|
||||
commands.put("/curiosidade reacao remover <chave>", "remove uma reação");
|
||||
commands.put("/curiosidade categoria <nome> <on|off>", "liga/desliga uma categoria");
|
||||
commands.put("/curiosidade limpar [cooldown|historico|tudo]", "zera cooldowns/histórico");
|
||||
commands.put("/curiosidade reload", "recarrega o config.yml");
|
||||
commands.forEach((cmd, description) -> sender.sendMessage(
|
||||
Component.text(" " + cmd, NamedTextColor.AQUA)
|
||||
.append(Component.text(" — " + description, NamedTextColor.GRAY))));
|
||||
}
|
||||
|
||||
// --- helpers ------------------------------------------------------------
|
||||
|
||||
private boolean admin(CommandSender sender) {
|
||||
if (sender.hasPermission(ADMIN)) {
|
||||
return true;
|
||||
}
|
||||
denied(sender);
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Parses a positive integer argument for an admin-only setting. */
|
||||
private Integer adminNumber(CommandSender sender, String[] args, String usage) {
|
||||
if (!admin(sender)) {
|
||||
return null;
|
||||
}
|
||||
if (args.length == 0) {
|
||||
error(sender, "Uso: " + usage);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Integer.parseInt(args[0]);
|
||||
} catch (NumberFormatException e) {
|
||||
error(sender, "'" + args[0] + "' não é um número.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean denied(CommandSender sender) {
|
||||
error(sender, "Você não tem permissão para isso.");
|
||||
return true;
|
||||
}
|
||||
|
||||
private void ok(CommandSender sender, String text) {
|
||||
sender.sendMessage(Component.text("[Curiosidades] ", NamedTextColor.GOLD)
|
||||
.append(Component.text(text, NamedTextColor.GREEN)));
|
||||
}
|
||||
|
||||
private void error(CommandSender sender, String text) {
|
||||
sender.sendMessage(Component.text("[Curiosidades] ", NamedTextColor.GOLD)
|
||||
.append(Component.text(text, NamedTextColor.RED)));
|
||||
}
|
||||
|
||||
private void header(CommandSender sender, String text) {
|
||||
sender.sendMessage(Component.text("— " + text + " —", NamedTextColor.GOLD, TextDecoration.BOLD));
|
||||
}
|
||||
|
||||
private void line(CommandSender sender, String key, String value) {
|
||||
sender.sendMessage(Component.text(" " + key + ": ", NamedTextColor.GRAY)
|
||||
.append(Component.text(value, NamedTextColor.AQUA)));
|
||||
}
|
||||
|
||||
// --- tab completion -----------------------------------------------------
|
||||
|
||||
@Override
|
||||
public List<String> onTabComplete(CommandSender sender, Command command, String label, String[] args) {
|
||||
if (args.length == 1) {
|
||||
List<String> options = new ArrayList<>(List.of("ver", "listar", "toggle", "status",
|
||||
"categorias", "ajuda"));
|
||||
if (sender.hasPermission(ADMIN)) {
|
||||
options.addAll(List.of("modo", "intervalo", "atraso", "cooldown", "repetir",
|
||||
"janela", "reacoes", "reacao", "categoria", "limpar", "reload"));
|
||||
}
|
||||
Bukkit.getOnlinePlayers().forEach(p -> options.add(p.getName()));
|
||||
return filter(options, args[0]);
|
||||
}
|
||||
if (args.length == 2) {
|
||||
return switch (args[0].toLowerCase()) {
|
||||
case "modo" -> filter(List.of("entrada", "intervalo", "ambos", "manual"), args[1]);
|
||||
case "reacoes" -> filter(List.of("on", "off"), args[1]);
|
||||
case "reacao" -> filter(List.of("add", "remover"), args[1]);
|
||||
case "limpar" -> filter(List.of("cooldown", "historico", "tudo"), args[1]);
|
||||
case "categoria" -> filter(categoryKeys(), args[1]);
|
||||
case "ver", "listar" -> filter(onlineNames(), args[1]);
|
||||
default -> List.of();
|
||||
};
|
||||
}
|
||||
if (args.length == 3 && args[0].equalsIgnoreCase("categoria")) {
|
||||
return filter(List.of("on", "off"), args[2]);
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private List<String> categoryKeys() {
|
||||
List<String> keys = new ArrayList<>();
|
||||
for (Category category : Category.values()) {
|
||||
keys.add(category.key());
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
private List<String> onlineNames() {
|
||||
List<String> names = new ArrayList<>();
|
||||
Bukkit.getOnlinePlayers().forEach(p -> names.add(p.getName()));
|
||||
return names;
|
||||
}
|
||||
|
||||
private static List<String> filter(List<String> options, String prefix) {
|
||||
List<String> matches = new ArrayList<>();
|
||||
for (String option : options) {
|
||||
if (option.toLowerCase().startsWith(prefix.toLowerCase())) {
|
||||
matches.add(option);
|
||||
}
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
package dev.marcospaulo.curiosidades;
|
||||
|
||||
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 net.kyori.adventure.text.format.TextDecoration;
|
||||
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.PlayerJoinEvent;
|
||||
import org.bukkit.persistence.PersistentDataType;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.bukkit.scheduler.BukkitTask;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Deque;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
import java.util.UUID;
|
||||
|
||||
public final class Curiosidades extends JavaPlugin implements Listener {
|
||||
|
||||
private final Random random = new Random();
|
||||
private final Map<UUID, Long> lastFeatured = new HashMap<>();
|
||||
private final Deque<String> recentFacts = new ArrayDeque<>();
|
||||
|
||||
private Settings settings;
|
||||
private NamespacedKey optOutKey;
|
||||
private BukkitTask timerTask;
|
||||
private int nextSessionId = 1;
|
||||
private ReactionSession active;
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
saveDefaultConfig();
|
||||
settings = new Settings(this);
|
||||
optOutKey = new NamespacedKey(this, "opt_out");
|
||||
|
||||
CuriosidadeCommand command = new CuriosidadeCommand(this);
|
||||
if (getCommand("curiosidade") != null) {
|
||||
getCommand("curiosidade").setExecutor(command);
|
||||
getCommand("curiosidade").setTabCompleter(command);
|
||||
}
|
||||
getServer().getPluginManager().registerEvents(this, this);
|
||||
rescheduleTimer();
|
||||
|
||||
getLogger().info("Curiosidades ativo — modo " + settings.mode()
|
||||
+ (settings.mode().firesOnTimer()
|
||||
? " (intervalo de " + settings.intervalMinutes() + " min)" : ""));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
if (active != null) {
|
||||
active.hide();
|
||||
}
|
||||
}
|
||||
|
||||
Settings settings() {
|
||||
return settings;
|
||||
}
|
||||
|
||||
/** Starts, stops or restarts the repeating announcement task to match the mode. */
|
||||
void rescheduleTimer() {
|
||||
if (timerTask != null) {
|
||||
timerTask.cancel();
|
||||
timerTask = null;
|
||||
}
|
||||
if (!settings.mode().firesOnTimer()) {
|
||||
return;
|
||||
}
|
||||
long ticks = settings.intervalMinutes() * 60L * 20L;
|
||||
timerTask = getServer().getScheduler().runTaskTimer(this, () -> announceRandom(null), ticks, ticks);
|
||||
}
|
||||
|
||||
// --- announcing ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Announces one curiosity.
|
||||
*
|
||||
* @param subject who to talk about, or null to pick a random eligible player
|
||||
* @return false if there was nobody eligible or nothing notable to say
|
||||
*/
|
||||
boolean announceRandom(Player subject) {
|
||||
if (subject == null) {
|
||||
List<Player> candidates = new ArrayList<>();
|
||||
for (Player player : Bukkit.getOnlinePlayers()) {
|
||||
if (isEligible(player)) {
|
||||
candidates.add(player);
|
||||
}
|
||||
}
|
||||
if (candidates.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
subject = candidates.get(random.nextInt(candidates.size()));
|
||||
}
|
||||
|
||||
List<Fact> facts = CuriosityFactory.facts(subject, settings);
|
||||
if (facts.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Prefer a fact that has not been announced recently; fall back to any.
|
||||
List<Fact> fresh = new ArrayList<>(facts);
|
||||
fresh.removeIf(fact -> recentFacts.contains(plain(fact.text())));
|
||||
Fact chosen = (fresh.isEmpty() ? facts : fresh).get(random.nextInt(
|
||||
(fresh.isEmpty() ? facts : fresh).size()));
|
||||
|
||||
remember(chosen);
|
||||
lastFeatured.put(subject.getUniqueId(), System.currentTimeMillis());
|
||||
broadcast(subject, chosen.text());
|
||||
return true;
|
||||
}
|
||||
|
||||
private void remember(Fact fact) {
|
||||
recentFacts.addLast(plain(fact.text()));
|
||||
while (recentFacts.size() > settings.noRepeat()) {
|
||||
recentFacts.removeFirst();
|
||||
}
|
||||
}
|
||||
|
||||
private static String plain(Component component) {
|
||||
return PlainTextComponentSerializer.plainText().serialize(component);
|
||||
}
|
||||
|
||||
/** True if the player can currently be the subject of an announcement. */
|
||||
boolean isEligible(Player player) {
|
||||
if (isOptedOut(player) || player.hasPermission("curiosidades.isento")) {
|
||||
return false;
|
||||
}
|
||||
Long last = lastFeatured.get(player.getUniqueId());
|
||||
if (last == null) {
|
||||
return true;
|
||||
}
|
||||
long cooldownMillis = settings.cooldownMinutes() * 60_000L;
|
||||
return System.currentTimeMillis() - last >= cooldownMillis;
|
||||
}
|
||||
|
||||
/** Builds the message for {@code subject} without broadcasting it. */
|
||||
Component render(Player subject, Component fact) {
|
||||
return Component.text("[Curiosidade] ", NamedTextColor.GOLD, TextDecoration.BOLD)
|
||||
.append(Component.text("Sabia que o ", NamedTextColor.WHITE)
|
||||
.decoration(TextDecoration.BOLD, false))
|
||||
.append(Component.text(subject.getName(), NamedTextColor.GREEN)
|
||||
.decoration(TextDecoration.BOLD, false))
|
||||
.append(Component.text(" ", NamedTextColor.WHITE).decoration(TextDecoration.BOLD, false))
|
||||
.append(fact.decoration(TextDecoration.BOLD, false))
|
||||
.append(Component.text("?", NamedTextColor.WHITE).decoration(TextDecoration.BOLD, false));
|
||||
}
|
||||
|
||||
private void broadcast(Player subject, Component fact) {
|
||||
if (active != null) {
|
||||
active.hide();
|
||||
active = null;
|
||||
}
|
||||
Bukkit.broadcast(render(subject, fact));
|
||||
|
||||
if (!settings.reactionsEnabled()) {
|
||||
return;
|
||||
}
|
||||
ReactionSession session = new ReactionSession(nextSessionId++, settings.reactions());
|
||||
active = session;
|
||||
Bukkit.broadcast(buttons(session));
|
||||
session.show();
|
||||
getServer().getScheduler().runTaskLater(this, () -> {
|
||||
session.hide();
|
||||
if (active == session) {
|
||||
active = null;
|
||||
}
|
||||
}, settings.reactionWindowSeconds() * 20L);
|
||||
}
|
||||
|
||||
/** The clickable reaction row. Counts are frozen at send time; the boss bar is live. */
|
||||
private Component buttons(ReactionSession session) {
|
||||
Component row = Component.text(" ");
|
||||
for (Map.Entry<String, String> entry : settings.reactions().entrySet()) {
|
||||
row = row.append(Component.text(entry.getValue() + " ", NamedTextColor.YELLOW)
|
||||
.clickEvent(ClickEvent.runCommand(
|
||||
"/curiosidade reagir " + session.id() + " " + entry.getKey()))
|
||||
.hoverEvent(HoverEvent.showText(
|
||||
Component.text("Clique para reagir com " + entry.getValue(),
|
||||
NamedTextColor.GRAY))));
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
ReactionSession activeSession() {
|
||||
return active;
|
||||
}
|
||||
|
||||
// --- events -------------------------------------------------------------
|
||||
|
||||
@EventHandler
|
||||
public void onJoin(PlayerJoinEvent event) {
|
||||
Player player = event.getPlayer();
|
||||
if (active != null) {
|
||||
active.showTo(player);
|
||||
}
|
||||
if (!settings.mode().firesOnJoin() || !isEligible(player)) {
|
||||
return;
|
||||
}
|
||||
// Delayed so the curiosity lands after the join message rather than racing it.
|
||||
getServer().getScheduler().runTaskLater(this, () -> {
|
||||
if (player.isOnline()) {
|
||||
announceRandom(player);
|
||||
}
|
||||
}, settings.joinDelaySeconds() * 20L);
|
||||
}
|
||||
|
||||
// --- per-player opt out -------------------------------------------------
|
||||
|
||||
boolean isOptedOut(Player player) {
|
||||
Byte value = player.getPersistentDataContainer().get(optOutKey, PersistentDataType.BYTE);
|
||||
return value != null && value == 1;
|
||||
}
|
||||
|
||||
void setOptedOut(Player player, boolean optedOut) {
|
||||
player.getPersistentDataContainer()
|
||||
.set(optOutKey, PersistentDataType.BYTE, (byte) (optedOut ? 1 : 0));
|
||||
}
|
||||
|
||||
void clearCooldowns() {
|
||||
lastFeatured.clear();
|
||||
}
|
||||
|
||||
void clearHistory() {
|
||||
recentFacts.clear();
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
package dev.marcospaulo.curiosidades;
|
||||
|
||||
import net.kyori.adventure.bossbar.BossBar;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Reaction state for one announced curiosity.
|
||||
*
|
||||
* <p>Chat messages cannot be edited after the fact in vanilla, so the counts baked
|
||||
* into the clickable buttons are frozen at send time. The live tally is carried by
|
||||
* a boss bar instead, which <em>can</em> be updated in place and is visible to
|
||||
* everyone until the window closes.
|
||||
*/
|
||||
final class ReactionSession {
|
||||
|
||||
private final int id;
|
||||
private final Map<String, Set<UUID>> votes = new LinkedHashMap<>();
|
||||
private final Map<String, String> labels;
|
||||
private final BossBar bar;
|
||||
|
||||
ReactionSession(int id, Map<String, String> labels) {
|
||||
this.id = id;
|
||||
this.labels = labels;
|
||||
labels.keySet().forEach(key -> votes.put(key, new LinkedHashSet<>()));
|
||||
this.bar = BossBar.bossBar(renderTally(), 1.0f, BossBar.Color.PURPLE, BossBar.Overlay.PROGRESS);
|
||||
}
|
||||
|
||||
int id() {
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a reaction. A player gets one reaction per curiosity; reacting again
|
||||
* with a different key moves their vote rather than double-counting it.
|
||||
*
|
||||
* @return false if the key is not a configured reaction
|
||||
*/
|
||||
boolean react(Player player, String key) {
|
||||
if (!votes.containsKey(key)) {
|
||||
return false;
|
||||
}
|
||||
votes.values().forEach(set -> set.remove(player.getUniqueId()));
|
||||
votes.get(key).add(player.getUniqueId());
|
||||
bar.name(renderTally());
|
||||
return true;
|
||||
}
|
||||
|
||||
int count(String key) {
|
||||
Set<UUID> set = votes.get(key);
|
||||
return set == null ? 0 : set.size();
|
||||
}
|
||||
|
||||
void show() {
|
||||
Bukkit.getOnlinePlayers().forEach(p -> p.showBossBar(bar));
|
||||
}
|
||||
|
||||
void hide() {
|
||||
Bukkit.getOnlinePlayers().forEach(p -> p.hideBossBar(bar));
|
||||
}
|
||||
|
||||
/** Shows the bar to someone who joined mid-window. */
|
||||
void showTo(Player player) {
|
||||
player.showBossBar(bar);
|
||||
}
|
||||
|
||||
private Component renderTally() {
|
||||
Component tally = Component.text("Reações: ", NamedTextColor.WHITE);
|
||||
for (Map.Entry<String, String> entry : labels.entrySet()) {
|
||||
tally = tally
|
||||
.append(Component.text(entry.getValue() + " ", NamedTextColor.YELLOW))
|
||||
.append(Component.text(count(entry.getKey()) + " ", NamedTextColor.AQUA));
|
||||
}
|
||||
return tally;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,21 @@
|
||||
# Curiosidades — anúncios automáticos sobre os jogadores.
|
||||
# Canalhandia — interações de chat do servidor.
|
||||
#
|
||||
# Tudo aqui também pode ser mudado em jogo com /curiosidade, e os comandos
|
||||
# gravam neste arquivo na hora. Veja /curiosidade ajuda.
|
||||
# Tudo aqui também pode ser mudado em jogo, e os comandos gravam neste arquivo
|
||||
# na hora. Veja /canalhandia status e /curiosidade ajuda.
|
||||
#
|
||||
# Nada neste plugin afeta a jogabilidade: só mensagens de chat, barras de
|
||||
# progresso e botões clicáveis.
|
||||
|
||||
# Cada módulo pode ser ligado/desligado com /canalhandia modulo <nome> <on|off>
|
||||
modulos:
|
||||
curiosidades: true # "Sabia que o Fulano já minerou 5.966 blocos de Pedra?"
|
||||
adivinha: true # curiosidade anônima + botões com os nomes dos jogadores
|
||||
luto: true # botão [F] embaixo das mensagens de morte
|
||||
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.
|
||||
|
||||
# --- Curiosidades ------------------------------------------------------------
|
||||
|
||||
# Quando as curiosidades disparam sozinhas:
|
||||
# entrada - só quando um jogador entra no mundo (padrão)
|
||||
@@ -17,27 +31,12 @@ atraso-entrada-segundos: 6
|
||||
# Intervalo do modo temporizado, em minutos. Ignorado nos modos entrada/manual.
|
||||
intervalo-minutos: 20
|
||||
|
||||
# Tempo mínimo antes do mesmo jogador ser citado de novo. Evita spam quando
|
||||
# alguém fica entrando e saindo.
|
||||
# Tempo mínimo antes do mesmo jogador ser citado de novo.
|
||||
cooldown-minutos: 30
|
||||
|
||||
# Quantas curiosidades recentes lembrar para não repetir.
|
||||
evitar-repetir: 15
|
||||
|
||||
# Reações clicáveis embaixo de cada curiosidade.
|
||||
reacoes-ativas: true
|
||||
|
||||
# Por quanto tempo a barra de reações fica visível, em segundos.
|
||||
janela-reacao-segundos: 90
|
||||
|
||||
# As reações disponíveis. A chave é usada no comando; o valor aparece no chat.
|
||||
# Emoji funcionam no Java; no Bedrock (Geyser) alguns não renderizam, então
|
||||
# rótulos em texto puro são a opção segura se você tem muitos jogadores Bedrock.
|
||||
reacoes:
|
||||
joia: "[👍]"
|
||||
uau: "[😮]"
|
||||
fogo: "[🔥]"
|
||||
|
||||
# Tipos de curiosidade que podem ser sorteados.
|
||||
categorias:
|
||||
mineracao: true
|
||||
@@ -47,3 +46,34 @@ categorias:
|
||||
distancia: true
|
||||
tempo: true
|
||||
diversos: true
|
||||
|
||||
# --- Reações -----------------------------------------------------------------
|
||||
|
||||
reacoes-ativas: true
|
||||
|
||||
# Por quanto tempo a barra de reações fica visível, em segundos.
|
||||
janela-reacao-segundos: 90
|
||||
|
||||
# Por quanto tempo um clique ainda conta depois que a barra some. As pessoas
|
||||
# rolam o chat e clicam minutos depois; descartar esses cliques em silêncio
|
||||
# parece defeito.
|
||||
reacao-validade-minutos: 15
|
||||
|
||||
# As reações disponíveis. A chave é usada no comando; o valor aparece no chat.
|
||||
# Emoji funcionam no Java; no Bedrock (Geyser) alguns não renderizam, então
|
||||
# rótulos em texto puro são a opção segura se você tem muitos jogadores Bedrock.
|
||||
reacoes:
|
||||
joia: "[👍]"
|
||||
uau: "[😮]"
|
||||
fogo: "[🔥]"
|
||||
|
||||
# --- Jogos -------------------------------------------------------------------
|
||||
|
||||
# Quanto tempo cada rodada de /adivinha fica aberta, em segundos.
|
||||
adivinha-segundos: 45
|
||||
|
||||
# Quanto tempo cada enquete fica aberta, em minutos.
|
||||
enquete-minutos: 5
|
||||
|
||||
# Quantas posições mostrar em cada /ranking.
|
||||
ranking-tamanho: 5
|
||||
|
||||
@@ -1,32 +1,50 @@
|
||||
name: Curiosidades
|
||||
name: Canalhandia
|
||||
version: "${project.version}"
|
||||
main: dev.marcospaulo.curiosidades.Curiosidades
|
||||
main: dev.marcospaulo.canalhandia.Canalhandia
|
||||
api-version: "26.1.1"
|
||||
description: Anuncia curiosidades sobre os jogadores com base nas estatísticas do servidor.
|
||||
description: Interações sociais de chat para o servidor Canalhandia — curiosidades, adivinhas, enquetes, rankings e marcos.
|
||||
author: Canalhandia
|
||||
folia-supported: false
|
||||
|
||||
commands:
|
||||
canalhandia:
|
||||
description: Raiz de administração e destino dos botões clicáveis.
|
||||
usage: /canalhandia
|
||||
aliases: [cnh]
|
||||
curiosidade:
|
||||
description: Anuncia curiosidades e ajusta como elas funcionam.
|
||||
description: Curiosidades sobre os jogadores.
|
||||
usage: /curiosidade ajuda
|
||||
aliases: [curiosidades, cur]
|
||||
aliases: [curiosidades]
|
||||
adivinha:
|
||||
description: Inicia uma rodada de "adivinhe de quem é".
|
||||
usage: /adivinha
|
||||
enquete:
|
||||
description: Abre uma enquete com opções clicáveis.
|
||||
usage: /enquete Pergunta | Opção 1 | Opção 2
|
||||
aliases: [poll]
|
||||
ranking:
|
||||
description: Placares do servidor.
|
||||
usage: /ranking [categoria]
|
||||
aliases: [rankings, placar]
|
||||
|
||||
permissions:
|
||||
# Declared explicitly: an undeclared Bukkit permission falls back to op-only,
|
||||
# which would silently stop normal players reacting or opting out.
|
||||
curiosidades.reagir:
|
||||
description: Permite reagir às curiosidades.
|
||||
# which would silently stop normal players reacting, voting or guessing.
|
||||
canalhandia.reagir:
|
||||
description: Permite reagir e prestar luto.
|
||||
default: true
|
||||
curiosidades.ver:
|
||||
description: Permite usar /curiosidade ver e /curiosidade listar.
|
||||
canalhandia.ver:
|
||||
description: Permite /curiosidade ver, listar e /ranking.
|
||||
default: true
|
||||
curiosidades.forcar:
|
||||
description: Permite disparar um anúncio manualmente.
|
||||
canalhandia.enquete:
|
||||
description: Permite abrir enquetes.
|
||||
default: true
|
||||
canalhandia.forcar:
|
||||
description: Permite disparar curiosidades e adivinhas manualmente.
|
||||
default: op
|
||||
curiosidades.admin:
|
||||
description: Permite mudar modo, intervalo, categorias e reações.
|
||||
canalhandia.admin:
|
||||
description: Permite mudar módulos, modo, intervalo, categorias e reações.
|
||||
default: op
|
||||
curiosidades.isento:
|
||||
canalhandia.isento:
|
||||
description: Quem tem isto nunca é sorteado como assunto.
|
||||
default: false
|
||||
|
||||
Reference in New Issue
Block a user