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 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", "faz-se", + "criar", "crio", "cria", "craftar", "crafta", "crafto", "craftear", "fabricar", + "montar", "monta", "para", "pra", "no", "na", "minecraft", "pra fazer"); + + 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 and stripped of accents, so "Poção" and "pocao" compare equal. */ + private static String plain(String text) { + String lower = text.toLowerCase(Locale.ROOT); + 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. + * + *

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 BREWING = new LinkedHashMap<>(); + + private static final String AWKWARD = + "Base: Garrafa 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, " + + "Fogo do Dragão em persistente."; + + static { + BREWING.put("estranha", "Poção Estranha: Garrafa 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 Melancia Reluzente."); + BREWING.put("regeneracao", "Poção de Regeneração: " + AWKWARD + + " Depois adicione Lágrima de Ghast."); + BREWING.put("velocidade", "Poção de Velocidade: " + AWKWARD + + " Depois adicione Açúcar."); + BREWING.put("rapidez", "Poção de Rapidez (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 Pata 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: Garrafa 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."); + BREWING.put("dano instantaneo", "Poção de Dano Instantâneo: faça Poção de Cura " + + "e adicione Olho de Aranha Fermentado."); + } + + /** + * 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); + String best = null; + String bestKey = null; + for (Map.Entry entry : BREWING.entrySet()) { + String key = entry.getKey(); + if (q.contains(key) && (bestKey == null || key.length() > bestKey.length())) { + bestKey = key; + 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}. + * + *

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 BY_NAME; + /** The same keys, longest first, for the substring pass in {@link #materialFor}. */ + private static final List NAMES_LONGEST_FIRST; + + static { + Map 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. Left in, "legacy bow" could outrank "bow". + if (!name.startsWith("legacy ")) { + byName.put(name, material); + } + } + BY_NAME = Map.copyOf(byName); + List names = new ArrayList<>(byName.keySet()); + // Longest first, then alphabetically so equal-length ties resolve the + // same way on every JVM rather than following HashMap iteration order. + names.sort(Comparator.comparingInt(String::length).reversed() + .thenComparing(Comparator.naturalOrder())); + NAMES_LONGEST_FIRST = List.copyOf(names); + } + + /** + * The material an English wiki title names, or null. + * + *

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 lines = new ArrayList<>(); + Iterator it = Bukkit.recipeIterator(); + 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 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 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)); + } + } + return "Sem formato: " + String.join(" + ", parts); + } + + /** One ingredient slot, which may accept any of several materials. */ + private static String describeChoice(RecipeChoice choice) { + List 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 names, String name) { + if (!names.contains(name)) { + names.add(name); + } + } + + private static String pretty(Material material) { + return material.name().toLowerCase(Locale.ROOT).replace('_', ' '); + } +} diff --git a/src/main/java/dev/marcospaulo/canalhandia/Wiki.java b/src/main/java/dev/marcospaulo/canalhandia/Wiki.java index a61a694..5d8ef57 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Wiki.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Wiki.java @@ -22,8 +22,9 @@ import java.util.regex.Pattern; * right, because the specifics live further down the page. * *

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 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. + * + *

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 englishTitles = new LinkedHashMap<>(); Wiki(Fetcher fetcher, int maxChars) { this(fetcher, maxChars, message -> { @@ -112,6 +123,82 @@ final class Wiki { } } + /** + * The English wiki title for a Portuguese search term, or null if there is + * none. + * + *

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 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 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"); + } }