22 Commits

Author SHA1 Message Date
marcos c1a6b9730f docs: document the IA module
README: add ia to the modules table, the four ia permissions, and a full
IA section covering commands, profile switching, operator corrections,
memory/context, and the key + rate-limit model. Notes the safety boundary
(no tools, reply never executed, leading slashes stripped) and which config
values are baked vs live.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-06 03:56:58 +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 2d867cefca docs: implementation plan for /ia grounding 2026-08-05 14:49:08 +00:00
marcos 3f9e80047c docs: design for grounding and feedback in the /ia module
Records what was measured rather than assumed: pt.minecraft.wiki as the
source, forced tool_choice for term selection (5/5 against 2/7 for
free-text extraction), full article text over exintro, and M2.7 over M3.

Also records two defects found while measuring — empty replies caused by
hidden reasoning eating max_tokens at the deployed value of 300, and
foreign-token leakage into Portuguese answers.
2026-08-05 14:45:03 +00:00
28 changed files with 5316 additions and 145 deletions
+78
View File
@@ -20,6 +20,7 @@ All player-facing text is Portuguese (pt-BR).
| `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. |
Toggle any of them: `/canalhandia modulo <nome> <on|off>`
@@ -184,6 +185,10 @@ 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.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
@@ -191,6 +196,79 @@ 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.
- **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
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,218 @@
# Design — grounding and feedback for the `/ia` module
**Date:** 2026-08-05
**Status:** approved
## Why
`/ia` shipped earlier today and was used five times by real players. Every
substantive answer contained an error:
| asked | answered | reality |
| --- | --- | --- |
| how to get a camel | spawns in desert villages "and badlands" | badlands is wrong; never said a saddle is involved |
| crossing the nether roof | "boat on soul sand, water on top, push the boat" | invented; not a technique |
| best mob farm spot | "Y 064, mobs spawnam mais *concentrated there*" | dubious, and English leaked into the Portuguese |
The model states Minecraft mechanics confidently and incorrectly. No amount of
prompt tuning fixes that, because the model does not know what it does not
know. It needs sources.
Two further defects were found while measuring:
- **Empty answers.** M2.7 emits hidden reasoning that counts against
`max_tokens`. At the deployed value of 300 it returned empty content twice in
testing. Players see "Não consegui resposta agora" and assume the feature is
broken.
- **Foreign-token leakage.** Replies contained `搭档` (Chinese) and
`contiennent` (French) mid-sentence.
## What was measured
Everything below is measured against the live MiniMax key, not assumed.
### Retrieval source
`pt.minecraft.wiki` answers in Portuguese, needs no API key, and responds in
~0.3s. Its camel article contains the exact fact the model missed. It returns
**HTTP 403 to a default user agent** — an identifying `User-Agent` is required.
### How to choose the search term
MediaWiki cannot parse conversational Portuguese, so the raw question is
useless as a query. Four methods, same questions:
| method | usable terms |
| --- | --- |
| raw question | 0 / 6 |
| local stopword strip | 1 / 6 |
| free-text extraction call | 2 / 7 |
| **tool call with forced `tool_choice`** | **5 / 5** |
Free-text extraction fails for the same reason answers came back empty: hidden
reasoning eats a small `max_tokens` budget. A tool call returns a *structured*
argument, which survives. Optional tool choice is not enough — with
`tool_choice: auto` the model skipped the search on the hardest question, the
one it had already answered wrong twice. Forcing the call fixes that.
### How much of the article to send
`exintro` returns only the lead paragraph. Grounded on intros alone, both
models honestly answered "não tenho certeza" to questions they had previously
answered correctly — truthful but useless. Sending the **full article text**
(~7k chars) produced correct answers for camels, mob farms and creeper drops.
### Which model
M2.7 and M3, both grounded on full articles:
| | tokens | outcome |
| --- | --- | --- |
| **M2.7** | 6647 | correct on camel, mob farm, creeper |
| M3 | 7749 | refused the mob farm question M2.7 answered well |
M2.7 is chosen. M3 costs ~17% more and refuses more. Ungrounded, M3 also
invented "poeira de guncotton" as a creeper drop.
### The gap wiki text cannot close
`explaintext` strips tables, and brewing and crafting recipes live in tables.
The fire-resistance article is only 1086 characters for this reason, and the
recipe question failed even fully grounded.
Crafting and brewing close differently, and an earlier draft of this document
was wrong to treat them as one problem.
**Crafting comes from the running server.** `Bukkit.recipeIterator()` is
authoritative for this exact version, free and instant. It is read through
`getChoiceMap()` / `getChoiceList()`; the older `getIngredientMap()` /
`getIngredientList()` are deprecated and collapse a choice to one arbitrary
stack, which prints "oak planks" where the recipe really accepts any plank.
**Item names are translated through the wiki.** `Material` names are English
and players ask in Portuguese, and the two do not meet on their own: scanning
every material name for a substring of the question was measured over twenty
realistic pt-BR questions and resolved **1 of 20** — and that one, "tridente"
containing "trident", by coincidence rather than translation. So the question's
subject is searched on the pt wiki and the article's `prop=langlinks&lllang=en`
gives the English title, which uppercases onto the enum: "Espada de Diamante"
→ "Diamond Sword" → `DIAMOND_SWORD`. The same twenty questions now resolve
**17 of 20**. Articles without an English link yield no grounding rather than
a guess.
The match is exact, plus a small hand-checked alias table ("Redstone Repeater"
`REPEATER`, "Book and Quill" → `WRITABLE_BOOK`). There is deliberately no
fuzzy fallback. Resolving to the longest material name contained in the title
was tried and returned confidently wrong items: "Redstone Repeater" tied on
length and gave `REDSTONE`, so a player asking about repeaters got the recipe
for redstone dust; "Minecart with Chest" gave `MINECART`; "Rabbit's Foot" gave
`RABBIT`, the raw meat. A suffix rule fails identically — "axe" is a suffix of
"pickaxe". Colour and material families (`Bed`, `Wool`) therefore resolve to
null, which is correct: a plausible recipe for the wrong item is the failure
this feature exists to remove, and it is worse than no answer.
**Brewing is not exposed by Bukkit at all, so potions use a hardcoded table.**
Checked against the 26.2 API: there is no brewing `Recipe` implementation;
`PotionBrewer` has `addPotionMix` / `removePotionMix` / `resetPotionMixes` but
no getter and no iterator; and vanilla brewing is hardcoded in `PotionBrewing`
rather than registered as a recipe, so `recipeIterator()` never yields it. The
server genuinely cannot supply this. `RecipeBook.BREWING` therefore lists the
~17 base potions in pt-BR by hand. This is the part that actually answers the
fire-resistance question that motivated the feature — `recipeIterator()` alone
would never have fixed it.
## Constraints
- **No restart now.** The jar is built and deployed dormant; it goes live at
the next restart.
- **Cost is not a constraint.** The MiniMax Plus plan allows 4.512M tokens per
5 hours. At ~2000 tokens per grounded question that is ~1800 questions per
window, which a five-player server will never approach. `limite-diario`
stays, but as chat-spam protection, not spend control.
- **Latency is the real budget.** Answers already take 46s. Every added
context token makes chat feel slower.
## Architecture
```
/ia <pergunta>
├─ permission · module · cooldown · daily cap (existing)
├─ context assembly
│ contexto.yml always server facts, hand written
│ correcoes.yml on keyword match operator corrections
│ recipe lookup recipe questions server registry + wiki langlinks;
│ potions from a hardcoded table
│ wiki article PRECISO only forced tool call → pt.minecraft.wiki
│ last 3 exchanges same player, 10 min window
├─ MiniMax-M2.7, max_tokens 1200
└─ sanitise → chat, with reaction buttons
```
Both HTTP calls run on the async thread that already exists; only delivery hops
back to the main thread.
**Caching.** Wiki articles cached in memory by title, 200 entries, 6h TTL.
Five people asking about creepers costs one fetch. Lost on restart, which is
fine.
**Profiles.** `ia.perfil: ECONOMICO | PRECISO`, switched live with
`/ia perfil <nome>`, no restart. ECONOMICO skips the wiki round trip. Its
purpose is latency, not cost.
## Safety
The boundary is structural, not prompt-level, and it does not weaken by adding
a tool:
- **Tools may only read.** Never write, never execute, never touch game state.
`buscar_wiki` performs one HTTPS GET against a hardcoded host with a
URL-encoded term. The model chooses an *argument*, never an operation.
- The reply goes to `sendMessage` and nowhere else. It never reaches the
command dispatcher.
- `sanitise()` strips `§` codes, markdown, emoji and leading slashes.
A player asking the model to run `ls` or `/give` gets a sentence back. There is
no function that could do otherwise.
## Error handling
| failure | behaviour |
| --- | --- |
| empty content | retry once at a higher token ceiling, then apologise |
| reply contains a non-Latin script (CJK, Cyrillic, Arabic, …) | discard, retry once |
| wiki 403 / timeout / no hit | answer without the article, and say the wiki was not consulted |
| MiniMax non-zero `base_resp` | log and apologise; HTTP 200 does not mean success |
| tool call absent despite forcing | fall back to answering ungrounded |
Known gap: the foreign-script check works by alphabet, so it only catches
non-Latin scripts. Latin-script leakage — the observed French `contiennent` in
an otherwise Portuguese answer — passes straight through, and this design does
not close that. Catching it would need dictionary or language-identification
work that is out of scope here.
## Features
- **Privacy per question.** `/ia` public, `/iap` visible only to the asker.
Operators can force the module private in config.
- **Reactions** on answers, reusing the existing `Reactions` class so Bedrock
gets typed equivalents.
- **Correction loop.** The asker marks an answer wrong; an operator writes the
right answer with `/ia corrigir`; it is stored in `correcoes.yml` and
injected on similar future questions. No fine-tuning, no extra API cost.
- **New permissions.** `canalhandia.ia.privado`, `canalhandia.ia.corrigir`,
`canalhandia.ia.perfil`.
## Testing
- Unit: `sanitise()` against markdown, emoji, `§`, leading slashes, CJK.
- Unit: term extraction from a stubbed tool response; recipe lookup for a known
item.
- Integration, against the live key: the five questions in this document, with
the camel, mob farm and creeper answers checked for the specific facts they
previously got wrong.
- Live: after the next restart, confirm `/canalhandia status` reports the
profile and that a real question is grounded.
+39
View File
@@ -22,6 +22,21 @@
</repository>
</repositories>
<dependencyManagement>
<dependencies>
<!-- The BOM keeps the jupiter engine and the platform launcher on matching
versions. Left to transitive resolution they drift apart, and a mismatched
launcher fails to discover tests rather than failing the build. -->
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>5.11.3</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>io.papermc.paper</groupId>
@@ -29,6 +44,12 @@
<version>26.2.build.92-stable</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<!-- Version comes from junit-bom above. -->
<scope>test</scope>
</dependency>
</dependencies>
<build>
@@ -39,5 +60,23 @@
<filtering>true</filtering>
</resource>
</resources>
<plugins>
<!-- Pinned because the version Maven bundles by default decides whether JUnit 5
tests are discovered at all. An older default would skip the whole suite and
still report BUILD SUCCESS, which is the one failure this project can't see. -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.4</version>
<configuration>
<!-- The pin above guards against a surefire version that quietly stops
discovering tests. It does not guard against a test class being
misplaced or misnamed, which ends the same way: a green build that
ran nothing. Failing on an empty suite closes that gap. A disabled
class is still reported as skipped, so it is not covered here. -->
<failIfNoTests>true</failIfNoTests>
</configuration>
</plugin>
</plugins>
</build>
</project>
+227 -133
View File
@@ -1,23 +1,14 @@
package dev.marcospaulo.canalhandia;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextDecoration;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.Map;
@@ -32,12 +23,17 @@ import java.util.UUID;
* dispatcher, never written to disk, and no tool/function definitions are sent
* in the request, so there is nothing for the model to call. A player asking it
* to "run /op me" gets a string back, not an executed command. As a second
* layer, {@link #sanitise} strips leading slashes so a reply cannot even be
* layer, {@link AiText#sanitise} strips leading slashes so a reply cannot even be
* mistaken for a command someone should paste.
*
* <p>The API key never lives in config.yml, because config.yml is committed to
* git. It comes from the {@code MINIMAX_API_KEY} environment variable, or from
* {@code plugins/Canalhandia/minimax.key}, which is gitignored.
*
* <p>This class is an <em>orchestrator</em>: it assembles context (server facts,
* operator corrections, recipes, conversation memory, a wiki article) into a
* list of {@link MiniMax.Turn}s and delegates the HTTP to {@link MiniMax}. It
* holds no {@code HttpClient} of its own.
*/
final class Ai {
@@ -45,7 +41,10 @@ final class Ai {
private static final String KEY_ENV = "MINIMAX_API_KEY";
private final Canalhandia plugin;
private final HttpClient http;
private final MiniMax api;
private final Wiki wiki;
private final Conversations conversations;
private final Corrections corrections;
/** Per-player cooldown, so one person cannot spend the whole budget. */
private final Map<UUID, Long> lastAsk = new HashMap<>();
/** In-flight guard: one question per player at a time. */
@@ -54,11 +53,69 @@ final class Ai {
private LocalDate day = LocalDate.now();
private int askedToday;
/**
* The most recent answered question, so {@code /ia corrigir} (Task 11) can
* correct it without the operator retyping anything.
*/
private Answered lastAnswer;
record Answered(UUID asker, String question, String answer) {
}
/**
* How many answers players have flagged wrong with {@code /ia feedback ruim}.
* Shown in {@code /canalhandia status}. Simple in-memory counter — a restart
* resets it, like {@link #askedToday}.
*/
private int feedbackWrong;
/** Package-private so Task 11's {@code /ia corrigir} can read what to correct. */
Answered lastAnswer() {
return lastAnswer;
}
/** Package-private so Task 11 can wire quit-forget. */
Conversations conversations() {
return conversations;
}
/** Package-private so Task 11 can wire {@code /ia corrigir}. */
Corrections corrections() {
return corrections;
}
/** A player marked the last answer wrong with {@code /ia feedback ruim}. */
boolean flagLastAnswerWrong() {
if (lastAnswer == null) {
return false;
}
feedbackWrong++;
plugin.getLogger().info("IA: resposta marcada como errada — pergunta: " + lastAnswer.question());
return true;
}
/** How many answers players have flagged wrong. Shown in /canalhandia status. */
int feedbackWrong() {
return feedbackWrong;
}
Ai(Canalhandia plugin) {
this.plugin = plugin;
this.http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
Settings settings = plugin.settings();
Fetcher fetcher = new HttpFetcher(settings.aiTimeoutSeconds());
this.api = new MiniMax(fetcher, settings.aiUrl(), plugin.getLogger()::warning);
// 3-arg ctor + logger (carry-forward #1): the 2-arg ctor is silent in
// production, so every wiki failure here is logged.
this.wiki = new Wiki(fetcher, settings.aiWikiChars(), plugin.getLogger()::warning);
this.conversations = new Conversations(settings.aiMemoryExchanges(), settings.aiMemoryMinutes());
this.corrections = new Corrections(new java.io.File(plugin.getDataFolder(), "correcoes.yml"));
// Note: aiUrl(), aiWikiChars(), aiMemoryExchanges() and aiMemoryMinutes()
// are baked here at construction (passed to the MiniMax/Wiki/Conversations
// constructors and not re-read). Everything else — aiProfile(), aiModel(),
// aiMaxTokens(), aiTemperature(), aiInstructions(), aiServerContext() — is
// read live in the async body, so operators can hot-swap them. Profiles in
// particular switch live without a restart; the baked four are not meant
// to be hot-swapped.
}
/** True if a key is configured. Without one the module stays quiet. */
@@ -66,18 +123,66 @@ final class Ai {
return apiKey() != null;
}
/**
* The first non-blank line of a key file or variable, with any control
* character removed. Null if there is nothing usable.
*
* <p>{@code trim()} alone leaves an <em>interior</em> newline — a key file
* with the key on line 1 and a comment on line 2 survives it. Such a key
* cannot go in an HTTP header, and the JDK's rejection of it quotes the
* whole header value, key included, into the exception message, which then
* reaches the server log. Cleaning at the source means that never happens;
* {@link HttpFetcher} checks again as a backstop.
*
* <p>Takes the first line rather than deleting the newline and joining, so
* a trailing comment line cannot be silently welded onto the key to make a
* different, wrong one — that would turn a readable failure into a puzzling
* authentication error.
*/
static String cleanKey(String raw) {
if (raw == null) {
return null;
}
for (String line : raw.split("\\R")) {
StringBuilder out = new StringBuilder(line.length());
for (int i = 0; i < line.length(); i++) {
char c = line.charAt(i);
// Printable ASCII only, which is all a bearer token contains.
// A deny-list of low controls is not enough: the JDK also
// rejects every char above U+00FF, so a U+FEFF byte order mark
// — what Notepad and PowerShell Out-File put at the head of a
// file — would survive and produce a header the JDK quotes
// back, key included. See HttpFetcher.checkBearer for the
// invariant this half must satisfy.
if (c > 0x20 && c < 0x7F) {
out.append(c);
}
}
String key = out.toString();
if (!key.isEmpty()) {
return key;
}
}
return null;
}
/** Logs a warning with any occurrence of the key removed. */
private void warnWithout(String key, String message) {
plugin.getLogger().warning(
key == null || key.isEmpty() ? message : message.replace(key, "***"));
}
private String apiKey() {
String fromEnv = System.getenv(KEY_ENV);
if (fromEnv != null && !fromEnv.isBlank()) {
return fromEnv.trim();
return cleanKey(fromEnv);
}
Path file = plugin.getDataFolder().toPath().resolve(KEY_FILE);
try {
if (Files.isReadable(file)) {
String key = Files.readString(file, StandardCharsets.UTF_8).trim();
return key.isEmpty() ? null : key;
return cleanKey(Files.readString(file, StandardCharsets.UTF_8));
}
} catch (IOException e) {
} catch (java.io.IOException e) {
plugin.getLogger().warning("Não consegui ler " + KEY_FILE + ": " + e.getMessage());
}
return null;
@@ -90,6 +195,17 @@ final class Ai {
* reply is posted back on it.
*/
void ask(Player asker, String question) {
ask(asker, question, false);
}
/**
* Asks the model on behalf of a player and delivers the answer to chat.
*
* @param isPrivate true for {@code /iap} (Task 11): the question and answer
* go only to the asker even when the module is public. A private
* question never broadcasts the question either.
*/
void ask(Player asker, String question, boolean isPrivate) {
Settings settings = plugin.settings();
String key = apiKey();
@@ -121,7 +237,7 @@ final class Ai {
pending.put(asker.getUniqueId(), true);
askedToday++;
if (settings.aiPublic()) {
if (!isPrivate && settings.aiPublic()) {
Bukkit.broadcast(Msg.tag("IA", NamedTextColor.LIGHT_PURPLE)
.append(Component.text(asker.getName() + " perguntou: ", NamedTextColor.GRAY)
.decoration(TextDecoration.BOLD, false))
@@ -132,23 +248,94 @@ final class Ai {
String prompt = question;
UUID id = asker.getUniqueId();
final boolean isPriv = isPrivate;
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
String answer;
String answer = null;
try {
answer = call(key, prompt, settings);
java.util.List<MiniMax.Turn> messages = compose(asker, prompt, settings);
if (settings.aiProfile() == AiProfile.PRECISO) {
String term = api.searchTerm(key, settings.aiModel(), prompt);
Wiki.Article article = term == null ? null : wiki.lookup(term);
if (article != null) {
messages.add(messages.size() - 1, new MiniMax.Turn("system",
"Artigo da Minecraft Wiki pt-BR — '" + article.title() + "':\n"
+ article.text()));
}
}
answer = api.answer(key, settings.aiModel(), messages,
settings.aiMaxTokens(), settings.aiTemperature());
// Hidden reasoning can swallow the budget, and the model
// occasionally drops a foreign word mid-sentence. Both are
// worth one retry before giving up (carry-forwards #3 and #7).
if (answer == null || AiText.hasForeignScript(answer)) {
// Cap before doubling: an absurd ia.max-tokens near
// Integer.MAX_VALUE would overflow to a negative budget
// and be sent to the API. The default (1200) is unaffected.
int retryTokens = Math.min(settings.aiMaxTokens(), Integer.MAX_VALUE / 2) * 2;
answer = api.answer(key, settings.aiModel(), messages, retryTokens, 0.1);
}
if (answer != null && AiText.hasForeignScript(answer)) {
plugin.getLogger().warning("Resposta descartada por idioma estrangeiro.");
answer = null;
}
} catch (Exception e) {
plugin.getLogger().warning("Falha na chamada à IA: " + e);
// Redacts the key: the JDK's header validator quotes the whole
// Authorization value back into the exception message, so a key
// with a stray newline would otherwise reach the log verbatim.
// cleanKey makes that impossible; this is the backstop.
warnWithout(key, "Falha na chamada à IA: " + e);
answer = null;
}
String finalAnswer = answer;
Bukkit.getScheduler().runTask(plugin, () -> {
pending.remove(id);
deliver(id, finalAnswer, settings);
deliver(id, prompt, finalAnswer, settings, isPriv);
});
});
}
private void deliver(UUID askerId, String answer, Settings settings) {
/**
* Builds the messages for one question.
*
* <p>Order matters: system instructions, server context, operator
* corrections, recipes (which the wiki cannot supply — {@code explaintext}
* drops tables), the conversation history, then the question itself.
*/
private java.util.List<MiniMax.Turn> compose(Player asker, String question, Settings settings) {
java.util.List<MiniMax.Turn> messages = new java.util.ArrayList<>();
messages.add(new MiniMax.Turn("system", settings.aiInstructions()));
String serverContext = settings.aiServerContext();
if (!serverContext.isBlank()) {
messages.add(new MiniMax.Turn("system", "Sobre este servidor: " + serverContext));
}
for (Corrections.Entry entry : Corrections.matching(corrections.all(), question)) {
messages.add(new MiniMax.Turn("system",
"Correção registrada por um operador. Pergunta parecida: \""
+ entry.question() + "\" Resposta correta: " + entry.answer()));
}
// Recipes never appear in wiki text: explaintext drops tables. The wiki
// is consulted here only for the englishTitle translation; the recipe
// data comes from the Bukkit snapshot taken at enable (RecipeBook.preload).
if (RecipeBook.isRecipeQuestion(question)) {
String recipes = RecipeBook.describe(question, wiki);
if (recipes != null) {
messages.add(new MiniMax.Turn("system", recipes));
}
}
messages.addAll(conversations.history(asker.getUniqueId()));
messages.add(new MiniMax.Turn("user", question));
return messages;
}
private void deliver(UUID askerId, String question, String answer,
Settings settings, boolean isPrivate) {
Player asker = Bukkit.getPlayer(askerId);
if (answer == null || answer.isBlank()) {
if (asker != null) {
@@ -156,85 +343,27 @@ final class Ai {
}
return;
}
String clean = AiText.sanitise(answer, settings.aiMaxAnswer());
// Only remember if the asker is still online: a PlayerQuitEvent forgets
// the player's 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 even after the asker left.
if (asker != null) {
conversations.remember(askerId, question, clean);
}
lastAnswer = new Answered(askerId, question, clean);
Component message = Msg.tag("IA", NamedTextColor.LIGHT_PURPLE)
.append(Component.text(sanitise(answer, settings.aiMaxAnswer()), NamedTextColor.WHITE)
.append(Component.text(clean, NamedTextColor.WHITE)
.decoration(TextDecoration.BOLD, false));
if (settings.aiPublic()) {
Bukkit.broadcast(message);
} else if (asker != null) {
if (isPrivate || !settings.aiPublic()) {
if (asker != null) {
asker.sendMessage(message);
}
return;
}
// --- HTTP ---------------------------------------------------------------
private String call(String key, String question, Settings settings) throws Exception {
JsonArray messages = new JsonArray();
messages.add(message("system", settings.aiInstructions()));
messages.add(message("user", question));
JsonObject body = new JsonObject();
body.addProperty("model", settings.aiModel());
body.add("messages", messages);
body.addProperty("max_tokens", settings.aiMaxTokens());
body.addProperty("temperature", settings.aiTemperature());
// No "tools" and no "tool_choice": the model is given nothing it could call.
HttpRequest request = HttpRequest.newBuilder(URI.create(settings.aiUrl()))
.timeout(Duration.ofSeconds(settings.aiTimeoutSeconds()))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + key)
.POST(HttpRequest.BodyPublishers.ofString(body.toString(), StandardCharsets.UTF_8))
.build();
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() / 100 != 2) {
plugin.getLogger().warning("IA respondeu HTTP " + response.statusCode() + ": "
+ trim(response.body()));
return null;
}
return extract(response.body());
}
private static JsonObject message(String role, String content) {
JsonObject object = new JsonObject();
object.addProperty("role", role);
object.addProperty("content", content);
return object;
}
/**
* Pulls the reply text out of an OpenAI-shaped response.
*
* <p>MiniMax returns HTTP 200 even for application-level failures, putting
* the real outcome in {@code base_resp.status_code}, so that is checked too.
*/
private String extract(String json) {
JsonObject root = JsonParser.parseString(json).getAsJsonObject();
if (root.has("base_resp")) {
JsonObject base = root.getAsJsonObject("base_resp");
int status = base.has("status_code") ? base.get("status_code").getAsInt() : 0;
if (status != 0) {
plugin.getLogger().warning("IA recusou: base_resp " + base);
return null;
}
}
if (!root.has("choices")) {
return null;
}
JsonArray choices = root.getAsJsonArray("choices");
if (choices.isEmpty()) {
return null;
}
JsonObject first = choices.get(0).getAsJsonObject();
if (!first.has("message")) {
return null;
}
JsonObject message = first.getAsJsonObject("message");
// Reasoning models also return "reasoning_content"; only "content" is shown.
return message.has("content") && !message.get("content").isJsonNull()
? message.get("content").getAsString()
: null;
Bukkit.broadcast(message);
plugin.openAiReactions(askerId);
}
// --- limits and cleanup -------------------------------------------------
@@ -263,39 +392,4 @@ final class Ai {
return askedToday;
}
/**
* Makes a model reply safe and readable in chat.
*
* <p>Strips colour codes so the reply cannot forge server messages, folds
* newlines so one answer stays one chat entry, and removes leading slashes
* so nothing that comes back reads as a command to run.
*
* <p>It also strips markdown and emoji, which the models emit freely.
* Minecraft chat renders neither: {@code **negrito**} arrives as literal
* asterisks, and emoji show up as empty boxes on Bedrock.
*/
static String sanitise(String raw, int max) {
String text = raw.replace('§', ' ')
.replaceAll("[\\r\\n]+", " ")
// Markdown emphasis and code fences: chat shows the characters, not the effect.
.replaceAll("\\*{1,3}([^*]+)\\*{1,3}", "$1")
.replaceAll("`{1,3}([^`]+)`{1,3}", "$1")
.replaceAll("^#{1,6}\\s+", "")
// Emoji live outside the BMP, plus the symbol blocks and the
// variation selector. Accented pt-BR letters are far below this.
.replaceAll("[\\x{1F000}-\\x{1FAFF}\\x{2190}-\\x{2BFF}\\x{FE0F}\\x{20E3}]", "")
.replaceAll("\\s{2,}", " ")
.trim();
while (text.startsWith("/")) {
text = text.substring(1).trim();
}
if (text.length() > max) {
text = text.substring(0, max).trim() + "";
}
return text;
}
private static String trim(String text) {
return text.length() > 300 ? text.substring(0, 300) + "" : text;
}
}
@@ -0,0 +1,38 @@
package dev.marcospaulo.canalhandia;
/**
* How much work to do per question.
*
* <p>This trades latency, not money: the plan's token allowance is far beyond
* what a small server can spend, but every added context token makes chat feel
* slower.
*/
enum AiProfile {
/** Skip the wiki round trip. Fast, ungrounded. */
ECONOMICO,
/** Consult the wiki. Slower, accurate. */
PRECISO;
static AiProfile byKey(String key) {
for (AiProfile profile : values()) {
if (profile.name().equalsIgnoreCase(key)) {
return profile;
}
}
return PRECISO;
}
/** True if {@code key} names a profile. Use before {@link #byKey} to tell a
* real key from the {@code PRECISO} fallback, so {@code /ia perfil blah}
* can be rejected (or fall through to a question) rather than silently
* switching to PRECISO. */
static boolean isValid(String key) {
for (AiProfile profile : values()) {
if (profile.name().equalsIgnoreCase(key)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,90 @@
package dev.marcospaulo.canalhandia;
import java.util.regex.Pattern;
/**
* Text guards for model replies.
*
* <p>Pure functions, deliberately free of Bukkit, so they can be tested without
* a server.
*/
final class AiText {
/**
* Non-Latin scripts that should never appear in a Portuguese answer. The
* model has been observed dropping single Chinese words mid-sentence.
*/
private static final Pattern FOREIGN = Pattern.compile(
"[\\p{IsHan}\\p{IsHiragana}\\p{IsKatakana}\\p{IsHangul}\\p{IsCyrillic}\\p{IsArabic}]");
private AiText() {
}
/**
* True if the text contains a character from a non-Latin script.
*
* <p>This detects leakage by alphabet, so it catches only what a different
* alphabet makes visible. A foreign word written in Latin script is
* <b>not</b> caught: the French {@code contiennent}, observed in an
* otherwise Portuguese reply, passes this check. Catching that would need
* dictionary or language-identification work this method does not do.
*/
static boolean hasForeignScript(String text) {
return text != null && FOREIGN.matcher(text).find();
}
/**
* Makes a reply safe and readable in chat: no colour codes to forge server
* messages, no markdown or emoji (chat renders neither, and emoji are empty
* boxes on Bedrock), no leading slash that could read as a command.
*
* <p>{@code null} returns {@code ""}: {@code Ai.deliver} never calls here
* with a null, but hardening is cheap and keeps the contract total.
*
* <p>Truncation backs off one char when it lands on a high surrogate, so the
* result is never left with an orphan surrogate that would render as a
* replacement box.
*/
static String sanitise(String raw, int max) {
if (raw == null) {
return "";
}
String text = raw
// A colour code is the section sign plus the code character, so both
// go. Dropping only the sign would leave the bare letter behind and
// "§c" would read as a stray "c" in the middle of the sentence.
.replaceAll("§[0-9A-Za-z]", " ")
.replace('§', ' ')
.replaceAll("[\\r\\n]+", " ")
// Markdown emphasis/inline-code: only strip the markers when they
// flank non-space text. A lone "*" surrounded by spaces is not
// emphasis (chat uses it as a bullet), so the regex requires a
// non-space right after the opening markers and before the
// closing ones. "{1,3}" keeps it handling *, ** and ***.
.replaceAll("(?s)\\*{1,3}(?!\\s)(.+?)(?<!\\s)\\*{1,3}", "$1")
.replaceAll("(?s)`{1,3}(?!\\s)(.+?)(?<!\\s)`{1,3}", "$1")
.replaceAll("^#{1,6}\\s+", "")
.replaceAll("[\\x{1F000}-\\x{1FAFF}\\x{2190}-\\x{2BFF}\\x{FE0F}\\x{20E3}]", "")
.replaceAll("\\s{2,}", " ")
.trim();
while (text.startsWith("/")) {
text = text.substring(1).trim();
}
if (text.length() > max) {
int cut = max;
// Back off one char if we sliced a high surrogate off its low half,
// otherwise the String is left with an orphan surrogate that
// renders as a replacement character.
if (cut > 0 && Character.isHighSurrogate(text.charAt(cut - 1))) {
cut--;
}
text = text.substring(0, cut).trim() + "";
}
return text;
}
/** Shortens text for a log line. */
static String forLog(String text) {
return text.length() > 300 ? text.substring(0, 300) + "" : text;
}
}
@@ -62,11 +62,17 @@ public final class Canalhandia extends JavaPlugin implements Listener {
offlineStats = new OfflineStats(this);
milestones = new Milestones(this);
ai = new Ai(this);
// Snapshot the server's recipes on the main thread; RecipeBook.describe
// reads from the async answer path and Bukkit.recipeIterator() is not
// safe off the main thread. Datapack reloads after this are not
// re-snapshotted.
RecipeBook.preload();
optOutKey = new NamespacedKey(this, "opt_out");
CanalhandiaCommand root = new CanalhandiaCommand(this);
for (String name : List.of("canalhandia", "curiosidade", "adivinha", "enquete", "ranking",
"reagir", "reacoes", "palpite", "votar", "legal", "wow", "top", "f", "ia")) {
"reagir", "reacoes", "palpite", "votar", "legal", "wow", "top", "f", "ia", "iap",
"errado")) {
register(name, root);
}
@@ -299,6 +305,38 @@ public final class Canalhandia extends JavaPlugin implements Listener {
return reactions;
}
/**
* Opens the reaction row on a public AI answer: 👍 (reuses the shared
* {@code joia}/{@code /legal} reaction so the typed Bedrock twin resolves
* via {@link Settings#reactionForCommand}) and ❌ ({@code /errado}, new).
*
* <p>The {@code askerId} param is kept in the signature because
* {@link Ai#deliver} passes it, but is not used structurally here.
*/
void openAiReactions(UUID askerId) {
if (!settings.reactionsEnabled()) {
return;
}
Reactions reactions = new Reactions(nextId++, List.of(
new ReactionDef("joia", "[👍]", "[+1]", "legal"),
new ReactionDef("errado", "[❌]", "[ERRADO]", "errado")));
liveReactions = reactions;
remember(reactions);
reactions.show();
getServer().getScheduler().runTaskLater(this, () -> {
reactions.hide();
if (liveReactions == reactions) {
liveReactions = null;
}
if (reactions.hasAnyVote()) {
broadcastPerPlatform(bedrock -> Component.text(" ")
.append(reactions.summary(bedrock, settings.summaryNames())));
}
}, settings.reactionWindowSeconds() * 20L);
broadcastPerPlatform(bedrock -> Component.text(" ")
.append(reactions.buttons(bedrock)));
}
/**
* The newest reaction set still accepting clicks, for typed shortcuts like
* {@code /wow} where the player never sees an id.
@@ -475,6 +513,17 @@ public final class Canalhandia extends JavaPlugin implements Listener {
}, settings.reactionWindowSeconds() * 20L);
}
/**
* Drops a player's short-term AI memory on quit, so a rejoin does not
* answer a fresh question with an old one (carry-forward #6).
*/
@EventHandler
public void onQuit(org.bukkit.event.player.PlayerQuitEvent event) {
if (ai != null) {
ai.conversations().forget(event.getPlayer().getUniqueId());
}
}
// --- per-player opt out -------------------------------------------------
boolean isOptedOut(Player player) {
@@ -14,6 +14,7 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/**
@@ -46,7 +47,8 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
case "palpite" -> guessLatest(sender, args);
case "votar" -> voteLatest(sender, args);
case "reacoes" -> whoReacted(sender);
case "ia" -> ia(sender, args);
case "ia" -> ia(sender, args, false);
case "iap" -> ia(sender, args, true);
default -> {
String reaction = plugin.settings().reactionForCommand(command.getName());
if (reaction != null) {
@@ -656,10 +658,12 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
}
Msg.line(sender, "categorias ativas", enabled.isEmpty() ? "nenhuma" : String.join(", ", enabled));
Msg.line(sender, "ia", plugin.ai().configured()
? settings.aiModel() + " · " + plugin.ai().askedToday() + "/"
+ settings.aiDailyLimit() + " hoje · cooldown "
+ settings.aiCooldownSeconds() + "s · "
? settings.aiModel() + " · perfil " + settings.aiProfile().name().toLowerCase(Locale.ROOT)
+ " · " + plugin.ai().askedToday() + "/" + settings.aiDailyLimit() + " hoje"
+ " · cooldown " + settings.aiCooldownSeconds() + "s · "
+ (settings.aiPublic() ? "resposta pública" : "resposta privada")
+ " · " + plugin.ai().corrections().all().size() + " correções"
+ " · " + plugin.ai().feedbackWrong() + " feedback ruim"
: "sem chave configurada");
}
@@ -754,8 +758,13 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
* has it out of the box and LuckPerms can hand it to anyone else with
* {@code lp user <nome> permission set canalhandia.ia true}.
*/
private boolean ia(CommandSender sender, String[] args) {
if (!sender.hasPermission("canalhandia.ia")) {
private boolean ia(CommandSender sender, String[] args, boolean isPrivate) {
// /ia and /iap are separate gates so LuckPerms can grant them
// independently — an operator can let someone ask privately without
// letting them spam public chat, or vice-versa. Both default to op,
// matching the "operator + LuckPerms-permitted only" rule.
String perm = isPrivate ? "canalhandia.ia.privado" : "canalhandia.ia";
if (!sender.hasPermission(perm)) {
return denied(sender);
}
if (!plugin.settings().moduleEnabled(Module.IA)) {
@@ -766,13 +775,82 @@ final class CanalhandiaCommand implements CommandExecutor, TabCompleter {
Msg.error(sender, "Só jogadores podem usar /ia.");
return true;
}
if (args.length == 0) {
Msg.error(sender, "Uso: /ia <pergunta>");
// Subcommands come before the question. They only hijack when the
// second token is one they would actually act on, so a real question
// like "/ia perfil do servidor" or "/ia feedback do mapa?" falls
// through and is asked. "corrigir" stays greedy — a correction always
// reads the rest of the line, and "/ia corrigir ..." is never a natural
// question.
if (args.length > 0) {
String sub = args[0].toLowerCase(Locale.ROOT);
if (sub.equals("perfil") && (args.length == 1 || AiProfile.isValid(args[1]))) {
iaProfile(sender, args);
return true;
}
plugin.ai().ask(player, String.join(" ", args));
if (sub.equals("corrigir")) {
iaCorrect(sender, args);
return true;
}
if (sub.equals("feedback") && args.length >= 2 && args[1].equalsIgnoreCase("ruim")) {
iaFeedback(sender, args);
return true;
}
// default: treat all args as the question
}
if (args.length == 0) {
Msg.error(sender, isPrivate ? "Uso: /iap <pergunta>" : "Uso: /ia <pergunta>");
return true;
}
plugin.ai().ask(player, String.join(" ", args), isPrivate);
return true;
}
private void iaProfile(CommandSender sender, String[] args) {
if (!sender.hasPermission("canalhandia.ia.perfil")) {
denied(sender);
return;
}
if (args.length < 2) {
Msg.error(sender, "Uso: /ia perfil <economico|preciso> (atual: "
+ plugin.settings().aiProfile().name().toLowerCase(Locale.ROOT) + ")");
return;
}
AiProfile profile = AiProfile.byKey(args[1]);
plugin.settings().aiProfile(profile);
Msg.ok(sender, "Perfil da IA: " + profile.name().toLowerCase(Locale.ROOT)
+ (profile == AiProfile.PRECISO ? " (consulta a wiki)" : " (sem wiki, mais rápido)"));
}
private void iaCorrect(CommandSender sender, String[] args) {
if (!sender.hasPermission("canalhandia.ia.corrigir")) {
denied(sender);
return;
}
Ai.Answered last = plugin.ai().lastAnswer();
if (last == null) {
Msg.error(sender, "Nenhuma resposta recente para corrigir.");
return;
}
if (args.length < 2) {
Msg.error(sender, "Uso: /ia corrigir <resposta correta>");
return;
}
String correct = String.join(" ", Arrays.copyOfRange(args, 1, args.length));
plugin.ai().corrections().add(last.question(), correct);
Msg.ok(sender, "Correção registrada para perguntas parecidas com: " + last.question());
}
private void iaFeedback(CommandSender sender, String[] args) {
if (args.length < 2 || !args[1].equalsIgnoreCase("ruim")) {
Msg.error(sender, "Uso: /ia feedback ruim (marca a última resposta como errada)");
return;
}
if (plugin.ai().flagLastAnswerWrong()) {
Msg.ok(sender, "Obrigado pelo feedback. Um operador vai revisar.");
} else {
Msg.error(sender, "Nenhuma resposta recente para marcar.");
}
}
private boolean admin(CommandSender sender) {
if (sender.hasPermission(ADMIN)) {
@@ -0,0 +1,141 @@
package dev.marcospaulo.canalhandia;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Short per-player memory, so a follow-up like "e no nether?" makes sense.
*
* <p>Deliberately small and forgetful: the point is continuity within one
* exchange, not a transcript. Every way of losing memory here — expiry,
* eviction, a restart — costs one player one lost follow-up, so this class
* always prefers forgetting to growing.
*
* <p>Thread-safe. {@link Ai} calls both methods from
* {@code runTaskAsynchronously}, so two players asking at once means two
* threads in here at the same time, and {@link #history} <em>writes</em> while
* it reads — it prunes expired entries. Unsynchronised that is not a stale
* read but a corrupt map: a concurrent resize can lose entries or spin, and
* pruning under another thread's write throws {@code ConcurrentModification}
* straight up into the player's answer. Guarded by {@link #byPlayer}'s own
* monitor, consistently with {@link Wiki}; nothing slow happens under the lock.
*/
final class Conversations {
/**
* A hard ceiling on remembered players. Expiry alone does not bound this:
* entries are only pruned for the player who asks, so someone who asks once
* and logs off would sit in the map until the next restart. Least-recently
* used goes first, and at this size a busy server loses nothing that is
* still a live conversation.
*/
static final int MAX_PLAYERS = 200;
private static final long NANOS_PER_MINUTE = 60_000_000_000L;
/** {@code at} is a {@link System#nanoTime} reading; see {@link #expired}. */
private record Entry(MiniMax.Turn turn, long at) {
}
private final int maxTurns;
private final long windowNanos;
/**
* Access-ordered so eviction drops the player who has been quiet longest
* rather than whoever happened to ask first.
*/
private final Map<UUID, Deque<Entry>> byPlayer = new LinkedHashMap<>(16, 0.75f, true);
Conversations(int maxExchanges, int windowMinutes) {
// Each exchange is two turns, and the product is computed in long
// arithmetic: a config of Integer.MAX_VALUE would overflow an int
// multiply to a negative bound, which silently disables memory instead
// of honouring the (absurd) request.
this.maxTurns = (int) Math.min(2L * Math.max(0, maxExchanges), Integer.MAX_VALUE);
long minutes = Math.max(0, windowMinutes);
// Saturating rather than wrapping: an overflowed window would come out
// negative and expire every entry on the spot, turning "remember for a
// very long time" into "remember nothing".
this.windowNanos = minutes > Long.MAX_VALUE / NANOS_PER_MINUTE
? Long.MAX_VALUE
: minutes * NANOS_PER_MINUTE;
}
void remember(UUID player, String question, String answer) {
if (maxTurns == 0) {
// Short-circuit before touching the map. Falling through would add
// two turns, drop both, and leave an empty deque behind — a memory
// configured off would still grow one map entry per player.
return;
}
long now = System.nanoTime();
synchronized (byPlayer) {
Deque<Entry> entries = byPlayer.computeIfAbsent(player, key -> new ArrayDeque<>());
entries.addLast(new Entry(new MiniMax.Turn("user", question), now));
entries.addLast(new Entry(new MiniMax.Turn("assistant", answer), now));
while (entries.size() > maxTurns) {
entries.removeFirst();
}
while (byPlayer.size() > MAX_PLAYERS) {
byPlayer.remove(byPlayer.keySet().iterator().next());
}
}
}
/** Recent messages still inside the window, oldest first. */
List<MiniMax.Turn> history(UUID player) {
long now = System.nanoTime();
List<MiniMax.Turn> out = new ArrayList<>();
synchronized (byPlayer) {
Deque<Entry> entries = byPlayer.get(player);
if (entries == null) {
return out;
}
entries.removeIf(entry -> expired(entry, now));
if (entries.isEmpty()) {
// Do not leave the key behind: a player whose memory has run
// out is indistinguishable from one who never asked.
byPlayer.remove(player);
return out;
}
for (Entry entry : entries) {
out.add(entry.turn());
}
}
return out;
}
void forget(UUID player) {
synchronized (byPlayer) {
byPlayer.remove(player);
}
}
/** How many players are currently remembered. For tests. */
int size() {
synchronized (byPlayer) {
return byPlayer.size();
}
}
/**
* Elapsed time compared as a difference, and against {@link System#nanoTime}
* rather than the wall clock. Two reasons. The difference form is the only
* correct way to compare nanoTime readings, which are allowed to be negative
* and to wrap. And nanoTime is monotonic: an NTP step backwards mid-session
* would leave wall-clock entries stamped in the future, so {@code now - at}
* would go negative and the entry would never expire — memory that outlives
* its window and answers a fresh question with an hour-old one.
*
* <p>{@code >=} and not {@code >}, so a window of zero expires everything
* immediately instead of depending on whether two calls landed in the same
* clock tick.
*/
private boolean expired(Entry entry, long now) {
return now - entry.at() >= windowNanos;
}
}
@@ -0,0 +1,104 @@
package dev.marcospaulo.canalhandia;
import org.bukkit.configuration.file.YamlConfiguration;
import java.io.File;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
/**
* Operator corrections, injected when a new question resembles one that was
* answered wrongly before.
*
* <p>This is the cheap alternative to fine-tuning: a wrong answer becomes
* context, so the same mistake stops recurring.
*/
final class Corrections {
record Entry(String question, String answer) {
}
private final File file;
/**
* Read by {@link #all()} from the async answer thread (Ai.compose) and
* mutated by {@link #add()}/{@link #load()} from the main thread (/ia
* corrigir). Guarded by its own monitor, like {@link Conversations}: only
* fast, in-memory work happens under the lock — file parsing and the YAML
* save stay outside it — so {@link #all()}'s {@code List.copyOf} never
* races a structural change and throws no
* {@code ConcurrentModificationException}.
*/
private final List<Entry> entries = new ArrayList<>();
Corrections(File file) {
this.file = file;
load();
}
void load() {
YamlConfiguration yaml = file.exists() ? YamlConfiguration.loadConfiguration(file) : null;
synchronized (entries) {
entries.clear();
if (yaml == null) {
return;
}
for (String key : yaml.getKeys(false)) {
String question = yaml.getString(key + ".pergunta");
String answer = yaml.getString(key + ".resposta");
if (question != null && answer != null) {
entries.add(new Entry(question, answer));
}
}
}
}
void add(String question, String answer) {
YamlConfiguration yaml = new YamlConfiguration();
synchronized (entries) {
entries.add(new Entry(question, answer));
for (int i = 0; i < entries.size(); i++) {
yaml.set("c" + i + ".pergunta", entries.get(i).question());
yaml.set("c" + i + ".resposta", entries.get(i).answer());
}
}
try {
yaml.save(file);
} catch (Exception e) {
throw new IllegalStateException("não consegui gravar " + file, e);
}
}
List<Entry> all() {
synchronized (entries) {
return List.copyOf(entries);
}
}
/** Corrections sharing at least one significant word with the question. */
static List<Entry> matching(List<Entry> all, String question) {
Set<String> asked = significantWords(question);
List<Entry> out = new ArrayList<>();
for (Entry entry : all) {
Set<String> known = significantWords(entry.question());
known.retainAll(asked);
if (known.size() >= 1) {
out.add(entry);
}
}
return out;
}
private static Set<String> significantWords(String text) {
Set<String> words = new HashSet<>();
for (String word : text.toLowerCase(Locale.ROOT).split("[^\\p{L}0-9]+")) {
// Short words are almost all articles and prepositions in Portuguese.
if (word.length() > 4) {
words.add(word);
}
}
return words;
}
}
@@ -0,0 +1,14 @@
package dev.marcospaulo.canalhandia;
import java.io.IOException;
/** The one place the plugin talks to the network, so tests can replace it. */
interface Fetcher {
/** GET a URL, returning the body. */
String get(String url) throws IOException, InterruptedException;
/** POST JSON with a bearer token, returning the body. */
String postJson(String url, String json, String bearer)
throws IOException, InterruptedException;
}
@@ -0,0 +1,100 @@
package dev.marcospaulo.canalhandia;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
final class HttpFetcher implements Fetcher {
/**
* pt.minecraft.wiki answers 403 to a default user agent — MediaWiki policy
* requires callers to identify themselves.
*/
private static final String AGENT =
"Canalhandia-Minecraft-Bot/1.0 (https://marcospaulo.dev.br)";
private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
private final int timeoutSeconds;
HttpFetcher(int timeoutSeconds) {
this.timeoutSeconds = timeoutSeconds;
}
@Override
public String get(String url) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.timeout(Duration.ofSeconds(timeoutSeconds))
.header("User-Agent", AGENT)
.GET()
.build();
return body(http.send(request, HttpResponse.BodyHandlers.ofString()));
}
/**
* Rejects a bearer token that cannot go in a header, before it reaches one.
*
* <p>{@code HttpRequest.Builder.header()} validates the value itself, but
* its {@code IllegalArgumentException} quotes the offending value back —
* measured on temurin-25: {@code invalid header value: "Bearer sk-…"}. That
* throw happens before any I/O, so it reaches callers as an ordinary call
* failure and lands the credential in whatever log catches it. A key with a
* stray second line is enough to trigger it.
*
* <p>Checking here rather than at the call site means no future caller has
* to remember to redact. The message deliberately names only the position
* of the offending character, never any part of the key.
*
* <p>The invariant, demonstrated by fuzzing all 65,536 char values:
* {@code Ai.cleanKey output ⊆ checkBearer accepts ⊆ JDK accepts}. Widening
* either of the first two without rechecking the third reopens the leak.
*/
static void checkBearer(String bearer) throws IOException {
if (bearer == null || bearer.isEmpty()) {
throw new IOException("Chave de API vazia.");
}
for (int i = 0; i < bearer.length(); i++) {
char c = bearer.charAt(i);
// Printable ASCII only. Restricting to what a bearer token is
// actually made of is the only range that is safe by construction:
// the JDK rejects every char above U+00FF as well as the low
// controls — 65,312 of the 65,536 values — so an allow-list of the
// 94 printable ones cannot drift out of what it accepts. A key
// saved by Notepad or PowerShell Out-File carries a U+FEFF byte
// order mark, which a deny-list of low controls alone lets through.
if (c < 0x21 || c > 0x7E) {
throw new IOException("Chave de API inválida: caractere não imprimível na posição "
+ i + " (de " + bearer.length() + "). "
+ "Verifique se o arquivo da chave tem uma única linha, "
+ "sem quebra de linha e sem marca de ordem de byte (BOM).");
}
}
}
@Override
public String postJson(String url, String json, String bearer)
throws IOException, InterruptedException {
checkBearer(bearer);
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.timeout(Duration.ofSeconds(timeoutSeconds))
.header("Content-Type", "application/json")
.header("User-Agent", AGENT)
.header("Authorization", "Bearer " + bearer)
.POST(HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8))
.build();
return body(http.send(request, HttpResponse.BodyHandlers.ofString()));
}
private String body(HttpResponse<String> response) throws IOException {
if (response.statusCode() / 100 != 2) {
throw new IOException("HTTP " + response.statusCode() + ": "
+ AiText.forLog(response.body()));
}
return response.body();
}
}
@@ -0,0 +1,269 @@
package dev.marcospaulo.canalhandia;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.util.List;
import java.util.function.Consumer;
/**
* MiniMax chat-completions client.
*
* <p>Two calls per grounded question: {@link #searchTerm} makes the model name
* a wiki article, then {@link #answer} answers with that article in context.
*
* <p>The API key is a parameter rather than a field so it is never held longer
* than a call, and it leaves this class by exactly one route: the {@code bearer}
* argument of {@link Fetcher#postJson}, which {@link HttpFetcher} turns into an
* {@code Authorization} header. It is never put in the request body, never
* concatenated into a message, and never passed to {@link #warn}. The failure
* paths below log the response or the exception, and neither carries it.
*/
final class MiniMax {
static final String URL = "https://api.minimax.io/v1/text/chatcompletion_v2";
/**
* The tool the model is forced to call. Written as JSON rather than built
* with {@code JsonObject} calls because it is a fixed schema that never
* varies at runtime, and this way it can be read against the API docs — and
* against the request that was measured to work — line for line.
*/
private static final String TOOLS = """
[{"type":"function","function":{
"name":"buscar_wiki",
"description":"Busca um artigo na Minecraft Wiki em português.",
"parameters":{"type":"object",
"properties":{"termo":{"type":"string",
"description":"Termo curto do jogo, ex: Camelo, Creeper"}},
"required":["termo"]}}}]
""";
private static final String TOOL_CHOICE = """
{"type":"function","function":{"name":"buscar_wiki"}}
""";
/**
* Enough for a tool call and the reasoning that precedes it. Answers get far
* more; see {@link #answer}.
*/
private static final int TERM_TOKENS = 500;
/**
* Term selection is deterministic on purpose: the same question must pick
* the same article every time, or the wiki cache thrashes and two players
* asking the same thing get differently grounded answers. Only
* {@link #answer} takes its temperature from config.
*/
private static final double TERM_TEMPERATURE = 0.0;
/**
* One message in the request.
*
* <p>Named {@code Turn} and not {@code Msg} because the package already has
* a top-level {@link Msg}, the chat-formatting helper. A nested record of
* that name would shadow it inside this file, so a later {@code Msg.error}
* here would resolve to the record instead.
*/
record Turn(String role, String content) {
}
private final Fetcher fetcher;
private final String url;
private final Consumer<String> warn;
MiniMax(Fetcher fetcher) {
this(fetcher, URL, message -> {
});
}
/**
* @param url the chat-completions endpoint, or null for {@link #URL}.
* MiniMax has regional hosts ({@code api.minimax.io} versus
* {@code api.minimaxi.com}) that answer only for accounts registered
* against them, so the wrong one fails every call with a valid key.
* {@code Settings.aiUrl()} supplies it.
* @param warn where failures are reported. Every failure mode here returns
* null, which the caller cannot tell apart from "no answer": an expired
* key, an exhausted balance, a regional host mismatch and a network
* blip all look identical in chat. Without this the only symptom of a
* dead API key is {@code /ia} quietly getting worse.
*/
MiniMax(Fetcher fetcher, String url, Consumer<String> warn) {
this.fetcher = fetcher;
this.url = url == null || url.isBlank() ? URL : url;
this.warn = warn;
}
/**
* Asks the model which wiki article to read.
*
* <p>The call is forced with {@code tool_choice} rather than left to
* {@code auto}. Given the choice the model skipped the search on exactly
* the questions it was most likely to get wrong. A tool argument is also
* structured output, so unlike a free-text reply it survives the model's
* hidden reasoning eating the token budget: measured over the same
* questions, a free-text extraction call answered 2 of 7 while this
* answered 5 of 5.
*/
String searchTerm(String key, String model, String question) {
JsonObject body = base(model, List.of(
new Turn("system", "Você escolhe qual artigo da Minecraft Wiki consultar."),
new Turn("user", question)), TERM_TOKENS, TERM_TEMPERATURE);
body.add("tools", JsonParser.parseString(TOOLS).getAsJsonArray());
body.add("tool_choice", JsonParser.parseString(TOOL_CHOICE).getAsJsonObject());
JsonObject message = message(post(key, body));
if (message == null) {
return null;
}
JsonElement calls = message.get("tool_calls");
// No tool_calls at all is a plain no-result, not a malformed response:
// the model answered in prose instead. Nothing to warn about.
if (calls == null || !calls.isJsonArray() || calls.getAsJsonArray().isEmpty()) {
return null;
}
try {
// The arguments are a JSON *string* that has to be parsed again.
String arguments = calls.getAsJsonArray().get(0).getAsJsonObject()
.getAsJsonObject("function").get("arguments").getAsString();
JsonObject parsed = JsonParser.parseString(arguments).getAsJsonObject();
if (!parsed.has("termo")) {
warn.accept("IA: tool call sem \"termo\": " + AiText.forLog(arguments));
return null;
}
String term = parsed.get("termo").getAsString().trim();
return term.isEmpty() ? null : term;
} catch (RuntimeException e) {
// Nothing guarantees the shape of a tool call, and an unchecked
// throw from here would surface as a bare stack trace in the
// async worker rather than as a lost bit of grounding.
warn.accept("IA: tool call malformada: " + e);
return null;
}
}
/**
* Answers a question. Returns null on any failure, including empty content.
*
* <p>Empty content is a failure and not a short answer: the model's hidden
* reasoning is charged against {@code max_tokens}, so too small a budget
* spends the whole allowance thinking and returns nothing at all. Measured
* twice at 400 tokens, which is why answers are given 1200.
*/
String answer(String key, String model, List<Turn> messages, int maxTokens, double temperature) {
JsonObject message = message(post(key, base(model, messages, maxTokens, temperature)));
if (message == null) {
return null;
}
JsonElement content = message.get("content");
// isJsonPrimitive before getAsString: on an object or array that call
// throws UnsupportedOperationException, and this runs outside post()'s
// try. Some OpenAI-compatible servers return content as an array of
// parts rather than a string.
if (content == null || !content.isJsonPrimitive() || content.getAsString().isBlank()) {
warn.accept("IA: resposta vazia com max_tokens=" + maxTokens
+ " (o raciocínio do modelo pode ter consumido o orçamento).");
return null;
}
return content.getAsString();
}
private JsonObject base(String model, List<Turn> messages, int maxTokens, double temperature) {
JsonArray array = new JsonArray();
for (Turn msg : messages) {
JsonObject object = new JsonObject();
object.addProperty("role", msg.role());
object.addProperty("content", msg.content());
array.add(object);
}
JsonObject body = new JsonObject();
body.addProperty("model", model);
body.add("messages", array);
body.addProperty("max_tokens", maxTokens);
body.addProperty("temperature", temperature);
return body;
}
/**
* POSTs and parses. The request timeout lives in {@link HttpFetcher}, which
* owns the connection; there is no retry, because {@code /ia} already runs
* on a per-player cooldown and a silent retry would double the wait a player
* sees with no way to tell why.
*/
private JsonObject post(String key, JsonObject body) {
try {
return JsonParser.parseString(fetcher.postJson(url, body.toString(), key))
.getAsJsonObject();
} catch (InterruptedException e) {
// Swallowing this would leave an async worker running through a
// plugin disable or reload as if nothing had happened.
Thread.currentThread().interrupt();
warn.accept("IA: chamada interrompida.");
return null;
} catch (Exception e) {
// The host is named because MiniMax's regional endpoints reject
// each other's keys: without it a wrong-host misconfiguration and
// an expired key produce the same log line.
redacted(key, "IA: falha na chamada a " + url + ": " + e);
return null;
}
}
/**
* Warns with any occurrence of the key removed.
*
* <p>This is the one place an exception could carry it. A key with a stray
* newline in it — two lines pasted into {@code minimax.key}, which
* {@code trim()} does not fix — makes the JDK reject the Authorization
* header with {@code invalid header value: "Bearer sk-…"}, quoting the
* whole value back. That throws before the request leaves, so it arrives
* here as a plain call failure and would otherwise be logged verbatim.
*/
private void redacted(String key, String message) {
warn.accept(key == null || key.isEmpty() ? message : message.replace(key, "***"));
}
/**
* The first choice's message, or null if the response reported a failure.
*
* <p>Every step checks the <em>type</em> it is about to assume, not just
* the presence of the key. This is called outside {@link #post}'s try, and
* Gson's {@code getAsJsonObject}/{@code getAsInt} throw unchecked on JSON
* that parses but has the wrong shape — {@code {"base_resp":"texto"}} or
* {@code {"choices":["texto"]}} — which would escape to the async worker as
* a bare stack trace instead of the null this method promises.
*/
private JsonObject message(JsonObject root) {
if (root == null) {
return null;
}
// HTTP 200 does not mean success here: MiniMax reports application
// errors — bad key, no balance, rate limit — with a 200 and a non-zero
// status_code, so checking the HTTP status alone misses all of them.
JsonElement baseResp = root.get("base_resp");
if (baseResp != null && baseResp.isJsonObject()) {
JsonObject base = baseResp.getAsJsonObject();
JsonElement status = base.get("status_code");
if (status != null && status.isJsonPrimitive() && status.getAsJsonPrimitive().isNumber()
&& status.getAsInt() != 0) {
warn.accept("IA: recusada, base_resp " + AiText.forLog(base.toString()));
return null;
}
}
JsonElement choices = root.get("choices");
if (choices == null || !choices.isJsonArray() || choices.getAsJsonArray().isEmpty()) {
warn.accept("IA: resposta sem \"choices\": " + AiText.forLog(root.toString()));
return null;
}
JsonElement first = choices.getAsJsonArray().get(0);
JsonElement message = first.isJsonObject() ? first.getAsJsonObject().get("message") : null;
if (message == null || !message.isJsonObject()) {
warn.accept("IA: choices com formato inesperado: " + AiText.forLog(root.toString()));
return null;
}
return message.getAsJsonObject();
}
}
@@ -0,0 +1,478 @@
package dev.marcospaulo.canalhandia;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.Recipe;
import org.bukkit.inventory.RecipeChoice;
import org.bukkit.inventory.ShapedRecipe;
import org.bukkit.inventory.ShapelessRecipe;
import java.text.Normalizer;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
/**
* Recipes for {@code /ia}, from the two places they can actually be had.
*
* <p>The wiki cannot supply them: MediaWiki's {@code explaintext} strips
* tables, and every recipe lives in one. The fire-resistance article comes back
* as 1086 characters for that reason, and the model still answered "não tenho
* certeza" grounded on it.
*
* <p><strong>Crafting</strong> comes from {@link Bukkit#recipeIterator()} — the
* running server's own data, authoritative for this exact version, free and
* instant. Player questions are Portuguese and {@link Material} names are
* English, so {@link Wiki#englishTitle} bridges the two.
*
* <p><strong>Brewing</strong> comes from {@link #BREWING} below, because Bukkit
* does not expose it. Checked against the 26.2 API: the {@link Recipe}
* implementations are shaped, shapeless, cooking, smithing, stonecutting,
* transmute, merchant and complex — there is no brewing recipe type;
* {@code PotionBrewer} has {@code addPotionMix}, {@code removePotionMix} and
* {@code resetPotionMixes} but no getter and no iterator; and vanilla brewing
* is hardcoded in {@code PotionBrewing} rather than registered as a recipe, so
* {@code recipeIterator()} never yields it. A table is the only option, and
* potions are the questions that motivated this whole feature.
*/
final class RecipeBook {
/** Chat is narrow and the model only needs a sample. */
private static final int MAX_RECIPES = 3;
/**
* A {@link RecipeChoice.MaterialChoice} built from a tag can hold dozens of
* materials ("any planks", "any log"). Printing all of them would bury the
* shape it belongs to.
*/
private static final int MAX_CHOICES = 4;
private RecipeBook() {
}
// ------------------------------------------------------------------
// Question parsing (pure — unit tested)
// ------------------------------------------------------------------
/** True if the question looks like it is asking how to make something. */
static boolean isRecipeQuestion(String question) {
if (question == null) {
return false;
}
String q = plain(question);
return q.contains("receita") || q.contains("como faz") || q.contains("como faco")
|| q.contains("como fazer") || q.contains("como criar")
|| q.contains("como craft") || q.contains("crafta")
|| q.contains("como se faz") || q.contains("como fabricar");
}
/**
* Words that only ever introduce the question. Stripped from the front so
* "como faço uma espada de diamante" is searched as "espada de diamante" —
* the wiki finds the article far more reliably without the preamble.
*
* <p>Leading position only. "de" is in here, but "espada de diamante" keeps
* its "de" because stripping stops at the first word that is not listed.
*/
private static final Set<String> LEADING_NOISE = Set.of(
"qual", "quais", "e", "o", "a", "os", "as", "um", "uma", "receita", "receitas",
"de", "do", "da", "dos", "das", "como", "se", "faz", "faco", "fazer",
"criar", "crio", "cria", "craftar", "crafta", "crafto", "craftear", "fabricar",
"montar", "monta", "para", "pra", "no", "na", "minecraft");
private static final Pattern WHITESPACE = Pattern.compile("\\s+");
private static final Pattern ACCENTS = Pattern.compile("\\p{M}+");
/**
* The thing the question is about: the question with its interrogative
* preamble removed. Accents and case are preserved, because this is what
* gets searched on the Portuguese wiki.
*/
static String subject(String question) {
if (question == null) {
return "";
}
String cleaned = question.replace("?", " ").replace("!", " ").trim();
if (cleaned.isEmpty()) {
return "";
}
String[] words = WHITESPACE.split(cleaned);
int start = 0;
while (start < words.length && LEADING_NOISE.contains(plain(words[start]))) {
start++;
}
// Every word was noise ("como se faz?"). There is no subject to look
// up, and returning the preamble would search the wiki for "como faz".
if (start == words.length) {
return "";
}
return String.join(" ", List.of(words).subList(start, words.length));
}
/**
* Lowercased, stripped of accents and with hyphens as spaces, so "Poção"
* and "pocao" compare equal and the in-game "Mestre-Tartaruga" reaches a
* table keyed on "mestre tartaruga".
*/
private static String plain(String text) {
String lower = text.toLowerCase(Locale.ROOT).replace('-', ' ');
return ACCENTS.matcher(Normalizer.normalize(lower, Normalizer.Form.NFD)).replaceAll("");
}
// ------------------------------------------------------------------
// Brewing (pure — unit tested)
// ------------------------------------------------------------------
/**
* Base potions, keyed by the accent-free Portuguese name of the effect.
*
* <p>Insertion-ordered only for readability; lookup picks the longest
* matching key, so "resistencia ao fogo" wins over any shorter key that
* also appears. Every one starts from the awkward potion described in
* {@link #AWKWARD} unless it says otherwise.
*/
private static final Map<String, String> BREWING = new LinkedHashMap<>();
private static final String AWKWARD =
"Base: Frasco de Água + Fungo do Nether (nether wart) = Poção Estranha.";
private static final String MODIFIERS =
"Modificadores: Pó de Pedra Luminosa deixa mais forte (II), "
+ "Redstone aumenta a duração, Pólvora transforma em arremessável, "
+ "Bafo do Dragão em prolongada.";
static {
BREWING.put("estranha", "Poção Estranha: Frasco de Água + Fungo do Nether. "
+ "Ela não faz nada sozinha; é a base de quase todas as outras.");
BREWING.put("resistencia ao fogo", "Poção de Resistência ao Fogo: "
+ AWKWARD + " Depois adicione Creme de Magma.");
BREWING.put("forca", "Poção de Força: " + AWKWARD + " Depois adicione Pó de Blaze.");
BREWING.put("cura", "Poção de Cura: " + AWKWARD
+ " Depois adicione Fatia de Melancia Reluzente.");
BREWING.put("regeneracao", "Poção de Regeneração: " + AWKWARD
+ " Depois adicione Lágrima de Ghast.");
// "Agilidade" is the item's in-game name; "Velocidade" is the effect's.
// Players type both, so both are keys.
BREWING.put("agilidade", "Poção de Agilidade (Velocidade): " + AWKWARD
+ " Depois adicione Açúcar.");
BREWING.put("velocidade", "Poção de Agilidade (Velocidade): " + AWKWARD
+ " Depois adicione Açúcar.");
BREWING.put("rapidez", "Poção de Agilidade (Velocidade): " + AWKWARD
+ " Depois adicione Açúcar.");
BREWING.put("visao noturna", "Poção de Visão Noturna: " + AWKWARD
+ " Depois adicione Cenoura Dourada.");
BREWING.put("respiracao aquatica", "Poção de Respiração Aquática: " + AWKWARD
+ " Depois adicione Baiacu.");
BREWING.put("salto", "Poção de Salto: " + AWKWARD + " Depois adicione Pé de Coelho.");
BREWING.put("queda lenta", "Poção de Queda Lenta: " + AWKWARD
+ " Depois adicione Membrana de Phantom.");
BREWING.put("veneno", "Poção de Veneno: " + AWKWARD + " Depois adicione Olho de Aranha.");
BREWING.put("mestre tartaruga", "Poção do Mestre-Tartaruga: " + AWKWARD
+ " Depois adicione Casco de Tartaruga.");
// The four below do not come from the awkward potion, which is the part
// players get wrong most often.
BREWING.put("fraqueza", "Poção de Fraqueza: Frasco de Água + Olho de Aranha Fermentado. "
+ "Não precisa de Fungo do Nether.");
BREWING.put("lentidao", "Poção de Lentidão: faça Poção de Velocidade ou de Salto "
+ "e adicione Olho de Aranha Fermentado.");
BREWING.put("invisibilidade", "Poção de Invisibilidade: faça Poção de Visão Noturna "
+ "e adicione Olho de Aranha Fermentado.");
// "Poção de Dano" is the item's in-game name. Longest-key-wins keeps
// the fuller phrasing working when a player types it.
BREWING.put("dano", "Poção de Dano (Dano Instantâneo): faça Poção de Cura "
+ "e adicione Olho de Aranha Fermentado.");
BREWING.put("dano instantaneo", "Poção de Dano (Dano Instantâneo): faça Poção de Cura "
+ "e adicione Olho de Aranha Fermentado.");
}
/**
* Keys matched on word boundaries, built once.
*
* <p>Bare {@code contains} hijacked real questions: "salto" sits inside
* <em>basalto</em>, so "como faço basalto" answered with the jump potion;
* "cura" sits inside <em>curar</em>, so "como faço para curar um aldeão
* zumbi" answered with the healing potion; "forca" sits inside
* <em>reforcar</em>. Because {@link #describe} consults brewing first,
* each of those took over the entire answer.
*/
private static final Map<Pattern, String> BREWING_PATTERNS = buildBrewingPatterns();
private static Map<Pattern, String> buildBrewingPatterns() {
Map<Pattern, String> patterns = new LinkedHashMap<>();
BREWING.forEach((key, value) ->
patterns.put(Pattern.compile("\\b" + Pattern.quote(key) + "\\b"), value));
return Collections.unmodifiableMap(patterns);
}
/**
* The brewing entry the question asks for, or null. Longest matching key
* wins, so "resistencia ao fogo" is not shadowed by a shorter key.
*/
static String brewing(String question) {
if (question == null) {
return null;
}
String q = plain(question);
// The question must actually be about a potion. "poc" covers poção,
// poções, pocao and pocoes once accents are stripped. Without this
// gate an effect name alone is enough to hijack the answer, and the
// effect names are ordinary Portuguese words.
if (!q.contains("poc")) {
return null;
}
String best = null;
int bestLength = -1;
for (Map.Entry<Pattern, String> entry : BREWING_PATTERNS.entrySet()) {
Pattern key = entry.getKey();
if (key.matcher(q).find() && key.pattern().length() > bestLength) {
bestLength = key.pattern().length();
best = entry.getValue();
}
}
return best == null ? null : "Fabricação de poções (alambique):\n" + best + "\n" + MODIFIERS;
}
// ------------------------------------------------------------------
// Material lookup (pure — unit tested)
// ------------------------------------------------------------------
/**
* Every material keyed by its name lowercased with underscores as spaces,
* so "diamond sword" finds {@code DIAMOND_SWORD}.
*
* <p>Built once. The obvious alternative — rebuilding the names on each
* question — allocates two throwaway strings per material per question,
* which over 2154 materials is some 4300 allocations to answer one line of
* chat.
*
* <p>{@code Material.values()} is safe in a static initialiser and off a
* server; {@code Material.isItem()} is not, and throws
* {@code ExceptionInInitializerError} without one. Nothing here calls it,
* which is what keeps this class unit-testable.
*/
private static final Map<String, Material> BY_NAME;
static {
Map<String, Material> byName = new HashMap<>();
for (Material material : Material.values()) {
String name = material.name().toLowerCase(Locale.ROOT).replace('_', ' ');
// Legacy constants duplicate modern ones under a "legacy " prefix
// and have no recipes.
if (!name.startsWith("legacy ")) {
byName.put(name, material);
}
}
BY_NAME = Map.copyOf(byName);
}
/**
* English wiki titles that name a real item under a different word than the
* enum uses. Only titles verified by hand belong here.
*
* <p>This exists because the obvious generalisation — falling back to the
* longest material name contained in the title — is actively harmful. It
* was measured returning confidently wrong items: "Redstone Repeater" tied
* on length and resolved to {@code REDSTONE}, handing a player asking about
* repeaters the recipe for redstone dust; "Book and Quill" gave
* {@code BOOK}, "Minecart with Chest" gave {@code MINECART}, "Rabbit's Foot"
* gave {@code RABBIT} (raw meat), "Chestplate" gave {@code CHEST}. A
* plausible recipe for the wrong item is exactly the failure this whole
* feature exists to remove, and it is worse than no answer, because the
* model will state it confidently. A suffix rule fails the same way —
* "axe" is a suffix of "pickaxe".
*
* <p>So: exact match, this table, or null.
*/
private static final Map<String, Material> ALIASES = Map.ofEntries(
Map.entry("redstone repeater", Material.REPEATER),
Map.entry("redstone comparator", Material.COMPARATOR),
Map.entry("book and quill", Material.WRITABLE_BOOK),
Map.entry("minecart with chest", Material.CHEST_MINECART),
Map.entry("minecart with furnace", Material.FURNACE_MINECART),
Map.entry("minecart with hopper", Material.HOPPER_MINECART),
Map.entry("minecart with tnt", Material.TNT_MINECART),
Map.entry("rabbit's foot", Material.RABBIT_FOOT),
Map.entry("jack o'lantern", Material.JACK_O_LANTERN),
Map.entry("steak", Material.COOKED_BEEF),
Map.entry("eye of ender", Material.ENDER_EYE),
Map.entry("nether quartz", Material.QUARTZ),
Map.entry("bottle o' enchanting", Material.EXPERIENCE_BOTTLE),
Map.entry("firework rocket", Material.FIREWORK_ROCKET),
Map.entry("clock", Material.CLOCK));
/**
* The material an English wiki title names, or null.
*
* <p>Exact match, then {@link #ALIASES}. Nothing else: a title this cannot
* resolve yields no grounding, which is the correct outcome. See the note on
* {@link #ALIASES} for why there is no fuzzy fallback.
*
* <p>Colour and material families resolve to null on purpose. There is no
* {@code Material.BED}, only {@code WHITE_BED} and its fifteen siblings, so
* "cama" is not answered rather than answered with an arbitrary colour.
*/
static Material materialFor(String englishTitle) {
if (englishTitle == null || englishTitle.isBlank()) {
return null;
}
String title = englishTitle.toLowerCase(Locale.ROOT).replace('_', ' ').trim();
Material exact = BY_NAME.get(title);
return exact != null ? exact : ALIASES.get(title);
}
// ------------------------------------------------------------------
// The server-dependent part (verified in Task 13, not unit tested)
// ------------------------------------------------------------------
/**
* Plain text describing how to make whatever the question names, or null if
* nothing was found. Null must be passed through as "no grounding", never
* guessed at.
*
* <p>Brewing is answered first and without touching the network: potions
* have no crafting recipe, so the wiki round trip would only end in a null.
*/
static String describe(String question, Wiki wiki) {
String potion = brewing(question);
if (potion != null) {
return potion;
}
String term = subject(question);
if (term.isEmpty() || wiki == null) {
return null;
}
Material material = materialFor(wiki.englishTitle(term));
if (material == null) {
return null;
}
return craftingFor(material);
}
/**
* Snapshot of the server's recipes, taken once on the main thread at
* enable. {@link Bukkit#recipeIterator()} is documented main-thread-only
* and has been observed misbehaving off it, so {@link #describe} iterates
* this copy instead. Datapack reloads after enable are not re-snapshotted.
*/
private static volatile List<Recipe> recipes = List.of();
/**
* Snapshot the server's recipes on the main thread (at enable).
*/
static void preload() {
List<Recipe> snapshot = new ArrayList<>();
Iterator<Recipe> it = Bukkit.recipeIterator();
while (it.hasNext()) {
snapshot.add(it.next());
}
recipes = List.copyOf(snapshot);
}
/** Reads the snapshot taken at enable. */
private static String craftingFor(Material material) {
List<String> lines = new ArrayList<>();
Iterator<Recipe> it = recipes.iterator();
while (it.hasNext() && lines.size() < MAX_RECIPES) {
Recipe recipe = it.next();
if (recipe.getResult().getType() != material) {
continue;
}
if (recipe instanceof ShapedRecipe shaped) {
lines.add(describeShaped(shaped));
} else if (recipe instanceof ShapelessRecipe shapeless) {
lines.add(describeShapeless(shapeless));
}
}
return lines.isEmpty() ? null
: "Receitas do servidor para " + pretty(material) + ":\n"
+ String.join("\n", lines);
}
private static String describeShaped(ShapedRecipe recipe) {
StringBuilder out = new StringBuilder("Bancada, formato ");
for (String row : recipe.getShape()) {
out.append('[').append(row).append(']');
}
List<String> parts = new ArrayList<>();
// getChoiceMap, not the deprecated getIngredientMap: the latter
// collapses "any plank" to one arbitrary stack, which reads as a recipe
// that only accepts oak.
recipe.getChoiceMap().forEach((symbol, choice) -> {
if (choice != null) {
parts.add(symbol + "=" + describeChoice(choice));
}
});
// The map's iteration order is not specified; sorting keeps the same
// recipe from being described two different ways on two calls.
Collections.sort(parts);
return parts.isEmpty() ? out.toString() : out.append(" onde ")
.append(String.join(", ", parts)).toString();
}
private static String describeShapeless(ShapelessRecipe recipe) {
List<String> parts = new ArrayList<>();
// getChoiceList, not the deprecated getIngredientList, for the same
// reason as getChoiceMap above.
for (RecipeChoice choice : recipe.getChoiceList()) {
if (choice != null) {
parts.add(describeChoice(choice));
}
}
// Guarded like describeShaped: a bare "Sem formato: " with nothing
// after it reads as a recipe with no ingredients.
return parts.isEmpty() ? "Sem formato" : "Sem formato: " + String.join(" + ", parts);
}
/**
* The brewing keys, for the invariant test that they are all matchable.
* An accented or uppercase key could never fire and nothing would notice.
*/
static Set<String> brewingKeys() {
return Collections.unmodifiableSet(BREWING.keySet());
}
/**
* One ingredient slot, which may accept any of several materials.
*
* <p>Package-private rather than private: {@code MaterialChoice} needs no
* running server, so this is the one part of the recipe rendering that can
* be covered before Task 13.
*/
static String describeChoice(RecipeChoice choice) {
List<String> names = new ArrayList<>();
if (choice instanceof RecipeChoice.MaterialChoice materials) {
for (Material material : materials.getChoices()) {
addOnce(names, pretty(material));
}
} else if (choice instanceof RecipeChoice.ExactChoice exact) {
for (ItemStack stack : exact.getChoices()) {
addOnce(names, pretty(stack.getType()));
}
}
if (names.isEmpty()) {
return "?";
}
if (names.size() > MAX_CHOICES) {
return String.join("/", names.subList(0, MAX_CHOICES)) + "/...";
}
return String.join("/", names);
}
private static void addOnce(List<String> names, String name) {
if (!names.contains(name)) {
names.add(name);
}
}
private static String pretty(Material material) {
return material.name().toLowerCase(Locale.ROOT).replace('_', ' ');
}
}
@@ -252,7 +252,7 @@ final class Settings {
}
int aiMaxTokens() {
return Math.max(32, plugin.getConfig().getInt("ia.max-tokens", 300));
return Math.max(32, plugin.getConfig().getInt("ia.max-tokens", 1200));
}
double aiTemperature() {
@@ -297,6 +297,31 @@ final class Settings {
set("ia.publico", value);
}
AiProfile aiProfile() {
return AiProfile.byKey(plugin.getConfig().getString("ia.perfil", "PRECISO"));
}
void aiProfile(AiProfile profile) {
set("ia.perfil", profile.name());
}
/** How much article text to send. Lead paragraphs alone were not enough. */
int aiWikiChars() {
return Math.max(500, plugin.getConfig().getInt("ia.wiki-caracteres", 7000));
}
int aiMemoryExchanges() {
return Math.max(0, plugin.getConfig().getInt("ia.memoria-perguntas", 3));
}
int aiMemoryMinutes() {
return Math.max(1, plugin.getConfig().getInt("ia.memoria-minutos", 10));
}
String aiServerContext() {
return String.join(" ", plugin.getConfig().getStringList("ia.contexto"));
}
// --- content ------------------------------------------------------------
boolean categoryEnabled(Category category) {
@@ -0,0 +1,260 @@
package dev.marcospaulo.canalhandia;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import java.util.function.Consumer;
import java.util.regex.Pattern;
/**
* Reads articles from the Portuguese Minecraft Wiki.
*
* <p>Full article text, not {@code exintro}: grounded on lead paragraphs alone
* the model answered "não tenho certeza" to questions it could otherwise get
* right, because the specifics live further down the page.
*
* <p>Note that {@code explaintext} drops tables, so crafting and brewing
* recipes never appear here. Crafting comes from the running server's recipe
* registry instead; brewing is not exposed by Bukkit at all and comes from a
* hardcoded table. See {@link RecipeBook}.
*/
final class Wiki {
private static final String API = "https://pt.minecraft.wiki/api.php";
/** Entries are small and a server sees few distinct topics; this is generous. */
private static final int MAX_ENTRIES = 200;
/** {@code [\r\n]} rather than {@code \n}, so a CRLF article collapses too. */
private static final Pattern BLANK_LINES = Pattern.compile("[\r\n]{2,}");
record Article(String title, String text) {
}
private final Fetcher fetcher;
private final int maxChars;
private final Consumer<String> warn;
/**
* Keyed by the normalised <em>search term</em>, not by article title: two
* terms that resolve to the same article get their own entry, which costs a
* duplicate copy of the text and saves a round trip on each. Newest-last,
* evicted at {@value #MAX_ENTRIES} entries. Lost on restart, which is fine.
*
* <p>Guarded by its own monitor. {@link Ai} calls {@link #lookup} from
* {@code runTaskAsynchronously}, so several players asking at once means
* several threads in here at the same time, and an unsynchronised
* {@code LinkedHashMap} can corrupt its own links under a concurrent write.
* The lock is never held across a network call.
*/
private final Map<String, Article> cache = new LinkedHashMap<>();
/**
* Portuguese search term to English wiki title, for {@link #englishTitle}.
* Separate from {@link #cache} because the two are populated independently:
* a recipe question needs the translation but not the article text.
*
* <p>Misses are cached as null values — hence {@code containsKey} rather
* than a null check at the read. A term the wiki has no English link for
* would otherwise re-fetch on every repeat of the same question.
*/
private final Map<String, String> englishTitles = new LinkedHashMap<>();
Wiki(Fetcher fetcher, int maxChars) {
this(fetcher, maxChars, message -> {
});
}
/**
* @param warn where failures are reported. Grounding is the entire point of
* this class, and every one of its failure modes returns null, which the
* caller cannot tell apart from a question the wiki simply has no article
* for. A 403 — the exact failure the custom user agent exists to prevent —
* would revert {@code /ia} to the confidently wrong answers it was built
* to stop, and do it against a clean server log. Grounding you cannot
* tell is broken is grounding you do not have.
*/
Wiki(Fetcher fetcher, int maxChars, Consumer<String> warn) {
this.fetcher = fetcher;
// A config value of 0 would make every substring throw, and since the
// catch below turns that into null, grounding would switch itself off
// for good and silently. Cheaper to clamp than to diagnose.
this.maxChars = Math.max(1, maxChars);
this.warn = warn;
}
/** The best article for a search term, or null if there is none. */
Article lookup(String term) {
String key = key(term);
synchronized (cache) {
Article cached = cache.get(key);
if (cached != null) {
return cached;
}
}
try {
String title = search(term);
if (title == null) {
return null;
}
String text = extract(title);
if (text == null || text.isBlank()) {
warn.accept("Wiki: artigo \"" + title + "\" veio sem texto.");
return null;
}
Article article = new Article(title, trim(text));
remember(key, article);
return article;
} catch (InterruptedException e) {
// Swallowing this would leave an async worker running through a
// plugin disable or reload as if nothing had happened.
Thread.currentThread().interrupt();
warn.accept("Wiki: consulta de \"" + term + "\" interrompida.");
return null;
} catch (Exception e) {
// A wiki outage must not break the answer; the caller falls back
// to answering without a source and says so.
warn.accept("Wiki: falha ao consultar \"" + term + "\": " + e);
return null;
}
}
/**
* The English wiki title for a Portuguese search term, or null if there is
* none.
*
* <p>This exists because {@link Material} names are English and players ask
* in Portuguese. Matching "espada de diamante" against {@code DIAMOND_SWORD}
* directly does not work: measured over twenty realistic questions, scanning
* every material name for a substring of the question resolved exactly one,
* and that one ("tridente" containing "trident") by coincidence rather than
* translation. The wiki already knows the mapping, so we ask it: the pt
* article carries an interlanguage link to its English counterpart, and
* "Espada de Diamante" &rarr; "Diamond Sword" uppercases straight onto the
* enum constant.
*
* <p>Not every article has the link — "Mesa de Encantamento" has none — and
* a missing link simply means no recipe grounding for that question. The
* caller must not guess from a null.
*/
String englishTitle(String term) {
String key = key(term);
synchronized (englishTitles) {
if (englishTitles.containsKey(key)) {
return englishTitles.get(key);
}
}
String english = null;
try {
String title = search(term);
if (title != null) {
english = langlink(title);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
warn.accept("Wiki: tradução de \"" + term + "\" interrompida.");
// Deliberately not cached: an interrupt says nothing about whether
// the term has an English title, and caching null here would make
// one shutdown poison the term until the next restart.
return null;
} catch (Exception e) {
warn.accept("Wiki: falha ao traduzir \"" + term + "\": " + e);
return null;
}
synchronized (englishTitles) {
englishTitles.put(key, english);
while (englishTitles.size() > MAX_ENTRIES) {
englishTitles.remove(englishTitles.keySet().iterator().next());
}
}
return english;
}
/** The English interlanguage link of a pt article title, or null. */
private String langlink(String title) throws IOException, InterruptedException {
String url = API + "?action=query&prop=langlinks&lllang=en&format=json&titles="
+ URLEncoder.encode(title, StandardCharsets.UTF_8);
JsonObject root = JsonParser.parseString(fetcher.get(url)).getAsJsonObject();
JsonElement pages = root.has("query") ? root.getAsJsonObject("query").get("pages") : null;
if (pages == null || !pages.isJsonObject()) {
throw new IOException("langlinks sem 'pages': " + AiText.forLog(root.toString()));
}
JsonObject byPageId = pages.getAsJsonObject();
for (String key : byPageId.keySet()) {
JsonElement links = byPageId.getAsJsonObject(key).get("langlinks");
// An article with no English counterpart carries no langlinks key
// at all. That is a plain no-result, not a malformed response.
if (links != null && links.isJsonArray() && !links.getAsJsonArray().isEmpty()) {
// MediaWiki puts the title in "*", not in a named field.
JsonElement value = links.getAsJsonArray().get(0).getAsJsonObject().get("*");
if (value != null) {
return value.getAsString();
}
}
}
return null;
}
private String search(String term) throws IOException, InterruptedException {
String url = API + "?action=query&list=search&format=json&srlimit=1&srsearch="
+ URLEncoder.encode(term, StandardCharsets.UTF_8);
JsonObject root = JsonParser.parseString(fetcher.get(url)).getAsJsonObject();
JsonElement hits = root.has("query") ? root.getAsJsonObject("query").get("search") : null;
// Kept distinct from an outage so the two do not read alike in the log:
// MediaWiki answers some warning shapes with a query object and no
// search key, which would otherwise surface as a bare NullPointerException.
if (hits == null || !hits.isJsonArray()) {
throw new IOException("busca sem 'search': " + AiText.forLog(root.toString()));
}
JsonArray array = hits.getAsJsonArray();
return array.isEmpty() ? null : array.get(0).getAsJsonObject().get("title").getAsString();
}
private String extract(String title) throws IOException, InterruptedException {
String url = API + "?action=query&prop=extracts&explaintext=1&format=json&titles="
+ URLEncoder.encode(title, StandardCharsets.UTF_8);
JsonObject root = JsonParser.parseString(fetcher.get(url)).getAsJsonObject();
JsonElement pages = root.has("query") ? root.getAsJsonObject("query").get("pages") : null;
if (pages == null || !pages.isJsonObject()) {
throw new IOException("extrato sem 'pages': " + AiText.forLog(root.toString()));
}
JsonObject byPageId = pages.getAsJsonObject();
for (String key : byPageId.keySet()) {
JsonObject page = byPageId.getAsJsonObject(key);
// A missing page carries "missing" and no extract. That is a plain
// no-result, not a malformed response.
if (page.has("extract")) {
return page.get("extract").getAsString();
}
}
return null;
}
private String trim(String text) {
String collapsed = BLANK_LINES.matcher(text).replaceAll("\n").trim();
return collapsed.length() > maxChars ? collapsed.substring(0, maxChars) : collapsed;
}
/**
* {@code Locale.ROOT} rather than the default locale: a server started
* under a Turkish locale lowercases "I" to a dotless "ı", and any code that
* later calls {@code Locale.setDefault} would leave earlier entries keyed
* under rules no lookup uses again.
*/
private static String key(String term) {
return term.toLowerCase(Locale.ROOT);
}
private void remember(String key, Article article) {
synchronized (cache) {
cache.put(key, article);
while (cache.size() > MAX_ENTRIES) {
cache.remove(cache.keySet().iterator().next());
}
}
}
}
+26 -1
View File
@@ -93,6 +93,10 @@ reacoes:
java: "[🔥]"
texto: "[TOP]"
comando: "top"
errado:
java: "[❌]"
texto: "[ERRADO]"
comando: "errado"
# --- Jogos -------------------------------------------------------------------
@@ -129,7 +133,9 @@ ia:
modelo: "MiniMax-M2.7"
# Tamanho da resposta pedida ao modelo, e o corte final no chat.
max-tokens: 300
# 1200, não 300: o raciocínio oculto do M3 consome o orçamento e a resposta
# chega vazia quando o teto é baixo.
max-tokens: 1200
max-caracteres: 500
# Tamanho máximo da pergunta, em caracteres.
@@ -161,3 +167,22 @@ ia:
terminal nem de Minecraft. Escreva em texto puro: nada de markdown,
asteriscos, crases ou emoji, porque o chat do Minecraft não formata nada
disso.
# ECONOMICO pula a consulta à wiki (resposta rápida, sem fonte).
# PRECISO consulta a wiki (mais lento, mais correto). Troque em jogo com
# /ia perfil <nome>.
perfil: PRECISO
# Quantos caracteres do artigo da wiki enviar. Só a introdução não basta:
# a receita e os detalhes ficam mais abaixo na página.
wiki-caracteres: 7000
# Memória curta por jogador, para perguntas de seguimento ("e no nether?").
memoria-perguntas: 3
memoria-minutos: 10
# Fatos do servidor que a IA nunca teria como saber. Uma linha por fato.
contexto:
- "O servidor se chama Canalhandia e roda Minecraft 26.2 (Paper)."
- "Jogadores de Bedrock entram pelo Geyser e o nome deles começa com ponto."
- "O servidor tem BlueMap, voice chat e Distant Horizons."
+16
View File
@@ -60,6 +60,13 @@ commands:
description: Faz uma pergunta simples para a IA.
usage: /ia <pergunta>
aliases: [pergunta]
iap:
description: Pergunta para a IA, resposta só para você.
usage: /iap <pergunta>
aliases: [iaprivado]
errado:
description: Reage com "errado" à última mensagem (resposta da IA).
usage: /errado
permissions:
# Declared explicitly: an undeclared Bukkit permission falls back to op-only,
@@ -82,6 +89,15 @@ permissions:
canalhandia.ia:
description: Permite usar /ia. Padrão op; o LuckPerms pode conceder a outros.
default: op
canalhandia.ia.privado:
description: Permite perguntar em privado com /iap. Padrão op; o LuckPerms pode conceder a outros.
default: op
canalhandia.ia.corrigir:
description: Permite registrar correções para as respostas da IA.
default: op
canalhandia.ia.perfil:
description: Permite trocar o perfil da IA entre economico e preciso.
default: op
canalhandia.isento:
description: Quem tem isto nunca é sorteado como assunto.
default: false
@@ -0,0 +1,90 @@
package dev.marcospaulo.canalhandia;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* The key-cleaning half of {@link Ai}. The rest of that class needs a running
* server; {@code cleanKey} is pure and is where the credential is made safe to
* put in a header.
*/
class AiKeyTest {
private static final char LF = 10;
private static final char CR = 13;
@Test
void keepsAWellFormedKeyUnchanged() {
assertEquals("sk-abc123", Ai.cleanKey("sk-abc123"));
}
@Test
void stripsSurroundingWhitespaceAndTrailingNewline() {
assertEquals("sk-abc123", Ai.cleanKey(" sk-abc123 " + LF));
assertEquals("sk-abc123", Ai.cleanKey("sk-abc123" + CR + LF));
}
/** The case trim() misses: a second line in the key file. */
@Test
void takesTheFirstLineRatherThanWeldingTheSecondOn() {
assertEquals("sk-abc123", Ai.cleanKey("sk-abc123" + LF + "# comentário"));
assertEquals("sk-abc123", Ai.cleanKey("sk-abc123" + LF + LF + "lixo"));
}
@Test
void skipsLeadingBlankLines() {
assertEquals("sk-abc123", Ai.cleanKey(LF + " " + LF + "sk-abc123"));
}
@Test
void removesControlCharactersFromWithinTheLine() {
assertEquals("sk-abc", Ai.cleanKey("sk-" + (char) 0 + "abc" + (char) 0x7F));
}
/**
* The case a deny-list of low control characters misses. Notepad and
* PowerShell {@code Out-File} put a U+FEFF byte order mark at the head of
* the file; the JDK rejects every char above U+00FF, quoting the header
* value back as it does.
*/
@Test
void stripsAByteOrderMarkAndAnythingElseNonAscii() {
assertEquals("sk-abc123", Ai.cleanKey((char) 0xFEFF + "sk-abc123"));
assertEquals("sk-abc", Ai.cleanKey("sk-" + (char) 0x00E7 + "abc" + (char) 0x2013));
assertEquals("sk-abc", Ai.cleanKey("sk-" + (char) 0x00A0 + "abc"));
assertEquals("sk-abc", Ai.cleanKey("sk-" + (char) 0x0100 + "abc"));
}
@Test
void nothingUsableIsNull() {
assertNull(Ai.cleanKey(null));
assertNull(Ai.cleanKey(""));
assertNull(Ai.cleanKey(" " + LF + " "));
}
/**
* The contract that matters, asserted end to end: anything {@code cleanKey}
* hands back is something {@link HttpFetcher} accepts, so a real key can
* never reach the JDK's header validator and be quoted back into a log.
*/
@Test
void whateverSurvivesCleaningIsAcceptedAsABearer() {
String[] messy = {
"sk-abc123",
" sk-abc " + LF,
"sk-a" + LF + "b",
"sk" + (char) 9 + "-x",
"sk-x" + CR + LF + "# nota",
LF + "sk-y",
(char) 0xFEFF + "sk-bom",
"sk-" + (char) 0x00E7 + (char) 0x0100 + (char) 0xFFFD + "z",
};
for (String raw : messy) {
String key = Ai.cleanKey(raw);
assertNotNull(key, "nothing survived cleaning of " + raw);
assertDoesNotThrow(() -> HttpFetcher.checkBearer(key),
"HttpFetcher rejected a key cleanKey had approved");
}
}
}
@@ -0,0 +1,73 @@
package dev.marcospaulo.canalhandia;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class AiTextTest {
@Test
void stripsMarkdownEmphasis() {
assertEquals("use magma cream",
AiText.sanitise("use **magma cream**", 500));
}
@Test
void stripsEmojiAndSectionSigns() {
assertEquals("boa sorte",
AiText.sanitise("boa sorte 😄 §c", 500));
}
@Test
void stripsLeadingSlashesSoRepliesCannotLookLikeCommands() {
assertEquals("give me diamonds",
AiText.sanitise("//give me diamonds", 500));
}
@Test
void keepsPortugueseAccents() {
assertEquals("poção de resistência ao fogo",
AiText.sanitise("poção de resistência ao fogo", 500));
}
@Test
void truncatesToLimit() {
assertEquals("abc…", AiText.sanitise("abcdefg", 3));
}
// The model leaked "搭档" and "contiennent" into Portuguese answers.
@Test
void detectsCjk() {
assertTrue(AiText.hasForeignScript("te aceite como搭档"));
}
@Test
void plainPortugueseIsNotForeign() {
assertFalse(AiText.hasForeignScript("camelos são pacíficos e mansos"));
}
// null must not reach the regex chain — it is total hardening for a method
// whose caller promises non-null but should not explode if it does not.
@Test
void sanitiseReturnsEmptyForNull() {
assertEquals("", AiText.sanitise(null, 500));
}
// Truncating a supplementary character in half leaves an orphan surrogate
// that renders as a replacement box. The cut backs off one char.
@Test
void truncationBacksOffAHighSurrogate() {
// 𝄞 (U+1D11E) is two UTF-16 chars; it is not in the emoji strip range,
// so it survives to the truncation step. Cutting at index 4 would slice
// the high surrogate (D834) off its low half (DD1E).
assertEquals("abc…", AiText.sanitise("abc𝄞def", 4));
}
// A lone "*" surrounded by spaces is a bullet, not markdown emphasis.
// The strip must only remove markers that flank non-space text.
@Test
void keepsStandaloneAsterisk() {
assertEquals("foo * bar", AiText.sanitise("foo * bar", 500));
// And real emphasis still strips.
assertEquals("use magma cream", AiText.sanitise("use *magma cream*", 500));
}
}
@@ -0,0 +1,146 @@
package dev.marcospaulo.canalhandia;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.*;
class ConversationsTest {
@Test
void keepsRecentExchangesForFollowUps() {
Conversations c = new Conversations(3, 10);
UUID id = UUID.randomUUID();
c.remember(id, "onde acho diamante?", "abaixo do Y 16");
assertEquals(2, c.history(id).size());
assertEquals("onde acho diamante?", c.history(id).get(0).content());
}
@Test
void dropsOldestBeyondLimit() {
Conversations c = new Conversations(2, 10);
UUID id = UUID.randomUUID();
c.remember(id, "q1", "a1");
c.remember(id, "q2", "a2");
c.remember(id, "q3", "a3");
assertEquals(4, c.history(id).size());
assertEquals("q2", c.history(id).get(0).content());
}
@Test
void expiresAfterTheWindow() {
Conversations c = new Conversations(3, 0);
UUID id = UUID.randomUUID();
c.remember(id, "q", "a");
assertTrue(c.history(id).isEmpty());
}
@Test
void playersDoNotShareHistory() {
Conversations c = new Conversations(3, 10);
UUID a = UUID.randomUUID();
UUID b = UUID.randomUUID();
c.remember(a, "q", "resposta de A");
assertTrue(c.history(b).isEmpty());
}
/** Roles alternate user/assistant, which is what the API expects. */
@Test
void alternatesUserAndAssistantRoles() {
Conversations c = new Conversations(3, 10);
UUID id = UUID.randomUUID();
c.remember(id, "q", "a");
List<MiniMax.Turn> history = c.history(id);
assertEquals("user", history.get(0).role());
assertEquals("assistant", history.get(1).role());
assertEquals("a", history.get(1).content());
}
/** A memory of zero exchanges must remember nothing at all, not even a key. */
@Test
void zeroExchangesRemembersNothing() {
Conversations c = new Conversations(0, 10);
UUID id = UUID.randomUUID();
c.remember(id, "q", "a");
assertTrue(c.history(id).isEmpty());
assertEquals(0, c.size());
}
@Test
void forgetDropsOnlyThatPlayer() {
Conversations c = new Conversations(3, 10);
UUID a = UUID.randomUUID();
UUID b = UUID.randomUUID();
c.remember(a, "q", "a");
c.remember(b, "q", "b");
c.forget(a);
assertTrue(c.history(a).isEmpty());
assertEquals(2, c.history(b).size());
}
/** Expired entries must not leave the player behind as a permanent key. */
@Test
void expiryEvictsThePlayerEntirely() {
Conversations c = new Conversations(3, 0);
UUID id = UUID.randomUUID();
c.remember(id, "q", "a");
c.history(id);
assertEquals(0, c.size());
}
/** A busy server must not accumulate a map entry per player forever. */
@Test
void boundsTheNumberOfRememberedPlayers() {
Conversations c = new Conversations(3, 10);
for (int i = 0; i < Conversations.MAX_PLAYERS + 50; i++) {
c.remember(UUID.randomUUID(), "q", "a");
}
assertEquals(Conversations.MAX_PLAYERS, c.size());
}
/**
* {@link Ai} calls this from {@code runTaskAsynchronously}, so several
* players asking at once means several threads in here at the same time.
* Unsynchronised, this corrupts the map or spins forever on a resize.
*/
@Test
void survivesConcurrentUse() throws Exception {
Conversations c = new Conversations(3, 10);
int threads = 8;
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(threads);
AtomicReference<Throwable> failure = new AtomicReference<>();
List<Thread> workers = new ArrayList<>();
for (int t = 0; t < threads; t++) {
UUID id = UUID.randomUUID();
Thread worker = new Thread(() -> {
try {
start.await();
for (int i = 0; i < 400; i++) {
c.remember(id, "q" + i, "a" + i);
assertFalse(c.history(id).isEmpty());
c.history(UUID.randomUUID());
}
} catch (Throwable e) {
failure.compareAndSet(null, e);
} finally {
done.countDown();
}
});
workers.add(worker);
worker.start();
}
start.countDown();
assertTrue(done.await(30, TimeUnit.SECONDS), "threads travaram");
for (Thread worker : workers) {
worker.join();
}
assertNull(failure.get(), String.valueOf(failure.get()));
}
}
@@ -0,0 +1,39 @@
package dev.marcospaulo.canalhandia;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class CorrectionsTest {
@Test
void matchesOnSharedSignificantWords() {
List<Corrections.Entry> all = List.of(
new Corrections.Entry("como pegar um camelo",
"camelos são mansos, basta pôr uma sela"));
assertEquals(1, Corrections.matching(all, "como eu pego camelo?").size());
}
@Test
void ignoresUnrelatedCorrections() {
List<Corrections.Entry> all = List.of(
new Corrections.Entry("como pegar um camelo", ""));
assertTrue(Corrections.matching(all, "onde acho diamante?").isEmpty());
}
@Test
void shortWordsDoNotCreateMatches() {
List<Corrections.Entry> all = List.of(
new Corrections.Entry("o que e um creeper", "explode"));
assertTrue(Corrections.matching(all, "o que e um zumbi").isEmpty());
}
@Test
void singleSharedContentWordMatches() {
// "camelo" is the only word > 4 chars shared with the conjugated
// question; the threshold is one, not two, precisely for this case.
List<Corrections.Entry> all = List.of(
new Corrections.Entry("como pegar um camelo", "ponha uma sela"));
assertEquals(1, Corrections.matching(all, "como eu pego camelo?").size());
}
}
@@ -0,0 +1,97 @@
package dev.marcospaulo.canalhandia;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import static org.junit.jupiter.api.Assertions.*;
/**
* Only the bearer check is exercised here. It runs before the request is built
* and before any I/O, so these tests reach no network: the URL below is a
* reserved-for-invalid TLD and would fail loudly if one were ever attempted.
*/
class HttpFetcherTest {
private static final String URL = "https://nunca.invalid/v1/chat";
/**
* A key file with a second line survives {@code trim()}. The resulting
* header value makes the JDK throw
* {@code invalid header value: "Bearer sk-…"} — the credential, in an
* exception message, in whatever log catches it.
*/
@Test
void rejectsABearerWithANewlineBeforeBuildingTheRequest() {
String key = "sk-canalhandia-segredo" + (char) 10 + "# comentário";
IOException thrown = assertThrows(IOException.class,
() -> new HttpFetcher(5).postJson(URL, "{}", key));
assertFalse(thrown.getMessage().contains("segredo"),
"key leaked into the exception: " + thrown.getMessage());
assertTrue(thrown.getMessage().contains("imprimível"), thrown.getMessage());
}
@Test
void rejectsCarriageReturnAndNulAndDelete() {
for (char c : new char[]{13, 0, 0x7F, 9}) {
String key = "sk-segredo" + c + "x";
IOException thrown = assertThrows(IOException.class,
() -> new HttpFetcher(5).postJson(URL, "{}", key),
"accepted a bearer containing char " + (int) c);
assertFalse(thrown.getMessage().contains("segredo"), thrown.getMessage());
}
}
/**
* The message assertions matter here: {@code UnknownHostException} is an
* {@code IOException}, so a bare {@code assertThrows} would still pass if
* the guard regressed — while quietly doing real DNS I/O.
*/
@Test
void rejectsAnEmptyOrNullBearer() {
for (String bearer : new String[]{"", null}) {
IOException thrown = assertThrows(IOException.class,
() -> new HttpFetcher(5).postJson(URL, "{}", bearer));
assertTrue(thrown.getMessage().contains("vazia"),
"guard did not fire; this may have hit the network: " + thrown);
}
}
/**
* A key file saved by Notepad or PowerShell {@code Out-File} starts with a
* U+FEFF byte order mark. The JDK rejects every char above U+00FF, so a
* deny-list of low control characters alone lets this through to the
* validator that quotes the value back.
*/
@Test
void rejectsAByteOrderMarkAndOtherNonAsciiCharacters() {
// Written as code points, not literals: a real BOM in this source
// file would be invisible to whoever next reads the test.
for (char c : new char[]{0xFEFF, 0x00A0, 0x00E7, 0x2013, 0xFFFD, 0x0100, 0x20}) {
String key = c + "sk-segredo";
IOException thrown = assertThrows(IOException.class,
() -> new HttpFetcher(5).postJson(URL, "{}", key),
"accepted a bearer containing U+" + Integer.toHexString(c));
assertFalse(thrown.getMessage().contains("segredo"), thrown.getMessage());
assertTrue(thrown.getMessage().contains("imprimível"), thrown.getMessage());
}
}
@Test
void acceptsAnOrdinaryPrintableAsciiKey() {
assertDoesNotThrow(() -> HttpFetcher.checkBearer("sk-Abc123_-.~+/=:"));
}
/**
* The message must be actionable without quoting the key, so it names the
* position and the likely cause instead.
*/
@Test
void theMessageSaysWhereTheProblemIsWithoutQuotingTheKey() {
IOException thrown = assertThrows(IOException.class,
() -> new HttpFetcher(5).postJson(URL, "{}", "abc" + (char) 10 + "def"));
assertTrue(thrown.getMessage().contains("3"), thrown.getMessage());
assertFalse(thrown.getMessage().contains("abc"), thrown.getMessage());
assertFalse(thrown.getMessage().contains("def"), thrown.getMessage());
}
}
@@ -0,0 +1,361 @@
package dev.marcospaulo.canalhandia;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class MiniMaxTest {
private static Fetcher replying(String body) {
return new Fetcher() {
@Override
public String get(String url) {
throw new UnsupportedOperationException();
}
@Override
public String postJson(String url, String json, String bearer) {
return body;
}
};
}
@Test
void readsAnswerContent() {
MiniMax api = new MiniMax(replying(
"{\"choices\":[{\"message\":{\"content\":\"pólvora\"}}]}"));
assertEquals("pólvora", api.answer("k", "m", List.of(), 1200, 0.3));
}
// MiniMax reports application errors with HTTP 200 and a non-zero base_resp.
@Test
void treatsNonZeroBaseRespAsFailure() {
MiniMax api = new MiniMax(replying(
"{\"base_resp\":{\"status_code\":1004,\"status_msg\":\"bad key\"},\"choices\":[]}"));
assertNull(api.answer("k", "m", List.of(), 1200, 0.3));
}
// Hidden reasoning can consume the whole budget, leaving content empty.
@Test
void emptyContentIsNullNotBlank() {
MiniMax api = new MiniMax(replying(
"{\"choices\":[{\"message\":{\"content\":\"\"}}]}"));
assertNull(api.answer("k", "m", List.of(), 1200, 0.3));
}
@Test
void readsForcedToolArgument() {
MiniMax api = new MiniMax(replying(
"{\"choices\":[{\"message\":{\"tool_calls\":[{\"id\":\"1\",\"function\":"
+ "{\"name\":\"buscar_wiki\",\"arguments\":\"{\\\"termo\\\":\\\"Camelo\\\"}\"}}]}}]}"));
assertEquals("Camelo", api.searchTerm("k", "m", "como pego um camelo?"));
}
@Test
void missingToolCallYieldsNull() {
MiniMax api = new MiniMax(replying(
"{\"choices\":[{\"message\":{\"content\":\"sei lá\"}}]}"));
assertNull(api.searchTerm("k", "m", "oi"));
}
// --- request shape ------------------------------------------------------
/** Captures what was sent, so the request shape itself can be asserted. */
private static final class Recorder implements Fetcher {
String url;
String json;
String bearer;
private final String reply;
Recorder(String reply) {
this.reply = reply;
}
@Override
public String get(String u) {
throw new UnsupportedOperationException();
}
@Override
public String postJson(String u, String body, String token) {
this.url = u;
this.json = body;
this.bearer = token;
return reply;
}
}
@Test
void forcesTheBuscarWikiFunctionAtTemperatureZero() {
Recorder recorder = new Recorder("{\"choices\":[]}");
new MiniMax(recorder).searchTerm("k", "MiniMax-M2.7", "onde acho diamante?");
JsonObject body = JsonParser.parseString(recorder.json).getAsJsonObject();
assertEquals(0.0, body.get("temperature").getAsDouble(),
"term selection must be deterministic");
assertEquals(500, body.get("max_tokens").getAsInt());
// A regression that sent only the system prompt would still produce a
// tool call — just an unguided one — and every other test would pass.
JsonArray messages = body.getAsJsonArray("messages");
assertEquals(2, messages.size());
assertEquals("user", messages.get(1).getAsJsonObject().get("role").getAsString());
assertEquals("onde acho diamante?",
messages.get(1).getAsJsonObject().get("content").getAsString());
JsonObject function = body.getAsJsonArray("tools").get(0).getAsJsonObject()
.getAsJsonObject("function");
assertEquals("buscar_wiki", function.get("name").getAsString());
JsonObject parameters = function.getAsJsonObject("parameters");
assertEquals("object", parameters.get("type").getAsString());
assertTrue(parameters.getAsJsonObject("properties").has("termo"));
assertEquals("string", parameters.getAsJsonObject("properties")
.getAsJsonObject("termo").get("type").getAsString());
assertEquals("termo", parameters.getAsJsonArray("required").get(0).getAsString());
// "auto" let the model skip the search on exactly the questions it was
// most likely to get wrong, so the function is named explicitly.
JsonObject choice = body.getAsJsonObject("tool_choice");
assertEquals("function", choice.get("type").getAsString());
assertEquals("buscar_wiki", choice.getAsJsonObject("function").get("name").getAsString());
}
@Test
void answerSendsNoToolsAndCarriesItsOwnBudget() {
Recorder recorder = new Recorder("{\"choices\":[{\"message\":{\"content\":\"ok\"}}]}");
new MiniMax(recorder).answer("k", "MiniMax-M2.7",
List.of(new MiniMax.Turn("system", "regras"), new MiniMax.Turn("user", "oi")),
1200, 0.3);
JsonObject body = JsonParser.parseString(recorder.json).getAsJsonObject();
assertFalse(body.has("tools"), "the answering call must give the model nothing to call");
assertFalse(body.has("tool_choice"));
assertEquals("MiniMax-M2.7", body.get("model").getAsString());
assertEquals(1200, body.get("max_tokens").getAsInt());
assertEquals(0.3, body.get("temperature").getAsDouble());
assertEquals(2, body.getAsJsonArray("messages").size());
assertEquals("system", body.getAsJsonArray("messages").get(0).getAsJsonObject()
.get("role").getAsString());
}
@Test
void postsToTheDefaultEndpointUnlessOneIsGiven() {
Recorder standard = new Recorder("{\"choices\":[{\"message\":{\"content\":\"ok\"}}]}");
new MiniMax(standard).answer("k", "m", List.of(), 1200, 0.3);
assertEquals(MiniMax.URL, standard.url);
Recorder regional = new Recorder("{\"choices\":[{\"message\":{\"content\":\"ok\"}}]}");
new MiniMax(regional, "https://api.minimaxi.com/v1/text/chatcompletion_v2", warn -> {
}).answer("k", "m", List.of(), 1200, 0.3);
assertEquals("https://api.minimaxi.com/v1/text/chatcompletion_v2", regional.url);
}
// --- failures are reported, not swallowed -------------------------------
@Test
void reportsTransportFailureRatherThanFailingSilently() {
List<String> warnings = new ArrayList<>();
MiniMax api = new MiniMax(new Fetcher() {
@Override
public String get(String url) {
throw new UnsupportedOperationException();
}
@Override
public String postJson(String url, String json, String bearer) throws IOException {
throw new IOException("HTTP 401: {\"base_resp\":{\"status_code\":1004}}");
}
}, null, warnings::add);
assertNull(api.answer("k", "m", List.of(), 1200, 0.3));
assertEquals(1, warnings.size(), warnings.toString());
assertTrue(warnings.get(0).contains("401"), warnings.get(0));
}
@Test
void reportsARefusalDistinctlyFromATransportFailure() {
List<String> warnings = new ArrayList<>();
MiniMax api = new MiniMax(replying(
"{\"base_resp\":{\"status_code\":1008,\"status_msg\":\"sem saldo\"}}"),
null, warnings::add);
assertNull(api.answer("k", "m", List.of(), 1200, 0.3));
assertEquals(1, warnings.size(), warnings.toString());
assertTrue(warnings.get(0).contains("1008"), warnings.get(0));
}
@Test
void reportsAnEmptyAnswerBecauseThatMeansTheBudgetWasTooSmall() {
List<String> warnings = new ArrayList<>();
MiniMax api = new MiniMax(replying("{\"choices\":[{\"message\":{\"content\":\"\"}}]}"),
null, warnings::add);
assertNull(api.answer("k", "m", List.of(), 400, 0.3));
assertEquals(1, warnings.size(), warnings.toString());
assertTrue(warnings.get(0).contains("400"), warnings.get(0));
}
/**
* The key travels in the Authorization header and must never reach a log
* line. Every failure path is exercised with a distinctive key and the
* warnings are searched for it.
*/
@Test
void theApiKeyNeverReachesAWarning() {
String key = "sk-canalhandia-segredo-nao-vaze";
List<String> warnings = new ArrayList<>();
Recorder recorder = new Recorder("{\"base_resp\":{\"status_code\":1004,"
+ "\"status_msg\":\"invalid api key\"}}");
MiniMax refused = new MiniMax(recorder, null, warnings::add);
assertNull(refused.answer(key, "m", List.of(new MiniMax.Turn("user", "oi")), 1200, 0.3));
assertNull(refused.searchTerm(key, "m", "oi"));
MiniMax broken = new MiniMax(new Fetcher() {
@Override
public String get(String url) {
throw new UnsupportedOperationException();
}
@Override
public String postJson(String url, String json, String bearer) throws IOException {
throw new IOException("connection reset");
}
}, null, warnings::add);
assertNull(broken.answer(key, "m", List.of(), 1200, 0.3));
assertNull(broken.searchTerm(key, "m", "oi"));
MiniMax garbage = new MiniMax(replying("not json at all"), null, warnings::add);
assertNull(garbage.answer(key, "m", List.of(), 1200, 0.3));
assertFalse(warnings.isEmpty());
for (String warning : warnings) {
assertFalse(warning.contains(key), "key leaked into a log line: " + warning);
}
// It does reach the header, which is the only place it belongs.
assertEquals(key, recorder.bearer);
assertFalse(recorder.json.contains(key), "key leaked into the request body");
}
/**
* A key with a stray newline — two lines pasted into minimax.key, which
* {@code trim()} does not fix — makes the JDK reject the Authorization
* header with an {@code IllegalArgumentException} whose message quotes the
* whole header value back, key included. Measured on temurin-25:
* {@code invalid header value: "Bearer sk-..."}. Logging that exception
* verbatim would print the key.
*/
@Test
void aKeyThatBreaksTheHeaderIsRedactedFromTheWarning() {
String key = "sk-canalhandia-segredo" + (char) 10 + "linha2";
List<String> warnings = new ArrayList<>();
MiniMax api = new MiniMax(new Fetcher() {
@Override
public String get(String url) {
throw new UnsupportedOperationException();
}
@Override
public String postJson(String url, String json, String bearer) {
throw new IllegalArgumentException("invalid header value: \"Bearer " + bearer + "\"");
}
}, null, warnings::add);
assertNull(api.answer(key, "m", List.of(), 1200, 0.3));
assertEquals(1, warnings.size(), warnings.toString());
assertFalse(warnings.get(0).contains("segredo"), warnings.get(0));
}
/**
* JSON that parses but has the wrong shape. Gson throws unchecked on these,
* and {@code message()} runs outside {@code post()}'s try, so without type
* checks they escape to the async Bukkit worker as a bare stack trace
* rather than the null the API promises.
*/
@Test
void malformedButParseableResponsesReturnNullRatherThanThrowing() {
String[] shapes = {
"{\"choices\":[\"texto\"]}",
"{\"base_resp\":\"texto\"}",
"{\"choices\":[{\"message\":\"texto\"}]}",
"{\"base_resp\":{\"status_code\":\"nao-e-numero\"},\"choices\":[]}",
"{\"choices\":{\"nao\":\"array\"}}",
"[]",
"\"apenas uma string\"",
"null",
};
for (String shape : shapes) {
MiniMax api = new MiniMax(replying(shape), null, w -> {
});
assertNull(api.answer("k", "m", List.of(), 1200, 0.3), shape);
assertNull(api.searchTerm("k", "m", "oi"), shape);
}
}
/** Some OpenAI-compatible servers return content as an array of parts. */
@Test
void nonStringContentIsNullRatherThanThrowing() {
MiniMax api = new MiniMax(replying(
"{\"choices\":[{\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"oi\"}]}}]}"));
assertNull(api.answer("k", "m", List.of(), 1200, 0.3));
}
@Test
void theFailureWarningNamesTheHostSoAWrongRegionIsDiagnosable() {
List<String> warnings = new ArrayList<>();
new MiniMax(new Fetcher() {
@Override
public String get(String url) {
throw new UnsupportedOperationException();
}
@Override
public String postJson(String url, String json, String bearer) throws IOException {
throw new IOException("HTTP 401");
}
}, "https://api.minimaxi.com/v1/text/chatcompletion_v2", warnings::add)
.answer("k", "m", List.of(), 1200, 0.3);
assertTrue(warnings.get(0).contains("api.minimaxi.com"), warnings.get(0));
}
// --- malformed tool calls -----------------------------------------------
@Test
void malformedToolArgumentsYieldNullRatherThanThrowing() {
List<String> warnings = new ArrayList<>();
MiniMax api = new MiniMax(replying(
"{\"choices\":[{\"message\":{\"tool_calls\":[{\"id\":\"1\",\"function\":"
+ "{\"name\":\"buscar_wiki\",\"arguments\":\"nao sou json\"}}]}}]}"),
null, warnings::add);
assertNull(api.searchTerm("k", "m", "oi"));
assertEquals(1, warnings.size(), warnings.toString());
}
@Test
void emptyToolCallsListYieldsNull() {
MiniMax api = new MiniMax(replying(
"{\"choices\":[{\"message\":{\"tool_calls\":[]}}]}"));
assertNull(api.searchTerm("k", "m", "oi"));
}
@Test
void blankTermYieldsNull() {
MiniMax api = new MiniMax(replying(
"{\"choices\":[{\"message\":{\"tool_calls\":[{\"function\":"
+ "{\"name\":\"buscar_wiki\",\"arguments\":\"{\\\"termo\\\":\\\" \\\"}\"}}]}}]}"));
assertNull(api.searchTerm("k", "m", "oi"));
}
@Test
void trimsTheTerm() {
MiniMax api = new MiniMax(replying(
"{\"choices\":[{\"message\":{\"tool_calls\":[{\"function\":"
+ "{\"name\":\"buscar_wiki\",\"arguments\":\"{\\\"termo\\\":\\\" Creeper \\\"}\"}}]}}]}"));
assertEquals("Creeper", api.searchTerm("k", "m", "oi"));
}
}
@@ -0,0 +1,222 @@
package dev.marcospaulo.canalhandia;
import org.bukkit.Material;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/**
* Covers the pure half of {@link RecipeBook}. The other half calls
* {@code Bukkit.recipeIterator()} and is verified on a running server.
*
* <p>Nothing here may touch {@code Material.isItem()}: it throws
* {@code ExceptionInInitializerError} off a server, which is why
* {@link RecipeBook} resolves materials by name instead of filtering by it.
*/
class RecipeBookTest {
@Test
void recognisesTheWaysPlayersAskForARecipe() {
assertTrue(RecipeBook.isRecipeQuestion("como faço uma espada de diamante"));
assertTrue(RecipeBook.isRecipeQuestion("qual a receita da bigorna"));
assertTrue(RecipeBook.isRecipeQuestion("como fazer um baú"));
assertTrue(RecipeBook.isRecipeQuestion("como criar um funil"));
assertTrue(RecipeBook.isRecipeQuestion("como craftar um escudo"));
assertTrue(RecipeBook.isRecipeQuestion("como se faz pão"));
}
@Test
void acceptsUnaccentedAndShoutedSpellings() {
// Players type without accents constantly, and the cedilla in "faço"
// is the single most common word in these questions.
assertTrue(RecipeBook.isRecipeQuestion("como faco uma cama"));
assertTrue(RecipeBook.isRecipeQuestion("COMO FAÇO UMA CAMA"));
assertTrue(RecipeBook.isRecipeQuestion("Qual A RECEITA do bolo"));
}
@Test
void ignoresQuestionsThatAreNotAboutRecipes() {
assertFalse(RecipeBook.isRecipeQuestion("quantos jogadores estão online"));
assertFalse(RecipeBook.isRecipeQuestion("onde encontro diamante"));
assertFalse(RecipeBook.isRecipeQuestion("o que come um camelo"));
assertFalse(RecipeBook.isRecipeQuestion(null));
}
@Test
void stripsThePreambleToLeaveTheSubject() {
assertEquals("espada de diamante",
RecipeBook.subject("como faço uma espada de diamante"));
assertEquals("picareta de ferro",
RecipeBook.subject("qual a receita da picareta de ferro"));
assertEquals("baú", RecipeBook.subject("como fazer um baú?"));
assertEquals("funil", RecipeBook.subject("como criar um funil"));
}
@Test
void keepsInnerPrepositionsWhileStrippingLeadingOnes() {
// "de" is leading noise in "receita de ferro" but load-bearing inside
// "espada de diamante". Stripping stops at the first real word.
assertEquals("espada de diamante", RecipeBook.subject("receita de espada de diamante"));
}
@Test
void yieldsNoSubjectWhenTheQuestionIsAllPreamble() {
// Searching the wiki for "como faz" would return an unrelated article
// and ground the answer on it.
assertEquals("", RecipeBook.subject("como se faz?"));
assertEquals("", RecipeBook.subject(""));
assertEquals("", RecipeBook.subject(null));
}
@Test
void translatesEnglishTitlesOntoMaterials() {
assertEquals(Material.DIAMOND_SWORD, RecipeBook.materialFor("Diamond Sword"));
assertEquals(Material.HOPPER, RecipeBook.materialFor("Hopper"));
assertEquals(Material.ANVIL, RecipeBook.materialFor("Anvil"));
assertEquals(Material.MAGMA_CREAM, RecipeBook.materialFor("Magma Cream"));
}
@Test
void resolvesTheHandCheckedAliases() {
assertEquals(Material.REPEATER, RecipeBook.materialFor("Redstone Repeater"));
assertEquals(Material.WRITABLE_BOOK, RecipeBook.materialFor("Book and Quill"));
assertEquals(Material.CHEST_MINECART, RecipeBook.materialFor("Minecart with Chest"));
assertEquals(Material.RABBIT_FOOT, RecipeBook.materialFor("Rabbit's Foot"));
assertEquals(Material.JACK_O_LANTERN, RecipeBook.materialFor("Jack o'Lantern"));
}
@Test
void neverResolvesAQualifiedTitleToTheWrongItem() {
// Every one of these was returned by a longest-substring fallback, and
// every one is a plausible recipe for something the player did not ask
// about — the exact failure this feature exists to remove. Null is the
// correct answer: no grounding beats confident grounding on the wrong
// item. These must never resolve by inference, only by ALIASES above.
assertNotEquals(Material.REDSTONE, RecipeBook.materialFor("Redstone Repeater"));
assertNotEquals(Material.BOOK, RecipeBook.materialFor("Book and Quill"));
assertNotEquals(Material.MINECART, RecipeBook.materialFor("Minecart with Chest"));
assertNotEquals(Material.RABBIT, RecipeBook.materialFor("Rabbit's Foot"));
assertNull(RecipeBook.materialFor("Chestplate"));
assertNull(RecipeBook.materialFor("Water Bottle"));
assertNull(RecipeBook.materialFor("Diamond Sword (item)"));
}
@Test
void colourFamiliesResolveToNullRatherThanAnArbitraryVariant() {
// There is no Material.BED, only WHITE_BED and fifteen siblings. A
// suffix rule would pick one at random — and would also resolve "axe"
// from "pickaxe", which is the same bug in a worse place.
assertNull(RecipeBook.materialFor("Bed"));
assertNull(RecipeBook.materialFor("Wool"));
}
@Test
void returnsNoMaterialWhenTheTitleNamesNone() {
assertNull(RecipeBook.materialFor("Enchanting"));
assertNull(RecipeBook.materialFor("Pocket Edition v0.2.0 alpha"));
assertNull(RecipeBook.materialFor(null));
assertNull(RecipeBook.materialFor(" "));
}
@Test
void answersTheFireResistancePotionThatMotivatedThisFeature() {
// The exact question that failed while fully grounded on the wiki.
String answer = RecipeBook.brewing("como faço poção de resistência ao fogo");
assertNotNull(answer, "the question this whole task exists for");
assertTrue(answer.contains("Creme de Magma"), answer);
assertTrue(answer.contains("Fungo do Nether"), answer);
}
@Test
void picksTheLongestBrewingKeySoShorterOnesDoNotShadowIt() {
// "fogo" and "resistencia" both appear; only the full key is right.
String answer = RecipeBook.brewing("receita da pocao de resistencia ao fogo");
assertNotNull(answer);
assertTrue(answer.contains("Creme de Magma"), answer);
}
@Test
void coversTheBrewsThatDoNotStartFromTheAwkwardPotion() {
assertTrue(RecipeBook.brewing("como faço poção de fraqueza")
.contains("Olho de Aranha Fermentado"));
assertTrue(RecipeBook.brewing("como faço poção de invisibilidade")
.contains("Visão Noturna"));
assertTrue(RecipeBook.brewing("receita da poção de dano instantâneo")
.contains("Cura"));
}
@Test
void explainsTheBrewingModifiers() {
String answer = RecipeBook.brewing("como faço poção de força");
assertTrue(answer.contains("Redstone"), answer);
assertTrue(answer.contains("Pólvora"), answer);
}
@Test
void returnsNoBrewingForNonPotionQuestions() {
assertNull(RecipeBook.brewing("como faço uma espada de diamante"));
assertNull(RecipeBook.brewing("como fazer uma cama"));
assertNull(RecipeBook.brewing(null));
}
@Test
void doesNotHijackQuestionsThatMerelyContainAnEffectName() {
// "salto" is inside basalto, "cura" inside curar, "forca" inside
// reforcar. describe() answers brewing first, so a false positive here
// takes over the entire answer.
assertNull(RecipeBook.brewing("como faço basalto"));
assertNull(RecipeBook.brewing("como faço para curar um aldeão zumbi"));
assertNull(RecipeBook.brewing("como faço para reforçar a base"));
}
@Test
void stillMatchesWhenTheEffectNameIsAWholeWord() {
assertNotNull(RecipeBook.brewing("como faço poção de salto"));
assertNotNull(RecipeBook.brewing("como faço uma poção de cura"));
}
@Test
void usesTheInGameItemNamesPlayersActuallySee() {
// Frasco de Água, not Garrafa de Água — the latter is the empty bottle.
assertTrue(RecipeBook.brewing("como faço poção de cura").contains("Frasco de Água"));
assertTrue(RecipeBook.brewing("como faço poção de salto").contains("Pé de Coelho"));
assertTrue(RecipeBook.brewing("como faço poção de cura")
.contains("Fatia de Melancia Reluzente"));
// Bafo do Dragão, not "Fogo do Dragão", which is not an item at all and
// would send a player hunting for something that does not exist.
assertTrue(RecipeBook.brewing("como faço poção de força").contains("Bafo do Dragão"));
}
@Test
void findsThePotionsUnderTheirInGameNames() {
// The item is "Poção de Agilidade"; Velocidade is the effect's name.
assertNotNull(RecipeBook.brewing("como faço poção de agilidade"));
// The item is "Poção de Dano"; players rarely type "Dano Instantâneo".
assertNotNull(RecipeBook.brewing("como faço poção de dano"));
// Hyphenated exactly as the game writes it.
assertNotNull(RecipeBook.brewing("como faço poção do mestre-tartaruga"));
}
@Test
void everyBrewingKeyIsMatchable() {
// A key with an accent or an uppercase letter can never match, because
// lookup compares against accent-free lowercase text. Nothing else
// would notice: the entry would just silently never fire.
for (String key : RecipeBook.brewingKeys()) {
assertEquals(key.toLowerCase(java.util.Locale.ROOT), key, "key must be lowercase");
assertEquals(java.text.Normalizer.normalize(key, java.text.Normalizer.Form.NFD), key,
"key must be accent-free: " + key);
assertFalse(key.contains("-"), "hyphens are normalised to spaces: " + key);
assertNotNull(RecipeBook.brewing("como faço poção de " + key),
"key should match its own question: " + key);
}
}
// Note: describeChoice cannot be covered here after all. Constructing a
// RecipeChoice.MaterialChoice initialises org.bukkit.Registry, which needs
// a running server, and the class is sealed so it cannot be subclassed or
// faked either. MAX_CHOICES truncation and ingredient dedup are therefore
// verified in Task 13 along with the rest of the recipe rendering.
}
@@ -0,0 +1,18 @@
package dev.marcospaulo.canalhandia;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Guards the test harness itself. The rest of the suite is written test-first, so
* a silently broken harness (surefire not discovering JUnit 5, wrong JDK) would
* read as "all tests pass" instead of "no tests ran". This one is here to fail loudly.
*/
class SanityTest {
@Test
void harnessRuns() {
assertEquals(2, 1 + 1);
}
}
@@ -0,0 +1,375 @@
package dev.marcospaulo.canalhandia;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class WikiTest {
/** Returns canned bodies keyed by a substring of the requested URL. */
private static Fetcher stub(Map<String, String> byUrlFragment) {
return new Fetcher() {
@Override
public String get(String url) {
for (Map.Entry<String, String> e : byUrlFragment.entrySet()) {
if (url.contains(e.getKey())) {
return e.getValue();
}
}
throw new AssertionError("unexpected url: " + url);
}
@Override
public String postJson(String url, String json, String bearer) {
throw new UnsupportedOperationException();
}
};
}
@Test
void findsArticleAndReturnsFullText() {
Wiki wiki = new Wiki(stub(Map.of(
"list=search", "{\"query\":{\"search\":[{\"title\":\"Camelo\"}]}}",
"prop=extracts", "{\"query\":{\"pages\":{\"1\":{\"title\":\"Camelo\","
+ "\"extract\":\"Um camelo pode ser equipado com uma sela.\"}}}}")),
7000);
Wiki.Article article = wiki.lookup("Camelo");
assertNotNull(article);
assertEquals("Camelo", article.title());
assertTrue(article.text().contains("sela"));
}
@Test
void returnsNullWhenNothingMatches() {
Wiki wiki = new Wiki(stub(Map.of(
"list=search", "{\"query\":{\"search\":[]}}")), 7000);
assertNull(wiki.lookup("asdfghjkl"));
}
@Test
void truncatesLongArticles() {
String longText = "x".repeat(9000);
Wiki wiki = new Wiki(stub(Map.of(
"list=search", "{\"query\":{\"search\":[{\"title\":\"T\"}]}}",
"prop=extracts", "{\"query\":{\"pages\":{\"1\":{\"title\":\"T\","
+ "\"extract\":\"" + longText + "\"}}}}")), 100);
assertEquals(100, wiki.lookup("T").text().length());
}
@Test
void cachesByTitleSoRepeatQuestionsCostOneFetch() {
int[] calls = {0};
Fetcher counting = new Fetcher() {
@Override
public String get(String url) {
calls[0]++;
return url.contains("list=search")
? "{\"query\":{\"search\":[{\"title\":\"Creeper\"}]}}"
: "{\"query\":{\"pages\":{\"1\":{\"title\":\"Creeper\",\"extract\":\"polvora\"}}}}";
}
@Override
public String postJson(String url, String json, String bearer) {
throw new UnsupportedOperationException();
}
};
Wiki wiki = new Wiki(counting, 7000);
wiki.lookup("Creeper");
int afterFirst = calls[0];
wiki.lookup("Creeper");
assertEquals(afterFirst, calls[0], "second lookup should be served from cache");
}
@Test
void networkFailureYieldsNullRatherThanThrowing() {
Wiki wiki = new Wiki(new Fetcher() {
@Override
public String get(String url) throws java.io.IOException {
throw new java.io.IOException("boom");
}
@Override
public String postJson(String url, String json, String bearer) {
throw new UnsupportedOperationException();
}
}, 7000);
assertNull(wiki.lookup("Camelo"));
}
// --- Added in review. The stub above matches on URL substrings, so nothing
// yet asserted what the request URLs actually say: dropping explaintext=1
// would feed the model raw HTML as its source of truth, and switching back
// to raw concatenation would throw on the accented terms that are the norm
// in Portuguese, both while every test above stayed green. ---
/** Records every requested URL, then answers by URL fragment. */
private static Fetcher recording(java.util.List<String> seen, Map<String, String> byUrlFragment) {
return new Fetcher() {
@Override
public String get(String url) {
seen.add(url);
for (Map.Entry<String, String> e : byUrlFragment.entrySet()) {
if (url.contains(e.getKey())) {
return e.getValue();
}
}
throw new AssertionError("unexpected url: " + url);
}
@Override
public String postJson(String url, String json, String bearer) {
throw new UnsupportedOperationException();
}
};
}
private static Fetcher answering(Map<String, String> byUrlFragment) {
return recording(new java.util.ArrayList<>(), byUrlFragment);
}
@Test
void percentEncodesAccentedTerms() {
java.util.List<String> seen = new java.util.ArrayList<>();
Wiki wiki = new Wiki(recording(seen, Map.of(
"list=search", "{\"query\":{\"search\":[{\"title\":\"Poção\"}]}}",
"prop=extracts", "{\"query\":{\"pages\":{\"1\":{\"title\":\"Poção\","
+ "\"extract\":\"Uma poção de cura.\"}}}}")), 7000);
assertNotNull(wiki.lookup("poção"));
assertTrue(seen.get(0).contains("srsearch=po%C3%A7%C3%A3o"), seen.get(0));
assertTrue(seen.get(1).contains("titles=Po%C3%A7%C3%A3o"), seen.get(1));
}
@Test
void asksForTheFullPlainTextArticle() {
java.util.List<String> seen = new java.util.ArrayList<>();
Wiki wiki = new Wiki(recording(seen, Map.of(
"list=search", "{\"query\":{\"search\":[{\"title\":\"Camelo\"}]}}",
"prop=extracts", "{\"query\":{\"pages\":{\"1\":{\"title\":\"Camelo\","
+ "\"extract\":\"texto\"}}}}")), 7000);
wiki.lookup("Camelo");
assertTrue(seen.get(0).contains("srlimit=1"), seen.get(0));
assertTrue(seen.get(1).contains("explaintext=1"), seen.get(1));
assertFalse(seen.get(1).contains("exintro"),
"lead paragraphs alone made the model answer 'não tenho certeza'");
}
@Test
void cacheIgnoresCase() {
int[] calls = {0};
Fetcher counting = new Fetcher() {
@Override
public String get(String url) {
calls[0]++;
return url.contains("list=search")
? "{\"query\":{\"search\":[{\"title\":\"Creeper\"}]}}"
: "{\"query\":{\"pages\":{\"1\":{\"title\":\"Creeper\",\"extract\":\"polvora\"}}}}";
}
@Override
public String postJson(String url, String json, String bearer) {
throw new UnsupportedOperationException();
}
};
Wiki wiki = new Wiki(counting, 7000);
wiki.lookup("Creeper");
int afterFirst = calls[0];
assertNotNull(wiki.lookup("creeper"));
assertEquals(afterFirst, calls[0], "differing case is the same article");
}
@Test
void missingPageYieldsNull() {
Wiki wiki = new Wiki(answering(Map.of(
"list=search", "{\"query\":{\"search\":[{\"title\":\"Nada\"}]}}",
"prop=extracts", "{\"query\":{\"pages\":{\"-1\":{\"missing\":\"\"}}}}")), 7000);
assertNull(wiki.lookup("Nada"));
}
@Test
void nonJsonBodyYieldsNull() {
// What a blocked request really looks like: an HTML error page, HTTP 200.
Wiki wiki = new Wiki(answering(Map.of(
"list=search", "<html><head><title>403 Forbidden</title></head></html>")), 7000);
assertNull(wiki.lookup("Camelo"));
}
@Test
void malformedResponseYieldsNull() {
Wiki wiki = new Wiki(answering(Map.of(
"list=search", "{\"query\":{\"warnings\":{}}}")), 7000);
assertNull(wiki.lookup("Camelo"));
}
@Test
void reportsFailuresSoASilentlyBrokenWikiIsVisible() {
java.util.List<String> warnings = new java.util.ArrayList<>();
Wiki wiki = new Wiki(new Fetcher() {
@Override
public String get(String url) throws java.io.IOException {
throw new java.io.IOException("403 Forbidden");
}
@Override
public String postJson(String url, String json, String bearer) {
throw new UnsupportedOperationException();
}
}, 7000, warnings::add);
assertNull(wiki.lookup("Camelo"));
assertEquals(1, warnings.size(), warnings.toString());
assertTrue(warnings.get(0).contains("Camelo"), warnings.get(0));
assertTrue(warnings.get(0).contains("403"), warnings.get(0));
}
@Test
void clampsNonPositiveMaxChars() {
// A misconfigured 0 would make every substring throw, silently turning
// grounding off for good.
Wiki wiki = new Wiki(answering(Map.of(
"list=search", "{\"query\":{\"search\":[{\"title\":\"T\"}]}}",
"prop=extracts", "{\"query\":{\"pages\":{\"1\":{\"title\":\"T\","
+ "\"extract\":\"texto longo\"}}}}")), 0);
assertEquals(1, wiki.lookup("T").text().length());
}
@Test
void restoresTheInterruptFlag() {
Wiki wiki = new Wiki(new Fetcher() {
@Override
public String get(String url) throws InterruptedException {
throw new InterruptedException("disable");
}
@Override
public String postJson(String url, String json, String bearer) {
throw new UnsupportedOperationException();
}
}, 7000);
assertNull(wiki.lookup("Camelo"));
assertTrue(Thread.interrupted(), "the interrupt must survive the catch");
}
// --- englishTitle: Material names are English and players ask in
// Portuguese. Scanning material names for a substring of the question was
// measured at 1 hit in 20 real questions, so the translation runs through
// the wiki's interlanguage links instead. ---
@Test
void translatesAPortugueseTermToItsEnglishTitle() {
Wiki wiki = new Wiki(answering(Map.of(
"list=search", "{\"query\":{\"search\":[{\"title\":\"Espada de Diamante\"}]}}",
"prop=langlinks", "{\"query\":{\"pages\":{\"1\":{\"langlinks\":"
+ "[{\"lang\":\"en\",\"*\":\"Diamond Sword\"}]}}}}")), 7000);
assertEquals("Diamond Sword", wiki.englishTitle("espada de diamante"));
}
@Test
void asksOnlyForTheEnglishLink() {
java.util.List<String> seen = new java.util.ArrayList<>();
Wiki wiki = new Wiki(recording(seen, Map.of(
"list=search", "{\"query\":{\"search\":[{\"title\":\"Funil\"}]}}",
"prop=langlinks", "{\"query\":{\"pages\":{\"1\":{\"langlinks\":"
+ "[{\"lang\":\"en\",\"*\":\"Hopper\"}]}}}}")), 7000);
wiki.englishTitle("funil");
// Without lllang=en the response carries every language the article has,
// and the first one is not reliably English.
assertTrue(seen.get(1).contains("lllang=en"), seen.get(1));
assertTrue(seen.get(1).contains("titles=Funil"), seen.get(1));
}
@Test
void articlesWithoutAnEnglishLinkYieldNull() {
// Real case: "Mesa de Encantamento" has no langlink. No English title
// means no recipe grounding, which must not be guessed at.
Wiki wiki = new Wiki(answering(Map.of(
"list=search", "{\"query\":{\"search\":[{\"title\":\"Mesa de Encantamento\"}]}}",
"prop=langlinks", "{\"query\":{\"pages\":{\"1\":{\"title\":\"Mesa\"}}}}")), 7000);
assertNull(wiki.englishTitle("mesa de encantamento"));
}
@Test
void unknownTermYieldsNullWithoutAskingForLinks() {
Wiki wiki = new Wiki(answering(Map.of(
"list=search", "{\"query\":{\"search\":[]}}")), 7000);
// The stub throws on any URL it does not recognise, so a langlinks call
// here would fail the test rather than pass silently.
assertNull(wiki.englishTitle("asdfghjkl"));
}
@Test
void cachesTranslationsIncludingTheMisses() {
int[] calls = {0};
Fetcher counting = new Fetcher() {
@Override
public String get(String url) {
calls[0]++;
return url.contains("list=search")
? "{\"query\":{\"search\":[{\"title\":\"Mesa\"}]}}"
: "{\"query\":{\"pages\":{\"1\":{\"title\":\"Mesa\"}}}}";
}
@Override
public String postJson(String url, String json, String bearer) {
throw new UnsupportedOperationException();
}
};
Wiki wiki = new Wiki(counting, 7000);
assertNull(wiki.englishTitle("mesa"));
int afterFirst = calls[0];
assertNull(wiki.englishTitle("mesa"));
assertEquals(afterFirst, calls[0], "a term with no English title must not re-fetch");
}
@Test
void translationFailureYieldsNullAndIsReported() {
java.util.List<String> warnings = new java.util.ArrayList<>();
Wiki wiki = new Wiki(new Fetcher() {
@Override
public String get(String url) throws java.io.IOException {
throw new java.io.IOException("403 Forbidden");
}
@Override
public String postJson(String url, String json, String bearer) {
throw new UnsupportedOperationException();
}
}, 7000, warnings::add);
assertNull(wiki.englishTitle("funil"));
assertEquals(1, warnings.size(), warnings.toString());
assertTrue(warnings.get(0).contains("funil"), warnings.get(0));
}
@Test
void translationRestoresTheInterruptFlag() {
Wiki wiki = new Wiki(new Fetcher() {
@Override
public String get(String url) throws InterruptedException {
throw new InterruptedException("disable");
}
@Override
public String postJson(String url, String json, String bearer) {
throw new UnsupportedOperationException();
}
}, 7000);
assertNull(wiki.englishTitle("funil"));
assertTrue(Thread.interrupted(), "the interrupt must survive the catch");
}
}