fix: never resolve a recipe question to the wrong item
The substring fallback in materialFor returned plausible recipes for items the player did not ask about. "Redstone Repeater" tied on length and resolved to REDSTONE, handing someone asking about repeaters the recipe for redstone dust; "Book and Quill" gave BOOK, "Minecart with Chest" gave MINECART, "Rabbit's Foot" gave RABBIT, "Chestplate" gave CHEST. That is the exact failure this feature exists to remove, and it is worse than no answer because the model states it confidently. Dropped it. Exact match plus a hand-checked alias table, else null. No suffix rule either: "axe" is a suffix of "pickaxe", so tool and armour families would fail the same way. Beds and wool now return null, which is correct. brewing() matched keys as bare substrings and hijacked real questions: "salto" inside basalto, "cura" inside curar, "forca" inside reforcar. Since describe() answers brewing first, each took over the whole answer. Now gated on the question mentioning a potion, and matched on word boundaries. Table corrections: "Fogo do Dragao" is not an item, it is Bafo do Dragao giving a lingering potion; Frasco de Agua, not Garrafa de Agua, which is the empty bottle; Pe de Coelho; Fatia de Melancia Reluzente. Added the in-game item names players actually type (Agilidade, Dano) and stripped hyphens so Mestre-Tartaruga reaches the table. describeShapeless no longer emits a dangling "Sem formato: ". describeChoice could not be covered after all: constructing a MaterialChoice initialises org.bukkit.Registry, which needs a server, and the class is sealed so it cannot be faked. Verified in Task 13. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jSvSTrG4qsniLSr6TpC6
This commit is contained in:
@@ -84,9 +84,9 @@ final class RecipeBook {
|
||||
*/
|
||||
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",
|
||||
"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<String, String> 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.
|
||||
*
|
||||
* <p>Bare {@code contains} hijacked real questions: "salto" sits inside
|
||||
* <em>basalto</em>, so "como faço basalto" answered with the jump potion;
|
||||
* "cura" sits inside <em>curar</em>, so "como faço para curar um aldeão
|
||||
* zumbi" answered with the healing potion; "forca" sits inside
|
||||
* <em>reforcar</em>. Because {@link #describe} consults brewing first,
|
||||
* each of those took over the entire answer.
|
||||
*/
|
||||
private static final Map<Pattern, String> BREWING_PATTERNS = buildBrewingPatterns();
|
||||
|
||||
private static Map<Pattern, String> buildBrewingPatterns() {
|
||||
Map<Pattern, String> patterns = new LinkedHashMap<>();
|
||||
BREWING.forEach((key, value) ->
|
||||
patterns.put(Pattern.compile("\\b" + Pattern.quote(key) + "\\b"), value));
|
||||
return Collections.unmodifiableMap(patterns);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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<String, String> 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<Pattern, String> entry : BREWING_PATTERNS.entrySet()) {
|
||||
Pattern key = entry.getKey();
|
||||
if (key.matcher(q).find() && key.pattern().length() > bestLength) {
|
||||
bestLength = key.pattern().length();
|
||||
best = entry.getValue();
|
||||
}
|
||||
}
|
||||
@@ -220,36 +259,65 @@ final class RecipeBook {
|
||||
* 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".
|
||||
// and have no recipes.
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* English wiki titles that name a real item under a different word than the
|
||||
* enum uses. Only titles verified by hand belong here.
|
||||
*
|
||||
* <p>This exists because the obvious generalisation — falling back to the
|
||||
* longest material name contained in the title — is actively harmful. It
|
||||
* was measured returning confidently wrong items: "Redstone Repeater" tied
|
||||
* on length and resolved to {@code REDSTONE}, handing a player asking about
|
||||
* repeaters the recipe for redstone dust; "Book and Quill" gave
|
||||
* {@code BOOK}, "Minecart with Chest" gave {@code MINECART}, "Rabbit's Foot"
|
||||
* gave {@code RABBIT} (raw meat), "Chestplate" gave {@code CHEST}. A
|
||||
* plausible recipe for the wrong item is exactly the failure this whole
|
||||
* feature exists to remove, and it is worse than no answer, because the
|
||||
* model will state it confidently. A suffix rule fails the same way —
|
||||
* "axe" is a suffix of "pickaxe".
|
||||
*
|
||||
* <p>So: exact match, this table, or null.
|
||||
*/
|
||||
private static final Map<String, Material> ALIASES = Map.ofEntries(
|
||||
Map.entry("redstone repeater", Material.REPEATER),
|
||||
Map.entry("redstone comparator", Material.COMPARATOR),
|
||||
Map.entry("book and quill", Material.WRITABLE_BOOK),
|
||||
Map.entry("minecart with chest", Material.CHEST_MINECART),
|
||||
Map.entry("minecart with furnace", Material.FURNACE_MINECART),
|
||||
Map.entry("minecart with hopper", Material.HOPPER_MINECART),
|
||||
Map.entry("minecart with tnt", Material.TNT_MINECART),
|
||||
Map.entry("rabbit's foot", Material.RABBIT_FOOT),
|
||||
Map.entry("jack o'lantern", Material.JACK_O_LANTERN),
|
||||
Map.entry("steak", Material.COOKED_BEEF),
|
||||
Map.entry("eye of ender", Material.ENDER_EYE),
|
||||
Map.entry("nether quartz", Material.QUARTZ),
|
||||
Map.entry("bottle o' enchanting", Material.EXPERIENCE_BOTTLE),
|
||||
Map.entry("firework rocket", Material.FIREWORK_ROCKET),
|
||||
Map.entry("clock", Material.CLOCK));
|
||||
|
||||
/**
|
||||
* The material an English wiki title names, or null.
|
||||
*
|
||||
* <p>Exact match 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".
|
||||
* <p>Exact match, then {@link #ALIASES}. Nothing else: a title this cannot
|
||||
* resolve yields no grounding, which is the correct outcome. See the note on
|
||||
* {@link #ALIASES} for why there is no fuzzy fallback.
|
||||
*
|
||||
* <p>Colour and material families resolve to null on purpose. There is no
|
||||
* {@code Material.BED}, only {@code WHITE_BED} and its fifteen siblings, so
|
||||
* "cama" is not answered rather than answered with an arbitrary colour.
|
||||
*/
|
||||
static Material materialFor(String englishTitle) {
|
||||
if (englishTitle == null || englishTitle.isBlank()) {
|
||||
@@ -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<String> brewingKeys() {
|
||||
return Collections.unmodifiableSet(BREWING.keySet());
|
||||
}
|
||||
|
||||
/**
|
||||
* One ingredient slot, which may accept any of several materials.
|
||||
*
|
||||
* <p>Package-private rather than private: {@code MaterialChoice} needs no
|
||||
* running server, so this is the one part of the recipe rendering that can
|
||||
* be covered before Task 13.
|
||||
*/
|
||||
static String describeChoice(RecipeChoice choice) {
|
||||
List<String> names = new ArrayList<>();
|
||||
if (choice instanceof RecipeChoice.MaterialChoice materials) {
|
||||
for (Material material : materials.getChoices()) {
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user