diff --git a/docs/plans/2026-08-05-ia-improvements-design.md b/docs/plans/2026-08-05-ia-improvements-design.md index f56e911..f4302f4 100644 --- a/docs/plans/2026-08-05-ia-improvements-design.md +++ b/docs/plans/2026-08-05-ia-improvements-design.md @@ -97,8 +97,19 @@ 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. +**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; diff --git a/src/main/java/dev/marcospaulo/canalhandia/RecipeBook.java b/src/main/java/dev/marcospaulo/canalhandia/RecipeBook.java index b32a6e8..d1ae94e 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/RecipeBook.java +++ b/src/main/java/dev/marcospaulo/canalhandia/RecipeBook.java @@ -84,9 +84,9 @@ final class RecipeBook { */ 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", + "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", "pra fazer"); + "montar", "monta", "para", "pra", "no", "na", "minecraft"); private static final Pattern WHITESPACE = Pattern.compile("\\s+"); private static final Pattern ACCENTS = Pattern.compile("\\p{M}+"); @@ -117,9 +117,13 @@ final class RecipeBook { return String.join(" ", List.of(words).subList(start, words.length)); } - /** Lowercased and stripped of accents, so "Poção" and "pocao" compare equal. */ + /** + * 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); + String lower = text.toLowerCase(Locale.ROOT).replace('-', ' '); return ACCENTS.matcher(Normalizer.normalize(lower, Normalizer.Form.NFD)).replaceAll(""); } @@ -138,46 +142,74 @@ final class RecipeBook { private static final Map BREWING = new LinkedHashMap<>(); private static final String AWKWARD = - "Base: Garrafa de Água + Fungo do Nether (nether wart) = Poção Estranha."; + "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, " - + "Fogo do Dragão em persistente."; + + "Bafo do Dragão em prolongada."; static { - BREWING.put("estranha", "Poção Estranha: Garrafa de Água + Fungo do Nether. " + 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 Melancia Reluzente."); + 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."); - BREWING.put("velocidade", "Poção de Velocidade: " + AWKWARD + // "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("rapidez", "Poção de Rapidez (Velocidade): " + AWKWARD + 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 Pata de Coelho."); + 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 + 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. " + 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."); - BREWING.put("dano instantaneo", "Poção de Dano Instantâneo: faça Poção de Cura " + // "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. + * + *

Bare {@code contains} hijacked real questions: "salto" sits inside + * basalto, so "como faço basalto" answered with the jump potion; + * "cura" sits inside curar, so "como faço para curar um aldeão + * zumbi" answered with the healing potion; "forca" sits inside + * reforcar. Because {@link #describe} consults brewing first, + * each of those took over the entire answer. + */ + private static final Map BREWING_PATTERNS = buildBrewingPatterns(); + + private static Map buildBrewingPatterns() { + Map patterns = new LinkedHashMap<>(); + BREWING.forEach((key, value) -> + patterns.put(Pattern.compile("\\b" + Pattern.quote(key) + "\\b"), value)); + return Collections.unmodifiableMap(patterns); } /** @@ -189,12 +221,19 @@ final class RecipeBook { 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; - 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; + int bestLength = -1; + for (Map.Entry entry : BREWING_PATTERNS.entrySet()) { + Pattern key = entry.getKey(); + if (key.matcher(q).find() && key.pattern().length() > bestLength) { + bestLength = key.pattern().length(); best = entry.getValue(); } } @@ -220,36 +259,65 @@ final class RecipeBook { * 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". + // and have no recipes. 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); } + /** + * English wiki titles that name a real item under a different word than the + * enum uses. Only titles verified by hand belong here. + * + *

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

So: exact match, this table, or null. + */ + private static final Map 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. * - *

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

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

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()) { @@ -257,16 +325,7 @@ final class RecipeBook { } 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; + return exact != null ? exact : ALIASES.get(title); } // ------------------------------------------------------------------ @@ -347,11 +406,27 @@ final class RecipeBook { parts.add(describeChoice(choice)); } } - return "Sem formato: " + String.join(" + ", parts); + // 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); } - /** One ingredient slot, which may accept any of several materials. */ - private static String describeChoice(RecipeChoice choice) { + /** + * 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 brewingKeys() { + return Collections.unmodifiableSet(BREWING.keySet()); + } + + /** + * One ingredient slot, which may accept any of several materials. + * + *

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 names = new ArrayList<>(); if (choice instanceof RecipeChoice.MaterialChoice materials) { for (Material material : materials.getChoices()) { diff --git a/src/test/java/dev/marcospaulo/canalhandia/RecipeBookTest.java b/src/test/java/dev/marcospaulo/canalhandia/RecipeBookTest.java index ce1c35d..bb74f4a 100644 --- a/src/test/java/dev/marcospaulo/canalhandia/RecipeBookTest.java +++ b/src/test/java/dev/marcospaulo/canalhandia/RecipeBookTest.java @@ -77,15 +77,44 @@ class RecipeBookTest { } @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)")); + 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(" ")); } @@ -131,4 +160,63 @@ class RecipeBookTest { 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. }