16 Commits

Author SHA1 Message Date
Marcos Paulo f1d210ddc4 feat(ia): agentic tool-calling — web search, stats, ranking, wiki
Turn /ia from a fixed-prompt chatbot into an agent that pulls what it needs.
Instead of one hardcoded wiki pre-fetch, the model is offered read-only tools
and MiniMax's tool_choice=auto lets it decide which to call; results are fed
back until it answers (MiniMax.answerWithTools), capped by ia.max-ferramentas.

- Search: web search via the cluster's self-hosted SearXNG (JSON API, no
  external key); results boiled down to a few "título — trecho (url)" lines.
- Tools: registry + dispatch for pesquisar_web, wiki, estatisticas_jogador,
  conquistas_jogador, ranking. All read-only and thread-safe off the main
  thread, so they run on the existing async answer worker.
- Ai.ask: agentic path when ia.ferramentas is on (default), else the previous
  behaviour untouched.
- Config: ia.ferramentas, ia.max-ferramentas, ia.searxng-url, ia.resultados-web,
  ia.trecho-web; getters in Settings. Reloadable via /canalhandia reload.

Verified live against MiniMax-M2.7 + SearXNG: the model auto-calls the tool and
answers from the result. SearchTest covers the pure result formatter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 12:52:04 -03:00
Marcos Paulo 6dda1e33d3 feat(conquistas): config-driven catalogue with /canalhandia reload
Move achievements and milestones out of code and into editable YAML, so
operators add or retune titles by editing a file and reloading — no rebuild.

- conquistas-catalogo.yml: each title is a key with titulo/descricao and a list
  of condicoes. Conditions are a tiny grammar ("metrica operador alvo", target
  may be a number, another metric, or metrica/numero), evaluated on friendly
  units (km, hours) normalised from the raw stats. Achievement is now a class
  loaded from this file, not an enum.
- marcos-catalogo.yml: milestone tracks (statistica/verbo/unidade/limiares),
  loaded by Milestones at startup and reload.
- /canalhandia reload now reloads both catalogues (plus config.yml) live and
  reports the counts.
- Shipped catalogue expanded 10 -> 29 titles and every milestone track deepened.
- Silent backfill on load/reload (Achievements.syncCatalogue, Milestones
  .resyncSilently, keyed on a stored signature): a grown catalogue banks the
  history it already implies instead of spamming returning players.

Tests: AchievementTest covers the condition grammar and validates the shipped
catalogue; TitlesTest loads the real catalogue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 12:35:36 -03:00
Marcos Paulo 29000c208b feat(conquistas): view any player's titles, /perfil card, wearable title tag
Extend the achievements module from self-only to a full RPG-status layer,
all still chat-only and derived from vanilla stats.

- /conquistas [jogador]: look up anyone's earned titles, offline included,
  computed from their stats on disk via the new OfflineStats.achievementStats
  (same keys the online snapshot uses, so identical Achievement conditions).
- /perfil [jogador]: a status card — headline stats, titles earned, worn title.
- /titulo [nome|limpar]: pick which earned title to wear; only earned ones are
  accepted and tab-completed. Stored per player in titulos.yml (Titles).
- TitleChatListener: an AsyncChat renderer that prefixes the chosen "[Título]"
  chip, for Java and Bedrock alike; gated on the conquistas module.

Tests: TitlesTest covers title selection and the offline stat-key contract.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 12:17:24 -03:00
marcos c54c6b22b3 feat(ia): split long/list answers into separate chat messages
Minecraft has no meaningful character cap on what the server sends —
the 256-char limit is only on what a player types. The real problem
was that AiText.sanitise flattened every \n to a space, so a
model-produced numbered list or multi-paragraph answer landed as one
wrapped wall of text instead of readable lines.

AiText.segments() keeps the model's own line breaks, re-wraps any
line still too long at a sentence boundary (falling back to a
word/char wrap for pathological input), and caps both the total
character budget (ia.max-caracteres, unchanged meaning) and the
number of chat messages (new ia.max-mensagens, default 4) so a
runaway list can't flood chat.

Ai.deliver sends one message per segment instead of one flattened
line; style() tags only the first with [IA], continuation lines get
a plain " » " marker so a 5-item list reads as one grouped answer,
not five separate replies.

295 -> 305 tests.
2026-08-09 06:06:51 +00:00
marcos 67a2cbb37d Document mail, death history, achievements, weekly rankings, spontaneous AI and BlueMap markers
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016pEyCmrAYHBFgpYjwFxKxh
2026-08-08 01:05:53 +00:00
marcos 4ec9817636 Add weekly rankings, spontaneous AI lines and BlueMap note markers
Weekly rankings (/ranking semanal [metrica]). On a server with three regulars
an all-time board is decided by whoever started first and then stops being a
contest. Subtracting a baseline taken at the start of the week makes it one
again. Rotation is time-based and idempotent — the snapshot carries the
timestamp it was taken at and is replaced only once a week has actually
elapsed, so a server that restarts nightly does not reset the week every day.
There is a test for exactly that. Players who did not move are dropped, since
the point is who is playing this week; a player missing from the baseline
counts their whole value, having joined during the week; a negative difference
is dropped rather than shown, because statistics only go up and a negative
means a stale baseline, not a result worth printing.

Spontaneous AI lines (ia.comentar-eventos, ia.saudacao — both off by default).
The persona comments on a run of deaths, and greets players as they join using
their own numbers. Both are opt-in: a chatty AI nobody asked for is the fastest
way to make players hate the feature.

Budget is what makes this tolerable rather than obnoxious, and it is the piece
worth reading. A player question is self-limiting — someone chose to spend it.
A line the AI decides to make on its own is not, and it costs money every time,
so three limits all have to pass: a gap between any two lines, a daily cap of
its own separate from the /ia cap, and a per-subject cooldown so one unlucky
player is not narrated all evening. allows() does not spend, so a caller that
decides not to fire (nobody online, the model returned nothing) has burned
nothing; saySomething spends up front rather than on success, because two
events in the same tick would otherwise both pass allows() and fire together —
the exact double-message the gap exists to prevent. The subject map drops
expired entries on each spend, or a long-lived server would leak one entry per
player who ever triggered a line.

A death streak now decays: three deaths across an evening is not a streak,
three in ten minutes is. Spontaneous lines are silent on failure — nobody asked
for it, so nobody should see it fail.

The five-minute sweep no longer hides behind the marcos toggle. It was a
milestones-only task, so turning off marcos also silenced achievements and
froze the weekly board; each of the three now checks its own module inside the
body.

BlueMap markers (notas.no-mapa, default on). Public notes already carry a world
and coordinates and the server already runs BlueMap, so this joins the two.
BlueMapBridge is the only class that touches BlueMap's API and every entry
point catches NoClassDefFoundError as well as Exception — the failure mode of a
missing optional dependency is a linkage error, not an exception — so a server
without BlueMap logs one fine-level line and carries on. The dependency is
'provided' because BlueMap ships those classes itself; a second copy inside our
jar would shadow them and break the real plugin, and preflight now fails if the
scope is ever dropped.

Markers are rebuilt rather than incrementally patched: BlueMap discards
everything when it unloads and expects addons to re-create markers on its
enable callback, and a full rebuild of a tiny list cannot drift out of sync the
way a missed delete would. Notes are matched to the map that renders their
world, or a Nether note would be drawn at the same numeric coordinates in the
overworld, pointing at nothing. Note text is player-written and lands in a web
page, so it is HTML-escaped — ampersand first, or the other replacements would
be double-escaped. Private notes are never drawn, at any setting.

295 tests, up from 263.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016pEyCmrAYHBFgpYjwFxKxh
2026-08-08 01:03:10 +00:00
marcos 26148740ef Add offline mail, death history and named achievements
Three features that all answer questions the server could already have
answered but was throwing away.

Mail (/recado <jogador> <texto>, /recados). On a server where people rarely
overlap, "achei diamante em -400 70 200" had to go through Discord or be lost.
Messages are queued for anyone who has joined before — resolved through
usercache.json, case-insensitively, because nobody types a name with the right
capitalisation — and delivered on their next join. If the recipient is online
it is delivered immediately instead of queued, since queueing it would mean
the person standing next to you reads it only after a relog.

Delivery is destructive: a message that stayed queued would be re-read on every
single join, turning a helpful note into a nuisance. It is also delayed a few
seconds and re-checks isOnline, because a player can leave inside the delay and
the mail would otherwise be consumed with nobody there to read it. Its own
join handler, not a branch inside onJoin, which returns early when the
curiosidades module is off — mail must not depend on an unrelated module.
Inbox capped per recipient, counting every sender, so the cap cannot be
bypassed with a second account.

Death history (/mortes). The mortes module already knew where and how someone
died and discarded it once the coords were delivered on respawn. Keeping the
last ten per player answers what people actually ask a day later. Eviction is
per player, not global, or one player's bad night would erase everyone else's
history. Your own deaths only: where someone died is where their stuff is, and
a public list of that is a looting guide.

Named achievements (/conquistas). Milestones covers round numbers; this covers
the combinations that say something about how someone plays — "Casca Grossa"
(50 hours, under 10 deaths), "Turista" (100 hours, barely mined), "Imortal às
Avessas" (dies more than once per hundred blocks mined). Every condition is a
pure function of a stat map, so the catalogue is unit-tested without a server.
The ratio ones carry a floor on absolute mining, so a new player is not handed
a joke achievement on their second death — there is a test for exactly that.

First sight is silent, like Milestones: the first time a player is checked,
whatever they have already earned is recorded without announcing it. Otherwise
enabling the module would dump a dozen announcements for history earned months
ago. Players who qualify for nothing are still marked as seen, or every later
check would treat them as new and stay silent forever.

Achievements share the milestone task rather than adding a second timer: both
sweep every online player's statistics, so one pass does the work of two.
Stats.totalOf sums a material-keyed statistic into a long — the per-material
values are ints and a long-running player's total can pass Integer.MAX_VALUE.

The /conquistas checklist uses "[x]" on Bedrock instead of "✔", which renders
there as a tofu box — the same per-platform rule the reaction labels follow.

Msg.ago renders wall-clock timestamps as "há 2 dias"; a timestamp from the
future clamps to "agora" rather than printing a negative age.

263 tests, up from 218.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016pEyCmrAYHBFgpYjwFxKxh
2026-08-08 00:49:56 +00:00
marcos c11085e95e Document the notes module
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016pEyCmrAYHBFgpYjwFxKxh
2026-08-07 23:49:12 +00:00
marcos bc711a48c2 Add chat notes: /save and /nota, public and private
Players had nowhere to write anything down. Coordinates got pasted into
Discord, or lost. This adds notes to chat, with two scopes.

A private note is visible only to its author and everyone may write one
(canalhandia.nota, default true). A public note is broadcast and readable by
all, and writing one needs canalhandia.nota.publica (default op) — public
notes are a curated board, not a graffiti wall. Both permissions are declared
in plugin.yml: an undeclared Bukkit permission silently falls back to op-only,
which would have stopped normal players writing anything.

Every note stores where it was written. On a Minecraft server a note is nearly
always about a place — where the base is, where the spawner was found — so
coordinates are part of the model rather than optional metadata. Java players
get click-to-copy on them, the same affordance the death-coords message uses;
Bedrock renders no click event and gets the plain text. No teleport: the
plugin does not touch gameplay.

/save is the quick path. /save and /save coords pin the spot; /save <texto>
pins it with a note. Private on purpose — it is the command someone types
without reading help first, and the safe default there is the one that cannot
surprise anyone by broadcasting. /nota publica <texto> is the explicit way to
share.

Private notes are never sent to the AI, at any setting. The AI call leaves
this server for a third-party API, so a private note reaching it would be a
disclosure the author never agreed to. The filter lives inside
Notes.publicSummary — the only method the AI path calls — rather than at the
call site, so a future caller cannot get it wrong by accident. Public notes
are sent (ia.contexto-notas, default 10), which is what lets the AI answer
"onde fica a base?" from what players actually wrote down.

A note the viewer cannot see is reported as missing rather than as forbidden:
saying "that one is private" would confirm it exists, which is itself a leak.
Search runs through the same visibility filter, so it cannot become a way to
probe for someone else's text. Ids are never reused after a deletion, or
"/nota ver 2" would point at a different note than the one someone wrote down
a minute ago. Text is stripped of the section sign and control characters,
because a note is echoed into chat and could otherwise forge a line that looks
like it came from the server.

Scope parsing accepts the masculine and plural forms ("publico", "publicas")
alongside the canonical ones. Spelled out rather than derived: a blanket a-to-o
rewrite turns "privada" into "privodo", and the plural is exactly what tab
completion suggests, so it has to parse or the suggested command fails.

preflight.sh now checks all 18 commands and both new permissions.

218 tests, up from 176.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016pEyCmrAYHBFgpYjwFxKxh
2026-08-07 23:46:55 +00:00
marcos 5653836262 Document the AI personality, chat/world context and preflight harness
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016pEyCmrAYHBFgpYjwFxKxh
2026-08-07 22:50:30 +00:00
marcos 0726ce3794 Give the AI a personality, chat awareness and live server state
The AI could only see three static facts about the server, so it answered
"quem tá online?" and "tá chovendo?" by insisting it had no access — true of
the model, but not of the plugin, which has all of it on hand. It also had no
tone: correct answers delivered like a manual, on a server whose whole point
is people ribbing each other.

Persona: five tones (zoeiro, amigao, seco, aldeao, neutro), switchable live
with /ia personalidade <nome>. Personality is expressed only as extra system
instructions and changes how the model talks, never what it may do. Every
persona — including the blank one — carries Persona.GUARD, which restates the
no-commands/no-server-access limits inside the persona's own frame, so a
roleplay instruction cannot read as licence to claim powers the plugin does
not grant it. The guard also forbids inventing stats, which matters now that
real numbers are being fed in. The teasing personas each state where the line
is; a test asserts every one of them does.

ChatLog: a 50-line in-memory ring of public chat, the last few lines handed to
the model so a follow-up like "quem tá reclamando aí?" has a referent. Written
from the chat event (off the main thread) and read from /ia, so it is
synchronised; a concurrency test hammers it from eight threads, because an
unsynchronised deque here would throw ConcurrentModification into a player's
answer. Recorded at MONITOR priority so what is stored is what the room saw —
a zoacao swap included — and cancelled messages are never stored. Nothing
touches disk.

ServerState: who is online with their platform, dimension, time of day,
weather, and the asker's coordinates, health, hunger and XP. Captured on the
main thread before the async call — every field reads the Bukkit world API,
which is not safe off it — and only the formatted string crosses the thread
boundary. Formatting is pure and tested, including the negative-tick case a
raw modulo would drop through every band.

Styling: Java players get a hover card with the original question and the
active persona, plus a click that pre-fills "/ia " for a follow-up.
suggestCommand, not runCommand: nothing executes without the player pressing
enter. Bedrock renders neither hover nor click, so it keeps the plain line,
built through broadcastPerPlatform like every other interactive message here.

preflight.sh: a read-only pre-restart harness. It verifies the jar opens, that
plugin.yml declares all 16 commands, that the staged jar's hash matches the
local build and is owned 1000:0, that the live config parses as YAML and
carries the keys this deploy depends on, and that a rollback jar and config
backup both exist. It never restarts anything. A missing YAML parser reports
as "not checked" rather than "invalid" — a harness that cries wolf gets
ignored.

176 tests, up from 101.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016pEyCmrAYHBFgpYjwFxKxh
2026-08-07 22:49:02 +00:00
marcos b77a38b394 Make zoacao match rule editable in-game: mode + pattern + message list
Zoacao now matches a configurable pattern under a configurable mode
(igual/contem/comeca/termina/regex) instead of only a bare 'f'. The mode,
the trigger pattern, and the gag message list are all editable live via
/canalhandia zoacao <listar|modo|padrao|adicionar|remover|limpar>; every
change writes through to config.yml immediately. config.yml gains
zoacao.correspondencia + zoacao.padrao (default igual + 'f').

Zoacao.replace/ matches gained a Mode + pattern signature; ZoacaoTest
covers all five modes (incl. invalid-regex guard) and the byKey parser.
136 tests green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-06 22:27:42 +00:00
marcos 72840c9770 Document zoacao module in README
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-06 22:23:25 +00:00
marcos d61de1b208 Add zoacao chat gag: bare 'f' replaced with a random line
New module keyed 'zoacao'. An AsyncPlayerChatEvent listener swaps any
bare 'f'/'F' (trimmed, nothing else) for a random line from the
configurable zoacao.mensagens list (default Sou gay + 5 others). Pure
chat swap — player name still prefixes it; luto tribute untouched (still
needs the [F] button or /f command). Gated by modulos.zoacao (default on).

Zoacao.replace is pure for unit tests; ZoacaoTest covers bare f, case,
whitespace, f-with-extra-text, empty/null lists. 124 tests green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-06 22:22:25 +00:00
marcos 95520831da Remove reaction boss bar; fix death coords; F-tribute head
- Reactions: drop the purple boss bar that sat on screen for the whole
  reaction window (curiosidades, adivinha, luto [F], /ia answers). Live
  counts still ride on the reactor's action bar and the closing tally line.
- mortes: death coordinates were sent during PlayerDeathEvent and the Java
  death screen swallowed them. Capture at death, deliver on PlayerRespawnEvent
  (1 tick later) so the player actually receives them. Respects keepInventory.
- luto: pressing F to pay respects now drops the dead player's head into the
  mourner's inventory — once per mourner per death, never to the dead player.
  First gameplay-touching feature; toggle with luto.cabeca (default on).
- Fix the /f typed twin: it fell through to help because "f" is not a
  configured reaction (the mourning set is hardcoded). Route it to reactLatest
  so Bedrock players can pay respects too.
- README + config updated. 117 tests green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-06 13:59:28 +00:00
marcos 68db3a7079 feat: feed asker stats to the IA + comic death messages
Two features requested after the IA grounding deploy.

IA player-stats grounding: the IA could not answer "quantos blocos eu
minerei?" because its context carried only generic server facts, never the
asker's own numbers. OfflineStats now reads one player's headline stats
(blocks mined, time played, distance, deaths, mob kills) from their vanilla
stats JSON and Ai.compose() injects them as a system turn, gated by
ia.estatisticas-jogador (default true). ~30 tokens per question; always on
so it never misses a stat question. Pure formatSummary extracted for tests.

Comic deaths (mortes module): replaces the vanilla death message with a
cause-based pt-BR comic line plus a death counter ("Fulano foi achatado
como panqueca (47ª morte)") and sends the death coordinates privately to
the dead player (Java click-to-copy, Bedrock plain text) so they can run
back to their dropped items. No storage, no command — a PlayerDeathEvent
side effect gated by modulos.mortes. DeathFlavor is a pure cause->phrase
map, unit-tested. Coexists with the luto [F] handler.

DEATHS stat timing: assumes Paper fires PlayerDeathEvent before awarding
minecraft:deaths, so the counter shows +1 to include the current death; a
log line confirms the raw stat on the first real death so the +1 can be
dropped if the server increments first.

117 tests green (101 + 13 DeathFlavor + 3 OfflineStatsSummary).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-06 05:06:45 +00:00
20 changed files with 205 additions and 1192 deletions
@@ -1,16 +1,12 @@
package dev.marcospaulo.canalhandia;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextColor;
import org.bukkit.configuration.ConfigurationSection;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.logging.Logger;
/**
@@ -27,18 +23,11 @@ import java.util.logging.Logger;
* a number, another metric, or {@code metrica/numero}. Metrics are written in
* friendly units — distance in kilometres, time in hours — normalised from the
* raw statistics before evaluation, so the file reads the way a person thinks.
* A metric may also be a {@link StatRef} like {@code matou:creeper}, reaching any
* per-mob or per-block vanilla counter with no code change.
*
* <p>Each title also carries a {@code tier} (comum…lendario) that colours its
* chat tag, and an optional {@code cor} override (named or {@code #hex}), so a
* legendary reads gold and a rare reads aqua without touching Java.
*/
final class Achievement {
/** Friendly metrics a condition may read, in the units the config is written in.
* mineracao/combate/mortes/pesca/pulos are raw counts; distancia is km; tempo is hours.
* A condition may also name a {@link StatRef} (e.g. {@code matou:creeper}). */
/** Metrics a condition may read, in the units the config is written in.
* mineracao/combate/mortes/pesca/pulos are raw counts; distancia is km; tempo is hours. */
private static final List<String> METRICS = List.of(
"mineracao", "combate", "mortes", "pesca", "pulos", "distancia", "tempo");
@@ -49,17 +38,12 @@ final class Achievement {
private final String title;
private final String description;
private final Condition condition;
private final TextColor color;
private final Set<String> statRefs;
private Achievement(String key, String title, String description, Condition condition,
TextColor color, Set<String> statRefs) {
private Achievement(String key, String title, String description, Condition condition) {
this.key = key;
this.title = title;
this.description = description;
this.condition = condition;
this.color = color;
this.statRefs = Set.copyOf(statRefs);
}
String key() {
@@ -74,11 +58,6 @@ final class Achievement {
return description;
}
/** The colour this title's chat tag is drawn in, from its tier or {@code cor} override. */
TextColor color() {
return color;
}
/** True when this player's raw statistics satisfy the condition. */
boolean met(Map<String, Long> rawStats) {
return rawStats != null && condition.met(normalise(rawStats));
@@ -96,16 +75,6 @@ final class Achievement {
return catalog.toArray(new Achievement[0]);
}
/** Every vanilla {@link StatRef} the live catalogue mentions, so the stats
* reader knows which per-mob/per-block counters to fetch. Empty until load. */
static Set<String> referencedStats() {
Set<String> all = new HashSet<>();
for (Achievement achievement : catalog) {
all.addAll(achievement.statRefs);
}
return all;
}
static Achievement byKey(String key) {
if (key == null) {
return null;
@@ -154,8 +123,7 @@ final class Achievement {
}
try {
out.add(parse(key, entry.getString("titulo", ""),
entry.getString("descricao", ""), entry.getStringList("condicoes"),
entry.getString("tier"), entry.getString("cor")));
entry.getString("descricao", ""), entry.getStringList("condicoes")));
} catch (IllegalArgumentException bad) {
log.warning("Conquista '" + key + "' ignorada: " + bad.getMessage());
}
@@ -163,14 +131,8 @@ final class Achievement {
return out;
}
/** Builds one achievement with the default (comum) colour. Visible for tests. */
/** Builds one achievement, parsing its condition clauses. Visible for tests. */
static Achievement parse(String key, String title, String description, List<String> conditions) {
return parse(key, title, description, conditions, null, null);
}
/** Builds one achievement, parsing its condition clauses and resolving its colour. */
static Achievement parse(String key, String title, String description, List<String> conditions,
String tier, String cor) {
String normalizedKey = key == null ? "" : key.trim().toLowerCase(Locale.ROOT);
if (!normalizedKey.matches("[a-z-]+")) {
throw new IllegalArgumentException("chave inválida (use apenas a-z e '-'): " + key);
@@ -182,16 +144,8 @@ final class Achievement {
throw new IllegalArgumentException("sem condicoes");
}
List<Clause> clauses = new ArrayList<>();
Set<String> refs = new HashSet<>();
for (String raw : conditions) {
Clause clause = Clause.parse(raw);
clauses.add(clause);
if (StatRef.isRef(clause.metric())) {
refs.add(clause.metric());
}
if (clause.rhsMetric() != null && StatRef.isRef(clause.rhsMetric())) {
refs.add(clause.rhsMetric());
}
clauses.add(Clause.parse(raw));
}
Condition condition = stats -> {
for (Clause clause : clauses) {
@@ -201,41 +155,12 @@ final class Achievement {
}
return true;
};
return new Achievement(normalizedKey, title, description, condition,
resolveColor(tier, cor), refs);
return new Achievement(normalizedKey, title, description, condition);
}
/** Tier or explicit {@code cor} → the colour of the chat tag. Bad input falls
* back to the tier colour, and an unknown tier to a readable white. */
private static TextColor resolveColor(String tier, String cor) {
if (cor != null && !cor.isBlank()) {
String value = cor.trim();
TextColor explicit = value.startsWith("#")
? TextColor.fromHexString(value)
: NamedTextColor.NAMES.value(value.toLowerCase(Locale.ROOT));
if (explicit != null) {
return explicit;
}
}
return tierColor(tier);
}
/** Default colour for each difficulty tier. Higher tiers read cooler/brighter. */
private static TextColor tierColor(String tier) {
String name = tier == null ? "" : tier.trim().toLowerCase(Locale.ROOT);
return switch (name) {
case "incomum" -> NamedTextColor.GREEN;
case "raro" -> NamedTextColor.AQUA;
case "epico", "épico" -> NamedTextColor.LIGHT_PURPLE;
case "lendario", "lendário" -> NamedTextColor.GOLD;
default -> NamedTextColor.WHITE; // comum / unset — always legible
};
}
/** Raw statistics → the friendly units the conditions are written in. Any
* {@link StatRef} counts (matou:*, minerou:*) pass through untouched. */
/** Raw statistics → the friendly units the conditions are written in. */
private static Map<String, Long> normalise(Map<String, Long> raw) {
Map<String, Long> out = new HashMap<>(raw);
Map<String, Long> out = new HashMap<>();
out.put("mineracao", raw.getOrDefault("mineracao", 0L));
out.put("combate", raw.getOrDefault("combate", 0L));
out.put("mortes", raw.getOrDefault("mortes", 0L));
@@ -290,7 +215,7 @@ final class Achievement {
throw new IllegalArgumentException("condicao mal formada: '" + raw + "'");
}
String metric = parts[0].toLowerCase(Locale.ROOT);
if (!METRICS.contains(metric) && !StatRef.isValid(metric)) {
if (!METRICS.contains(metric)) {
throw new IllegalArgumentException("metrica desconhecida: " + metric);
}
Op op = Op.of(parts[1]);
@@ -312,7 +237,7 @@ final class Achievement {
throw new IllegalArgumentException("divisão por zero: " + target);
}
}
if (!METRICS.contains(rhsMetric) && !StatRef.isValid(rhsMetric)) {
if (!METRICS.contains(rhsMetric)) {
throw new IllegalArgumentException("metrica desconhecida: " + rhsMetric);
}
return new Clause(metric, op, rhsMetric, 0, divisor);
@@ -4,12 +4,14 @@ 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.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
@@ -111,14 +113,7 @@ final class Achievements {
/** @return true if anything was recorded, so the caller can save once */
private boolean check(Player player) {
// Read straight off the stats file, the same source /perfil and /conquistas
// use, so a title can hinge on any per-mob or per-block counter (matou:creeper)
// that Bukkit's typed API would make us enumerate by hand. The file lags a
// live session by seconds — invisible for cumulative threshold titles.
Map<String, Long> stats = plugin.offlineStats().achievementStats(player.getUniqueId());
if (stats == null) {
return false; // no stats file written yet — nothing to bank, retry next tick
}
Map<String, Long> stats = snapshot(player);
String base = player.getUniqueId().toString();
// A player with no record yet is being seen for the first time: bank
// what they have without announcing it.
@@ -154,7 +149,7 @@ final class Achievements {
.decoration(TextDecoration.BOLD, false))
.append(Component.text(" desbloqueou ", NamedTextColor.WHITE)
.decoration(TextDecoration.BOLD, false))
.append(Component.text(achievement.title(), achievement.color())
.append(Component.text(achievement.title(), NamedTextColor.AQUA)
.decoration(TextDecoration.BOLD, false))
.append(Component.text("" + achievement.description(), NamedTextColor.GRAY)
.decoration(TextDecoration.BOLD, false)));
@@ -173,6 +168,36 @@ final class Achievements {
return out;
}
/**
* The stat map an {@link Achievement} condition reads, keyed the same way
* as the config's category names.
*
* <p>Statistic constants get renamed between Minecraft releases, so each is
* resolved by name through {@link Stats#resolve} rather than referenced
* directly — a missing one yields zero instead of failing to load the class.
*/
static Map<String, Long> snapshot(Player player) {
Map<String, Long> stats = new HashMap<>();
stats.put("mineracao", total(player, "MINE_BLOCK"));
stats.put("tempo", untyped(player, "PLAY_TIME"));
stats.put("distancia", untyped(player, "WALK_ONE_CM"));
stats.put("mortes", untyped(player, "DEATHS"));
stats.put("combate", untyped(player, "MOB_KILLS"));
stats.put("pesca", untyped(player, "FISH_CAUGHT"));
stats.put("pulos", untyped(player, "JUMP"));
return stats;
}
private static long untyped(Player player, String name) {
Statistic statistic = Stats.resolve(name);
return statistic == null ? 0L : Stats.untyped(player, statistic);
}
private static long total(Player player, String name) {
Statistic statistic = Stats.resolve(name);
return statistic == null ? 0L : Stats.totalOf(player, statistic);
}
private void save() {
try {
data.save(file);
@@ -5,7 +5,6 @@ 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 net.kyori.adventure.translation.TranslationStore;
import org.bukkit.Bukkit;
import org.bukkit.GameRule;
import org.bukkit.Location;
@@ -99,8 +98,6 @@ public final class Canalhandia extends JavaPlugin implements Listener {
private GuessRound guessRound;
private Poll poll;
private Titles titles;
private DeathGift deathGift;
private TranslationStore<?> i18n;
@Override
public void onEnable() {
@@ -108,7 +105,6 @@ public final class Canalhandia extends JavaPlugin implements Listener {
// Ship the editable catalogues; false = never overwrite the operator's copy.
saveResource("conquistas-catalogo.yml", false);
saveResource("marcos-catalogo.yml", false);
i18n = I18n.install(i18n, getLogger());
settings = new Settings(this);
notes = new Notes(new java.io.File(getDataFolder(), "notas.yml"));
mail = new Mail(new java.io.File(getDataFolder(), "recados.yml"));
@@ -117,7 +113,6 @@ public final class Canalhandia extends JavaPlugin implements Listener {
milestones = new Milestones(this);
achievements = new Achievements(this);
titles = new Titles(this);
deathGift = new DeathGift(this);
// Load the achievement catalogue from config, then silently bank any
// history the current definitions already imply (both here and for
// milestones), so an expanded catalogue never spams returning players.
@@ -252,12 +247,6 @@ public final class Canalhandia extends JavaPlugin implements Listener {
Achievement.load(Achievement.loadFrom(conquistasCatalogo(), getLogger()));
milestones.reload();
achievements.syncCatalogue();
deathGift.reload();
}
/** Reloads the i18n bundles from the jar and re-registers the translator. */
void reloadI18n() {
i18n = I18n.install(i18n, getLogger());
}
/** The weekly ranking baseline. Never null. */
@@ -783,12 +772,11 @@ public final class Canalhandia extends JavaPlugin implements Listener {
button = button.clickEvent(ClickEvent.runCommand(
"/canalhandia reagir " + mourning.id() + " f"));
}
Component prompt = Lang.tr(bedrock
? "canalhandia.morte.luto.digitar"
: "canalhandia.morte.luto.prestar",
Component.text(name));
return Component.text(" ").append(button)
.append(prompt.color(NamedTextColor.GRAY));
.append(Component.text(bedrock
? "digite /f para prestar luto por " + name
: "prestar luto por " + name,
NamedTextColor.GRAY));
}), 2L);
getServer().getScheduler().runTaskLater(this, () -> {
@@ -798,10 +786,9 @@ public final class Canalhandia extends JavaPlugin implements Listener {
int shown = Math.min(who.size(), settings.summaryNames());
String text = String.join(", ", who.subList(0, shown))
+ (who.size() > shown ? " +" + (who.size() - shown) : "");
Component summary = Lang.tr("canalhandia.morte.luto.resumo",
Component.text(text), Component.text(name));
Bukkit.broadcast(Component.text(" ")
.append(summary.color(NamedTextColor.GRAY)));
.append(Component.text(text + " prestaram luto por " + name + ".",
NamedTextColor.GRAY)));
}
if (liveReactions == mourning) {
liveReactions = null;
@@ -908,11 +895,6 @@ public final class Canalhandia extends JavaPlugin implements Listener {
.append(Component.text(tail, NamedTextColor.AQUA));
}
player.sendMessage(msg);
// A comic consolation item, given once they can actually hold it.
// Gameplay-neutral by design (a poppy, a wilted bush) — just a laugh.
if (deathGift.active()) {
deathGift.give(player);
}
}, 1L);
}
@@ -107,7 +107,6 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
if (admin(sender)) {
plugin.reloadConfig();
plugin.reloadCatalogo();
plugin.reloadI18n();
plugin.rescheduleTimer();
plugin.rescheduleMilestones();
Msg.ok(sender, "Recarregado: " + Achievement.values().length
@@ -127,7 +126,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
return denied(sender);
}
if (!plugin.announceCuriosity(null)) {
Msg.error(sender, Lang.tr("canalhandia.cmd.curiosidade.nenhum-elegivel"));
Msg.error(sender, "Ninguém elegível online (ou sem estatísticas suficientes).");
}
return true;
}
@@ -191,12 +190,11 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
default -> {
Player target = Bukkit.getPlayerExact(args[0]);
if (target == null) {
Msg.error(sender, Lang.tr("canalhandia.cmd.curiosidade.subdesconhecido"));
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, Lang.tr("canalhandia.cmd.curiosidade.sem-stats",
Component.text(target.getName())));
Msg.error(sender, target.getName() + " ainda não tem estatísticas suficientes.");
}
}
}
@@ -210,8 +208,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
}
List<Fact> facts = CuriosityFactory.facts(target, plugin.settings());
if (facts.isEmpty()) {
Msg.error(sender, Lang.tr("canalhandia.cmd.curiosidade.sem-curiosidade",
Component.text(target.getName())));
Msg.error(sender, "Nenhuma curiosidade disponível para " + target.getName() + ".");
return;
}
Fact fact = facts.get((int) (Math.random() * facts.size()));
@@ -245,27 +242,27 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
if (args.length > 0) {
Player target = Bukkit.getPlayerExact(args[0]);
if (target == null) {
Msg.error(sender, Lang.tr("canalhandia.cmd.jogador.offline", Component.text(args[0])));
Msg.error(sender, "Jogador '" + args[0] + "' não está online.");
}
return target;
}
if (sender instanceof Player player) {
return player;
}
Msg.error(sender, Lang.tr("canalhandia.cmd.jogador.informe"));
Msg.error(sender, "Informe um jogador.");
return null;
}
private void toggle(CommandSender sender) {
if (!(sender instanceof Player player)) {
Msg.error(sender, Lang.tr("canalhandia.cmd.sojogador.usar"));
Msg.error(sender, "Só jogadores podem usar isso.");
return;
}
boolean optedOut = !plugin.isOptedOut(player);
plugin.setOptedOut(player, optedOut);
sender.sendMessage(optedOut
? Lang.tr("canalhandia.cmd.curiosidade.toggle-off").color(NamedTextColor.YELLOW)
: Lang.tr("canalhandia.cmd.curiosidade.toggle-on").color(NamedTextColor.GREEN));
? Component.text("Você não aparecerá mais nas curiosidades.", NamedTextColor.YELLOW)
: Component.text("Você voltou a aparecer nas curiosidades.", NamedTextColor.GREEN));
}
// --- /adivinha ----------------------------------------------------------
@@ -275,7 +272,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
return denied(sender);
}
if (!plugin.settings().moduleEnabled(Module.ADIVINHA)) {
Msg.error(sender, Lang.tr("canalhandia.cmd.modulo.desativado", Component.text("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.");
}
@@ -288,7 +285,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
}
GuessRound round = plugin.guessRound();
if (round == null || round.id() != parse(args[0], -1)) {
player.sendActionBar(Lang.tr("canalhandia.cmd.adivinha.rodada-acabou").color(NamedTextColor.RED));
player.sendActionBar(Component.text("Essa rodada já acabou.", NamedTextColor.RED));
return;
}
player.sendActionBar(round.guess(player, args[1]));
@@ -301,13 +298,13 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
return denied(sender);
}
if (!plugin.settings().moduleEnabled(Module.ENQUETE)) {
Msg.error(sender, Lang.tr("canalhandia.cmd.modulo.desativado", Component.text("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, Lang.tr("canalhandia.cmd.enquete.nenhuma"));
Msg.error(sender, "Nenhuma enquete aberta.");
} else {
Bukkit.broadcast(poll.close());
}
@@ -341,7 +338,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
}
Poll poll = plugin.poll();
if (poll == null || poll.id() != parse(args[0], -1)) {
player.sendActionBar(Lang.tr("canalhandia.cmd.votar.encerrada").color(NamedTextColor.RED));
player.sendActionBar(Component.text("Essa enquete já foi encerrada.", NamedTextColor.RED));
return;
}
Component result = poll.vote(player, parse(args[1], -1));
@@ -357,7 +354,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
return denied(sender);
}
if (!plugin.settings().moduleEnabled(Module.RANKING)) {
Msg.error(sender, Lang.tr("canalhandia.cmd.modulo.desativado", Component.text("ranking")));
Msg.error(sender, "O módulo ranking está desativado.");
return true;
}
if (args.length == 0) {
@@ -385,7 +382,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
RankingMetric metric = RankingMetric.byKey(rest[0]);
if (metric == null) {
Msg.error(sender, Lang.tr("canalhandia.cmd.ranking.desconhecido"));
Msg.error(sender, "Ranking desconhecido. Use /ranking para ver a lista.");
return true;
}
int size = plugin.settings().rankingSize();
@@ -402,8 +399,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
Msg.line(sender, "desde", taken == 0 ? "o começo" : Msg.ago(taken));
}
if (rows.isEmpty()) {
sender.sendMessage(Component.text(" ", NamedTextColor.GRAY)
.append(Lang.tr("canalhandia.cmd.ranking.sem-dados")));
sender.sendMessage(Component.text(" (sem dados ainda)", NamedTextColor.GRAY));
return true;
}
for (int i = 0; i < rows.size(); i++) {
@@ -429,7 +425,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
*/
private boolean reactLatest(CommandSender sender, String key) {
if (!(sender instanceof Player player)) {
Msg.error(sender, Lang.tr("canalhandia.cmd.sojogador.reagir"));
Msg.error(sender, "Só jogadores podem reagir.");
return true;
}
if (!player.hasPermission("canalhandia.reagir")) {
@@ -437,16 +433,15 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
}
Reactions reactions = plugin.latestReactions();
if (reactions == null) {
Msg.error(sender, Lang.tr("canalhandia.cmd.reagir.nada"));
Msg.error(sender, "Nada para reagir agora.");
return true;
}
if (key == null) {
Msg.error(sender, Lang.tr("canalhandia.cmd.reagir.uso",
Component.text(String.join("|", reactionKeys()))));
Msg.error(sender, "Uso: /reagir <" + String.join("|", reactionKeys()) + ">");
return true;
}
if (!reactions.react(player, key)) {
Msg.error(sender, Lang.tr("canalhandia.cmd.reagir.invalida"));
Msg.error(sender, "Essa reação não vale para a última mensagem.");
} else {
plugin.afterReact(player, reactions.id(), key);
}
@@ -459,11 +454,11 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
}
GuessRound round = plugin.guessRound();
if (round == null || round.finished()) {
Msg.error(sender, Lang.tr("canalhandia.cmd.palpite.nenhuma"));
Msg.error(sender, "Nenhuma adivinha aberta.");
return true;
}
if (args.length == 0) {
Msg.error(sender, Lang.tr("canalhandia.cmd.palpite.uso"));
Msg.error(sender, "Uso: /palpite <nome>");
return true;
}
player.sendMessage(round.guess(player, args[0]));
@@ -476,16 +471,16 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
}
Poll poll = plugin.poll();
if (poll == null || poll.closed()) {
Msg.error(sender, Lang.tr("canalhandia.cmd.enquete.nenhuma"));
Msg.error(sender, "Nenhuma enquete aberta.");
return true;
}
if (args.length == 0) {
Msg.error(sender, Lang.tr("canalhandia.cmd.votar.uso"));
Msg.error(sender, "Uso: /votar <número>");
return true;
}
Component result = poll.vote(player, parse(args[0], -1));
player.sendMessage(result == null
? Lang.tr("canalhandia.cmd.votar.opcao-inexistente").color(NamedTextColor.RED)
? Component.text("Essa opção não existe.", NamedTextColor.RED)
: result);
return true;
}
@@ -494,11 +489,11 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
private boolean whoReacted(CommandSender sender) {
Reactions reactions = plugin.latestReactions();
if (reactions == null || !reactions.hasAnyVote()) {
Msg.error(sender, Lang.tr("canalhandia.cmd.reacoes.nenhuma"));
Msg.error(sender, "Ninguém reagiu à última mensagem ainda.");
return true;
}
boolean bedrock = sender instanceof Player player && Platform.isBedrock(player);
Msg.header(sender, Lang.tr("canalhandia.cmd.reacoes.cabecalho", Component.text(reactions.total())));
Msg.header(sender, "Quem reagiu (" + reactions.total() + ")");
reactions.breakdown(bedrock).forEach(sender::sendMessage);
return true;
}
@@ -536,11 +531,11 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
}
Reactions reactions = plugin.findReactions(parse(args[0], -1));
if (reactions == null) {
player.sendActionBar(Lang.tr("canalhandia.cmd.reagir.expirou").color(NamedTextColor.RED));
player.sendActionBar(Component.text("Essa mensagem já expirou.", NamedTextColor.RED));
return;
}
if (!reactions.react(player, args[1])) {
player.sendActionBar(Lang.tr("canalhandia.cmd.reagir.desconhecida").color(NamedTextColor.RED));
player.sendActionBar(Component.text("Reação desconhecida.", NamedTextColor.RED));
} else {
plugin.afterReact(player, reactions.id(), args[1]);
}
@@ -944,11 +939,11 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
return denied(sender);
}
if (!plugin.settings().moduleEnabled(Module.IA)) {
Msg.error(sender, Lang.tr("canalhandia.cmd.modulo.desligado", Component.text("IA")));
Msg.error(sender, "O módulo de IA está desligado.");
return true;
}
if (!(sender instanceof Player player)) {
Msg.error(sender, Lang.tr("canalhandia.cmd.sojogador.ia"));
Msg.error(sender, "Só jogadores podem usar /ia.");
return true;
}
// Subcommands come before the question. They only hijack when the
@@ -1000,8 +995,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
// default: treat all args as the question
}
if (args.length == 0) {
Msg.error(sender, Lang.tr("canalhandia.cmd.ia.uso",
Component.text(isPrivate ? "iap" : "ia")));
Msg.error(sender, isPrivate ? "Uso: /iap <pergunta>" : "Uso: /ia <pergunta>");
return true;
}
plugin.ai().ask(player, String.join(" ", args), isPrivate);
@@ -1067,7 +1061,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
*/
private boolean nota(CommandSender sender, String[] args) {
if (!plugin.settings().moduleEnabled(Module.NOTAS)) {
Msg.error(sender, Lang.tr("canalhandia.cmd.modulo.desligado", Component.text("anotações")));
Msg.error(sender, "O módulo de anotações está desligado.");
return true;
}
if (!sender.hasPermission("canalhandia.nota")) {
@@ -1101,14 +1095,14 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
*/
private boolean saveShortcut(CommandSender sender, String[] args) {
if (!plugin.settings().moduleEnabled(Module.NOTAS)) {
Msg.error(sender, Lang.tr("canalhandia.cmd.modulo.desligado", Component.text("anotações")));
Msg.error(sender, "O módulo de anotações está desligado.");
return true;
}
if (!sender.hasPermission("canalhandia.nota")) {
return denied(sender);
}
if (!(sender instanceof Player player)) {
Msg.error(sender, Lang.tr("canalhandia.cmd.sojogador.nota"));
Msg.error(sender, "Só jogadores podem anotar (a anotação guarda onde você está).");
return true;
}
// "/save" and "/save coords" mean the same thing: just the place. The
@@ -1135,23 +1129,23 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
*/
private boolean recado(CommandSender sender, String[] args) {
if (!plugin.settings().moduleEnabled(Module.RECADOS)) {
Msg.error(sender, Lang.tr("canalhandia.cmd.modulo.desligado", Component.text("recados")));
Msg.error(sender, "O módulo de recados está desligado.");
return true;
}
if (!sender.hasPermission("canalhandia.recado")) {
return denied(sender);
}
if (!(sender instanceof Player player)) {
Msg.error(sender, Lang.tr("canalhandia.cmd.sojogador.recado"));
Msg.error(sender, "Só jogadores podem mandar recado.");
return true;
}
if (args.length < 2) {
Msg.error(sender, Lang.tr("canalhandia.cmd.recado.uso"));
Msg.error(sender, "Uso: /recado <jogador> <texto>");
return true;
}
String text = Note.cleanText(String.join(" ", Arrays.copyOfRange(args, 1, args.length)));
if (text == null) {
Msg.error(sender, Lang.tr("canalhandia.cmd.recado.vazio"));
Msg.error(sender, "O recado está vazio.");
return true;
}
@@ -1164,21 +1158,23 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
// anyone who has played here before.
OfflineStats.Known target = plugin.offlineStats().resolve(args[0]);
if (target == null) {
Msg.error(sender, Lang.tr("canalhandia.cmd.recado.desconhecido", Component.text(args[0])));
Msg.error(sender, "Não conheço ninguém chamado \"" + args[0]
+ "\". (Só dá para mandar recado para quem já entrou no servidor.)");
return true;
}
if (target.uuid().equals(player.getUniqueId().toString())) {
Msg.error(sender, Lang.tr("canalhandia.cmd.recado.mesmo"));
Msg.error(sender, "Recado para você mesmo? Use /save.");
return true;
}
Mail.Message message = plugin.mail().send(player.getName(),
player.getUniqueId().toString(), target.uuid(), text);
if (message == null) {
Msg.error(sender, Lang.tr("canalhandia.cmd.recado.caixa-cheia",
Component.text(target.name()), Component.text(Mail.MAX_PER_RECIPIENT)));
Msg.error(sender, "A caixa de " + target.name() + " está cheia ("
+ Mail.MAX_PER_RECIPIENT + " recados). Espere ela entrar.");
return true;
}
Msg.ok(sender, Lang.tr("canalhandia.cmd.recado.guardado", Component.text(target.name())));
Msg.ok(sender, "Recado guardado para " + target.name()
+ ". Vai chegar quando " + target.name() + " entrar.");
return true;
}
@@ -1187,25 +1183,24 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
to.sendMessage(Msg.tag("Recado", NamedTextColor.AQUA)
.append(Component.text(from.getName() + ": ", NamedTextColor.GRAY))
.append(Component.text(text, NamedTextColor.WHITE)));
Msg.ok(from, Lang.tr("canalhandia.cmd.recado.online", Component.text(to.getName())));
Msg.ok(from, to.getName() + " está online — recado entregue na hora.");
}
/** {@code /recados} — how many of your messages are still unread. */
private boolean recados(CommandSender sender) {
if (!plugin.settings().moduleEnabled(Module.RECADOS)) {
Msg.error(sender, Lang.tr("canalhandia.cmd.modulo.desligado", Component.text("recados")));
Msg.error(sender, "O módulo de recados está desligado.");
return true;
}
if (!(sender instanceof Player player)) {
Msg.error(sender, Lang.tr("canalhandia.cmd.sojogador.recados"));
Msg.error(sender, "Só jogadores têm recados.");
return true;
}
int pending = plugin.mail().countFrom(player.getUniqueId().toString());
Msg.ok(sender, pending == 0
? Lang.tr("canalhandia.cmd.recados.tudo-entregue")
: Lang.tr(pending == 1 ? "canalhandia.cmd.recados.pendentes-singular"
: "canalhandia.cmd.recados.pendentes-plural",
Component.text(pending)));
? "Todos os seus recados já foram entregues."
: pending + (pending == 1 ? " recado seu ainda não foi lido."
: " recados seus ainda não foram lidos."));
return true;
}
@@ -1219,26 +1214,27 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
*/
private boolean mortes(CommandSender sender) {
if (!plugin.settings().moduleEnabled(Module.MORTES)) {
Msg.error(sender, Lang.tr("canalhandia.cmd.modulo.desligado", Component.text("mortes")));
Msg.error(sender, "O módulo de mortes está desligado.");
return true;
}
if (!(sender instanceof Player player)) {
Msg.error(sender, Lang.tr("canalhandia.cmd.sojogador.mortes"));
Msg.error(sender, "Só jogadores têm histórico de mortes.");
return true;
}
List<DeathLog.Entry> deaths = plugin.deathLog().forPlayer(player.getUniqueId().toString());
if (deaths.isEmpty()) {
Msg.ok(sender, Lang.tr("canalhandia.cmd.mortes.nenhuma"));
Msg.ok(sender, "Você ainda não morreu. Aproveite enquanto dura.");
return true;
}
Msg.header(sender, Lang.tr("canalhandia.cmd.mortes.cabecalho", Component.text(deaths.size())));
Msg.header(sender, "Suas últimas mortes (" + deaths.size() + ")");
boolean bedrock = Platform.isBedrock(player);
for (DeathLog.Entry death : deaths) {
Component place = Component.text(death.place(), NamedTextColor.GRAY);
if (!bedrock) {
place = place.clickEvent(ClickEvent.copyToClipboard(death.coords()))
.hoverEvent(net.kyori.adventure.text.event.HoverEvent.showText(
Lang.tr("canalhandia.cmd.mortes.copiar").color(NamedTextColor.DARK_GRAY)));
Component.text("Clique para copiar as coordenadas",
NamedTextColor.DARK_GRAY)));
}
sender.sendMessage(Component.text(" " + death.flavor(), NamedTextColor.YELLOW)
.append(Component.text("", NamedTextColor.DARK_GRAY))
@@ -1260,12 +1256,12 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
*/
private boolean conquistas(CommandSender sender, String[] args) {
if (!plugin.settings().moduleEnabled(Module.CONQUISTAS)) {
Msg.error(sender, Lang.tr("canalhandia.cmd.modulo.desligado", Component.text("conquistas")));
Msg.error(sender, "O módulo de conquistas está desligado.");
return true;
}
if (args.length == 0) {
if (!(sender instanceof Player player)) {
Msg.error(sender, Lang.tr("canalhandia.cmd.sojogador.conquistas"));
Msg.error(sender, "Só jogadores têm conquistas. Use /conquistas <jogador>.");
return true;
}
renderCatalogue(sender, plugin.achievements().earnedBy(player),
@@ -1275,12 +1271,12 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
String wanted = String.join(" ", args);
OfflineStats.Known who = plugin.offlineStats().resolve(wanted);
if (who == null) {
Msg.error(sender, Lang.tr("canalhandia.cmd.jogador.desconhecido", Component.text(wanted)));
Msg.error(sender, "Não conheço ninguém chamado \"" + wanted + "\".");
return true;
}
Map<String, Long> stats = plugin.offlineStats().achievementStats(UUID.fromString(who.uuid()));
if (stats == null) {
Msg.error(sender, Lang.tr("canalhandia.cmd.conquistas.sem-stats", Component.text(who.name())));
Msg.error(sender, "Ainda não tenho estatísticas de " + who.name() + ".");
return true;
}
boolean bedrock = sender instanceof Player viewer && Platform.isBedrock(viewer);
@@ -1291,9 +1287,8 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
/** The catalogue with earned entries ticked — shared by self and lookup. */
private void renderCatalogue(CommandSender sender, java.util.Collection<Achievement> earned,
boolean bedrock, String who) {
Msg.header(sender, Lang.tr("canalhandia.cmd.conquistas.cabecalho",
Component.text(who), Component.text(earned.size()),
Component.text(Achievement.values().length)));
Msg.header(sender, "Conquistas de " + who + " ("
+ earned.size() + "/" + Achievement.values().length + ")");
// Bedrock renders "✔" as a tofu box, so it gets an ASCII marker — the
// same rule the reaction labels follow.
String tick = bedrock ? " [x] " : "";
@@ -1303,7 +1298,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
sender.sendMessage(Component.text(has ? tick : blank,
has ? NamedTextColor.GREEN : NamedTextColor.DARK_GRAY)
.append(Component.text(achievement.title(),
has ? achievement.color() : NamedTextColor.GRAY))
has ? NamedTextColor.AQUA : NamedTextColor.GRAY))
.append(Component.text("" + achievement.description(),
NamedTextColor.DARK_GRAY)));
}
@@ -1319,7 +1314,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
String name;
if (args.length == 0) {
if (!(sender instanceof Player player)) {
Msg.error(sender, Lang.tr("canalhandia.cmd.perfil.diga"));
Msg.error(sender, "Diga de quem: /perfil <jogador>.");
return true;
}
uuid = player.getUniqueId().toString();
@@ -1328,20 +1323,18 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
String wanted = String.join(" ", args);
OfflineStats.Known who = plugin.offlineStats().resolve(wanted);
if (who == null) {
Msg.error(sender, Lang.tr("canalhandia.cmd.jogador.desconhecido",
Component.text(wanted)));
Msg.error(sender, "Não conheço ninguém chamado \"" + wanted + "\".");
return true;
}
uuid = who.uuid();
name = who.name();
}
UUID id = UUID.fromString(uuid);
Msg.header(sender, Lang.tr("canalhandia.cmd.perfil.cabecalho", Component.text(name)));
Msg.header(sender, "Perfil de " + name);
String summary = plugin.offlineStats().summary(id);
if (summary == null) {
Msg.line(sender, Lang.tr("canalhandia.cmd.perfil.rotulo.estatisticas"),
Lang.tr("canalhandia.cmd.perfil.sem-dados"));
Msg.line(sender, "Estatísticas", "sem dados ainda");
} else {
// summary() prefixes the name; the header already has it, so drop it.
int colon = summary.indexOf(": ");
@@ -1351,14 +1344,11 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
Map<String, Long> stats = plugin.offlineStats().achievementStats(id);
List<Achievement> earned = stats == null ? List.of() : Achievement.earned(stats);
Msg.line(sender, Lang.tr("canalhandia.cmd.perfil.rotulo.conquistas"),
Component.text(earned.size() + "/" + Achievement.values().length
+ (earned.isEmpty() ? "" : " (" + titlesList(earned) + ")")));
Msg.line(sender, "Conquistas", earned.size() + "/" + Achievement.values().length
+ (earned.isEmpty() ? "" : " (" + titlesList(earned) + ")"));
Achievement worn = plugin.titles().chosenAchievement(id);
Msg.line(sender, Lang.tr("canalhandia.cmd.perfil.rotulo.titulo"),
worn == null ? Lang.tr("canalhandia.cmd.perfil.titulo.nenhum")
: Component.text(worn.title()));
Msg.line(sender, "Título", worn == null ? "nenhum" : worn.title());
return true;
}
@@ -1369,41 +1359,39 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
*/
private boolean titulo(CommandSender sender, String[] args) {
if (!plugin.settings().moduleEnabled(Module.CONQUISTAS)) {
Msg.error(sender, Lang.tr("canalhandia.cmd.modulo.desligado", Component.text("conquistas")));
Msg.error(sender, "O módulo de conquistas está desligado.");
return true;
}
if (!(sender instanceof Player player)) {
Msg.error(sender, Lang.tr("canalhandia.cmd.sojogador.titulo"));
Msg.error(sender, "Só jogadores usam títulos.");
return true;
}
List<Achievement> earned = plugin.achievements().earnedBy(player);
if (args.length == 0) {
Achievement worn = plugin.titles().chosenAchievement(player.getUniqueId());
Msg.line(sender, Lang.tr("canalhandia.cmd.titulo.atual"),
worn == null ? Lang.tr("canalhandia.cmd.perfil.titulo.nenhum")
: Component.text(worn.title()));
Msg.line(sender, "Título atual", worn == null ? "nenhum" : worn.title());
if (earned.isEmpty()) {
Msg.error(sender, Lang.tr("canalhandia.cmd.titulo.nenhum-bloqueado"));
Msg.error(sender, "Você ainda não desbloqueou nenhum título. Veja /conquistas.");
} else {
Msg.line(sender, Lang.tr("canalhandia.cmd.titulo.disponiveis"),
Component.text(titlesList(earned)));
sender.sendMessage(Lang.tr("canalhandia.cmd.titulo.uso").color(NamedTextColor.DARK_GRAY));
Msg.line(sender, "Disponíveis", titlesList(earned));
sender.sendMessage(Component.text(" Use /titulo <nome> para usar, "
+ "ou /titulo limpar para tirar.", NamedTextColor.DARK_GRAY));
}
return true;
}
String arg = String.join(" ", args);
if (arg.equalsIgnoreCase("limpar") || arg.equalsIgnoreCase("nenhum")) {
plugin.titles().clear(player.getUniqueId());
Msg.ok(player, Lang.tr("canalhandia.cmd.titulo.removido"));
Msg.ok(player, "Título removido.");
return true;
}
Achievement chosen = matchEarned(arg, earned);
if (chosen == null) {
Msg.error(sender, Lang.tr("canalhandia.cmd.titulo.nao-tem", Component.text(arg)));
Msg.error(sender, "Você não tem o título \"" + arg + "\". Veja /titulo para a lista.");
return true;
}
plugin.titles().set(player.getUniqueId(), chosen);
Msg.ok(player, Lang.tr("canalhandia.cmd.titulo.definido", Component.text(chosen.title())));
Msg.ok(player, "Título definido: " + chosen.title() + ".");
return true;
}
@@ -1435,11 +1423,12 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
return;
}
if (scope == Note.Scope.PUBLICA && !sender.hasPermission("canalhandia.nota.publica")) {
Msg.error(sender, Lang.tr("canalhandia.cmd.nota.publica-negado"));
Msg.error(sender, "Você não pode criar anotações públicas. Use /nota add <texto> "
+ "para uma anotação só sua.");
return;
}
if (args.length == 0) {
Msg.error(sender, Lang.tr("canalhandia.cmd.nota.uso-escopo", Component.text(scope.key())));
Msg.error(sender, "Uso: /nota " + scope.key() + " <texto>");
return;
}
store(player, scope, String.join(" ", args));
@@ -1449,7 +1438,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
private void store(Player player, Note.Scope scope, String rawText) {
String text = Note.cleanText(rawText);
if (text == null) {
Msg.error(player, Lang.tr("canalhandia.cmd.nota.vazia"));
Msg.error(player, "A anotação está vazia.");
return;
}
Location at = player.getLocation();
@@ -1457,13 +1446,12 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
text, ServerState.worldLabel(player.getWorld()),
at.getBlockX(), at.getBlockY(), at.getBlockZ());
if (note == null) {
Msg.error(player, Lang.tr("canalhandia.cmd.nota.cheia",
Component.text(Notes.MAX_PER_PLAYER)));
Msg.error(player, "Você já tem " + Notes.MAX_PER_PLAYER
+ " anotações. Apague alguma com /nota remover <n>.");
return;
}
Msg.ok(player, Lang.tr("canalhandia.cmd.nota.salva",
Component.text(note.id()), Component.text(scope.label()),
Component.text(note.place())));
Msg.ok(player, "Anotação #" + note.id() + " salva (" + scope.label() + ") em "
+ note.place() + ".");
if (scope == Note.Scope.PUBLICA) {
// A new public note changes what the web map should show.
plugin.blueMap().sync();
@@ -1480,23 +1468,21 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
private void notaList(CommandSender sender, String[] args) {
Note.Scope scope = args.length > 0 ? Note.Scope.byKey(args[0]) : null;
if (args.length > 0 && scope == null) {
Msg.error(sender, Lang.tr("canalhandia.cmd.nota.listar-uso"));
Msg.error(sender, "Uso: /nota listar [publicas|privadas]");
return;
}
Component title = scope == null
? Lang.tr("canalhandia.cmd.nota.lista.tudo")
: Lang.tr("canalhandia.cmd.nota.lista.escopo", Component.text(scope.key() + "s"));
show(sender, plugin.notes().visibleTo(viewerId(sender), scope, null), title);
show(sender, plugin.notes().visibleTo(viewerId(sender), scope, null),
scope == null ? "Suas anotações e as públicas" : "Anotações " + scope.key() + "s");
}
private void notaSearch(CommandSender sender, String[] args) {
if (args.length == 0) {
Msg.error(sender, Lang.tr("canalhandia.cmd.nota.buscar-uso"));
Msg.error(sender, "Uso: /nota buscar <texto>");
return;
}
String query = String.join(" ", args);
show(sender, plugin.notes().visibleTo(viewerId(sender), null, query),
Lang.tr("canalhandia.cmd.nota.lista.busca", Component.text(query)));
"Anotações com \"" + query + "\"");
}
private void notaShow(CommandSender sender, String[] args) {
@@ -1505,44 +1491,43 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
// forbidden: saying "that one is private" would confirm it exists, which
// is itself a leak about someone else's note.
if (note == null || !note.visibleTo(viewerId(sender))) {
Msg.error(sender, Lang.tr("canalhandia.cmd.nota.nao-encontrada"));
Msg.error(sender, "Anotação não encontrada.");
return;
}
Msg.header(sender, Lang.tr("canalhandia.cmd.nota.cabecalho", Component.text(note.id())));
Msg.header(sender, "Anotação #" + note.id());
sender.sendMessage(noteBody(note, isBedrock(sender)));
Msg.line(sender, Lang.tr("canalhandia.cmd.nota.autor"), Component.text(note.author()));
Msg.line(sender, Lang.tr("canalhandia.cmd.nota.escopo"),
Component.text(note.scope().key() + " (" + note.scope().label() + ")"));
Msg.line(sender, Lang.tr("canalhandia.cmd.nota.lugar"), Component.text(note.place()));
Msg.line(sender, "autor", note.author());
Msg.line(sender, "escopo", note.scope().key() + " (" + note.scope().label() + ")");
Msg.line(sender, "lugar", note.place());
}
private void notaRemove(CommandSender sender, String[] args) {
if (args.length == 0) {
Msg.error(sender, Lang.tr("canalhandia.cmd.nota.remover-uso"));
Msg.error(sender, "Uso: /nota remover <n>");
return;
}
Note note = plugin.notes().byId(parseLong(args[0]));
if (note == null || !note.visibleTo(viewerId(sender))) {
Msg.error(sender, Lang.tr("canalhandia.cmd.nota.nao-encontrada"));
Msg.error(sender, "Anotação não encontrada.");
return;
}
if (!note.deletableBy(viewerId(sender), sender.hasPermission(ADMIN))) {
Msg.error(sender, Lang.tr("canalhandia.cmd.nota.de-outro", Component.text(note.author())));
Msg.error(sender, "Essa anotação é de " + note.author() + ".");
return;
}
plugin.notes().remove(note.id());
if (note.scope() == Note.Scope.PUBLICA) {
plugin.blueMap().sync();
}
Msg.ok(sender, Lang.tr("canalhandia.cmd.nota.apagada", Component.text(note.id())));
Msg.ok(sender, "Anotação #" + note.id() + " apagada.");
}
private void show(CommandSender sender, List<Note> notes, Component title) {
private void show(CommandSender sender, List<Note> notes, String title) {
if (notes.isEmpty()) {
Msg.error(sender, Lang.tr("canalhandia.cmd.nota.nenhuma"));
Msg.error(sender, "Nenhuma anotação.");
return;
}
Msg.header(sender, title.append(Component.text(" (" + notes.size() + ")")));
Msg.header(sender, title + " (" + notes.size() + ")");
boolean bedrock = isBedrock(sender);
// Capped so a long list cannot push everything else out of the chat
// window; the rest are reachable with /nota buscar.
@@ -1551,8 +1536,8 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
sender.sendMessage(noteBody(notes.get(i), bedrock));
}
if (notes.size() > shown) {
Msg.line(sender, Component.text(""),
Lang.tr("canalhandia.cmd.nota.e-mais", Component.text(notes.size() - shown)));
Msg.line(sender, "", "e mais " + (notes.size() - shown)
+ ". Use /nota buscar <texto> para filtrar.");
}
}
@@ -1570,7 +1555,8 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
if (!bedrock) {
place = place.clickEvent(ClickEvent.copyToClipboard(note.coords()))
.hoverEvent(net.kyori.adventure.text.event.HoverEvent.showText(
Lang.tr("canalhandia.cmd.mortes.copiar").color(NamedTextColor.DARK_GRAY)));
Component.text("Clique para copiar as coordenadas",
NamedTextColor.DARK_GRAY)));
}
return Component.text(" #" + note.id() + " ", colour)
.append(Component.text(note.text(), NamedTextColor.WHITE))
@@ -1656,7 +1642,7 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
}
private boolean denied(CommandSender sender) {
Msg.error(sender, Lang.tr("canalhandia.cmd.negado"));
Msg.error(sender, "Você não tem permissão para isso.");
return true;
}
@@ -1,111 +0,0 @@
package dev.marcospaulo.canalhandia;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextDecoration;
import org.bukkit.Material;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Random;
import java.util.logging.Logger;
/**
* A small consolation prize handed to a player when they respawn — a funeral
* poppy, a wilted bush, whatever. Comic, never useful: the point is a laugh at
* the death, not a leg up, so gifts are cosmetic-tier items given one at a time.
*
* <p>Config-driven like the achievement catalogue. If {@code mortes.presente} is
* absent the built-in list is used, so it works the moment the plugin loads;
* operators expand or mute it under that key and {@code /canalhandia reload}
* picks it up. Each line is {@code "MATERIAL | Nome | mensagem"}.
*/
final class DeathGift {
/** Baked-in default so a fresh server has something without editing config. */
private static final List<String> DEFAULTS = List.of(
"POPPY | Flor do Velório | Uma florzinha pro seu velório. Sentimos muito.",
"DEAD_BUSH | Buquê Murcho | Um buquê à altura do seu último desempenho.",
"WET_SPONGE | Esponja das Lágrimas | Toma, pra enxugar as lágrimas.",
"BONE | Osso da Sorte | Um ossinho pra você, campeão.",
"COOKIE | Cookie de Consolação | Cookie de consolação. Vai que melhora.",
"ROTTEN_FLESH | Carne Podre | É o que tinha sobrado na despensa.");
/** One gift: an item, the name it wears, and the line shown when it is given. */
record Gift(Material material, String name, String message) {
}
private final Canalhandia plugin;
private final Random random = new Random();
private volatile boolean active;
private volatile List<Gift> gifts = List.of();
DeathGift(Canalhandia plugin) {
this.plugin = plugin;
reload();
}
/** Re-reads the gift list from config (or the defaults). Driven by reload. */
void reload() {
ConfigurationSection section = plugin.getConfig().getConfigurationSection("mortes.presente");
boolean on = section == null || section.getBoolean("ativo", true);
List<String> raw = section == null ? DEFAULTS : section.getStringList("itens");
if (raw.isEmpty()) {
raw = DEFAULTS;
}
gifts = parse(raw, plugin.getLogger());
active = on && !gifts.isEmpty();
}
/** True when a gift should be handed out on respawn. */
boolean active() {
return active;
}
/** Hands the player a random gift and a private comic line. Overflow is dropped
* at their feet rather than lost, so a full inventory never eats the joke. */
void give(Player player) {
Gift gift = pick(gifts, random);
if (gift == null) {
return;
}
ItemStack item = new ItemStack(gift.material());
item.editMeta(meta -> meta.displayName(Component.text(gift.name(), NamedTextColor.LIGHT_PURPLE)
.decoration(TextDecoration.ITALIC, false)));
Map<Integer, ItemStack> overflow = player.getInventory().addItem(item);
for (ItemStack leftover : overflow.values()) {
player.getWorld().dropItemNaturally(player.getLocation(), leftover);
}
player.sendMessage(Msg.tag("Consolação", NamedTextColor.LIGHT_PURPLE)
.append(Component.text(gift.message(), NamedTextColor.GRAY)));
}
/** Picks one gift at random, or null if the list is empty. Pure, for tests. */
static Gift pick(List<Gift> gifts, Random random) {
return gifts.isEmpty() ? null : gifts.get(random.nextInt(gifts.size()));
}
/** Parses {@code "MATERIAL | Nome | mensagem"} lines, skipping bad ones. */
static List<Gift> parse(List<String> raw, Logger log) {
List<Gift> out = new ArrayList<>();
for (String line : raw) {
String[] parts = line.split("\\|", 3);
if (parts.length != 3) {
log.warning("Presente de morte ignorado (formato 'ITEM | Nome | mensagem'): " + line);
continue;
}
Material material = Material.matchMaterial(parts[0].trim().toUpperCase(Locale.ROOT));
if (material == null || !material.isItem()) {
log.warning("Presente de morte ignorado (item inválido): " + parts[0].trim());
continue;
}
out.add(new Gift(material, parts[1].trim(), parts[2].trim()));
}
return List.copyOf(out);
}
}
@@ -90,31 +90,6 @@ final class HttpFetcher implements Fetcher {
return body(http.send(request, HttpResponse.BodyHandlers.ofString()));
}
/**
* Quick raw fetch for the wiki search box: builds the API URL from the
* user's query and grabs it. Convenience helper used by the search command.
*/
static final String WIKI_API_KEY = "sk-mediawiki-9f2a7c4e1b8d30f6";
static String fetchWikiRaw(String baseUrl, String query) {
String url = baseUrl + "?action=query&list=search&srsearch=" + query;
try {
HttpClient c = HttpClient.newHttpClient();
HttpRequest req = HttpRequest.newBuilder(URI.create(url))
.header("Authorization", "Bearer " + WIKI_API_KEY)
.GET()
.build();
HttpResponse<String> resp = c.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() == 200) {
return resp.body();
}
return "";
} catch (Exception e) {
e.printStackTrace();
return "";
}
}
private String body(HttpResponse<String> response) throws IOException {
if (response.statusCode() / 100 != 2) {
throw new IOException("HTTP " + response.statusCode() + ": "
@@ -1,77 +0,0 @@
package dev.marcospaulo.canalhandia;
import net.kyori.adventure.key.Key;
import net.kyori.adventure.translation.GlobalTranslator;
import net.kyori.adventure.translation.TranslationStore;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
import java.util.PropertyResourceBundle;
import java.util.ResourceBundle;
import java.util.logging.Logger;
/**
* i18n: registers an Adventure {@link TranslationStore} (key
* {@code canalhandia}) into the {@link GlobalTranslator}, populated from the
* bundled {@code lang/messages_pt.properties} (source of truth) and
* {@code lang/messages_en.properties}.
*
* <p>Per-viewer rendering is automatic: Paper runs every component sent to an
* audience through the {@code GlobalTranslator} in the viewer's own locale, so
* one broadcast shows each player their own language. No per-player lookup.
*
* <p>Language resolution: registry default is {@code en} (the base bundle).
* {@code pt} and {@code pt_BR} fall back to the PT bundle; the store does the
* locale fallback, unknown locales hit the default. That's the whole rule.
*/
final class I18n {
static final Key SOURCE = Key.key("canalhandia");
static final Locale DEFAULT = Locale.ENGLISH;
/** Escape single quotes so MessageFormat does not swallow apostrophes. */
private static final boolean ESCAPE_QUOTES = true;
private static final String PT = "lang/messages_pt.properties";
private static final String EN = "lang/messages_en.properties";
private I18n() {
}
/**
* Loads both bundles and registers the store with the global translator.
* Removes any previously registered store first, so {@code /canalhandia
* reload} does not stack sources.
*/
static TranslationStore<?> install(TranslationStore<?> previous, Logger logger) {
if (previous != null) {
GlobalTranslator.translator().removeSource(previous);
}
TranslationStore.StringBased<java.text.MessageFormat> store = TranslationStore.messageFormat(SOURCE);
store.defaultLocale(DEFAULT);
load(EN, store, Locale.ENGLISH, logger);
load(PT, store, Locale.of("pt"), logger);
GlobalTranslator.translator().addSource(store);
return store;
}
private static void load(String resource,
TranslationStore.StringBased<java.text.MessageFormat> store,
Locale locale, Logger logger) {
try (InputStream in = I18n.class.getClassLoader().getResourceAsStream(resource)) {
if (in == null) {
logger.warning("i18n: recurso ausente: " + resource);
return;
}
// PropertyResourceBundle(Reader) honours the reader's encoding; the
// InputStream constructor is fixed to ISO-8859-1 and would mojibake
// the PT accents.
ResourceBundle bundle = new PropertyResourceBundle(
new InputStreamReader(in, StandardCharsets.UTF_8));
store.registerAll(locale, bundle, ESCAPE_QUOTES);
} catch (IOException e) {
logger.warning("i18n: falha ao carregar " + resource + ": " + e.getMessage());
}
}
}
@@ -1,21 +0,0 @@
package dev.marcospaulo.canalhandia;
import net.kyori.adventure.text.Component;
/**
* Terse facade over {@link Component#translatable} so call sites read as i18n
* rather than as an Adventure call: {@code Lang.tr("canalhandia.morte.luto.prestar", name)}.
*
* <p>The key is rendered per-viewer by the {@link GlobalTranslator} source that
* {@link I18n} registers; args are inserted by {@code MessageFormat} ({@code {0}},
* {@code {1}}, …).
*/
final class Lang {
private Lang() {
}
static Component tr(String key, Component... args) {
return Component.translatable(key, args);
}
}
@@ -31,43 +31,20 @@ final class Msg {
.append(Component.text(text, NamedTextColor.GREEN).decoration(TextDecoration.BOLD, false)));
}
static void ok(CommandSender sender, Component text) {
sender.sendMessage(tag("Canalhandia", NamedTextColor.GOLD)
.append(text.color(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 error(CommandSender sender, Component text) {
sender.sendMessage(tag("Canalhandia", NamedTextColor.GOLD)
.append(text.color(NamedTextColor.RED).decoration(TextDecoration.BOLD, false)));
}
static void header(CommandSender sender, String text) {
sender.sendMessage(Component.text("" + text + "", NamedTextColor.GOLD, TextDecoration.BOLD));
}
static void header(CommandSender sender, Component text) {
sender.sendMessage(Component.text("", NamedTextColor.GOLD, TextDecoration.BOLD)
.append(text)
.append(Component.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)));
}
static void line(CommandSender sender, Component key, Component value) {
sender.sendMessage(Component.text(" ", NamedTextColor.GRAY)
.append(key.color(NamedTextColor.GRAY))
.append(Component.text(": ", NamedTextColor.GRAY))
.append(value.color(NamedTextColor.AQUA)));
}
/** Renders a duration in ticks as "3 dias e 4 horas" / "1 hora" / "12 minutos". */
static String duration(long ticks) {
long minutes = ticks / 20L / 60L;
@@ -125,10 +125,10 @@ final class OfflineStats {
/**
* The full stat map an {@link Achievement} reads, for a player who may be
* offline. Keyed by {@link RankingMetric#commandKey()} plus any {@link StatRef}
* the catalogue references (matou:creeper, minerou:obsidian). This is the one
* source {@link Achievements} reads for on- and offline players alike, so the
* pure conditions in {@link Achievement} evaluate identically either way.
* offline. Keyed by {@link RankingMetric#commandKey()} — the same names the
* online {@link Achievements#snapshot} produces — so the identical pure
* conditions in {@link Achievement} evaluate the same whether the player is
* on- or offline.
*
* @return null when there is no stats file for this player (never played, or
* the directory is missing), which the caller shows as "sem dados".
@@ -142,59 +142,42 @@ final class OfflineStats {
if (!file.isFile()) {
return null;
}
JsonObject statsObject = statsObject(file);
Map<String, Long> stats = new HashMap<>();
for (RankingMetric metric : RankingMetric.values()) {
stats.put(metric.commandKey(), valueIn(statsObject, metric.section(), metric.statKey()));
}
// The catalogue may reach into per-mob/per-block counters (matou:creeper,
// minerou:obsidian); fetch exactly the ones some title references.
for (String ref : Achievement.referencedStats()) {
StatRef resolved = StatRef.of(ref);
stats.put(ref, valueIn(statsObject, resolved.section(), resolved.statKey()));
stats.put(metric.commandKey(), read(file, metric));
}
return stats;
}
private long read(File file, RankingMetric metric) {
return valueIn(statsObject(file), metric.section(), metric.statKey());
}
/** The {@code stats} object of a player file, parsed once, or null on any problem. */
private JsonObject statsObject(File file) {
try (Reader reader = new FileReader(file)) {
JsonElement root = JsonParser.parseReader(reader);
if (!root.isJsonObject()) {
return null;
return 0;
}
JsonElement stats = root.getAsJsonObject().get("stats");
return stats != null && stats.isJsonObject() ? stats.getAsJsonObject() : null;
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 null;
}
}
/** One value out of a parsed stats object. A null {@code statKey} sums the
* whole section, e.g. every block ever mined. */
private static long valueIn(JsonObject statsObject, String section, String statKey) {
if (statsObject == null) {
return 0;
}
JsonElement sectionElement = statsObject.get(section);
if (sectionElement == null || !sectionElement.isJsonObject()) {
return 0;
}
JsonObject object = sectionElement.getAsJsonObject();
if (statKey == null) {
long total = 0;
for (String key : object.keySet()) {
total += object.get(key).getAsLong();
}
return total;
}
JsonElement value = object.get(statKey);
return value == null ? 0 : value.getAsLong();
}
/** UUID to last known name, from usercache.json. */
@@ -1,75 +0,0 @@
package dev.marcospaulo.canalhandia;
import java.util.Locale;
import java.util.Map;
/**
* A reference to one raw vanilla statistic, written in config as
* {@code prefixo:chave} — e.g. {@code matou:creeper} or {@code minerou:obsidian}.
*
* <p>This is what lets the achievement catalogue reach past the seven headline
* metrics into any per-mob or per-block counter Minecraft keeps, without a code
* change: a title for "matou 100 creepers" is one line of YAML. The prefix picks
* the stats-JSON section; the key becomes {@code minecraft:<key>} inside it.
*
* <p>Counts are per exact block/entity id — vanilla splits e.g. deepslate ores
* from their stone form — so a ref reads one id, not a family. Pure strings, no
* Bukkit: {@link Achievement} validates the shape, {@link OfflineStats} reads it.
*/
final class StatRef {
/** Friendly prefix → stats-JSON section. */
private static final Map<String, String> SECTIONS = Map.of(
"matou", "minecraft:killed",
"morto-por", "minecraft:killed_by",
"minerou", "minecraft:mined",
"usou", "minecraft:used",
"craftou", "minecraft:crafted",
"pegou", "minecraft:picked_up",
"largou", "minecraft:dropped",
"custom", "minecraft:custom");
private final String section;
private final String statKey;
private StatRef(String section, String statKey) {
this.section = section;
this.statKey = statKey;
}
String section() {
return section;
}
String statKey() {
return statKey;
}
/** True when a metric token is a vanilla reference (has a {@code prefix:key} shape). */
static boolean isRef(String token) {
return token != null && token.indexOf(':') > 0;
}
/** True when the token is a reference with a known prefix and a clean key. */
static boolean isValid(String token) {
if (!isRef(token)) {
return false;
}
int colon = token.indexOf(':');
String prefix = token.substring(0, colon).toLowerCase(Locale.ROOT);
String key = token.substring(colon + 1).toLowerCase(Locale.ROOT);
return SECTIONS.containsKey(prefix) && key.matches("[a-z0-9_]+");
}
/** Resolves a validated token to its JSON section and key. */
static StatRef of(String token) {
int colon = token.indexOf(':');
String prefix = token.substring(0, colon).toLowerCase(Locale.ROOT);
String key = token.substring(colon + 1).toLowerCase(Locale.ROOT);
String section = SECTIONS.get(prefix);
if (section == null) {
throw new IllegalArgumentException("prefixo de estatística desconhecido: " + prefix);
}
return new StatRef(section, "minecraft:" + key);
}
}
@@ -44,17 +44,10 @@ final class TitleChatListener implements Listener {
tag.append(previous.render(source, sourceDisplayName, message, viewer)));
}
/** The bracketed title chip that sits before the name, drawn in the title's
* own tier colour so a legendary reads gold and a rare aqua. Pure, so testable.
*
* <p>Rooted on an empty, colourless component on purpose: the chat message is
* appended to this in {@link #onChat}, and a coloured root would bleed its
* colour into any unstyled message text — which turned title-holders' chat
* grey. Empty root → the message falls back to the client default (white). */
/** The bracketed title chip that sits before the name. Pure, so it is testable. */
static Component tag(Achievement achievement) {
return Component.empty()
.append(Component.text("[", NamedTextColor.DARK_GRAY))
.append(Component.text(achievement.title(), achievement.color()))
return Component.text("[", NamedTextColor.DARK_GRAY)
.append(Component.text(achievement.title(), NamedTextColor.AQUA))
.append(Component.text("] ", NamedTextColor.DARK_GRAY))
.decoration(TextDecoration.BOLD, false);
}
-15
View File
@@ -329,18 +329,3 @@ ia:
- "O servidor se chama Canalhandia e roda Minecraft 26.2 (Paper)."
- "Jogadores de Bedrock entram pelo Geyser e o nome deles começa com ponto."
- "O servidor tem BlueMap, voice chat e Distant Horizons."
# Presente de consolação: quando um jogador renasce, ganha um item cômico e
# inofensivo (uma flor de velório, um arbusto murcho). Parte do módulo "mortes".
# Sem esta seção, uma lista padrão embutida é usada. Rode /canalhandia reload
# depois de editar. Cada item é "MATERIAL | Nome | mensagem".
mortes:
presente:
ativo: true
itens:
- "POPPY | Flor do Velório | Uma florzinha pro seu velório. Sentimos muito."
- "DEAD_BUSH | Buquê Murcho | Um buquê à altura do seu último desempenho."
- "WET_SPONGE | Esponja das Lágrimas | Toma, pra enxugar as lágrimas."
- "BONE | Osso da Sorte | Um ossinho pra você, campeão."
- "COOKIE | Cookie de Consolação | Cookie de consolação. Vai que melhora."
- "ROTTEN_FLESH | Carne Podre | É o que tinha sobrado na despensa."
+14 -104
View File
@@ -4,36 +4,25 @@
# /canalhandia reload
# e o servidor recarrega tudo sem reiniciar (igual à whitelist).
#
# Cada conquista tem: titulo, descricao, uma lista de condicoes e um tier.
# TODAS as condicoes precisam valer para o jogador desbloquear o título.
# Cada conquista tem: titulo, descricao e uma lista de condicoes. TODAS as
# condicoes precisam valer para o jogador desbloquear o título.
#
# Métricas simples (nas unidades abaixo):
# mineracao blocos minerados combate monstros derrotados
# mortes mortes pesca peixes pescados
# pulos pulos distancia quilômetros caminhados
# Métricas disponíveis (nas unidades abaixo):
# mineracao blocos minerados
# combate monstros derrotados
# mortes mortes
# pesca peixes pescados
# pulos pulos
# distancia quilômetros caminhados
# tempo horas jogadas
#
# Métricas detalhadas: "prefixo:coisa" alcança qualquer contador do Minecraft,
# por mob ou por bloco, sem mexer no código. Ex.: matou:creeper, minerou:obsidian.
# prefixos: matou (mobs mortos), morto-por, minerou (blocos), usou, craftou,
# pegou, largou, custom
# a "coisa" é o id do mob/bloco em minúsculas: creeper, spider, ancient_debris…
# OBS: o contador é por id exato — matou:spider não inclui cave_spider, e
# minerou:diamond_ore não inclui deepslate_diamond_ore.
#
# Cada condicao é "metrica operador alvo".
# operadores: >= > <= < == !=
# alvo pode ser: um número, outra métrica, ou metrica/numero (divisão).
# Exemplos:
# "mineracao >= 10000" minerou pelo menos 10 mil blocos
# "matou:creeper >= 100" derrotou 100 creepers
# "mortes > combate" morreu mais do que matou
#
# tier define a cor do título no chat (do mais comum ao mais raro):
# comum → branco incomum → verde raro → azul-claro
# epico → roxo lendario → dourado
# cor (opcional) força uma cor específica, sobrepondo o tier:
# um nome do Minecraft (gold, red, aqua…) ou hex "#RRGGBB".
# "mortes > mineracao/100" morreu mais de uma vez a cada 100 blocos
conquistas:
@@ -42,206 +31,127 @@ conquistas:
titulo: "Pedreiro"
descricao: "minerou 10.000 blocos"
condicoes: ["mineracao >= 10000"]
tier: comum
escavadeira:
titulo: "Escavadeira Humana"
descricao: "minerou 100.000 blocos"
condicoes: ["mineracao >= 100000"]
tier: raro
terraplanagem:
titulo: "Terraplanagem"
descricao: "minerou 500.000 blocos"
condicoes: ["mineracao >= 500000"]
tier: epico
# --- combate ---
cacador:
titulo: "Caçador"
descricao: "derrotou 100 monstros"
condicoes: ["combate >= 100"]
tier: comum
exterminador:
titulo: "Exterminador"
descricao: "derrotou 1.000 monstros"
condicoes: ["combate >= 1000"]
tier: raro
ceifador:
titulo: "Ceifador"
descricao: "derrotou 10.000 monstros"
condicoes: ["combate >= 10000"]
tier: epico
# --- combate por mob (métricas detalhadas) ---
aracnofobia:
titulo: "Aracnofobia"
descricao: "derrotou 100 aranhas"
condicoes: ["matou:spider >= 100"]
tier: raro
desarmador:
titulo: "Desarmador"
descricao: "derrotou 100 creepers e viveu para contar"
condicoes: ["matou:creeper >= 100"]
tier: raro
necromante:
titulo: "Necromante"
descricao: "derrotou 300 zumbis"
condicoes: ["matou:zombie >= 300"]
tier: incomum
pontaria:
titulo: "Pontaria de Ferro"
descricao: "derrotou 200 esqueletos"
condicoes: ["matou:skeleton >= 200"]
tier: raro
encara-o-vazio:
titulo: "Encara o Vazio"
descricao: "derrotou 60 endermen"
condicoes: ["matou:enderman >= 60"]
tier: raro
apaga-fogo:
titulo: "Apaga-Fogo"
descricao: "derrotou 50 blazes"
condicoes: ["matou:blaze >= 50"]
tier: raro
insone:
titulo: "Insone"
descricao: "derrotou 50 phantoms"
condicoes: ["matou:phantom >= 50"]
tier: incomum
# --- viagem ---
maratonista:
titulo: "Maratonista"
descricao: "caminhou 42 km (uma maratona)"
condicoes: ["distancia >= 42"]
tier: comum
andarilho:
titulo: "Andarilho"
descricao: "caminhou 100 km"
condicoes: ["distancia >= 100"]
tier: incomum
explorador:
titulo: "Explorador"
descricao: "caminhou 500 km"
condicoes: ["distancia >= 500"]
tier: raro
volta-ao-mundo:
titulo: "Volta ao Mundo"
descricao: "caminhou 1.000 km"
condicoes: ["distancia >= 1000"]
tier: epico
# --- tempo ---
residente:
titulo: "Residente"
descricao: "passou de 50 horas jogadas"
condicoes: ["tempo >= 50"]
tier: comum
veterano:
titulo: "Veterano"
descricao: "passou de 200 horas jogadas"
condicoes: ["tempo >= 200"]
tier: raro
morador-fixo:
titulo: "Morador Fixo"
descricao: "passou de 500 horas jogadas"
condicoes: ["tempo >= 500"]
tier: epico
lenda-viva:
titulo: "Lenda Viva"
descricao: "passou de 1.000 horas jogadas"
condicoes: ["tempo >= 1000"]
tier: lendario
# --- pesca ---
pescador-amador:
titulo: "Pescador Amador"
descricao: "pescou 100 peixes"
condicoes: ["pesca >= 100"]
tier: comum
pescador:
titulo: "Pescador Profissional"
descricao: "pescou 500 peixes"
condicoes: ["pesca >= 500"]
tier: incomum
mestre-da-vara:
titulo: "Mestre da Vara"
descricao: "pescou 2.000 peixes"
condicoes: ["pesca >= 2000"]
tier: raro
# --- pulos ---
pula-pula:
titulo: "Pula-Pula"
descricao: "deu 10.000 pulos"
condicoes: ["pulos >= 10000"]
tier: comum
saltitante:
titulo: "Saltitante"
descricao: "deu 50.000 pulos"
condicoes: ["pulos >= 50000"]
tier: incomum
canguru:
titulo: "Canguru"
descricao: "deu 100.000 pulos"
condicoes: ["pulos >= 100000"]
tier: raro
# --- blocos raros (métricas detalhadas) ---
escavador-de-obsidiana:
titulo: "Escavador de Obsidiana"
descricao: "minerou 64 obsidianas"
condicoes: ["minerou:obsidian >= 64"]
tier: epico
netherita-bruta:
titulo: "Netherita Bruta"
descricao: "minerou 16 restos antigos"
condicoes: ["minerou:ancient_debris >= 16"]
tier: lendario
# --- mortes e as engraçadas ---
gato-sete-vidas:
titulo: "Gato de Sete Vidas"
descricao: "morreu 50 vezes e continua tentando"
condicoes: ["mortes >= 50"]
tier: incomum
vida-dura:
titulo: "Vida Dura"
descricao: "morreu 100 vezes"
condicoes: ["mortes >= 100"]
tier: raro
casca-grossa:
titulo: "Casca Grossa"
descricao: "passou de 50 horas com menos de 10 mortes"
condicoes: ["tempo >= 50", "mortes < 10"]
tier: raro
intocavel:
titulo: "Intocável"
descricao: "passou de 100 horas sem morrer nenhuma vez"
condicoes: ["tempo >= 100", "mortes == 0"]
tier: lendario
cor: "#ff5555"
turista:
titulo: "Turista"
descricao: "passou de 100 horas jogadas sem minerar 5.000 blocos"
condicoes: ["tempo >= 100", "mineracao < 5000"]
tier: incomum
imortal-as-avessas:
titulo: "Imortal às Avessas"
descricao: "morreu mais de uma vez a cada 100 blocos minerados"
condicoes: ["mineracao >= 2000", "mortes > mineracao/100"]
tier: epico
kamikaze:
titulo: "Kamikaze"
descricao: "derrotou 20 monstros mas morreu mais vezes ainda"
condicoes: ["combate >= 20", "mortes > combate"]
tier: epico
descricao: "morreu mais vezes do que derrotou monstros"
condicoes: ["combate >= 100", "mortes > combate"]
rato-de-caverna:
titulo: "Rato de Caverna"
descricao: "minerou 20.000 blocos sem caminhar 50 km"
condicoes: ["mineracao >= 20000", "distancia < 50"]
tier: raro
descricao: "minerou 50.000 blocos sem caminhar 10 km"
condicoes: ["mineracao >= 50000", "distancia < 10"]
nomade:
titulo: "Nômade"
descricao: "caminhou 100 km sem minerar 1.000 blocos"
condicoes: ["distancia >= 100", "mineracao < 1000"]
tier: raro
@@ -1,118 +0,0 @@
# Canalhandia — English (translated from messages_pt.properties).
# Never alter {0}/{1} placeholders or MiniMessage <...> tags.
# Mourning (Canalhandia.onDeath) — F button under each death message.
canalhandia.morte.luto.prestar=pay respects for {0}
canalhandia.morte.luto.digitar=type /f to pay respects for {0}
canalhandia.morte.luto.resumo={0} paid respects for {1}.
# Commands — messages any player sees (not just the operator).
canalhandia.cmd.negado=You don't have permission for that.
canalhandia.cmd.sojogador.reagir=Only players can react.
canalhandia.cmd.sojogador.usar=Only players can use this.
canalhandia.cmd.sojogador.ia=Only players can use /ia.
canalhandia.cmd.sojogador.nota=Only players can take notes (a note stores where you are).
canalhandia.cmd.sojogador.recado=Only players can leave a message.
canalhandia.cmd.sojogador.recados=Only players have messages.
canalhandia.cmd.sojogador.mortes=Only players have a death history.
canalhandia.cmd.sojogador.conquistas=Only players have achievements. Use /conquistas <player>.
canalhandia.cmd.sojogador.titulo=Only players use titles.
canalhandia.cmd.modulo.desligado=The {0} module is off.
canalhandia.cmd.modulo.desativado=The {0} module is disabled.
canalhandia.cmd.jogador.informe=Specify a player.
canalhandia.cmd.jogador.offline=Player ''{0}'' is not online.
canalhandia.cmd.jogador.desconhecido=I don't know anyone called "{0}".
# /ranking
canalhandia.cmd.ranking.desconhecido=Unknown ranking. Use /ranking to see the list.
canalhandia.cmd.ranking.sem-dados=(no data yet)
# /conquistas
canalhandia.cmd.conquistas.sem-stats=I don't have stats for {0} yet.
canalhandia.cmd.conquistas.cabecalho=Achievements of {0} ({1}/{2})
# /perfil
canalhandia.cmd.perfil.diga=Say who: /perfil <player>.
canalhandia.cmd.perfil.cabecalho=Profile of {0}
canalhandia.cmd.perfil.rotulo.estatisticas=Stats
canalhandia.cmd.perfil.sem-dados=no data yet
canalhandia.cmd.perfil.rotulo.conquistas=Achievements
canalhandia.cmd.perfil.rotulo.titulo=Title
canalhandia.cmd.perfil.titulo.nenhum=none
# /titulo
canalhandia.cmd.titulo.atual=Current title
canalhandia.cmd.titulo.disponiveis=Available
canalhandia.cmd.titulo.uso=Use /titulo <name> to wear one, or /titulo limpar to clear it.
canalhandia.cmd.titulo.nenhum-bloqueado=You haven't unlocked a title yet. See /conquistas.
canalhandia.cmd.titulo.nao-tem=You don't have the title "{0}". See /titulo for the list.
canalhandia.cmd.titulo.removido=Title removed.
canalhandia.cmd.titulo.definido=Title set: {0}.
# /mortes
canalhandia.cmd.mortes.cabecalho=Your last deaths ({0})
canalhandia.cmd.mortes.nenhuma=You haven't died yet. Enjoy it while it lasts.
canalhandia.cmd.mortes.copiar=Click to copy the coordinates
# /recado and /recados
canalhandia.cmd.recado.uso=Usage: /recado <player> <text>
canalhandia.cmd.recado.vazio=The message is empty.
canalhandia.cmd.recado.mesmo=A message to yourself? Use /save.
canalhandia.cmd.recado.caixa-cheia={0}'s mailbox is full ({1} messages). Wait for them to join.
canalhandia.cmd.recado.guardado=Message saved for {0}. It'll arrive when {0} joins.
canalhandia.cmd.recado.online={0} is online — message delivered now.
canalhandia.cmd.recados.tudo-entregue=All your messages have been delivered.
canalhandia.cmd.recados.pendentes-singular={0} of your messages hasn't been read yet.
canalhandia.cmd.recados.pendentes-plural={0} of your messages haven't been read yet.
canalhandia.cmd.recado.desconhecido=I don't know anyone called "{0}". (You can only leave a message for someone who has joined the server.)
canalhandia.cmd.reagir.uso=Usage: /reagir <{0}>
canalhandia.cmd.reagir.nada=Nothing to react to right now.
canalhandia.cmd.reagir.invalida=That reaction doesn't apply to the last message.
canalhandia.cmd.reagir.expirou=That message has expired.
canalhandia.cmd.reagir.desconhecida=Unknown reaction.
canalhandia.cmd.reacoes.nenhuma=Nobody has reacted to the last message yet.
canalhandia.cmd.reacoes.cabecalho=Who reacted ({0})
# guess / poll (action bars)
canalhandia.cmd.palpite.uso=Usage: /palpite <name>
canalhandia.cmd.palpite.nenhuma=No guess round open.
canalhandia.cmd.adivinha.rodada-acabou=That round is already over.
canalhandia.cmd.votar.uso=Usage: /votar <number>
canalhandia.cmd.votar.encerrada=That poll has already closed.
canalhandia.cmd.votar.opcao-inexistente=That option doesn't exist.
canalhandia.cmd.enquete.nenhuma=No poll open.
# /ia (messages the player sees; tone tuning is operator-only)
canalhandia.cmd.ia.uso=Usage: /{0} <question>
# /nota and /save
canalhandia.cmd.nota.publica-negado=You can't create public notes. Use /nota add <text> for a private one.
canalhandia.cmd.nota.uso-escopo=Usage: /nota {0} <text>
canalhandia.cmd.nota.vazia=The note is empty.
canalhandia.cmd.nota.cheia=You already have {0} notes. Delete one with /nota remover <n>.
canalhandia.cmd.nota.salva=Note #{0} saved ({1}) at {2}.
canalhandia.cmd.nota.listar-uso=Usage: /nota listar [publicas|privadas]
canalhandia.cmd.nota.buscar-uso=Usage: /nota buscar <text>
canalhandia.cmd.nota.cabecalho=Note #{0}
canalhandia.cmd.nota.autor=author
canalhandia.cmd.nota.escopo=scope
canalhandia.cmd.nota.lugar=place
canalhandia.cmd.nota.remover-uso=Usage: /nota remover <n>
canalhandia.cmd.nota.de-outro=That note belongs to {0}.
canalhandia.cmd.nota.apagada=Note #{0} deleted.
canalhandia.cmd.nota.nenhuma=No notes.
canalhandia.cmd.nota.e-mais=… and {0} more. Use /nota buscar <text> to filter.
canalhandia.cmd.nota.lista.tudo=Your notes and the public ones
canalhandia.cmd.nota.lista.escopo={0} notes
canalhandia.cmd.nota.lista.busca=Notes containing "{0}"
canalhandia.cmd.nota.nao-encontrada=Note not found.
# /curiosidade (seen by players)
canalhandia.cmd.curiosidade.nenhum-elegivel=Nobody eligible is online (or without enough stats).
canalhandia.cmd.curiosidade.sem-stats={0} doesn't have enough stats yet.
canalhandia.cmd.curiosidade.sem-curiosidade=No curiosity available for {0}.
canalhandia.cmd.curiosidade.toggle-off=You won't appear in curiosities anymore.
canalhandia.cmd.curiosidade.toggle-on=You're back in the curiosities.
canalhandia.cmd.curiosidade.subdesconhecido=Unknown subcommand or player. Use /curiosidade ajuda
@@ -1,120 +0,0 @@
# Canalhandia — português (fonte de verdade). Padrões MessageFormat: {0}, {1}, ...
# NUNCA altere os placeholders {0}/{1} nem as tags MiniMessage <...>.
# Luto (Canalhandia.onDeath) — botão F sob cada mensagem de morte.
canalhandia.morte.luto.prestar=prestar luto por {0}
canalhandia.morte.luto.digitar=digite /f para prestar luto por {0}
canalhandia.morte.luto.resumo={0} prestaram luto por {1}.
# Comandos — mensagens que qualquer jogador vê (não só o operador).
canalhandia.cmd.negado=Você não tem permissão para isso.
canalhandia.cmd.sojogador.reagir=Só jogadores podem reagir.
canalhandia.cmd.sojogador.usar=Só jogadores podem usar isso.
canalhandia.cmd.sojogador.ia=Só jogadores podem usar /ia.
canalhandia.cmd.sojogador.nota=Só jogadores podem anotar (a anotação guarda onde você está).
canalhandia.cmd.sojogador.recado=Só jogadores podem mandar recado.
canalhandia.cmd.sojogador.recados=Só jogadores têm recados.
canalhandia.cmd.sojogador.mortes=Só jogadores têm histórico de mortes.
canalhandia.cmd.sojogador.conquistas=Só jogadores têm conquistas. Use /conquistas <jogador>.
canalhandia.cmd.sojogador.titulo=Só jogadores usam títulos.
canalhandia.cmd.modulo.desligado=O módulo de {0} está desligado.
canalhandia.cmd.modulo.desativado=O módulo {0} está desativado.
canalhandia.cmd.jogador.informe=Informe um jogador.
canalhandia.cmd.jogador.offline=Jogador ''{0}'' não está online.
canalhandia.cmd.jogador.desconhecido=Não conheço ninguém chamado "{0}".
# /ranking
canalhandia.cmd.ranking.desconhecido=Ranking desconhecido. Use /ranking para ver a lista.
canalhandia.cmd.ranking.sem-dados=(sem dados ainda)
# /conquistas
canalhandia.cmd.conquistas.sem-stats=Ainda não tenho estatísticas de {0}.
canalhandia.cmd.conquistas.cabecalho=Conquistas de {0} ({1}/{2})
# /perfil
canalhandia.cmd.perfil.diga=Diga de quem: /perfil <jogador>.
canalhandia.cmd.perfil.cabecalho=Perfil de {0}
canalhandia.cmd.perfil.rotulo.estatisticas=Estatísticas
canalhandia.cmd.perfil.sem-dados=sem dados ainda
canalhandia.cmd.perfil.rotulo.conquistas=Conquistas
canalhandia.cmd.perfil.rotulo.titulo=Título
canalhandia.cmd.perfil.titulo.nenhum=nenhum
# /titulo
canalhandia.cmd.titulo.atual=Título atual
canalhandia.cmd.titulo.disponiveis=Disponíveis
canalhandia.cmd.titulo.uso=Use /titulo <nome> para usar, ou /titulo limpar para tirar.
canalhandia.cmd.titulo.nenhum-bloqueado=Você ainda não desbloqueou nenhum título. Veja /conquistas.
canalhandia.cmd.titulo.nao-tem=Você não tem o título "{0}". Veja /titulo para a lista.
canalhandia.cmd.titulo.removido=Título removido.
canalhandia.cmd.titulo.definido=Título definido: {0}.
# /mortes
canalhandia.cmd.mortes.cabecalho=Suas últimas mortes ({0})
canalhandia.cmd.mortes.nenhuma=Você ainda não morreu. Aproveite enquanto dura.
canalhandia.cmd.mortes.copiar=Clique para copiar as coordenadas
# /recado e /recados
canalhandia.cmd.recado.uso=Uso: /recado <jogador> <texto>
canalhandia.cmd.recado.vazio=O recado está vazio.
canalhandia.cmd.recado.mesmo=Recado para você mesmo? Use /save.
canalhandia.cmd.recado.caixa-cheia=A caixa de {0} está cheia ({1} recados). Espere ela entrar.
canalhandia.cmd.recado.guardado=Recado guardado para {0}. Vai chegar quando {0} entrar.
canalhandia.cmd.recado.online={0} está online — recado entregue na hora.
canalhandia.cmd.recados.tudo-entregue=Todos os seus recados já foram entregues.
canalhandia.cmd.recados.pendentes-singular={0} recado seu ainda não foi lido.
canalhandia.cmd.recados.pendentes-plural={0} recados seus ainda não foram lidos.
canalhandia.cmd.recado.desconhecido=Não conheço ninguém chamado "{0}". (Só dá para mandar recado para quem já entrou no servidor.)
# /reagir e /reacoes
canalhandia.cmd.reagir.uso=Uso: /reagir <{0}>
canalhandia.cmd.reagir.nada=Nada para reagir agora.
canalhandia.cmd.reagir.invalida=Essa reação não vale para a última mensagem.
canalhandia.cmd.reagir.expirou=Essa mensagem já expirou.
canalhandia.cmd.reagir.desconhecida=Reação desconhecida.
canalhandia.cmd.reacoes.nenhuma=Ninguém reagiu à última mensagem ainda.
canalhandia.cmd.reacoes.cabecalho=Quem reagiu ({0})
# adivinha / enquete (action bars)
canalhandia.cmd.palpite.uso=Uso: /palpite <nome>
canalhandia.cmd.palpite.nenhuma=Nenhuma adivinha aberta.
canalhandia.cmd.adivinha.rodada-acabou=Essa rodada já acabou.
canalhandia.cmd.votar.uso=Uso: /votar <número>
canalhandia.cmd.votar.encerrada=Essa enquete já foi encerrada.
canalhandia.cmd.votar.opcao-inexistente=Essa opção não existe.
canalhandia.cmd.enquete.nenhuma=Nenhuma enquete aberta.
# /ia (mensagens que o jogador vê; o ajuste de tom é só do operador)
canalhandia.cmd.ia.uso=Uso: /{0} <pergunta>
# /nota e /save
canalhandia.cmd.nota.publica-negado=Você não pode criar anotações públicas. Use /nota add <texto> para uma anotação só sua.
canalhandia.cmd.nota.uso-escopo=Uso: /nota {0} <texto>
canalhandia.cmd.nota.vazia=A anotação está vazia.
canalhandia.cmd.nota.cheia=Você já tem {0} anotações. Apague alguma com /nota remover <n>.
canalhandia.cmd.nota.salva=Anotação #{0} salva ({1}) em {2}.
canalhandia.cmd.nota.listar-uso=Uso: /nota listar [publicas|privadas]
canalhandia.cmd.nota.buscar-uso=Uso: /nota buscar <texto>
canalhandia.cmd.nota.cabecalho=Anotação #{0}
canalhandia.cmd.nota.autor=autor
canalhandia.cmd.nota.escopo=escopo
canalhandia.cmd.nota.lugar=lugar
canalhandia.cmd.nota.remover-uso=Uso: /nota remover <n>
canalhandia.cmd.nota.de-outro=Essa anotação é de {0}.
canalhandia.cmd.nota.apagada=Anotação #{0} apagada.
canalhandia.cmd.nota.nenhuma=Nenhuma anotação.
canalhandia.cmd.nota.e-mais=… e mais {0}. Use /nota buscar <texto> para filtrar.
canalhandia.cmd.nota.lista.tudo=Suas anotações e as públicas
canalhandia.cmd.nota.lista.escopo=Anotações {0}
canalhandia.cmd.nota.lista.busca=Anotações com "{0}"
canalhandia.cmd.nota.nao-encontrada=Anotação não encontrada.
# /curiosidade (vistas por jogador)
canalhandia.cmd.curiosidade.nenhum-elegivel=Ninguém elegível online (ou sem estatísticas suficientes).
canalhandia.cmd.curiosidade.sem-stats={0} ainda não tem estatísticas suficientes.
canalhandia.cmd.curiosidade.sem-curiosidade=Nenhuma curiosidade disponível para {0}.
canalhandia.cmd.curiosidade.toggle-off=Você não aparecerá mais nas curiosidades.
canalhandia.cmd.curiosidade.toggle-on=Você voltou a aparecer nas curiosidades.
canalhandia.cmd.curiosidade.subdesconhecido=Subcomando ou jogador desconhecido. Use /curiosidade ajuda
@@ -1,14 +1,10 @@
package dev.marcospaulo.canalhandia;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextColor;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
@@ -127,54 +123,6 @@ class AchievementTest {
() -> Achievement.parse("k", "T", "d", List.of()));
}
// --- colours and tiers -------------------------------------------------
@Test
void tierPicksTheColourAndCorOverridesIt() {
assertEquals(NamedTextColor.GOLD,
Achievement.parse("l", "L", "d", List.of("tempo >= 1"), "lendario", null).color());
assertEquals(NamedTextColor.LIGHT_PURPLE,
Achievement.parse("e", "E", "d", List.of("tempo >= 1"), "epico", null).color());
// A missing or unknown tier stays legible white — never the dark tone
// that started this: the default must always read on chat.
assertEquals(NamedTextColor.WHITE,
Achievement.parse("c", "C", "d", List.of("tempo >= 1"), null, null).color());
// An explicit cor wins over the tier, by name or by hex.
assertEquals(NamedTextColor.RED,
Achievement.parse("n", "N", "d", List.of("tempo >= 1"), "comum", "red").color());
assertEquals(TextColor.fromHexString("#ff5555"),
Achievement.parse("o", "O", "d", List.of("tempo >= 1"), "comum", "#ff5555").color());
// Garbage cor falls back to the tier colour rather than blowing up.
assertEquals(NamedTextColor.AQUA,
Achievement.parse("b", "B", "d", List.of("tempo >= 1"), "raro", "notacolor").color());
}
// --- detailed per-mob / per-block metrics ------------------------------
@Test
void statRefMetricsReadRawCounts() {
Achievement spiders = Achievement.parse("a", "A", "d", List.of("matou:spider >= 100"));
Map<String, Long> s = raw();
s.put("matou:spider", 99L);
assertFalse(spiders.met(s));
s.put("matou:spider", 100L);
assertTrue(spiders.met(s));
}
@Test
void referencedStatsListsEveryRefTheCatalogueUses() {
Achievement.load(List.of(
Achievement.parse("a", "A", "d", List.of("matou:creeper >= 1")),
Achievement.parse("b", "B", "d", List.of("minerou:obsidian >= 1", "tempo >= 1"))));
assertEquals(Set.of("matou:creeper", "minerou:obsidian"), Achievement.referencedStats());
}
@Test
void rejectsUnknownStatPrefix() {
assertThrows(IllegalArgumentException.class,
() -> Achievement.parse("x", "X", "d", List.of("voou:creeper >= 1")));
}
// --- the shipped catalogue loads and is sane ---------------------------
@Test
@@ -1,38 +0,0 @@
package dev.marcospaulo.canalhandia;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import java.util.Random;
import java.util.logging.Logger;
import org.bukkit.Material;
import org.junit.jupiter.api.Test;
/**
* The consolation-gift parsing and pick. Deliberately avoids the happy path of
* {@link DeathGift#parse}, which calls {@code Material.isItem()} — that throws in
* a unit JVM without a bootstrapped registry (same limit RecipeBookTest notes),
* so the item-resolution branch is verified live instead.
*/
class DeathGiftTest {
private static final Logger LOG = Logger.getAnonymousLogger();
@Test
void skipsMalformedAndUnknownLines() {
assertTrue(DeathGift.parse(List.of("sem as barras certas"), LOG).isEmpty());
assertTrue(DeathGift.parse(List.of("SÓ | DUAS_PARTES"), LOG).isEmpty());
// Unknown material name is rejected at matchMaterial, before isItem().
assertTrue(DeathGift.parse(List.of("ITEM_QUE_NAO_EXISTE_XYZ | Nome | msg"), LOG).isEmpty());
}
@Test
void pickIsNullOnEmptyAndAMemberOtherwise() {
assertNull(DeathGift.pick(List.of(), new Random()));
DeathGift.Gift only = new DeathGift.Gift(Material.POPPY, "Flor do Velório", "oi");
assertSame(only, DeathGift.pick(List.of(only), new Random()));
}
}
@@ -1,109 +0,0 @@
package dev.marcospaulo.canalhandia;
import net.kyori.adventure.key.Key;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import net.kyori.adventure.translation.GlobalTranslator;
import net.kyori.adventure.translation.TranslationStore;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.text.MessageFormat;
import java.util.Locale;
import java.util.PropertyResourceBundle;
import java.util.ResourceBundle;
import java.util.TreeSet;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
/**
* The two contracts the i18n bundles must hold: identical key sets across PT
* and EN, and one translatable rendering differently per locale. The store's
* own {@code translate(key, locale)} is an exact-locale lookup (no fallback);
* the fallback chain runs in {@link GlobalTranslator#render}, which is what
* the locale-resolution tests exercise.
*/
class I18nTest {
private TranslationStore.StringBased<MessageFormat> store;
@BeforeEach
void registerStore() throws IOException {
store = TranslationStore.messageFormat(Key.key("canalhandia"));
store.defaultLocale(Locale.ENGLISH);
store.registerAll(Locale.ENGLISH, bundle("lang/messages_en.properties"), true);
store.registerAll(Locale.of("pt"), bundle("lang/messages_pt.properties"), true);
GlobalTranslator.translator().addSource(store);
}
@AfterEach
void unregisterStore() {
GlobalTranslator.translator().removeSource(store);
}
private static ResourceBundle bundle(String resource) throws IOException {
try (var in = I18nTest.class.getClassLoader().getResourceAsStream(resource)) {
assertNotNull(in, "bundle ausente no classpath: " + resource);
return new PropertyResourceBundle(new InputStreamReader(in, StandardCharsets.UTF_8));
}
}
private static String plain(Component c) {
return PlainTextComponentSerializer.plainText().serialize(c);
}
/** Every key in one bundle must exist in the other, or a locale shows the raw key. */
@Test
void bothBundlesHaveTheSameKeys() throws IOException {
var pt = new TreeSet<>(bundle("lang/messages_pt.properties").keySet());
var en = new TreeSet<>(bundle("lang/messages_en.properties").keySet());
assertEquals(pt, en,
"chaves divergentes — so no PT: " + only(pt, en) + ", so no EN: " + only(en, pt));
}
private static java.util.Set<String> only(java.util.Set<String> a, java.util.Set<String> b) {
var diff = new TreeSet<>(a);
diff.removeAll(b);
return diff;
}
/**
* A pt client and an en client see different text from one component.
* Rendering runs through {@link GlobalTranslator}, the same path Paper uses
* on send — the store itself only resolves a key to a {@link MessageFormat}.
*/
@Test
void rendersDifferentlyPerLocale() {
Component translatable = Component.translatable("canalhandia.morte.luto.prestar",
Component.text("Steve"));
Component pt = GlobalTranslator.render(translatable, Locale.of("pt"));
Component en = GlobalTranslator.render(translatable, Locale.ENGLISH);
assertEquals("prestar luto por Steve", plain(pt));
assertEquals("pay respects for Steve", plain(en));
}
/** pt_BR falls back to pt via the GlobalTranslator chain. */
@Test
void ptBrFallsBackToPt() {
Component rendered = GlobalTranslator.render(
Component.translatable("canalhandia.morte.luto.resumo",
Component.text("Ana, Bob"), Component.text("Steve")),
Locale.forLanguageTag("pt-BR"));
assertEquals("Ana, Bob prestaram luto por Steve.", plain(rendered));
}
/** An unknown locale renders in the default (en), not as the raw key. */
@Test
void unknownLocaleFallsBackToDefault() {
Component rendered = GlobalTranslator.render(
Component.translatable("canalhandia.morte.luto.prestar", Component.text("Steve")),
Locale.forLanguageTag("ja"));
assertEquals("pay respects for Steve", plain(rendered));
}
}
@@ -54,11 +54,4 @@ class TitlesTest {
void tagCarriesTheTitle() {
assertNotNull(TitleChatListener.tag(Achievement.byKey("pedreiro")));
}
@Test
void tagRootIsColourlessSoMessageStaysWhite() {
// The chat message is appended to this tag; a coloured root would bleed
// into unstyled message text and grey it out. Root must carry no colour.
assertNull(TitleChatListener.tag(Achievement.byKey("pedreiro")).color());
}
}