diff --git a/docs/plans/2026-08-05-ia-improvements-design.md b/docs/plans/2026-08-05-ia-improvements-design.md index 17937a2..f56e911 100644 --- a/docs/plans/2026-08-05-ia-improvements-design.md +++ b/docs/plans/2026-08-05-ia-improvements-design.md @@ -78,9 +78,37 @@ invented "poeira de guncotton" as a creeper drop. `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. Recipes therefore come from -`Bukkit.recipeIterator()` — the running server's own data, authoritative for -this exact version, free and instant. +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 (real case: "Mesa de +Encantamento") yield no grounding rather than a guess. + +**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 @@ -103,7 +131,8 @@ this exact version, free and instant. ├─ context assembly │ contexto.yml always server facts, hand written │ correcoes.yml on keyword match operator corrections - │ recipe lookup recipe questions Bukkit.recipeIterator() + │ 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 │ diff --git a/src/main/java/dev/marcospaulo/canalhandia/RecipeBook.java b/src/main/java/dev/marcospaulo/canalhandia/RecipeBook.java new file mode 100644 index 0000000..b32a6e8 --- /dev/null +++ b/src/main/java/dev/marcospaulo/canalhandia/RecipeBook.java @@ -0,0 +1,383 @@ +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. + * + *
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. + * + *
Crafting 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. + * + *
Brewing 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. + * + *
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 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 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.
+ *
+ * {@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 Exact match first: "Diamond Sword" is {@code DIAMOND_SWORD} and that is
+ * the overwhelmingly common case. The substring pass exists for titles the
+ * wiki qualifies — "Bow (weapon)", "Rail (transport)" — where the material
+ * name is present but the title is not only the material name. Longest match
+ * wins so "Diamond Sword (item)" resolves to the sword and not to "sword".
+ */
+ 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);
+ if (exact != null) {
+ return exact;
+ }
+ for (String name : NAMES_LONGEST_FIRST) {
+ // Short names match far too much inside a longer title.
+ if (name.length() > 3 && title.contains(name)) {
+ return BY_NAME.get(name);
+ }
+ }
+ return null;
+ }
+
+ // ------------------------------------------------------------------
+ // 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.
+ *
+ * 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);
+ }
+
+ /** Reads the server's registry. Requires a running server. */
+ private static String craftingFor(Material material) {
+ List Note that {@code explaintext} drops tables, so crafting and brewing
- * recipes never appear here. Those come from the running server's recipe
- * registry instead.
+ * 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 {
@@ -52,6 +53,16 @@ final class Wiki {
* The lock is never held across a network call.
*/
private final Map 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 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" → "Diamond Sword" uppercases straight onto the
+ * enum constant.
+ *
+ * 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);
diff --git a/src/test/java/dev/marcospaulo/canalhandia/RecipeBookTest.java b/src/test/java/dev/marcospaulo/canalhandia/RecipeBookTest.java
new file mode 100644
index 0000000..ce1c35d
--- /dev/null
+++ b/src/test/java/dev/marcospaulo/canalhandia/RecipeBookTest.java
@@ -0,0 +1,134 @@
+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.
+ *
+ * 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 prefersTheLongestMaterialNameInAQualifiedTitle() {
+ // Wiki titles are sometimes disambiguated. "sword" is also a material
+ // name, so the longest match has to win or the answer is wrong.
+ assertEquals(Material.DIAMOND_SWORD, RecipeBook.materialFor("Diamond Sword (item)"));
+ }
+
+ @Test
+ void returnsNoMaterialWhenTheTitleNamesNone() {
+ assertNull(RecipeBook.materialFor("Enchanting"));
+ 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));
+ }
+}
diff --git a/src/test/java/dev/marcospaulo/canalhandia/WikiTest.java b/src/test/java/dev/marcospaulo/canalhandia/WikiTest.java
index 181da9c..23e6962 100644
--- a/src/test/java/dev/marcospaulo/canalhandia/WikiTest.java
+++ b/src/test/java/dev/marcospaulo/canalhandia/WikiTest.java
@@ -256,4 +256,120 @@ class WikiTest {
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