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
This commit is contained in:
marcos
2026-08-05 15:40:36 +00:00
parent 0f3bdf209d
commit dba64df917
5 changed files with 755 additions and 6 deletions
@@ -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.
*
* <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", "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.
*
* <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: 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<String, String> 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}.
*
* <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;
/** The same keys, longest first, for the substring pass in {@link #materialFor}. */
private static final List<String> NAMES_LONGEST_FIRST;
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. Left in, "legacy bow" could outrank "bow".
if (!name.startsWith("legacy ")) {
byName.put(name, material);
}
}
BY_NAME = Map.copyOf(byName);
List<String> 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.
*
* <p>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.
*
* <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);
}
/** Reads the server's registry. Requires a running server. */
private static String craftingFor(Material material) {
List<String> lines = new ArrayList<>();
Iterator<Recipe> 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<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));
}
}
return "Sem formato: " + String.join(" + ", parts);
}
/** One ingredient slot, which may accept any of several materials. */
private 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('_', ' ');
}
}
@@ -22,8 +22,9 @@ import java.util.regex.Pattern;
* right, because the specifics live further down the page.
*
* <p>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<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 -> {
@@ -112,6 +123,82 @@ final class Wiki {
}
}
/**
* 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);
@@ -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.
*
* <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 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));
}
}
@@ -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<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");
}
}