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
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. No storage, no command. |
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). |
ia |
/ia <pergunta> asks an OpenAI-compatible model in chat. Optional wiki grounding, 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>.jsondirectly, because Bukkit only exposes statistics for players who are online.usercache.jsonmaps those UUIDs back to names, including Floodgate/Bedrock players whose UUIDs start with00000000-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 /reacoesfor 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
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
/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.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.
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 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.
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-perguntasexchanges withinmemoria-minutosare replayed, for follow-ups like "e no nether?". Forgotten on quit. Baked at construction; not hot-swappable. - Server context: the
contexto:list inconfig.ymlis facts the model would never know (server name, Bedrock prefix, installed mods). Sent on every question. - Asker's stats: when
estatisticas-jogadoris 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/canalhandiasettings oria.estatisticas-jogador: false. - Recipes:
RecipeBooksnapshotsBukkit.recipeIterator()at enable (main thread) and answers recipe questions from that snapshot —explaintextdrops 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 cp target/Canalhandia-1.0.0.jar minecraft/${POD#pod/}:$SRV/plugins/Canalhandia-1.0.0.jar
Source layout
| File | Role |
|---|---|
Canalhandia.java |
Plugin entry point, scheduling, broadcasting, listeners |
CanalhandiaCommand.java |
Every command and all click callbacks |
Settings.java |
Typed config access; all setters persist immediately |
Module.java / Category.java / Mode.java |
Toggleable feature, fact group, trigger mode |
CuriosityFactory.java |
Builds the Portuguese sentences from statistics |
Stats.java |
Defensive Bukkit statistics access |
Fact.java |
One sentence plus its category |
Reactions.java |
Reaction state, buttons, boss bar, tally |
GuessRound.java |
"Adivinhe de quem é" round state |
Poll.java |
Poll state, voting, results |
Milestones.java |
Threshold tracking, persisted to marcos.yml |
OfflineStats.java |
Reads stats JSON for offline players (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) |
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.