Commit Graph

40 Commits

Author SHA1 Message Date
Marcos (via Claude) 34a0b44a03 feat(conquistas): tiered title colours + per-mob/-block stats
- Titles carry a tier (comum..lendario) that colours their chat chip;
  optional per-title `cor` override (named or #hex). Low tier is now
  white, fixing the too-dark/unreadable default. Colour flows through
  the chat tag, /conquistas list, and unlock announcement.
- New StatRef metric grammar: matou:creeper, minerou:obsidian, etc.
  reach any per-mob/per-block vanilla counter from config, no code.
- Unified achievement reads onto the offline stats file (dropped the
  Bukkit snapshot path) so granular counters work on- and offline.
- Fixed unreachable titles: kamikaze (combate>=20 & mortes>combate),
  rato-de-caverna (20k mined, <50km). Catalogue 29 -> 38 titles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 18:37:05 -03:00
Marcos Paulo f1d210ddc4 feat(ia): agentic tool-calling — web search, stats, ranking, wiki
Turn /ia from a fixed-prompt chatbot into an agent that pulls what it needs.
Instead of one hardcoded wiki pre-fetch, the model is offered read-only tools
and MiniMax's tool_choice=auto lets it decide which to call; results are fed
back until it answers (MiniMax.answerWithTools), capped by ia.max-ferramentas.

- Search: web search via the cluster's self-hosted SearXNG (JSON API, no
  external key); results boiled down to a few "título — trecho (url)" lines.
- Tools: registry + dispatch for pesquisar_web, wiki, estatisticas_jogador,
  conquistas_jogador, ranking. All read-only and thread-safe off the main
  thread, so they run on the existing async answer worker.
- Ai.ask: agentic path when ia.ferramentas is on (default), else the previous
  behaviour untouched.
- Config: ia.ferramentas, ia.max-ferramentas, ia.searxng-url, ia.resultados-web,
  ia.trecho-web; getters in Settings. Reloadable via /canalhandia reload.

Verified live against MiniMax-M2.7 + SearXNG: the model auto-calls the tool and
answers from the result. SearchTest covers the pure result formatter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 12:52:04 -03:00
Marcos Paulo 6dda1e33d3 feat(conquistas): config-driven catalogue with /canalhandia reload
Move achievements and milestones out of code and into editable YAML, so
operators add or retune titles by editing a file and reloading — no rebuild.

- conquistas-catalogo.yml: each title is a key with titulo/descricao and a list
  of condicoes. Conditions are a tiny grammar ("metrica operador alvo", target
  may be a number, another metric, or metrica/numero), evaluated on friendly
  units (km, hours) normalised from the raw stats. Achievement is now a class
  loaded from this file, not an enum.
- marcos-catalogo.yml: milestone tracks (statistica/verbo/unidade/limiares),
  loaded by Milestones at startup and reload.
- /canalhandia reload now reloads both catalogues (plus config.yml) live and
  reports the counts.
- Shipped catalogue expanded 10 -> 29 titles and every milestone track deepened.
- Silent backfill on load/reload (Achievements.syncCatalogue, Milestones
  .resyncSilently, keyed on a stored signature): a grown catalogue banks the
  history it already implies instead of spamming returning players.

Tests: AchievementTest covers the condition grammar and validates the shipped
catalogue; TitlesTest loads the real catalogue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 12:35:36 -03:00
Marcos Paulo 29000c208b feat(conquistas): view any player's titles, /perfil card, wearable title tag
Extend the achievements module from self-only to a full RPG-status layer,
all still chat-only and derived from vanilla stats.

- /conquistas [jogador]: look up anyone's earned titles, offline included,
  computed from their stats on disk via the new OfflineStats.achievementStats
  (same keys the online snapshot uses, so identical Achievement conditions).
- /perfil [jogador]: a status card — headline stats, titles earned, worn title.
- /titulo [nome|limpar]: pick which earned title to wear; only earned ones are
  accepted and tab-completed. Stored per player in titulos.yml (Titles).
- TitleChatListener: an AsyncChat renderer that prefixes the chosen "[Título]"
  chip, for Java and Bedrock alike; gated on the conquistas module.

Tests: TitlesTest covers title selection and the offline stat-key contract.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-11 12:17:24 -03:00
marcos c54c6b22b3 feat(ia): split long/list answers into separate chat messages
Minecraft has no meaningful character cap on what the server sends —
the 256-char limit is only on what a player types. The real problem
was that AiText.sanitise flattened every \n to a space, so a
model-produced numbered list or multi-paragraph answer landed as one
wrapped wall of text instead of readable lines.

AiText.segments() keeps the model's own line breaks, re-wraps any
line still too long at a sentence boundary (falling back to a
word/char wrap for pathological input), and caps both the total
character budget (ia.max-caracteres, unchanged meaning) and the
number of chat messages (new ia.max-mensagens, default 4) so a
runaway list can't flood chat.

Ai.deliver sends one message per segment instead of one flattened
line; style() tags only the first with [IA], continuation lines get
a plain " » " marker so a 5-item list reads as one grouped answer,
not five separate replies.

295 -> 305 tests.
2026-08-09 06:06:51 +00:00
marcos 4ec9817636 Add weekly rankings, spontaneous AI lines and BlueMap note markers
Weekly rankings (/ranking semanal [metrica]). On a server with three regulars
an all-time board is decided by whoever started first and then stops being a
contest. Subtracting a baseline taken at the start of the week 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, so a server that restarts nightly does not reset the week every day.
There is a test for exactly that. Players who did not move are dropped, since
the point is who is playing this week; a player missing from the baseline
counts their whole value, having joined during the week; a negative difference
is dropped rather than shown, because statistics only go up and a negative
means a stale baseline, not a result worth printing.

Spontaneous AI lines (ia.comentar-eventos, ia.saudacao — both off by default).
The persona comments on a run of deaths, and greets players as they join using
their own numbers. Both are opt-in: a chatty AI nobody asked for is the fastest
way to make players hate the feature.

Budget is what makes this tolerable rather than obnoxious, and it is the piece
worth reading. 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,
so three limits all have to pass: a gap between any two lines, a daily cap of
its own separate from the /ia cap, and a per-subject cooldown so 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, because two
events in the same tick would otherwise both pass allows() and fire together —
the exact double-message the gap exists to prevent. The subject map drops
expired entries on each spend, or a long-lived server would leak one entry per
player who ever triggered a line.

A death streak now decays: 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.

The five-minute sweep no longer hides behind the marcos toggle. It was a
milestones-only task, so turning off marcos also silenced achievements and
froze the weekly board; each of the three now checks its own module inside the
body.

BlueMap markers (notas.no-mapa, default on). Public notes already carry a world
and coordinates and the server already runs BlueMap, so this joins the two.
BlueMapBridge is the only class that touches BlueMap's 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' because BlueMap ships those classes itself; a second copy inside our
jar would shadow them and break the real plugin, and preflight now fails if the
scope is ever dropped.

Markers are rebuilt rather than 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 — ampersand first, or the other replacements would
be double-escaped. Private notes are never drawn, at any setting.

295 tests, up from 263.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016pEyCmrAYHBFgpYjwFxKxh
2026-08-08 01:03:10 +00:00
marcos 26148740ef Add offline mail, death history and named achievements
Three features that all answer questions the server could already have
answered but was throwing away.

Mail (/recado <jogador> <texto>, /recados). On a server where people rarely
overlap, "achei diamante em -400 70 200" had to go through Discord or be lost.
Messages are queued for anyone who has joined before — resolved through
usercache.json, case-insensitively, because nobody types a name with the right
capitalisation — and delivered on their next join. If the recipient is online
it is delivered immediately instead of queued, since queueing it would mean
the person standing next to you reads it only after a relog.

Delivery is destructive: a message that stayed queued would be re-read on every
single join, turning a helpful note into a nuisance. It is also delayed a few
seconds and re-checks isOnline, because a player can leave inside the delay and
the mail would otherwise be consumed with nobody there to read it. Its own
join handler, not a branch inside onJoin, which returns early when the
curiosidades module is off — mail must not depend on an unrelated module.
Inbox capped per recipient, counting every sender, so the cap cannot be
bypassed with a second account.

Death history (/mortes). The mortes module already knew where and how someone
died and discarded it once the coords were delivered on respawn. Keeping the
last ten per player answers what people actually ask a day later. Eviction is
per player, not global, or one player's bad night would erase everyone else's
history. Your own deaths only: where someone died is where their stuff is, and
a public list of that is a looting guide.

Named achievements (/conquistas). Milestones covers round numbers; this covers
the combinations that say something about how someone plays — "Casca Grossa"
(50 hours, under 10 deaths), "Turista" (100 hours, barely mined), "Imortal às
Avessas" (dies more than once per hundred blocks mined). Every condition is a
pure function of a stat map, so the catalogue is unit-tested without a server.
The ratio ones carry a floor on absolute mining, so a new player is not handed
a joke achievement on their second death — there is a test for exactly that.

First sight is silent, like Milestones: the first time a player is checked,
whatever they have already earned is recorded without announcing it. Otherwise
enabling the module would dump a dozen announcements for history earned months
ago. Players who qualify for nothing are still marked as seen, or every later
check would treat them as new and stay silent forever.

Achievements share the milestone task rather than adding a second timer: both
sweep every online player's statistics, so one pass does the work of two.
Stats.totalOf sums a material-keyed statistic into a long — the per-material
values are ints and a long-running player's total can pass Integer.MAX_VALUE.

The /conquistas checklist uses "[x]" on Bedrock instead of "✔", which renders
there as a tofu box — the same per-platform rule the reaction labels follow.

Msg.ago renders wall-clock timestamps as "há 2 dias"; a timestamp from the
future clamps to "agora" rather than printing a negative age.

263 tests, up from 218.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016pEyCmrAYHBFgpYjwFxKxh
2026-08-08 00:49:56 +00:00
marcos bc711a48c2 Add chat notes: /save and /nota, public and private
Players had nowhere to write anything down. Coordinates got pasted into
Discord, or lost. This adds notes to chat, with two scopes.

A private note is visible only to its author and everyone may write one
(canalhandia.nota, default true). A public note is broadcast and readable by
all, and writing one needs canalhandia.nota.publica (default op) — public
notes are a curated board, not a graffiti wall. Both permissions are declared
in plugin.yml: an undeclared Bukkit permission silently falls back to op-only,
which would have stopped normal players writing anything.

Every note stores where it was written. On a Minecraft server a note is nearly
always about a place — where the base is, where the spawner was found — so
coordinates are part of the model rather than optional metadata. Java players
get click-to-copy on them, the same affordance the death-coords message uses;
Bedrock renders no click event and gets the plain text. No teleport: the
plugin does not touch gameplay.

/save is the quick path. /save and /save coords pin the spot; /save <texto>
pins it with a note. Private on purpose — it is the command someone types
without reading help first, and the safe default there is the one that cannot
surprise anyone by broadcasting. /nota publica <texto> is the explicit way to
share.

Private notes are 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. Public notes
are sent (ia.contexto-notas, default 10), which is what lets the AI answer
"onde fica a base?" from what players actually wrote down.

A note the viewer cannot see is reported as missing rather than as forbidden:
saying "that one is private" would confirm it exists, which is itself a leak.
Search runs through the same visibility filter, so it cannot become a way to
probe for someone else's text. Ids are never reused after a deletion, or
"/nota ver 2" would point at a different note than the one someone wrote down
a minute ago. Text is stripped of the section sign and control characters,
because a note is echoed into chat and could otherwise forge a line that looks
like it came from the server.

Scope parsing accepts the masculine and plural forms ("publico", "publicas")
alongside the canonical ones. Spelled out rather than derived: a blanket a-to-o
rewrite turns "privada" into "privodo", and the plural is exactly what tab
completion suggests, so it has to parse or the suggested command fails.

preflight.sh now checks all 18 commands and both new permissions.

218 tests, up from 176.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016pEyCmrAYHBFgpYjwFxKxh
2026-08-07 23:46:55 +00:00
marcos 0726ce3794 Give the AI a personality, chat awareness and live server state
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
2026-08-07 22:49:02 +00:00
marcos b77a38b394 Make zoacao match rule editable in-game: mode + pattern + message list
Zoacao now matches a configurable pattern under a configurable mode
(igual/contem/comeca/termina/regex) instead of only a bare 'f'. The mode,
the trigger pattern, and the gag message list are all editable live via
/canalhandia zoacao <listar|modo|padrao|adicionar|remover|limpar>; every
change writes through to config.yml immediately. config.yml gains
zoacao.correspondencia + zoacao.padrao (default igual + 'f').

Zoacao.replace/ matches gained a Mode + pattern signature; ZoacaoTest
covers all five modes (incl. invalid-regex guard) and the byKey parser.
136 tests green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-06 22:27:42 +00:00
marcos d61de1b208 Add zoacao chat gag: bare 'f' replaced with a random line
New module keyed 'zoacao'. An AsyncPlayerChatEvent listener swaps any
bare 'f'/'F' (trimmed, nothing else) for a random line from the
configurable zoacao.mensagens list (default Sou gay + 5 others). Pure
chat swap — player name still prefixes it; luto tribute untouched (still
needs the [F] button or /f command). Gated by modulos.zoacao (default on).

Zoacao.replace is pure for unit tests; ZoacaoTest covers bare f, case,
whitespace, f-with-extra-text, empty/null lists. 124 tests green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-06 22:22:25 +00:00
marcos 95520831da Remove reaction boss bar; fix death coords; F-tribute head
- Reactions: drop the purple boss bar that sat on screen for the whole
  reaction window (curiosidades, adivinha, luto [F], /ia answers). Live
  counts still ride on the reactor's action bar and the closing tally line.
- mortes: death coordinates were sent during PlayerDeathEvent and the Java
  death screen swallowed them. Capture at death, deliver on PlayerRespawnEvent
  (1 tick later) so the player actually receives them. Respects keepInventory.
- luto: pressing F to pay respects now drops the dead player's head into the
  mourner's inventory — once per mourner per death, never to the dead player.
  First gameplay-touching feature; toggle with luto.cabeca (default on).
- Fix the /f typed twin: it fell through to help because "f" is not a
  configured reaction (the mourning set is hardcoded). Route it to reactLatest
  so Bedrock players can pay respects too.
- README + config updated. 117 tests green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-06 13:59:28 +00:00
marcos 68db3a7079 feat: feed asker stats to the IA + comic death messages
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>
2026-08-06 05:06:45 +00:00
marcos a3b2e7dd27 fix(ia): wire canalhandia.ia.privado gate, tighten subcommand hijack
Review of Task 11 (commit 5f616d3) flagged a blocking spec regression:
canalhandia.ia.privado was declared (default: true) but never checked --
ia(sender, args, isPrivate) only checked canalhandia.ia, so /iap silently
required canalhandia.ia and the privado perm did nothing.

Per operator decision, /ia and /iap are now separate gates, both default
op, so LuckPerms can grant them independently (operator + permitted only,
not everyone). /ia needs canalhandia.ia; /iap needs
canalhandia.ia.privado.

Also fixes three should-fix findings:
- Subcommand hijack is no longer greedy: perfil and feedback only hijack
  when the second token is one they act on (a known profile key, or "ruim"),
  so "/ia perfil do servidor" and "/ia feedback do mapa?" fall through and
  are asked. corrigir stays greedy (a correction always reads the rest).
- AiProfile.isValid tells a real key from the PRECISO fallback, so /ia perfil
  blah no longer silently switches to PRECISO.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-06 03:54:01 +00:00
marcos 5f616d3d99 feat: /iap, reactions on answers, feedback and /ia corrigir 2026-08-06 03:43:19 +00:00
marcos a3f40d1408 fix: Ai review fixes — quit-race, token overflow, comment + nits
- Guard conversations.remember() on asker==null: a PlayerQuitEvent
  forgets the history (carry-forward #6), and re-adding here after the
  quit would resurrect it. lastAnswer stays regardless so /ia corrigir
  can still correct the last answer.
- Saturate the retry token ceiling: max_tokens near Integer.MAX_VALUE
  would overflow to a negative budget sent to the API.
- Tighten the constructor comment: aiUrl/aiWikiChars/aiMemoryExchanges/
  aiMemoryMinutes are baked; everything else (aiProfile, aiModel,
  aiMaxTokens, aiTemperature, aiInstructions, aiServerContext) is read
  live, not just aiProfile.
- Drop unused import java.util.List; add trailing newline.
2026-08-06 03:32:31 +00:00
marcos 5b13ec7713 feat: ground answers in the wiki, recipes, corrections and memory
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-06 03:19:54 +00:00
marcos 7940a8fc66 fix: guard Corrections against async read from compose
all() returns List.copyOf(entries), which iterates; add()/load()
structurally modify. Once Task 10 wires all() into runTaskAsynchronously
and Task 11 wires add() from the main-thread /ia corrigir command,
the race throws ConcurrentModificationException. Guarded by entries'
own monitor like Conversations: file parse and YAML save stay outside
the lock, only fast in-memory work is under it.
2026-08-06 03:12:27 +00:00
marcos 5cfa580997 feat: operator corrections injected into similar questions 2026-08-06 03:09:11 +00:00
marcos aee991bb7b feat: IA profiles, server context and a workable token ceiling
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-06 03:03:53 +00:00
marcos e0d7c289a8 feat: short per-player conversation memory
Guarded by a monitor: Ai calls this from runTaskAsynchronously and
history() prunes while it reads, so an unsynchronised map would throw
ConcurrentModification into a player's answer or corrupt itself on resize.

Expiry compares nanoTime differences rather than wall-clock instants, so
a zero window expires deterministically and an NTP step backwards cannot
leave entries stamped in the future and unexpirable. Player count is
capped LRU, since expiry only prunes players who ask again.
2026-08-05 16:23:57 +00:00
marcos c5d8f44bf2 fix: restrict API keys to printable ASCII, and harden response parsing
Fuzzing all 65,536 char values, not the 256 of the previous pass, shows
the JDK rejects 65,312 of them from a header value and every single one
echoes the value back. The previous deny-list covered 33. A key file
saved by Notepad or PowerShell Out-File carries a U+FEFF byte order
mark, which passed both filters and reached the quoting validator.

Ai.cleanKey and HttpFetcher.checkBearer now allow printable ASCII only,
an allow-list of the 94 characters a bearer token is made of, which
cannot drift out of what the JDK accepts. Demonstrated invariant:
cleanKey output ⊆ checkBearer accepts ⊆ JDK accepts, 0 violations.

Ai.call gains the same check and its failure log is now redacted; that
was the one path where the proof-of-concept leak surfaced.

MiniMax.message and answer now check JSON types before assuming them.
Gson throws unchecked on JSON that parses but has the wrong shape, and
both run outside post()'s try, so {"choices":["str"]} escaped to an
async Bukkit worker as a stack trace instead of the promised null.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3jSvSTrG4qsniLSr6TpC6
2026-08-05 16:18:32 +00:00
marcos 88fe9c9695 fix: stop a malformed API key reaching a log line
HttpRequest.Builder.header() quotes the offending header value back in
its IllegalArgumentException. Fuzzed on temurin-25: all 32 control
characters it rejects echo the value, so a key carrying any of them ends
up in whatever log catches the throw. A key file with a comment on line
two survives trim() and is enough to trigger it.

Ai.cleanKey now takes the first non-blank line and drops control
characters, so a malformed key never forms. HttpFetcher.checkBearer
rejects one anyway before the request is built, with a message naming
only the position, so no future caller has to remember to redact. Its
reject set is a strict superset of the JDK's.

Also renames MiniMax.Msg to MiniMax.Turn: the package already has a
top-level Msg, the chat-formatting helper, which the record shadowed
inside MiniMax.java.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3jSvSTrG4qsniLSr6TpC6
2026-08-05 16:05:22 +00:00
marcos 6e0167cff1 feat: MiniMax client with forced tool call for wiki term selection
Term selection is a forced tool call rather than free text: measured 5/5
against 2/7 for a free-text extraction call, because a tool argument is
structured output and survives the hidden reasoning eating the budget.
With tool_choice auto the model skipped the search on exactly the
questions it was most likely to get wrong.

Failures are reported through an optional warn consumer, as Wiki does,
and the endpoint is injectable for MiniMax's regional hosts.

Warnings are redacted of the key: a key with an embedded newline makes
the JDK throw invalid header value: "Bearer sk-...", quoting the whole
value back, and that lands in the generic call-failure path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3jSvSTrG4qsniLSr6TpC6
2026-08-05 16:00:46 +00:00
marcos 62497556a5 fix: never resolve a recipe question to the wrong item
The substring fallback in materialFor returned plausible recipes for
items the player did not ask about. "Redstone Repeater" tied on length
and resolved to REDSTONE, handing someone asking about repeaters the
recipe for redstone dust; "Book and Quill" gave BOOK, "Minecart with
Chest" gave MINECART, "Rabbit's Foot" gave RABBIT, "Chestplate" gave
CHEST. That is the exact failure this feature exists to remove, and it
is worse than no answer because the model states it confidently.

Dropped it. Exact match plus a hand-checked alias table, else null. No
suffix rule either: "axe" is a suffix of "pickaxe", so tool and armour
families would fail the same way. Beds and wool now return null, which
is correct.

brewing() matched keys as bare substrings and hijacked real questions:
"salto" inside basalto, "cura" inside curar, "forca" inside reforcar.
Since describe() answers brewing first, each took over the whole answer.
Now gated on the question mentioning a potion, and matched on word
boundaries.

Table corrections: "Fogo do Dragao" is not an item, it is Bafo do Dragao
giving a lingering potion; Frasco de Agua, not Garrafa de Agua, which is
the empty bottle; Pe de Coelho; Fatia de Melancia Reluzente. Added the
in-game item names players actually type (Agilidade, Dano) and stripped
hyphens so Mestre-Tartaruga reaches the table.

describeShapeless no longer emits a dangling "Sem formato: ".

describeChoice could not be covered after all: constructing a
MaterialChoice initialises org.bukkit.Registry, which needs a server,
and the class is sealed so it cannot be faked. Verified in Task 13.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3jSvSTrG4qsniLSr6TpC6
2026-08-05 15:54:40 +00:00
marcos dba64df917 feat: ground recipes on the server, translated via wiki langlinks
Crafting recipes come from Bukkit.recipeIterator(), which is authoritative
for this exact version. Player questions are Portuguese and Material names
are English, so the subject of the question is searched on the pt wiki and
prop=langlinks&lllang=en gives the English title, which uppercases onto the
enum constant.

Matching material names against the question directly does not work.
Measured over twenty realistic pt-BR questions it resolved 1 of 20, and that
one ("tridente" containing "trident") by coincidence rather than
translation. Through langlinks the same twenty resolve 17 of 20.

Brewing is not exposed by Bukkit at all: there is no brewing Recipe type,
PotionBrewer has no getter or iterator, and vanilla brewing is hardcoded in
PotionBrewing rather than registered as a recipe. Potions therefore come
from a hand-written pt-BR table. This is what actually answers the
fire-resistance question that motivated the feature; recipeIterator() alone
never could have.

Ingredients are read through getChoiceMap/getChoiceList. The deprecated
getIngredientMap/getIngredientList collapse a choice to one arbitrary stack,
printing "oak planks" where the recipe accepts any plank.

The design doc claimed recipeIterator() closed the potion case. It did not;
corrected to record what is true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3jSvSTrG4qsniLSr6TpC6
2026-08-05 15:40:36 +00:00
marcos 0f3bdf209d fix: report wiki failures instead of grounding silently on nothing
Every failure mode returned null, which the caller cannot tell apart
from a term the wiki has no article for. A 403 would revert /ia to the
confidently wrong answers grounding exists to stop, against a clean log.

Also clamps maxChars so a config of 0 cannot switch grounding off for
good, restores the interrupt flag on disable, and distinguishes a
malformed response from an outage in the log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:25:45 +00:00
marcos 13fb87fb8b feat: fetch full articles from the Portuguese Minecraft Wiki 2026-08-05 15:17:01 +00:00
marcos 9ffcf816ea feat: add Fetcher seam with an identifying user agent 2026-08-05 15:11:11 +00:00
marcos 3d2150d2a6 docs: scope the foreign-script and surefire claims to what the code does
hasForeignScript detects by alphabet, so Latin-script leakage such as
the observed French "contiennent" is not caught. Say so in the javadoc
and admit the gap in the design doc rather than implying coverage.

failIfNoTests catches a misplaced or misnamed test class, not a
disabled one: an @Disabled class still reports as skipped and the
build stays green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3jSvSTrG4qsniLSr6TpC6
2026-08-05 15:09:37 +00:00
marcos 4f427659bb refactor: extract AiText and detect foreign-script leakage
Moves sanitise out of the Bukkit-bound Ai class so it can be unit
tested, and adds hasForeignScript to catch the CJK words the model
intermittently drops into Portuguese answers.

Colour-code stripping now removes the code character too: replacing
only the section sign left "§c" reading as a stray "c" in chat.

Surefire now fails on an empty suite, so a misplaced or disabled test
class cannot pass as a green build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3jSvSTrG4qsniLSr6TpC6
2026-08-05 15:03:00 +00:00
marcos be1f31778a test: add JUnit 5 harness 2026-08-05 14:57:45 +00:00
marcos 0b3be45d76 feat: pick MiniMax-M2.7, and strip markdown and emoji from replies
Benchmarked M2.7, M3 and M2 against the same four pt-BR prompts (a
Minecraft question, a general-knowledge one, an unanswerable one, and a
jailbreak attempt).

M2.7 wins on the axis that matters for chat. All three held the
no-server-access line under the jailbreak, but:

- M3 is a reasoning model, and its thinking counts against max_tokens.
  At the 300 chat needs, its Minecraft answer was cut off mid-word at
  39 characters while reporting 590 tokens used. Raising the budget
  enough to fix that costs more per question than the answer is worth.
- M2 answered the brewing question wrong, going straight from a water
  bottle to magma cream and skipping the nether wart.
- M2.7 answered correctly in 2.7-5.2s.

The benchmark also showed both markdown and emoji in every model's
output. Minecraft chat renders neither: `**negrito**` arrives as
literal asterisks, and emoji are empty boxes on Bedrock. The system
prompt now asks for plain text, and sanitise() strips emphasis, code
fences, headings and emoji regardless, since a prompt is a request and
not a guarantee.
2026-08-05 14:01:22 +00:00
marcos 0d84a606d4 feat: /ia — chat question-and-answer backed by MiniMax
Adds an `ia` module: `/ia <pergunta>` sends the question to an
OpenAI-compatible chat-completions endpoint (MiniMax by default) and
posts the reply to chat.

Access is gated by `canalhandia.ia`, declared `default: op` so the
operator has it out of the box and LuckPerms can grant it to anyone
else.

The model can only ever produce chat text:

- the reply goes to sendMessage and nowhere else — it is never passed
  to the command dispatcher;
- no `tools`/`tool_choice` are sent, so there is nothing for the model
  to call;
- the system prompt states it has no server, shell or command access;
- replies are sanitised — colour codes stripped so they cannot forge
  server messages, newlines folded so one answer is one chat entry,
  and leading slashes removed so nothing reads as a command to run.

The API key is deliberately not a config value, since config.yml is
committed. It is read from MINIMAX_API_KEY or from
plugins/Canalhandia/minimax.key, which is now gitignored.

Cost is bounded by a per-player cooldown and a server-wide daily cap,
both visible in /canalhandia status. The HTTP call runs off the main
thread; only the delivery hops back onto it.
2026-08-05 13:41:28 +00:00
marcos be6d34f456 feat: show who reacted, and stop curiosities flooding chat
Reactions only ever showed counts, so with several reactors nobody could
tell who had reacted. Naming everyone on its own line does not scale, so
names now appear in three progressively larger places: a hover tooltip per
button (Java only), a one-line closing summary naming the first
resumo-nomes (3) and collapsing the rest to "+N", and /reacoes for the full
breakdown sent privately.

Reactors' names are captured at react time so the summary survives them
logging off.

Two separate causes of chat flooding:
- every join fired its own curiosity, so a wave of joins produced one each.
  Automatic triggers are now rate-limited by intervalo-minimo-segundos
  (120); a typed /curiosidade still bypasses it.
- a curiosity cost two messages. The button row is now appended to the
  headline instead of being broadcast separately.

Also fixes "1 horas" — CuriosityFactory had its own duration formatting
that missed the earlier plural fix; it now uses Msg.duration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ActvLGJApdxEAd2yfKPwqv
2026-08-04 21:38:28 +00:00
marcos 987ee13d74 feat: Bedrock support — typed commands and ASCII labels
Geyser cannot deliver a chat clickEvent to Bedrock, and most emoji render
as tofu boxes there, so Bedrock players could see the reaction row but
neither read nor use it.

Messages carrying buttons are now built twice and sent per player rather
than via Bukkit.broadcast. Each reaction gains a per-platform label plus a
typed shortcut:

    uau: { java: "[😮]", texto: "[UAU]", comando: "wow" }

Typed fallbacks for every clickable interaction: /reagir, /legal, /wow,
/top, /f, /palpite and /votar, all acting on the most recent message so no
message id is needed.

Bedrock detection uses Floodgate's UUID scheme (high 64 bits zero) rather
than the Floodgate API, keeping it an optional runtime dependency.
/canalhandia plataformas lists who is online and on which platform.

Reaction config moves from a flat key->label map to a section per
reaction; the old flat form is still accepted and gets an ASCII fallback
derived from the key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ActvLGJApdxEAd2yfKPwqv
2026-08-04 21:31:24 +00:00
marcos 18ac5a5d17 fix: locate stats directory under Paper's dimensions layout
Rankings returned "sem dados" because World#getWorldFolder points at the
dimension directory in Paper's newer layout, so <worldFolder>/players/stats
does not exist. The real path is <worldContainer>/<level-name>/players/stats.
Try that first, fall back to the older locations, then climb out of the
dimension folder, and warn instead of silently returning nothing.

Also fixes "1 dias e 0 horas" — durations now agree in number and drop a
zero remainder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ActvLGJApdxEAd2yfKPwqv
2026-08-04 20:52:38 +00:00
marcos 801b43610f feat!: rename to Canalhandia and add four chat games
Renames the plugin from Curiosidades to Canalhandia (the server name) now
that it covers more than curiosities, and adds:

- adivinha: the curiosity with the name hidden and clickable player names,
  revealing who guessed right. Curiosity sentences never contained the
  subject's name, so the same generator feeds both with no redaction step.
- luto: a clickable [F] under death messages with a closing count.
- enquete: polls with clickable options and a live boss-bar tally.
- ranking + marcos: leaderboards covering offline players, and one-time
  milestone announcements persisted to marcos.yml.

Fixes the reaction counter being invisible. Chat cannot be edited after
sending, so the button counts are necessarily frozen; previously the only
live surface was the boss bar and the buttons showed no number at all.
Counts now appear in the buttons at send time, on the boss bar, on the
reactor's action bar, and as a final tally line when the window closes.

Late clicks used to be dropped silently once the 90s window passed. The
last 8 reaction sets now stay live for reacao-validade-minutos (15).

Rankings read the stats JSON directly because Bukkit only exposes
statistics for online players.

Adds README.md documenting the design constraints that are easy to
mistake for bugs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ActvLGJApdxEAd2yfKPwqv
2026-08-04 20:46:42 +00:00
marcos 7fd3fc157c feat: join-triggered by default, plus runtime admin controls
Default trigger is now player join rather than a timer, with a short
delay so the curiosity lands after the join message instead of racing
it. Modes entrada/intervalo/ambos/manual select the triggers.

Adds a full /curiosidade tree so behaviour is adjustable in-game
without editing config.yml: mode, interval, join delay, per-player
cooldown, no-repeat history, reaction window, reaction labels, and
per-category toggles. Every setter writes through to disk immediately
so changes survive a restart.

Facts are now tagged with a category so they can be filtered, and a
per-player cooldown plus recent-fact history stop repeats when someone
relogs.

Config changes are gated behind curiosidades.admin; reacting and
opting out stay default-true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ActvLGJApdxEAd2yfKPwqv
2026-08-04 20:24:54 +00:00
marcos c528ed55c8 feat: curiosidades plugin for stat-based server announcements
Announces a random Portuguese fact about a random online player on a
timer, sourced from vanilla statistics, with clickable reactions.

Block/item/mob names are emitted as translatable components so each
client renders them in its own language instead of shipping a
translation table. Sentences are phrased to avoid number agreement with
the translated noun ("5.966 blocos de Pedra", not "5.966 Pedras").

Statistic constants are resolved by name with fallbacks because they get
renamed across Minecraft releases; a rename degrades one curiosity
rather than breaking the announcement.

Chat cannot be edited after sending, so live reaction tallies ride on a
boss bar while the in-chat buttons stay frozen at send time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ActvLGJApdxEAd2yfKPwqv
2026-08-04 20:18:05 +00:00