Files
canalhandia/README.md
T
2026-08-20 16:38:18 +00:00

31 KiB

Canalhandia

Chat-only social features for the Canalhandia Minecraft server (Paper 26.2).

Almost nothing here touches gameplay — no world edits, no attributes, no economy, and no items anywhere except one: the luto tribute. Pressing F to pay respects drops the dead player's head into the mourner's inventory as a symbolic memento (toggle: luto.cabeca). Everything else is chat messages and clickable buttons, and every module can be switched off independently. (The reaction-count boss bar was removed; live counts ride on the reactor's action bar and a closing tally line.)

All player-facing text is Portuguese (pt-BR).


Modules

Module What it does
curiosidades "Sabia que o Fulano já minerou 5.966 blocos de Pedra?" — a fact about a player, with reaction buttons. Fires on join by default.
adivinha The same fact with the name hidden, plus clickable player names. Reveals after 45s and names who guessed right.
luto A clickable [F] under each death message, with a count when the window closes. Pressing F drops the dead player's head into the mourner's inventory (once per mourner per death, never to the dead player themselves) — the one gameplay-touching feature; toggle with luto.cabeca. Bedrock types /f.
enquete /enquete Pergunta | A | B — clickable voting with a live tally on the boss bar.
ranking /ranking mineracao and friends. Covers offline players too.
marcos Announces round milestones — 100 km walked, 24 hours played — the first time someone crosses one.
mortes Replaces each death message with a comic pt-BR line (cause-based flavor + a death counter) and sends the death coordinates privately to the dead player on respawn (the death screen swallows chat sent during the event), so they can run back to their dropped items. Respects keepInventory. /mortes lists your last 10 deaths with the cause and where each happened (mortes.yml) — your own only, since where someone died is where their stuff is.
zoacao A chat message matching the trigger (a pattern + a match mode) is swapped for a random line from zoacao.mensagens — a chat gag. The player's name still prefixes it. Default: a bare f/F (trimmed) → a random gag line. Match modes: igual (equals), contem (contains), comeca (starts with), termina (ends with), regex. The mode, the pattern, and the message list are all editable in-game with /canalhandia zoacao .... Pure chat swap; the luto tribute is unaffected (paying respects still needs the [F] button or /f). Affects Bedrock chat too (it's a chat event, not a click).
notas Notes in chat. /save pins where you are; /nota add <texto> writes a private one (only you see it, everyone may write); /nota publica <texto> writes one everyone reads, and needs canalhandia.nota.publica (default op). Every note stores its coordinates — click-to-copy on Java. Persisted to notas.yml. No teleport: nothing here touches gameplay.
recados /recado <jogador> <texto> — a line for someone who is offline, delivered on their next join. /recados shows how many of yours are still unread. Persisted to recados.yml.
conquistas Named achievements beyond the numeric milestones: "Casca Grossa" (50h, under 10 deaths), "Turista" (100h, barely mined), "Imortal às Avessas". /conquistas lists them all and marks yours. Persisted to conquistas.yml.
ia /ia <pergunta> asks an OpenAI-compatible model in chat. Has a personality (zoeiro by default — it will tease you), sees the last few chat lines and the live server state, and grounds answers in the asker's real stats. Optional wiki lookup, per-player memory, operator corrections.

Toggle any of them: /canalhandia modulo <nome> <on|off>


Where the data comes from

Everything is derived from vanilla statistics. No database, no tracking code, no extra writes — the server was already recording all of it.

  • Online players use the Bukkit API (Player#getStatistic).
  • Rankings read <world>/players/stats/<uuid>.json directly, because Bukkit only exposes statistics for players who are online. usercache.json maps those UUIDs back to names, including Floodgate/Bedrock players whose UUIDs start with 00000000-0000-0000-0009.

Note the path: Paper writes to <world>/players/stats, not <world>/stats. OfflineStats checks both.


Two constraints worth knowing

These shaped the design, and anyone changing the code should know them before "fixing" what looks odd.

1. Chat messages cannot be edited after sending

There is no vanilla way to update a message that is already in the chat log. So the counts baked into the reaction buttons are frozen at send time and never change. The live numbers appear on two other surfaces instead:

  • an action bar shown to whoever just reacted
  • a final tally line broadcast when the window closes

(A boss bar spanning the whole reaction window was tried and removed — it sat on screen for janela-reacao-segundos and read as clutter, and the two surfaces above already carry the counts.)

2. Who reacted, without filling the screen

Naming every reactor on its own chat line does not scale — five people reacting to five reactions is a wall of text. So the names live in three progressively larger places:

  • hover tooltip on each button (Java only — Bedrock cannot hover)
  • one-line closing summary naming the first resumo-nomes (default 3) and collapsing the rest into +N
  • /reacoes for the full breakdown, sent privately so chat stays clean

The same cap applies to the mourning line, so a popular death is still one line.

Relatedly, a curiosity costs one chat message, not two — the button row is appended to the headline rather than broadcast separately.

And automatic curiosities are rate-limited by intervalo-minimo-segundos (default 120). Without it, five people joining together produced five curiosities back to back. A typed /curiosidade bypasses the limit.

3. Clicks arrive late

People scroll back and click minutes after a message. Reactions therefore keep counting for reacao-validade-minutos (default 15) even after the boss bar is gone, and the last 8 reaction sets stay in memory for that reason. Silently dropping a late click looks like a bug to the player.

4. Bedrock cannot click, and cannot show emoji

Geyser cannot deliver a chat clickEvent to a Bedrock client, and most emoji render as tofu boxes there. So every clickable interaction has a typed equivalent, and every reaction carries two labels:

reacoes:
  uau:
    java: "[😮]"     # Java clients
    texto: "[UAU]"   # Bedrock clients — ASCII only
    comando: "wow"   # what a Bedrock player types

Messages with buttons are therefore built twice and sent per player (broadcastPerPlatform), not via Bukkit.broadcast. Typed fallbacks: /reagir <chave>, /legal, /wow, /top, /f, /palpite <nome>, /votar <número> — all of which act on the most recent message, so the player never needs a message id.

Changing a comando to a new name also requires adding that command to plugin.yml and restarting; Bukkit commands are static.

Detecting Bedrock: Floodgate mints UUIDs whose high 64 bits are zero (00000000-0000-0000-0009-…), so Platform.isBedrock checks getUniqueId().getMostSignificantBits() == 0. That keeps Floodgate an optional runtime dependency rather than a compile-time one. /canalhandia plataformas lists who is online and on which platform.

5. Names are translated by the client, not by us

Block, item and mob names are emitted as translatable components (Component.translatable(material.translationKey())), so a pt-BR client renders "Pedra" and an en-US client renders "Stone" from the same broadcast. There is no translation table to maintain.

The consequence: the client only supplies the singular form. Every sentence is therefore phrased so the number never has to agree with the noun — "5.966 blocos de Pedra", never "5.966 Pedras". Keep that rule when adding sentences to CuriosityFactory.

Note that the server console renders translatable components in English, so [Curiosidade] ... 16 unidades de Copper Pickaxe in latest.log does not mean players saw English.


Commands

Player-facing:

/curiosidade                       anuncia uma curiosidade agora
/curiosidade <jogador>             anuncia sobre alguém específico
/curiosidade ver [jogador]         mostra só para você
/curiosidade listar [jogador]      lista todas as curiosidades disponíveis
/curiosidade toggle                entra/sai do sorteio
/adivinha                          inicia uma rodada de "adivinhe de quem é"
/enquete Pergunta | A | B          abre uma enquete
/enquete encerrar                  encerra a enquete aberta
/ranking [categoria]               placares do servidor
/canalhandia status                mostra toda a configuração
/canalhandia modulos               lista os módulos e seu estado
/save                              salva onde você está (privado)
/save coords                       o mesmo, escrito por extenso
/save <texto>                      salva o lugar com um texto
/nota add <texto>                  anotação privada, só você vê
/nota publica <texto>              anotação pública (precisa de permissão)
/nota listar [publicas|privadas]   lista o que você pode ver
/nota buscar <texto>               procura no texto das anotações
/nota ver <n>                      mostra uma anotação inteira
/nota remover <n>                  apaga uma anotação sua
/recado <jogador> <texto>          recado entregue quando a pessoa entrar
/recados                           quantos recados seus ainda não foram lidos
/mortes                            suas últimas mortes, com causa e lugar
/conquistas                        conquistas, com as suas marcadas
/ranking semanal [métrica]         só o que foi ganho nesta semana

Admin (canalhandia.admin):

/canalhandia modulo <nome> <on|off>       liga/desliga um módulo
/canalhandia marcos                       força uma verificação de marcos
/canalhandia limpar [cooldown|historico|tudo]
/canalhandia zoacao listar                mostra a regra e as frases da zoação
/canalhandia zoacao modo <m>              igual | contem | comeca | termina | regex
/canalhandia zoacao padrao <texto>        texto/regex que dispara a zoação
/canalhandia zoacao adicionar <frase>     adiciona uma frase de zoação
/canalhandia zoacao remover <n|texto>     remove uma frase de zoação
/canalhandia zoacao limpar                volta para as frases padrão
/ia personalidade                         lista as personalidades da IA
/ia personalidade <nome>                  zoeiro | amigao | seco | aldeao | neutro
/ia eventos <on|off>                      IA comenta mortes seguidas sozinha
/ia saudacao <on|off>                     IA dá as boas-vindas de quem entra
/canalhandia reload
/curiosidade modo <entrada|intervalo|ambos|manual>
/curiosidade intervalo <min>              intervalo do modo temporizado
/curiosidade atraso <seg>                 espera após o jogador entrar
/curiosidade cooldown <min>               mínimo entre citar o mesmo jogador
/curiosidade repetir <n>                  quantas recentes evitar repetir
/curiosidade janela <seg>                 duração da barra de reações
/curiosidade validade <min>               por quanto tempo cliques ainda contam
/curiosidade reacoes <on|off>
/curiosidade reacao add <chave> <rótulo>
/curiosidade reacao remover <chave>
/curiosidade categoria <nome> <on|off>

Every setter writes through to config.yml immediately, so in-game changes survive a restart.

Permissions

Permission Default Grants
canalhandia.reagir everyone react, press F
canalhandia.ver everyone ver, listar, /ranking
canalhandia.enquete everyone open polls
canalhandia.forcar op trigger curiosities and guess rounds
canalhandia.admin op change modules and all settings
canalhandia.isento nobody never be the subject
canalhandia.nota everyone /save and private notes
canalhandia.nota.publica op write notes everyone can read
canalhandia.recado everyone leave messages for other players
canalhandia.ia op /ia (public question, broadcast to chat)
canalhandia.ia.privado op /iap (private question, answer only to the asker)
canalhandia.ia.corrigir op /ia corrigir <resposta> — register a correction for the last answer
canalhandia.ia.perfil op /ia perfil <economico|preciso> — switch profile live

Permissions are declared explicitly in plugin.yml. An undeclared Bukkit permission falls back to op-only, which would silently stop normal players from reacting.


Notes (/save, /nota)

Somewhere to write things down without leaving the game. Persisted to plugins/Canalhandia/notas.yml.

Scopes

Scope Who reads it Who may write it
private (default) only the author everyone — canalhandia.nota
public everyone; announced when created canalhandia.nota.publica (op)

Public notes are gated because a board anyone can write to becomes a graffiti wall. Grant it per player with LuckPerms: lp user <nome> permission set canalhandia.nota.publica true.

/save — the quick path

/save and /save coords pin the spot you are standing on. /save <texto> pins it with a note. Always private: this is the command someone types without reading help first, and the safe default for that is the one that cannot surprise anyone by broadcasting. /nota publica <texto> is the explicit way to share.

Places, not teleports

Every note stores the world and block coordinates where it was written — on a Minecraft server a note is nearly always about a place. On Java the coordinates are click-to-copy (the same affordance the death-coords message uses); Bedrock renders no click event and gets the same text plainly.

There is no teleport. Nothing in this module touches gameplay; a note is a line of text and a set of numbers.

Privacy

A private note is 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. A test asserts it directly.

Public notes are sent (ia.contexto-notas, default 10, 0 disables), which is what lets /ia onde fica a base? answer from what players actually wrote down.

Two smaller rules follow from the same principle: a note the viewer cannot see is reported as missing rather than as forbidden (saying "that one is private" would confirm it exists), and /nota buscar runs through the same visibility filter, so search cannot become a way to probe for someone else's text.

Details

  • Ids are never reused after a deletion — otherwise /nota ver 2 would point at a different note than the one someone wrote down a minute ago.
  • 100 notes per player, both scopes together.
  • 256 characters per note. The section sign and control characters are stripped: a note is echoed into chat and could otherwise forge a line that looks like it came from the server.
  • Authors delete their own notes; canalhandia.admin deletes any, which is the only way to clear a public note left by someone who has stopped playing.
  • Search is case- and accent-insensitive — nobody types "após" into a chat search, and missing a match over an acute reads as broken.

Weekly rankings

/ranking semanal [métrica] shows only what was gained since the start of the week. On a server with three regulars an all-time board is decided by whoever started first and then stops being a contest; subtracting a weekly baseline 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. A restart never rotates it — a server that restarts nightly would otherwise reset the week every day, which is the failure this design exists to avoid.

Players who did not move are dropped (the point is who is playing this week). Someone missing from the baseline counts their whole value, having joined during the week. A negative difference is dropped rather than shown: statistics only go up, so a negative means a stale baseline or a reset stats file, not a result.


Spontaneous AI lines

Off by default (ia.comentar-eventos, ia.saudacao). The persona can comment on a run of deaths and greet players as they join, using their own numbers. Both are opt-in because a chatty AI nobody asked for is the fastest way to make players hate the feature.

/ia eventos <on|off> and /ia saudacao <on|off> toggle them live.

Why the budget is strict

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. Budget enforces three limits, all of which must pass:

Limit Default Why
gap between any two lines 10 min stops chat spam
daily cap, separate from /ia's 20 protects the spend
per-subject cooldown 30 min 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: two events landing in the same tick would otherwise both pass allows() and fire together — the exact double-message the gap exists to prevent.

A death streak decays after 15 minutes. 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.


Notes on the BlueMap web map

Public notes are drawn as markers on BlueMap (notas.no-mapa, default on). Notes already carry a world and coordinates and the server already runs BlueMap, so this joins the two. Private notes are never drawn, at any setting.

BlueMap is an optional dependency. BlueMapBridge is the only class that touches its 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 scope because BlueMap ships those classes itself; a second copy inside this jar would shadow them and break the real plugin. preflight.sh fails if that scope is ever dropped.

Markers are rebuilt, not 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.


IA (/ia)

Chat Q&A backed by an OpenAI-compatible endpoint (default MiniMax). Gated to operator + LuckPerms-permitted players; both /ia and /iap default to op and are granted independently, so an operator can let someone ask privately without letting them broadcast.

The model can only ever produce chat text. No tool/function definitions are sent in the request, the reply is passed to sendMessage and nowhere else, and AiText.sanitise strips leading slashes so a reply cannot be mistaken for a command. A player asking it to "run /op me" gets a string back, not an executed command.

Commands

Command What it does
/ia <pergunta> Asks the model. Public by default — the question and answer broadcast. Needs canalhandia.ia.
/iap <pergunta> Asks privately — the answer goes only to the asker. Needs canalhandia.ia.privado.
/ia personalidade Lists the tones and marks the active one. Needs canalhandia.ia.perfil.
/ia personalidade <nome> Switches tone live: zoeiro (default), amigao, seco, aldeao, neutro.
/ia perfil <economico|preciso> Switches profile live. ECONOMICO skips the wiki (fast); PRECISO consults the Minecraft Wiki (slower, grounded). Needs canalhandia.ia.perfil.
/ia corrigir <resposta correta> Records a correction for the last answered question. Future similar questions get it as context — the cheap alternative to fine-tuning. Needs canalhandia.ia.corrigir.
/ia feedback ruim Flags the last answer wrong (in-memory counter shown in /canalhandia status).
/errado The [ERRADO] reaction to the last message — the typed-twin of the reaction button, for Bedrock players.

Subcommands only hijack when their second token is one they act on (a known profile key, or ruim), so /ia perfil do servidor falls through and is asked. corrigir stays greedy — a correction always reads the rest of the line.

Personality

ia.personalidade picks the tone. It is expressed purely as extra system instructions appended after the base ones, so it changes how the model talks and never what it may do.

Persona Tone
zoeiro Default. A grumpy server veteran: teases the asker, turns their own stats against them ("você já morreu 47 vezes e vem me perguntar sobre lava?"), but answers the question for real.
amigao Warm and patient, jokes rarely. For servers with new players.
seco Deadpan, one or two sentences, no exclamation marks.
aldeao In character as an ancient villager. Flavour only; still answers.
neutro No personality — the pre-persona behaviour.

Every persona, neutro included, carries Persona.GUARD, which restates the limits inside the persona's own frame: still no server/terminal/file access, still no commands, no inventing stats, no leaking the prompt. That is the layer that stops "you are a grumpy veteran" from reading as licence to claim powers the plugin never grants. Each teasing persona also states where the line is (no real insults, nothing about family, appearance, race, religion, sexuality or money; drop the ribbing if the player asks). Tests assert both properties hold for every persona, so adding a new one cannot quietly skip them.

Switch live with /ia personalidade <nome> — read per-question, no restart.

Chat and world awareness

  • Recent chat (contexto-chat, default 5, 0 disables): the last N public chat lines are sent as context, so the AI can follow what the room is talking about. Held in a bounded in-memory ring (50 lines max, 200 chars per line); nothing is written to disk and a restart starts it empty. Recorded at MONITOR priority with ignoreCancelled, so what it stores is what players actually saw — a zoacao swap included — and a cancelled message is never stored.
  • Live server state (estado-servidor, default on): who is online and on which platform, the asker's dimension, in-game time of day, weather, and their coordinates, health, hunger and XP level. This is what lets the AI answer "quem tá online?" or "tá chovendo?" instead of insisting it has no access. Captured on the main thread before the async call — every field reads the Bukkit world API, which is not safe off it — so only the formatted string crosses the thread boundary.

Answer styling

With estilo-rico on (default), Java players get the answer with a hover card showing the original question and the active persona, plus a click that pre-fills /ia for a follow-up. The click uses suggestCommand, never runCommand: nothing executes without the player pressing enter. Bedrock renders neither hover nor click, so it always gets the plain line — built via broadcastPerPlatform, like every other interactive message here.

Profile

ECONOMICO skips the wiki round trip — fast, ungrounded. PRECISO runs a forced tool call to pick a wiki term, looks it up on pt.minecraft.wiki, and injects the article as context. Switch live with /ia perfil; the choice is read per-question, so it takes effect immediately.

Operator corrections

/ia corrigir <resposta> appends to plugins/Canalhandia/correcoes.yml. When a new question shares at least one significant word (length > 4) with a recorded correction, the correction is injected as system context. Pure string matching, no model round trip.

Memory and context

  • Per-player memory: the last memoria-perguntas exchanges within memoria-minutos are replayed, for follow-ups like "e no nether?". Forgotten on quit. Baked at construction; not hot-swappable.
  • Server context: the contexto: list in config.yml is facts the model would never know (server name, Bedrock prefix, installed mods). Sent on every question.
  • Asker's stats: when estatisticas-jogador is on (default), the asking player's headline numbers (blocks mined, time played, distance walked, deaths, mob kills) are read from their vanilla stats JSON and injected as context, so "quantos blocos eu minerei?" gets a real answer instead of "não tenho acesso". The stats file lags by under a minute. Gated off with /canalhandia settings or ia.estatisticas-jogador: false.
  • Recipes: RecipeBook snapshots Bukkit.recipeIterator() at enable (main thread) and answers recipe questions from that snapshot — explaintext drops tables, so the wiki cannot supply them.

Keys and limits

The API key never lives in config.yml (committed to git). Read from the MINIMAX_API_KEY env var, or plugins/Canalhandia/minimax.key (one line, printable ASCII only — control chars are stripped so a stray newline can't land the key in a server-log header exception).

Per-player cooldown (cooldown-segundos), a server-wide daily cap (limite-diario), and a one-question-at-a-time guard per player keep the token spend bounded. canalhandia.admin skips the cooldown.

The four values url, wiki-caracteres, memoria-perguntas and memoria-minutos are baked at construction. Everything else — modelo, max-tokens, temperatura, instrucoes, perfil, contexto, the limits — is read live, so operators can hot-swap them with /canalhandia reload or the /ia perfil command without a restart.


Building

Requires JDK 25. Paper 26.2's API ships Java 25 class files, and JDK 21 fails with a misleading cannot access org.bukkit.Bukkit — that phrasing means the class-file version is too new, not that the dependency is missing.

docker run --rm -v "$PWD":/work -v "$HOME/.m2":/root/.m2 -w /work \
  maven:3.9-eclipse-temurin-25 mvn -B package

Output: target/Canalhandia-1.0.0.jar

The dependency uses Paper's newer coordinate scheme: io.papermc.paper:paper-api:26.2.build.92-stable.

Deploying

Copy the jar into the server's plugins/ and restart. There is no hot-reload path for a new jar — /canalhandia reload only re-reads config.yml.

POD=$(microk8s kubectl get pod -n minecraft -l app=crafty-controller -o name | head -1)
SRV=/crafty/servers/6e39a8b2-300b-42d6-8139-f397c23e461b
microk8s kubectl exec ${POD#pod/} -n minecraft -- \
  cp $SRV/plugins/Canalhandia-1.0.0.jar $SRV/plugins/Canalhandia-1.0.0.jar.bak-$(date +%F)
microk8s kubectl cp target/Canalhandia-1.0.0.jar minecraft/${POD#pod/}:$SRV/plugins/Canalhandia-1.0.0.jar
# The JVM runs as uid 1000 / gid 0; a root-owned jar is one it cannot read,
# and the failure looks exactly like "the plugin just did not load".
microk8s kubectl exec ${POD#pod/} -n minecraft -- chown 1000:0 $SRV/plugins/Canalhandia-1.0.0.jar

Pre-flight

./preflight.sh [jar] checks a staged deploy before anyone restarts anything. Every check is read-only; it never restarts the server, never writes to plugins/, and never touches the world.

$ ./preflight.sh
1. Local build     jar opens, plugin.yml present, all 16 commands declared,
                   config.yml bundled
2. Tests           surefire totals, 0 failures
3. Cluster         crafty pod found, plugins directory reachable
4. Staged jar      hash matches the local build, owned 1000:0, a rollback
                   .bak jar exists
5. Live config     parses as YAML, carries the keys this deploy needs, has a
                   .bak to roll back to
6. Health          the log is readable and free of recent ERROR lines

PRE-FLIGHT CLEAN — safe to restart.

It exits non-zero on any failure. A missing YAML parser reports as not checked rather than invalid: a harness that cries wolf is one people learn to ignore.

Because the plugin is staged dormant (copied in, not restarted), a red line here is a crash-on-boot you get to fix while the server is still up.


Source layout

File Role
Canalhandia.java Plugin entry point, scheduling, broadcasting, listeners
CanalhandiaCommand.java Every command and all click callbacks
Settings.java Typed config access; all setters persist immediately
Module.java / Category.java / Mode.java Toggleable feature, fact group, trigger mode
CuriosityFactory.java Builds the Portuguese sentences from statistics
Stats.java Defensive Bukkit statistics access
Fact.java One sentence plus its category
Reactions.java Reaction state, buttons, boss bar, tally
GuessRound.java "Adivinhe de quem é" round state
Poll.java Poll state, voting, results
Milestones.java Threshold tracking, persisted to marcos.yml
OfflineStats.java Reads stats JSON for offline players (rankings + the asker's stat summary for the IA)
RankingMetric.java Leaderboard columns and their formatting
DeathFlavor.java Comic pt-BR verb phrases for each death cause (used by the mortes module)
Mail.java Offline messages and their YAML storage
DeathLog.java Recent deaths per player, for /mortes
Achievement.java / Achievements.java The named-achievement catalogue (pure) and its award bookkeeping
WeeklyStats.java Weekly ranking baseline and the delta arithmetic (pure)
Budget.java The three-limit gate on spontaneous AI lines (pure)
BlueMapBridge.java Public notes as markers on the BlueMap web map (optional dependency)
Note.java / Notes.java One note (scope, text, place, visibility rules) and its YAML storage
Persona.java The AI's five tones, each carrying the safety guard
ChatLog.java Bounded, thread-safe ring of recent public chat for the AI
ServerState.java Main-thread snapshot of the live world for the AI
Msg.java Shared chat formatting and pt-BR number/duration formatting

Adding a curiosity

Add one line to CuriosityFactory.facts(...) using the existing helpers (material, entities, distance, time, count), pick a Category, and phrase it so the count never has to agree with a translated noun.

Statistic constants get renamed between Minecraft releases, so resolve them via Stats.resolve("NEW_NAME", "OLD_NAME") — a rename then degrades one curiosity instead of breaking the whole announcement.