68db3a7079
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>
337 lines
15 KiB
Markdown
337 lines
15 KiB
Markdown
# 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. |
|
|
| `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, so they can run back to their dropped items. No storage, no command. |
|
|
| `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>.json` directly, because Bukkit
|
|
only exposes statistics for players who are online. `usercache.json` maps those
|
|
UUIDs back to names, including Floodgate/Bedrock players whose UUIDs start with
|
|
`00000000-0000-0000-0009`.
|
|
|
|
Note the path: Paper writes to `<world>/players/stats`, **not** `<world>/stats`.
|
|
`OfflineStats` checks both.
|
|
|
|
---
|
|
|
|
## Two constraints worth knowing
|
|
|
|
These shaped the design, and anyone changing the code should know them before
|
|
"fixing" what looks odd.
|
|
|
|
### 1. Chat messages cannot be edited after sending
|
|
|
|
There is no vanilla way to update a message that is already in the chat log. So
|
|
the counts baked into the reaction buttons are **frozen at send time** and never
|
|
change. The live numbers appear on three other surfaces instead:
|
|
|
|
- a **boss bar** while the window is open (`janela-reacao-segundos`, default 90)
|
|
- an **action bar** shown to whoever just reacted
|
|
- a **final tally line** broadcast when the window closes
|
|
|
|
An earlier version only had the boss bar, and it read as broken — the buttons
|
|
showed no number at all.
|
|
|
|
### 2. 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 <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 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-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.
|
|
|
|
```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 (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.
|