i18n: per-player EN/PT via Adventure GlobalTranslator (#1)

Foundation + commands module of the i18n spec.

- I18n registry/loader + Lang.tr facade + reloadI18n
- PT source-of-truth bundle + EN translation
- CanalhandiaCommand player-facing strings migrated; admin-tuning/help/enum-labels deferred
- I18nTest: parity + per-locale render + pt_BR fallback; 316/316 green

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit was merged in pull request #1.
This commit is contained in:
2026-08-12 15:53:27 +00:00
parent c1a6b9730f
commit dafd96a4b6
63 changed files with 9075 additions and 177 deletions
+294 -10
View File
@@ -2,9 +2,13 @@
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.
**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).
@@ -16,11 +20,16 @@ All player-facing text is Portuguese (pt-BR).
|---|---|
| `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. |
| `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. |
| `ia` | `/ia <pergunta>` asks an OpenAI-compatible model in chat. Optional wiki grounding, per-player memory, operator corrections. |
| `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>`
@@ -51,14 +60,14 @@ These shaped the design, and anyone changing the code should know them before
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:
change. The live numbers appear on two 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.
(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
@@ -150,6 +159,20 @@ Player-facing:
/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`):
@@ -158,6 +181,16 @@ 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
@@ -185,6 +218,9 @@ survive a restart.
| `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 |
@@ -196,6 +232,151 @@ 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
@@ -215,6 +396,8 @@ executed command.
|---|---|
| `/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`). |
@@ -224,6 +407,57 @@ 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
@@ -246,6 +480,12 @@ no model round trip.
- **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.
@@ -293,9 +533,42 @@ 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 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
@@ -313,8 +586,19 @@ microk8s kubectl cp target/Canalhandia-1.0.0.jar minecraft/${POD#pod/}:$SRV/plug
| `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 |
| `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