# Canalhandia Chat-only social features for the Canalhandia Minecraft server (Paper 26.2). **Nothing here touches gameplay.** No items, no world edits, no attributes, no economy. Everything is chat messages, boss bars and clickable buttons, and every module can be switched off independently. All player-facing text is Portuguese (pt-BR). --- ## Modules | Module | What it does | |---|---| | `curiosidades` | *"Sabia que o Fulano já minerou 5.966 blocos de Pedra?"* — a fact about a player, with reaction buttons. Fires on join by default. | | `adivinha` | The same fact with the name hidden, plus clickable player names. Reveals after 45s and names who guessed right. | | `luto` | A clickable `[F]` under each death message, with a count when the window closes. | | `enquete` | `/enquete Pergunta \| A \| B` — clickable voting with a live tally on the boss bar. | | `ranking` | `/ranking mineracao` and friends. Covers **offline players too**. | | `marcos` | Announces round milestones — 100 km walked, 24 hours played — the first time someone crosses one. | | `ia` | `/ia ` asks an OpenAI-compatible model in chat. Optional wiki grounding, per-player memory, operator corrections. | Toggle any of them: `/canalhandia modulo ` --- ## 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 `/players/stats/.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 `/players/stats`, **not** `/stats`. `OfflineStats` checks both. --- ## Two constraints worth knowing These shaped the design, and anyone changing the code should know them before "fixing" what looks odd. ### 1. Chat messages cannot be edited after sending There is no vanilla way to update a message that is already in the chat log. So the counts baked into the reaction buttons are **frozen at send time** and never change. The live numbers appear on three other surfaces instead: - a **boss bar** while the window is open (`janela-reacao-segundos`, default 90) - an **action bar** shown to whoever just reacted - a **final tally line** broadcast when the window closes An earlier version only had the boss bar, and it read as broken — the buttons showed no number at all. ### 2. 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: ```yaml 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 `, `/legal`, `/wow`, `/top`, `/f`, `/palpite `, `/votar ` — 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 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 liga/desliga um módulo /canalhandia marcos força uma verificação de marcos /canalhandia limpar [cooldown|historico|tudo] /canalhandia reload /curiosidade modo /curiosidade intervalo intervalo do modo temporizado /curiosidade atraso espera após o jogador entrar /curiosidade cooldown mínimo entre citar o mesmo jogador /curiosidade repetir quantas recentes evitar repetir /curiosidade janela duração da barra de reações /curiosidade validade por quanto tempo cliques ainda contam /curiosidade reacoes /curiosidade reacao add /curiosidade reacao remover /curiosidade categoria ``` 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 ` — register a correction for the last answer | | `canalhandia.ia.perfil` | op | `/ia perfil ` — 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 ` | Asks the model. Public by default — the question and answer broadcast. Needs `canalhandia.ia`. | | `/iap ` | Asks privately — the answer goes only to the asker. Needs `canalhandia.ia.privado`. | | `/ia perfil ` | Switches profile live. `ECONOMICO` skips the wiki (fast); `PRECISO` consults the Minecraft Wiki (slower, grounded). Needs `canalhandia.ia.perfil`. | | `/ia corrigir ` | 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 ` 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. - **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. ```bash docker run --rm -v "$PWD":/work -v "$HOME/.m2":/root/.m2 -w /work \ maven:3.9-eclipse-temurin-25 mvn -B package ``` Output: `target/Canalhandia-1.0.0.jar` The dependency uses Paper's newer coordinate scheme: `io.papermc.paper:paper-api:26.2.build.92-stable`. ### Deploying Copy the jar into the server's `plugins/` and restart. There is no hot-reload path for a new jar — `/canalhandia reload` only re-reads `config.yml`. ```bash POD=$(microk8s kubectl get pod -n minecraft -l app=crafty-controller -o name | head -1) SRV=/crafty/servers/6e39a8b2-300b-42d6-8139-f397c23e461b microk8s kubectl cp target/Canalhandia-1.0.0.jar minecraft/${POD#pod/}:$SRV/plugins/Canalhandia-1.0.0.jar ``` --- ## Source layout | File | Role | |---|---| | `Canalhandia.java` | Plugin entry point, scheduling, broadcasting, listeners | | `CanalhandiaCommand.java` | Every command and all click callbacks | | `Settings.java` | Typed config access; all setters persist immediately | | `Module.java` / `Category.java` / `Mode.java` | Toggleable feature, fact group, trigger mode | | `CuriosityFactory.java` | Builds the Portuguese sentences from statistics | | `Stats.java` | Defensive Bukkit statistics access | | `Fact.java` | One sentence plus its category | | `Reactions.java` | Reaction state, buttons, boss bar, tally | | `GuessRound.java` | "Adivinhe de quem é" round state | | `Poll.java` | Poll state, voting, results | | `Milestones.java` | Threshold tracking, persisted to `marcos.yml` | | `OfflineStats.java` | Reads stats JSON for offline players | | `RankingMetric.java` | Leaderboard columns and their formatting | | `Msg.java` | Shared chat formatting and pt-BR number/duration formatting | ### Adding a curiosity Add one line to `CuriosityFactory.facts(...)` using the existing helpers (`material`, `entities`, `distance`, `time`, `count`), pick a `Category`, and phrase it so the count never has to agree with a translated noun. Statistic constants get renamed between Minecraft releases, so resolve them via `Stats.resolve("NEW_NAME", "OLD_NAME")` — a rename then degrades one curiosity instead of breaking the whole announcement.