From 9775a50b269b08efce75ddc6b8f8154e834509d4 Mon Sep 17 00:00:00 2001 From: Brian Neumann-Fopiano Date: Tue, 8 Sep 2026 04:49:11 -0400 Subject: [PATCH] Split schema property builder - Move registry catalogs, enum definitions and block-state groups out of SchemaBuilder into SchemaRegistryCatalog, SchemaEnums and SchemaBlockStates - Decompose buildProperty into SchemaPropertyBuilder per-type handlers with SchemaRegistryProperties owning the per-annotation registry chains - Report the font enumeration, registry list function and default value probe failures instead of swallowing them - Pin the generated studio schema digest for fifteen pack roots in SchemaBuilderIdentityTest --- .../iris/core/project/SchemaBlockStates.java | 136 +++ .../iris/core/project/SchemaBuilder.java | 810 +----------------- .../arcane/iris/core/project/SchemaEnums.java | 100 +++ .../core/project/SchemaPropertyBuilder.java | 269 ++++++ .../core/project/SchemaRegistryCatalog.java | 252 ++++++ .../project/SchemaRegistryProperties.java | 288 +++++++ .../project/SchemaBuilderIdentityTest.java | 395 +++++++++ 7 files changed, 1453 insertions(+), 797 deletions(-) create mode 100644 core/src/main/java/art/arcane/iris/core/project/SchemaBlockStates.java create mode 100644 core/src/main/java/art/arcane/iris/core/project/SchemaEnums.java create mode 100644 core/src/main/java/art/arcane/iris/core/project/SchemaPropertyBuilder.java create mode 100644 core/src/main/java/art/arcane/iris/core/project/SchemaRegistryCatalog.java create mode 100644 core/src/main/java/art/arcane/iris/core/project/SchemaRegistryProperties.java create mode 100644 core/src/test/java/art/arcane/iris/core/project/SchemaBuilderIdentityTest.java diff --git a/core/src/main/java/art/arcane/iris/core/project/SchemaBlockStates.java b/core/src/main/java/art/arcane/iris/core/project/SchemaBlockStates.java new file mode 100644 index 000000000..6d9f49148 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/project/SchemaBlockStates.java @@ -0,0 +1,136 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.project; + +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.PlatformBlockProperty; +import art.arcane.iris.spi.PlatformNumericRange; +import art.arcane.volmlib.util.json.JSONArray; +import art.arcane.volmlib.util.json.JSONObject; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +final class SchemaBlockStates { + private final SchemaBuilder builder; + + SchemaBlockStates(SchemaBuilder builder) { + this.builder = builder; + } + + void applyBlockStateMap(JSONObject prop, Field k, String blockType) { + prop.put("!top", true); + JSONArray any = new JSONArray(); + prop.put("anyOf", any); + + for (BlockStateGroup group : reconstructBlockStateGroups()) { + List blocks = group.blocks(); + if (blocks.isEmpty()) { + continue; + } + + String raw = blocks.get(0).replace(':', '_'); + String enumKey = "enum-block-state-" + raw; + String propertiesKey = "obj-block-state-" + raw; + + any.put(new JSONObject() + .put("if", new JSONObject() + .put("properties", new JSONObject() + .put(blockType, new JSONObject() + .put("type", "string") + .put("$ref", definitionReference(enumKey))))) + .put("then", new JSONObject() + .put("properties", new JSONObject() + .put(k.getName(), new JSONObject() + .put("type", "object") + .put("$ref", definitionReference(propertiesKey))))) + .put("else", false)); + + if (!builder.definitions.containsKey(enumKey)) { + JSONArray filters = new JSONArray(); + blocks.forEach(filters::put); + + builder.definitions.put(enumKey, new JSONObject() + .put("type", "string") + .put("enum", filters)); + } + + if (!builder.definitions.containsKey(propertiesKey)) { + JSONObject props = new JSONObject(); + for (PlatformBlockProperty property : group.properties()) { + props.put(property.name(), buildBlockPropertyJson(property)); + } + + builder.definitions.put(propertiesKey, new JSONObject() + .put("type", "object") + .put("properties", props)); + } + } + } + + private static String definitionReference(String key) { + return "#/definitions/" + key.replace("~", "~0").replace("/", "~1"); + } + + private static JSONObject buildBlockPropertyJson(PlatformBlockProperty property) { + JSONObject json = new JSONObject(); + json.put("type", property.jsonType()); + json.put("default", property.defaultValue()); + List allowed = property.allowedValues(); + if (!allowed.isEmpty()) { + json.put("enum", new JSONArray(allowed)); + } + if (property.hasNumericRange()) { + PlatformNumericRange range = property.numericRange(); + if ("integer".equals(property.jsonType())) { + json.put("minimum", (long) range.minimum()); + json.put("maximum", (long) range.maximum()); + } else { + json.put("minimum", range.minimum()); + json.put("maximum", range.maximum()); + } + json.put("exclusiveMinimum", range.exclusiveMinimum()); + json.put("exclusiveMaximum", range.exclusiveMaximum()); + } + return json; + } + + private static List reconstructBlockStateGroups() { + List groups = new ArrayList<>(); + List currentProperties = null; + List currentBlocks = null; + for (Map.Entry> entry : IrisPlatforms.get().registries().blockStateProperties().entrySet()) { + List value = entry.getValue(); + if (currentProperties != null && value == currentProperties) { + currentBlocks.add(entry.getKey()); + } else { + currentBlocks = new ArrayList<>(); + currentBlocks.add(entry.getKey()); + currentProperties = value; + groups.add(new BlockStateGroup(currentBlocks, value)); + } + } + return groups; + } + + private record BlockStateGroup(List blocks, List properties) { + } +} diff --git a/core/src/main/java/art/arcane/iris/core/project/SchemaBuilder.java b/core/src/main/java/art/arcane/iris/core/project/SchemaBuilder.java index 41c41b45e..bf197724a 100644 --- a/core/src/main/java/art/arcane/iris/core/project/SchemaBuilder.java +++ b/core/src/main/java/art/arcane/iris/core/project/SchemaBuilder.java @@ -18,76 +18,37 @@ package art.arcane.iris.core.project; -import art.arcane.iris.spi.CapabilityProbe; import art.arcane.iris.spi.IrisLogging; -import art.arcane.iris.spi.IrisPlatforms; -import art.arcane.iris.spi.PlatformBlockProperty; -import art.arcane.iris.spi.PlatformNumericRange; import art.arcane.iris.core.loader.IrisData; import art.arcane.iris.core.loader.IrisRegistrant; -import art.arcane.iris.core.loader.ResourceLoader; -import art.arcane.iris.core.structure.StructureSchemaKeys; -import art.arcane.iris.engine.framework.ListFunction; -import art.arcane.iris.engine.object.annotations.ArrayType; import art.arcane.iris.engine.object.annotations.Desc; -import art.arcane.iris.engine.object.annotations.MaxNumber; -import art.arcane.iris.engine.object.annotations.MinNumber; -import art.arcane.iris.engine.object.annotations.RegistryListBiome; -import art.arcane.iris.engine.object.annotations.RegistryListBlockType; -import art.arcane.iris.engine.object.annotations.RegistryListEnchantment; -import art.arcane.iris.engine.object.annotations.RegistryListEntityType; -import art.arcane.iris.engine.object.annotations.RegistryListFont; -import art.arcane.iris.engine.object.annotations.RegistryListFunction; -import art.arcane.iris.engine.object.annotations.RegistryListItemType; -import art.arcane.iris.engine.object.annotations.RegistryListNativeJigsawPool; -import art.arcane.iris.engine.object.annotations.RegistryListPotionEffect; -import art.arcane.iris.engine.object.annotations.RegistryListResource; -import art.arcane.iris.engine.object.annotations.RegistryListSpecialEntity; -import art.arcane.iris.engine.object.annotations.RegistryListStructure; -import art.arcane.iris.engine.object.annotations.RegistryListVanillaStructure; -import art.arcane.iris.engine.object.annotations.RegistryListVanillaStructureSet; -import art.arcane.iris.engine.object.annotations.RegistryMapBlockState; import art.arcane.iris.engine.object.annotations.Required; import art.arcane.iris.engine.object.annotations.Snippet; import art.arcane.volmlib.util.collection.KList; import art.arcane.volmlib.util.collection.KMap; import art.arcane.volmlib.util.json.JSONArray; import art.arcane.volmlib.util.json.JSONObject; -import org.jetbrains.annotations.NotNull; -import java.awt.GraphicsEnvironment; import java.lang.reflect.Field; import java.lang.reflect.InaccessibleObjectException; import java.lang.reflect.Modifier; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Locale; import java.util.Map; -import java.util.Set; -import java.util.function.Function; -import java.util.function.Supplier; public class SchemaBuilder { - private static final String SYMBOL_LIMIT__N = "*"; - private static final String SYMBOL_TYPE__N = ""; - private static final String MINECRAFT_NAMESPACE = "minecraft:"; - /** Namespaced key or family/namespace prefix: "minecraft:village_plains", "minecraft:village", "nova_structures:". */ - private static final String VANILLA_STRUCTURE_PREFIX_PATTERN = "^[a-z0-9_.-]+:[a-z0-9_./-]*$"; - private static volatile JSONArray fontTypes; - private final KMap definitions; + static final String SYMBOL_LIMIT__N = "*"; + static final String SYMBOL_TYPE__N = ""; + final KMap definitions; + final KList warnings; + final IrisData data; private final Class root; - private final KList warnings; - private final IrisData data; - private JSONArray potionTypes; - private JSONArray enchantTypes; + private final SchemaPropertyBuilder propertyBuilder; public SchemaBuilder(Class root, IrisData data) { this.data = data; warnings = new KList<>(); this.definitions = new KMap<>(); this.root = root; + this.propertyBuilder = new SchemaPropertyBuilder(this); } public JSONObject construct() { @@ -118,209 +79,7 @@ public class SchemaBuilder { return schema; } - /** - * Font families are only used for schema completion. Enumerating them touches AWT, which can fail outright on a - * headless or mod-loader JVM - a failure degrades to no completion, never to a broken schema. - */ - private static JSONArray fontTypes() { - JSONArray cached = fontTypes; - if (cached != null) { - return cached; - } - JSONArray built = CapabilityProbe.attempt("java.awt font families", - () -> new JSONArray(GraphicsEnvironment.getLocalGraphicsEnvironment().getAvailableFontFamilyNames()), - new JSONArray()); - fontTypes = built; - return built; - } - - private JSONArray potionTypes() { - if (potionTypes == null) { - potionTypes = registryKeyForms(IrisPlatforms.get().registries().potionEffectKeys(), true); - } - return potionTypes; - } - - private JSONArray enchantTypes() { - if (enchantTypes == null) { - enchantTypes = registryKeyForms(IrisPlatforms.get().registries().enchantmentKeys(), false); - } - return enchantTypes; - } - - /** - * Emits the full namespaced key for every registry entry, so mod and datapack content is addressable - * unambiguously, plus the legacy short form for the vanilla namespace so existing packs stay valid. - */ - private static JSONArray registryKeyForms(List keys, boolean upperCaseLegacy) { - JSONArray a = new JSONArray(); - Set seen = new LinkedHashSet<>(); - if (keys != null) { - for (String key : keys) { - if (key == null || key.isBlank()) { - continue; - } - seen.add(key); - if (key.startsWith(MINECRAFT_NAMESPACE) || key.indexOf(':') < 0) { - String path = stripNamespace(key); - seen.add(upperCaseLegacy ? path.toUpperCase(Locale.ROOT).replace(' ', '_') : path); - } - } - } - for (String key : seen) { - a.put(key); - } - return a; - } - - /** - * Biome derivatives resolve through NamespacedKey.fromString, which accepts a full key or a bare vanilla path, - * so both forms are offered. - */ - private JSONArray biomeTypes() { - return registryKeyForms(IrisPlatforms.get().registries().biomeKeys(), false); - } - - private JSONArray entityTypes() { - return keysAsArray(IrisPlatforms.get().registries().entityKeys()); - } - - private JSONArray specialEntityTypes() { - return keysAsArray(IrisPlatforms.get().registries().specialEntityKeys()); - } - - private JSONArray vanillaStructures() { - return keysAsArray(IrisPlatforms.get().registries().structureKeys()); - } - - private JSONArray vanillaStructureSets() { - if (IrisPlatforms.get().structureHooks() == null) { - return new JSONArray(); - } - List keys = IrisPlatforms.get().structureHooks().structureSetKeys(); - return keysAsArray(keys == null ? List.of() : keys); - } - - private JSONArray nativeJigsawPools() { - return keysAsArray(templatePoolKeys()); - } - - private static JSONArray keysAsArray(List keys) { - JSONArray a = new JSONArray(); - if (keys != null) { - for (String key : keys) { - if (key != null && !key.isBlank()) { - a.put(key); - } - } - } - return a; - } - - /** - * Registers a registry-backed enum definition under {@code definitionKey} and points {@code target} at it. - *

- * An empty key list means the registry has nothing to offer yet - the server is still booting, a modded registry - * has not been frozen, or the host simply does not expose that catalog. Emitting {@code "enum": []} in that case - * writes a schema that rejects every value the author could possibly type, turning a missing autocomplete list - * into a pack that reads as broken in the editor. The reference is omitted instead, leaving the field - * unconstrained, and the values are only computed when the definition does not exist yet. - */ - private void putRegistryEnumRef(JSONObject target, String definitionKey, Supplier values) { - if (!definitions.containsKey(definitionKey)) { - JSONArray built = values.get(); - if (built == null || built.length() == 0) { - IrisLogging.debug("Schema enum '" + definitionKey + "' omitted: the registry returned no keys"); - return; - } - JSONObject definition = new JSONObject(); - definition.put("enum", built); - definitions.put(definitionKey, definition); - } - target.put("$ref", "#/definitions/" + definitionKey); - } - - /** - * {@link #putRegistryEnumRef(JSONObject, String, Supplier)} for a list-typed property. When the enum is omitted no - * {@code items} schema is written at all, which is valid and simply means "any element". - */ - private void putRegistryEnumItems(JSONObject prop, String definitionKey, Supplier values) { - JSONObject items = new JSONObject(); - putRegistryEnumRef(items, definitionKey, values); - if (items.has("$ref")) { - prop.put("items", items); - } - } - - /** - * A registry enum that ALSO accepts family/namespace prefixes ("minecraft:village", - * "nova_structures:") — the runtime prefix-matching contract of importedStructures.disabled and - * adjustments[].match. Emitted as anyOf(enum, pattern) so autocomplete still offers registered - * keys while prefix entries validate instead of being rejected. - */ - private void putRegistryEnumOrPrefixRef(JSONObject target, String definitionKey, - String enumDefinitionKey, Supplier values, - String pattern) { - if (!definitions.containsKey(definitionKey)) { - JSONArray anyOf = new JSONArray(); - JSONObject enumRef = new JSONObject(); - try { - putRegistryEnumRef(enumRef, enumDefinitionKey, values); - } catch (RuntimeException e) { - IrisLogging.debug("Schema enum '" + enumDefinitionKey + "' unavailable (" - + e.getMessage() + "); emitting prefix pattern only"); - } - if (enumRef.has("$ref")) { - anyOf.put(enumRef); - } - JSONObject prefix = new JSONObject(); - prefix.put("type", "string"); - prefix.put("pattern", pattern); - anyOf.put(prefix); - JSONObject definition = new JSONObject(); - definition.put("anyOf", anyOf); - definitions.put(definitionKey, definition); - } - target.put("$ref", "#/definitions/" + definitionKey); - } - - private void putRegistryEnumOrPrefixItems(JSONObject prop, String definitionKey, - String enumDefinitionKey, Supplier values, - String pattern) { - JSONObject items = new JSONObject(); - putRegistryEnumOrPrefixRef(items, definitionKey, enumDefinitionKey, values, pattern); - prop.put("items", items); - } - - private JSONArray itemTypes() { - JSONArray a = new JSONArray(); - for (String key : IrisPlatforms.get().registries().itemKeys()) { - a.put(key.startsWith(MINECRAFT_NAMESPACE) ? key.substring(MINECRAFT_NAMESPACE.length()) : key); - } - return a; - } - - private JSONArray blockTypes() { - JSONArray a = new JSONArray(); - for (String i : data.getBlockLoader().getPossibleKeys()) { - a.put(i); - } - for (String i : IrisPlatforms.get().registries().blockTypeKeys()) { - a.put(i); - } - return a; - } - - private static String definitionReference(String key) { - return "#/definitions/" + key.replace("~", "~0").replace("/", "~1"); - } - - private static String stripNamespace(String key) { - int idx = key.indexOf(':'); - return idx < 0 ? key : key.substring(idx + 1); - } - - private JSONObject buildProperties(Class c) { + JSONObject buildProperties(Class c) { JSONObject o = new JSONObject(); JSONObject properties = new JSONObject(); String desc = getDescription(c); @@ -363,7 +122,7 @@ public class SchemaBuilder { continue; } - JSONObject property = buildProperty(k, c); + JSONObject property = propertyBuilder.buildProperty(k, c); if (Boolean.TRUE == property.remove("!top")) { extended.put(property); @@ -378,437 +137,7 @@ public class SchemaBuilder { } } - private JSONObject buildProperty(Field k, Class cl) { - JSONObject prop = new JSONObject(); - String type = getType(k.getType()); - KList description = new KList<>(); - prop.put("!required", k.isAnnotationPresent(Required.class)); - prop.put("type", type); - String fancyType = "Unknown Type"; - - switch (type) { - case "boolean" -> fancyType = "Boolean"; - case "integer" -> { - fancyType = "Integer"; - if (k.isAnnotationPresent(MinNumber.class)) { - int min = (int) k.getDeclaredAnnotation(MinNumber.class).value(); - prop.put("minimum", min); - description.add(SYMBOL_LIMIT__N + " Minimum allowed is " + min); - } - if (k.isAnnotationPresent(MaxNumber.class)) { - int max = (int) k.getDeclaredAnnotation(MaxNumber.class).value(); - prop.put("maximum", max); - description.add(SYMBOL_LIMIT__N + " Maximum allowed is " + max); - } - } - case "number" -> { - fancyType = "Number"; - if (k.isAnnotationPresent(MinNumber.class)) { - double min = k.getDeclaredAnnotation(MinNumber.class).value(); - prop.put("minimum", min); - description.add(SYMBOL_LIMIT__N + " Minimum allowed is " + min); - } - if (k.isAnnotationPresent(MaxNumber.class)) { - double max = k.getDeclaredAnnotation(MaxNumber.class).value(); - prop.put("maximum", max); - description.add(SYMBOL_LIMIT__N + " Maximum allowed is " + max); - } - } - case "string" -> { - fancyType = "Text"; - if (k.isAnnotationPresent(MinNumber.class)) { - int min = (int) k.getDeclaredAnnotation(MinNumber.class).value(); - prop.put("minLength", min); - description.add(SYMBOL_LIMIT__N + " Minimum Length allowed is " + min); - } - if (k.isAnnotationPresent(MaxNumber.class)) { - int max = (int) k.getDeclaredAnnotation(MaxNumber.class).value(); - prop.put("maxLength", max); - description.add(SYMBOL_LIMIT__N + " Maximum Length allowed is " + max); - } - - - if (k.isAnnotationPresent(RegistryListResource.class)) { - RegistryListResource rr = k.getDeclaredAnnotation(RegistryListResource.class); - ResourceLoader loader = data.getLoaders().get(rr.value()); - - if (loader != null) { - String key = "erz" + loader.getFolderName(); - - if (!definitions.containsKey(key)) { - JSONObject j = new JSONObject(); - j.put("enum", new JSONArray(loader.getPossibleKeys())); - definitions.put(key, j); - } - - fancyType = "Iris " + loader.getResourceTypeName(); - prop.put("$ref", "#/definitions/" + key); - description.add(SYMBOL_TYPE__N + " Must be a valid " + loader.getFolderName() + " (use ctrl+space for auto complete!)"); - } else { - IrisLogging.error("Cannot find Registry Loader for type " + rr.value() + " used in " + k.getDeclaringClass().getCanonicalName() + " in field " + k.getName()); - } - } else if (k.isAnnotationPresent(RegistryListStructure.class)) { - String key = "enum-iris-structure-placement"; - - if (!definitions.containsKey(key)) { - JSONObject j = new JSONObject(); - j.put("enum", new JSONArray(StructureSchemaKeys.collect( - Arrays.asList(data.getStructureLoader().getPossibleKeys()), - Arrays.asList(data.getJigsawPieceLoader().getPossibleKeys())).toArray(new String[0]))); - definitions.put(key, j); - } - - fancyType = "Structure"; - prop.put("$ref", "#/definitions/" + key); - description.add(SYMBOL_TYPE__N + " Must be a valid vanilla, datapack, or imported Iris structure (use ctrl+space for auto complete!)"); - - } else if (k.isAnnotationPresent(RegistryListBlockType.class)) { - fancyType = "Block Type"; - putRegistryEnumRef(prop, "enum-block-type", this::blockTypes); - description.add(SYMBOL_TYPE__N + " Must be a valid Block Type (use ctrl+space for auto complete!)"); - - } else if (k.isAnnotationPresent(RegistryListNativeJigsawPool.class)) { - fancyType = "Native Jigsaw Pool"; - putRegistryEnumRef(prop, "enum-native-jigsaw-pool", this::nativeJigsawPools); - description.add(SYMBOL_TYPE__N + " Must be a registered vanilla, datapack, or modded template pool key (use ctrl+space for auto complete!)"); - - } else if (k.isAnnotationPresent(RegistryListVanillaStructure.class)) { - fancyType = "Vanilla Structure"; - if (k.getAnnotation(RegistryListVanillaStructure.class).prefixes()) { - putRegistryEnumOrPrefixRef(prop, "enum-vanilla-structure-or-prefix", - "enum-vanilla-structure", this::vanillaStructures, VANILLA_STRUCTURE_PREFIX_PATTERN); - description.add(SYMBOL_TYPE__N + " Must be a vanilla/datapack structure key or a family/namespace prefix like 'minecraft:village' or 'nova_structures:' (use ctrl+space for auto complete!)"); - } else { - putRegistryEnumRef(prop, "enum-vanilla-structure", this::vanillaStructures); - description.add(SYMBOL_TYPE__N + " Must be a valid vanilla/datapack structure key (use ctrl+space for auto complete!)"); - } - - } else if (k.isAnnotationPresent(RegistryListVanillaStructureSet.class)) { - fancyType = "Vanilla Structure Set"; - putRegistryEnumRef(prop, "enum-vanilla-structure-set", this::vanillaStructureSets); - description.add(SYMBOL_TYPE__N + " Must be a valid vanilla/datapack structure SET key (use ctrl+space for auto complete!)"); - - } else if (k.isAnnotationPresent(RegistryListItemType.class)) { - fancyType = "Item Type"; - putRegistryEnumRef(prop, "enum-item-type", this::itemTypes); - description.add(SYMBOL_TYPE__N + " Must be a valid Item Type (use ctrl+space for auto complete!)"); - - } else if (k.isAnnotationPresent(RegistryListEntityType.class)) { - fancyType = "Entity Type"; - putRegistryEnumRef(prop, "enum-entity-type", this::entityTypes); - description.add(SYMBOL_TYPE__N + " Must be a valid Entity Type (use ctrl+space for auto complete!)"); - - } else if (k.isAnnotationPresent(RegistryListBiome.class)) { - fancyType = "Biome Type"; - putRegistryEnumRef(prop, "enum-biome-type", this::biomeTypes); - description.add(SYMBOL_TYPE__N + " Must be a valid vanilla, datapack, or mod biome key (use ctrl+space for auto complete!)"); - - } else if (k.isAnnotationPresent(RegistryListSpecialEntity.class)) { - fancyType = "Custom Mob Type"; - putRegistryEnumRef(prop, "enum-reg-specialentity", this::specialEntityTypes); - description.add(SYMBOL_TYPE__N + " Must be a valid Custom Mob Type (use ctrl+space for auto complete!)"); - } else if (k.isAnnotationPresent(RegistryListFont.class)) { - fancyType = "Font Family"; - putRegistryEnumRef(prop, "enum-font", SchemaBuilder::fontTypes); - description.add(SYMBOL_TYPE__N + " Must be a valid Font Family (use ctrl+space for auto complete!)"); - - } else if (k.isAnnotationPresent(RegistryListEnchantment.class)) { - fancyType = "Enchantment Type"; - putRegistryEnumRef(prop, "enum-enchantment", this::enchantTypes); - description.add(SYMBOL_TYPE__N + " Must be a valid Enchantment Type (use ctrl+space for auto complete!)"); - } else if (k.isAnnotationPresent(RegistryListPotionEffect.class)) { - fancyType = "Potion Effect Type"; - putRegistryEnumRef(prop, "enum-potion-effect-type", this::potionTypes); - description.add(SYMBOL_TYPE__N + " Must be a valid Potion Effect Type (use ctrl+space for auto complete!)"); - } else if (k.isAnnotationPresent(RegistryListFunction.class)) { - Class>> functionClass = k.getDeclaredAnnotation(RegistryListFunction.class).value(); - try { - ListFunction> instance = functionClass.getDeclaredConstructor().newInstance(); - String key = instance.key(); - fancyType = instance.fancyName(); - - if (!definitions.containsKey(key)) { - JSONObject j = new JSONObject(); - j.put("enum", instance.apply(data)); - definitions.put(key, j); - } - - prop.put("$ref", "#/definitions/" + key); - description.add(SYMBOL_TYPE__N + " Must be a valid " + fancyType + " (use ctrl+space for auto complete!)"); - } catch (Throwable e) { - IrisLogging.error("Could not execute apply method in " + functionClass.getName()); - } - } else if (SchemaKeyedTypes.isKeyed(k.getType())) { - fancyType = addEnum(k.getType(), prop, description, SchemaKeyedTypes.values(k.getType()), Function.identity()); - } else if (k.getType().isEnum()) { - fancyType = addEnum(k.getType(), prop, description, enumNames(k.getType()), Function.identity()); - } - } - case "object" -> { - if (k.isAnnotationPresent(RegistryMapBlockState.class)) { - String blockType = k.getDeclaredAnnotation(RegistryMapBlockState.class).value(); - fancyType = "Block State"; - prop.put("!top", true); - JSONArray any = new JSONArray(); - prop.put("anyOf", any); - - for (BlockStateGroup group : reconstructBlockStateGroups()) { - List blocks = group.blocks(); - if (blocks.isEmpty()) { - continue; - } - - String raw = blocks.get(0).replace(':', '_'); - String enumKey = "enum-block-state-" + raw; - String propertiesKey = "obj-block-state-" + raw; - - any.put(new JSONObject() - .put("if", new JSONObject() - .put("properties", new JSONObject() - .put(blockType, new JSONObject() - .put("type", "string") - .put("$ref", definitionReference(enumKey))))) - .put("then", new JSONObject() - .put("properties", new JSONObject() - .put(k.getName(), new JSONObject() - .put("type", "object") - .put("$ref", definitionReference(propertiesKey))))) - .put("else", false)); - - if (!definitions.containsKey(enumKey)) { - JSONArray filters = new JSONArray(); - blocks.forEach(filters::put); - - definitions.put(enumKey, new JSONObject() - .put("type", "string") - .put("enum", filters)); - } - - if (!definitions.containsKey(propertiesKey)) { - JSONObject props = new JSONObject(); - for (PlatformBlockProperty property : group.properties()) { - props.put(property.name(), buildBlockPropertyJson(property)); - } - - definitions.put(propertiesKey, new JSONObject() - .put("type", "object") - .put("properties", props)); - } - } - } else { - fancyType = k.getType().getSimpleName().replaceAll("\\QIris\\E", "") + " (Object)"; - String key = "obj-" + k.getType().getCanonicalName().replaceAll("\\Q.\\E", "-").toLowerCase(); - if (!definitions.containsKey(key)) { - definitions.put(key, new JSONObject()); - definitions.put(key, buildProperties(k.getType())); - } - prop.put("$ref", "#/definitions/" + key); - } - } - case "array" -> { - fancyType = "List of Something...?"; - ArrayType t = k.getDeclaredAnnotation(ArrayType.class); - if (t != null) { - if (t.min() > 0) { - prop.put("minItems", t.min()); - if (t.min() == 1) { - description.add(SYMBOL_LIMIT__N + " At least one entry must be defined, or just remove this list."); - } else { - description.add(SYMBOL_LIMIT__N + " Requires at least " + t.min() + " entries."); - } - } - - String arrayType = getType(t.type()); - - switch (arrayType) { - case "integer" -> fancyType = "List of Integers"; - case "number" -> fancyType = "List of Numbers"; - case "object" -> { - fancyType = "List of " + t.type().getSimpleName().replaceAll("\\QIris\\E", "") + "s (Objects)"; - String key = "obj-" + t.type().getCanonicalName().replaceAll("\\Q.\\E", "-").toLowerCase(); - if (!definitions.containsKey(key)) { - definitions.put(key, new JSONObject()); - definitions.put(key, buildProperties(t.type())); - } - JSONObject items = new JSONObject(); - items.put("$ref", "#/definitions/" + key); - prop.put("items", items); - } - case "string" -> { - fancyType = "List of Text"; - - if (k.isAnnotationPresent(RegistryListResource.class)) { - RegistryListResource rr = k.getDeclaredAnnotation(RegistryListResource.class); - ResourceLoader loader = data.getLoaders().get(rr.value()); - - if (loader != null) { - fancyType = "List<" + loader.getResourceTypeName() + ">"; - String key = "erz" + loader.getFolderName(); - - if (!definitions.containsKey(key)) { - JSONObject j = new JSONObject(); - j.put("enum", new JSONArray(loader.getPossibleKeys())); - definitions.put(key, j); - } - - JSONObject items = new JSONObject(); - items.put("$ref", "#/definitions/" + key); - prop.put("items", items); - description.add(SYMBOL_TYPE__N + " Must be a valid " + loader.getResourceTypeName() + " (use ctrl+space for auto complete!)"); - } else { - IrisLogging.error("Cannot find Registry Loader for type (list schema) " + rr.value() + " used in " + k.getDeclaringClass().getCanonicalName() + " in field " + k.getName()); - } - } else if (k.isAnnotationPresent(RegistryListStructure.class)) { - fancyType = "List"; - String key = "enum-iris-structure-placement"; - - if (!definitions.containsKey(key)) { - JSONObject j = new JSONObject(); - j.put("enum", new JSONArray(StructureSchemaKeys.collect( - Arrays.asList(data.getStructureLoader().getPossibleKeys()), - Arrays.asList(data.getJigsawPieceLoader().getPossibleKeys())).toArray(new String[0]))); - definitions.put(key, j); - } - - JSONObject items = new JSONObject(); - items.put("$ref", "#/definitions/" + key); - prop.put("items", items); - description.add(SYMBOL_TYPE__N + " Must be a valid vanilla, datapack, or imported Iris structure (use ctrl+space for auto complete!)"); - } else if (k.isAnnotationPresent(RegistryListNativeJigsawPool.class)) { - fancyType = "List"; - putRegistryEnumItems(prop, "enum-native-jigsaw-pool", this::nativeJigsawPools); - description.add(SYMBOL_TYPE__N + " Must be a registered vanilla, datapack, or modded template pool key (use ctrl+space for auto complete!)"); - } else if (k.isAnnotationPresent(RegistryListVanillaStructure.class)) { - fancyType = "List"; - if (k.getAnnotation(RegistryListVanillaStructure.class).prefixes()) { - putRegistryEnumOrPrefixItems(prop, "enum-vanilla-structure-or-prefix", - "enum-vanilla-structure", this::vanillaStructures, VANILLA_STRUCTURE_PREFIX_PATTERN); - description.add(SYMBOL_TYPE__N + " Must be a vanilla/datapack structure key or a family/namespace prefix like 'minecraft:village' or 'nova_structures:' (use ctrl+space for auto complete!)"); - } else { - putRegistryEnumItems(prop, "enum-vanilla-structure", this::vanillaStructures); - description.add(SYMBOL_TYPE__N + " Must be a valid vanilla/datapack structure key (use ctrl+space for auto complete!)"); - } - } else if (k.isAnnotationPresent(RegistryListVanillaStructureSet.class)) { - fancyType = "List"; - putRegistryEnumItems(prop, "enum-vanilla-structure-set", this::vanillaStructureSets); - description.add(SYMBOL_TYPE__N + " Must be a valid vanilla/datapack structure set key (use ctrl+space for auto complete!)"); - } else if (k.isAnnotationPresent(RegistryListBlockType.class)) { - fancyType = "List of Block Types"; - putRegistryEnumItems(prop, "enum-block-type", this::blockTypes); - description.add(SYMBOL_TYPE__N + " Must be a valid Block Type (use ctrl+space for auto complete!)"); - } else if (k.isAnnotationPresent(RegistryListItemType.class)) { - fancyType = "List of Item Types"; - putRegistryEnumItems(prop, "enum-item-type", this::itemTypes); - description.add(SYMBOL_TYPE__N + " Must be a valid Item Type (use ctrl+space for auto complete!)"); - } else if (k.isAnnotationPresent(RegistryListEntityType.class)) { - fancyType = "List of Entity Types"; - putRegistryEnumItems(prop, "enum-entity-type", this::entityTypes); - description.add(SYMBOL_TYPE__N + " Must be a valid Entity Type (use ctrl+space for auto complete!)"); - } else if (k.isAnnotationPresent(RegistryListBiome.class)) { - fancyType = "List of Biome Types"; - putRegistryEnumItems(prop, "enum-biome-type", this::biomeTypes); - description.add(SYMBOL_TYPE__N + " Must be a valid vanilla, datapack, or mod biome key (use ctrl+space for auto complete!)"); - } else if (k.isAnnotationPresent(RegistryListFont.class)) { - fancyType = "List of Font Families"; - putRegistryEnumItems(prop, "enum-font", SchemaBuilder::fontTypes); - description.add(SYMBOL_TYPE__N + " Must be a valid Font Family (use ctrl+space for auto complete!)"); - } else if (k.isAnnotationPresent(RegistryListEnchantment.class)) { - fancyType = "List of Enchantment Types"; - putRegistryEnumItems(prop, "enum-enchantment", this::enchantTypes); - description.add(SYMBOL_TYPE__N + " Must be a valid Enchantment Type (use ctrl+space for auto complete!)"); - } else if (k.isAnnotationPresent(RegistryListPotionEffect.class)) { - fancyType = "List of Potion Effect Types"; - putRegistryEnumItems(prop, "enum-potion-effect-type", this::potionTypes); - description.add(SYMBOL_TYPE__N + " Must be a valid Potion Effect Type (use ctrl+space for auto complete!)"); - } else if (k.isAnnotationPresent(RegistryListFunction.class)) { - Class>> functionClass = k.getDeclaredAnnotation(RegistryListFunction.class).value(); - try { - ListFunction> instance = functionClass.getDeclaredConstructor().newInstance(); - String key = instance.key(); - fancyType = instance.fancyName(); - - if (!definitions.containsKey(key)) { - JSONObject j = new JSONObject(); - j.put("enum", instance.apply(data)); - definitions.put(key, j); - } - - JSONObject items = new JSONObject(); - items.put("$ref", "#/definitions/" + key); - prop.put("items", items); - description.add(SYMBOL_TYPE__N + " Must be a valid " + fancyType + " (use ctrl+space for auto complete!)"); - } catch (Throwable e) { - IrisLogging.error("Could not execute apply method in " + functionClass.getName()); - } - } else if (SchemaKeyedTypes.isKeyed(t.type())) { - fancyType = addEnumList(prop, description, t, SchemaKeyedTypes.values(t.type()), Function.identity()); - } else if (t.type().isEnum()) { - fancyType = addEnumList(prop, description, t, enumNames(t.type()), Function.identity()); - } - } - } - } else { - warnings.add("Undefined array type for field " + k.getName() + " (" + k.getType().getSimpleName() + ") in class " + cl.getSimpleName()); - } - } - default -> - warnings.add("Unexpected Schema Type: " + type + " for field " + k.getName() + " (" + k.getType().getSimpleName() + ") in class " + cl.getSimpleName()); - } - - KList d = new KList<>(); - d.add("" + k.getName() + ""); - d.add(getFieldDescription(k) + "


"); - d.add("" + fancyType + ""); - String typeDesc = getDescription(k.getType()); - boolean present = !typeDesc.isBlank(); - if (present) d.add(typeDesc); - - Snippet snippet = k.getType().getDeclaredAnnotation(Snippet.class); - if (snippet == null) { - ArrayType array = k.getType().getDeclaredAnnotation(ArrayType.class); - if (array != null) { - snippet = array.type().getDeclaredAnnotation(Snippet.class); - } - } - - if (snippet != null) { - String sm = snippet.value(); - if (present) d.add(" "); - d.add("You can instead specify \"snippet/" + sm + "/some-name.json\" to use a snippet file instead of specifying it here."); - present = false; - } - - try { - k.setAccessible(true); - Object value = k.get(cl.getDeclaredConstructor().newInstance()); - - if (value != null) { - if (present) d.add(" "); - if (value instanceof List) { - d.add(SYMBOL_LIMIT__N + " Default Value is an empty list"); - } else if (!k.getType().isPrimitive() && !(value instanceof Number) && !(value instanceof String) && !(value instanceof Enum) && !SchemaKeyedTypes.isKeyed(k.getType())) { - d.add(SYMBOL_LIMIT__N + " Default Value is a default object (create this object to see default properties)"); - } else { - d.add(SYMBOL_LIMIT__N + " Default Value is " + value); - } - } - } catch (Throwable ignored) { - - } - - description.forEach((g) -> d.add(g.trim())); - String desc = d.toString("\n") - .replace("
", "\n") - .replace("", "") - .replace("", ""); - String hDesc = d.toString("
"); - prop.put("type", type); - prop.put("description", desc); - prop.put("x-intellij-html-description", hDesc); - return buildSnippet(prop, k.getType()); - } - - private JSONObject buildSnippet(JSONObject prop, Class type) { + JSONObject buildSnippet(JSONObject prop, Class type) { Snippet snippet = type.getDeclaredAnnotation(Snippet.class); if (snippet == null) return prop; @@ -847,117 +176,7 @@ public class SchemaBuilder { return anyOf; } - @NotNull - private String addEnumList(JSONObject prop, KList description, ArrayType t, T[] values, Function function) { - JSONObject items = new JSONObject(); - String s = addEnum(t.type(), items, description, values, function); - prop.put("items", items); - - return "List of " + s + "s"; - } - - private static String[] enumNames(Class enumType) { - try { - Object[] constants = enumType.getEnumConstants(); - String[] names = new String[constants.length]; - for (int index = 0; index < constants.length; index++) { - names[index] = ((Enum) constants[index]).name(); - } - return names; - } catch (LinkageError error) { - return Arrays.stream(enumType.getDeclaredFields()) - .filter(Field::isEnumConstant) - .map(Field::getName) - .toArray(String[]::new); - } - } - - @NotNull - private String addEnum(Class type, JSONObject prop, KList description, T[] values, Function function) { - JSONArray a = new JSONArray(); - boolean advanced = type.isAnnotationPresent(Desc.class); - for (T gg : values) { - if (advanced) { - try { - JSONObject j = new JSONObject(); - String name = function.apply(gg); - j.put("const", name); - Desc dd = type.getField(name).getAnnotation(Desc.class); - String desc = dd == null ? ("No Description for " + name) : dd.value(); - j.put("description", desc); - j.put("x-intellij-html-description", desc.replace("\n", "
")); - a.put(j); - } catch (Throwable e) { - IrisLogging.reportError(e); - } - } else { - a.put(function.apply(gg)); - } - } - - String key = (advanced ? "oneof-" : "") + "enum-" + type.getCanonicalName().replaceAll("\\Q.\\E", "-").toLowerCase(); - - if (!definitions.containsKey(key)) { - JSONObject j = new JSONObject(); - j.put(advanced ? "oneOf" : "enum", a); - definitions.put(key, j); - } - - prop.put("$ref", "#/definitions/" + key); - description.add(SYMBOL_TYPE__N + " Must be a valid " + type.getSimpleName().replaceAll("\\QIris\\E", "") + " (use ctrl+space for auto complete!)"); - return type.getSimpleName().replaceAll("\\QIris\\E", ""); - } - - private JSONObject buildBlockPropertyJson(PlatformBlockProperty property) { - JSONObject json = new JSONObject(); - json.put("type", property.jsonType()); - json.put("default", property.defaultValue()); - List allowed = property.allowedValues(); - if (!allowed.isEmpty()) { - json.put("enum", new JSONArray(allowed)); - } - if (property.hasNumericRange()) { - PlatformNumericRange range = property.numericRange(); - if ("integer".equals(property.jsonType())) { - json.put("minimum", (long) range.minimum()); - json.put("maximum", (long) range.maximum()); - } else { - json.put("minimum", range.minimum()); - json.put("maximum", range.maximum()); - } - json.put("exclusiveMinimum", range.exclusiveMinimum()); - json.put("exclusiveMaximum", range.exclusiveMaximum()); - } - return json; - } - - private List reconstructBlockStateGroups() { - List groups = new ArrayList<>(); - List currentProperties = null; - List currentBlocks = null; - for (Map.Entry> entry : IrisPlatforms.get().registries().blockStateProperties().entrySet()) { - List value = entry.getValue(); - if (currentProperties != null && value == currentProperties) { - currentBlocks.add(entry.getKey()); - } else { - currentBlocks = new ArrayList<>(); - currentBlocks.add(entry.getKey()); - currentProperties = value; - groups.add(new BlockStateGroup(currentBlocks, value)); - } - } - return groups; - } - - private List templatePoolKeys() { - if (IrisPlatforms.get().structureHooks() == null) { - return List.of(); - } - List keys = IrisPlatforms.get().structureHooks().templatePoolKeys(); - return keys == null ? List.of() : keys; - } - - private String getType(Class c) { + String getType(Class c) { if (c.equals(int.class) || c.equals(Integer.class) || c.equals(long.class) || c.equals(Long.class)) { return "integer"; } @@ -989,7 +208,7 @@ public class SchemaBuilder { return "object"; } - private String getFieldDescription(Field r) { + String getFieldDescription(Field r) { if (r.isAnnotationPresent(Desc.class)) { return r.getDeclaredAnnotation(Desc.class).value(); @@ -999,7 +218,7 @@ public class SchemaBuilder { return "No Field Description"; } - private String getDescription(Class r) { + String getDescription(Class r) { if (r.isAnnotationPresent(Desc.class)) { return r.getDeclaredAnnotation(Desc.class).value(); } @@ -1009,7 +228,4 @@ public class SchemaBuilder { } return ""; } - - private record BlockStateGroup(List blocks, List properties) { - } } diff --git a/core/src/main/java/art/arcane/iris/core/project/SchemaEnums.java b/core/src/main/java/art/arcane/iris/core/project/SchemaEnums.java new file mode 100644 index 000000000..3fe493a15 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/project/SchemaEnums.java @@ -0,0 +1,100 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.project; + +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.engine.object.annotations.ArrayType; +import art.arcane.iris.engine.object.annotations.Desc; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.json.JSONArray; +import art.arcane.volmlib.util.json.JSONObject; +import org.jetbrains.annotations.NotNull; + +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.function.Function; + +final class SchemaEnums { + private final SchemaBuilder builder; + + SchemaEnums(SchemaBuilder builder) { + this.builder = builder; + } + + @NotNull + String addEnumList(JSONObject prop, KList description, ArrayType t, T[] values, Function function) { + JSONObject items = new JSONObject(); + String s = addEnum(t.type(), items, description, values, function); + prop.put("items", items); + + return "List of " + s + "s"; + } + + static String[] enumNames(Class enumType) { + try { + Object[] constants = enumType.getEnumConstants(); + String[] names = new String[constants.length]; + for (int index = 0; index < constants.length; index++) { + names[index] = ((Enum) constants[index]).name(); + } + return names; + } catch (LinkageError error) { + return Arrays.stream(enumType.getDeclaredFields()) + .filter(Field::isEnumConstant) + .map(Field::getName) + .toArray(String[]::new); + } + } + + @NotNull + String addEnum(Class type, JSONObject prop, KList description, T[] values, Function function) { + JSONArray a = new JSONArray(); + boolean advanced = type.isAnnotationPresent(Desc.class); + for (T gg : values) { + if (advanced) { + try { + JSONObject j = new JSONObject(); + String name = function.apply(gg); + j.put("const", name); + Desc dd = type.getField(name).getAnnotation(Desc.class); + String desc = dd == null ? ("No Description for " + name) : dd.value(); + j.put("description", desc); + j.put("x-intellij-html-description", desc.replace("\n", "
")); + a.put(j); + } catch (Throwable e) { + IrisLogging.reportError(e); + } + } else { + a.put(function.apply(gg)); + } + } + + String key = (advanced ? "oneof-" : "") + "enum-" + type.getCanonicalName().replaceAll("\\Q.\\E", "-").toLowerCase(); + + if (!builder.definitions.containsKey(key)) { + JSONObject j = new JSONObject(); + j.put(advanced ? "oneOf" : "enum", a); + builder.definitions.put(key, j); + } + + prop.put("$ref", "#/definitions/" + key); + description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid " + type.getSimpleName().replaceAll("\\QIris\\E", "") + " (use ctrl+space for auto complete!)"); + return type.getSimpleName().replaceAll("\\QIris\\E", ""); + } +} diff --git a/core/src/main/java/art/arcane/iris/core/project/SchemaPropertyBuilder.java b/core/src/main/java/art/arcane/iris/core/project/SchemaPropertyBuilder.java new file mode 100644 index 000000000..d9c77bf9f --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/project/SchemaPropertyBuilder.java @@ -0,0 +1,269 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.project; + +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.engine.object.annotations.ArrayType; +import art.arcane.iris.engine.object.annotations.MaxNumber; +import art.arcane.iris.engine.object.annotations.MinNumber; +import art.arcane.iris.engine.object.annotations.RegistryMapBlockState; +import art.arcane.iris.engine.object.annotations.Required; +import art.arcane.iris.engine.object.annotations.Snippet; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.json.JSONObject; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.function.Function; + +final class SchemaPropertyBuilder { + private final SchemaBuilder builder; + private final SchemaEnums enums; + private final SchemaBlockStates blockStates; + private final SchemaRegistryProperties registryProperties; + + SchemaPropertyBuilder(SchemaBuilder builder) { + this.builder = builder; + SchemaRegistryCatalog catalog = new SchemaRegistryCatalog(builder); + this.enums = new SchemaEnums(builder); + this.blockStates = new SchemaBlockStates(builder); + this.registryProperties = new SchemaRegistryProperties(builder, catalog); + } + + JSONObject buildProperty(Field k, Class cl) { + JSONObject prop = new JSONObject(); + String type = builder.getType(k.getType()); + KList description = new KList<>(); + prop.put("!required", k.isAnnotationPresent(Required.class)); + prop.put("type", type); + + String fancyType = switch (type) { + case "boolean" -> "Boolean"; + case "integer" -> integerProperty(k, prop, description); + case "number" -> numberProperty(k, prop, description); + case "string" -> stringProperty(k, prop, description); + case "object" -> objectProperty(k, prop); + case "array" -> arrayProperty(k, cl, prop, description); + default -> unexpectedType(type, k, cl); + }; + + return describeProperty(k, cl, prop, type, fancyType, description); + } + + private String integerProperty(Field k, JSONObject prop, KList description) { + if (k.isAnnotationPresent(MinNumber.class)) { + int min = (int) k.getDeclaredAnnotation(MinNumber.class).value(); + prop.put("minimum", min); + description.add(SchemaBuilder.SYMBOL_LIMIT__N + " Minimum allowed is " + min); + } + if (k.isAnnotationPresent(MaxNumber.class)) { + int max = (int) k.getDeclaredAnnotation(MaxNumber.class).value(); + prop.put("maximum", max); + description.add(SchemaBuilder.SYMBOL_LIMIT__N + " Maximum allowed is " + max); + } + return "Integer"; + } + + private String numberProperty(Field k, JSONObject prop, KList description) { + if (k.isAnnotationPresent(MinNumber.class)) { + double min = k.getDeclaredAnnotation(MinNumber.class).value(); + prop.put("minimum", min); + description.add(SchemaBuilder.SYMBOL_LIMIT__N + " Minimum allowed is " + min); + } + if (k.isAnnotationPresent(MaxNumber.class)) { + double max = k.getDeclaredAnnotation(MaxNumber.class).value(); + prop.put("maximum", max); + description.add(SchemaBuilder.SYMBOL_LIMIT__N + " Maximum allowed is " + max); + } + return "Number"; + } + + private String stringProperty(Field k, JSONObject prop, KList description) { + if (k.isAnnotationPresent(MinNumber.class)) { + int min = (int) k.getDeclaredAnnotation(MinNumber.class).value(); + prop.put("minLength", min); + description.add(SchemaBuilder.SYMBOL_LIMIT__N + " Minimum Length allowed is " + min); + } + if (k.isAnnotationPresent(MaxNumber.class)) { + int max = (int) k.getDeclaredAnnotation(MaxNumber.class).value(); + prop.put("maxLength", max); + description.add(SchemaBuilder.SYMBOL_LIMIT__N + " Maximum Length allowed is " + max); + } + + String registryType = registryProperties.stringRegistryType(k, prop, description); + if (registryType != null) { + return registryType; + } + if (SchemaKeyedTypes.isKeyed(k.getType())) { + return enums.addEnum(k.getType(), prop, description, SchemaKeyedTypes.values(k.getType()), Function.identity()); + } + if (k.getType().isEnum()) { + return enums.addEnum(k.getType(), prop, description, SchemaEnums.enumNames(k.getType()), Function.identity()); + } + return "Text"; + } + + private String objectProperty(Field k, JSONObject prop) { + if (k.isAnnotationPresent(RegistryMapBlockState.class)) { + blockStates.applyBlockStateMap(prop, k, k.getDeclaredAnnotation(RegistryMapBlockState.class).value()); + return "Block State"; + } + + String key = "obj-" + k.getType().getCanonicalName().replaceAll("\\Q.\\E", "-").toLowerCase(); + if (!builder.definitions.containsKey(key)) { + builder.definitions.put(key, new JSONObject()); + builder.definitions.put(key, builder.buildProperties(k.getType())); + } + prop.put("$ref", "#/definitions/" + key); + return k.getType().getSimpleName().replaceAll("\\QIris\\E", "") + " (Object)"; + } + + private String arrayProperty(Field k, Class cl, JSONObject prop, KList description) { + ArrayType t = k.getDeclaredAnnotation(ArrayType.class); + if (t == null) { + builder.warnings.add("Undefined array type for field " + k.getName() + " (" + k.getType().getSimpleName() + ") in class " + cl.getSimpleName()); + return "List of Something...?"; + } + + if (t.min() > 0) { + prop.put("minItems", t.min()); + if (t.min() == 1) { + description.add(SchemaBuilder.SYMBOL_LIMIT__N + " At least one entry must be defined, or just remove this list."); + } else { + description.add(SchemaBuilder.SYMBOL_LIMIT__N + " Requires at least " + t.min() + " entries."); + } + } + + return switch (builder.getType(t.type())) { + case "integer" -> "List of Integers"; + case "number" -> "List of Numbers"; + case "object" -> objectArrayProperty(t, prop); + case "string" -> stringArrayProperty(k, t, prop, description); + default -> "List of Something...?"; + }; + } + + private String objectArrayProperty(ArrayType t, JSONObject prop) { + String key = "obj-" + t.type().getCanonicalName().replaceAll("\\Q.\\E", "-").toLowerCase(); + if (!builder.definitions.containsKey(key)) { + builder.definitions.put(key, new JSONObject()); + builder.definitions.put(key, builder.buildProperties(t.type())); + } + JSONObject items = new JSONObject(); + items.put("$ref", "#/definitions/" + key); + prop.put("items", items); + return "List of " + t.type().getSimpleName().replaceAll("\\QIris\\E", "") + "s (Objects)"; + } + + private String stringArrayProperty(Field k, ArrayType t, JSONObject prop, KList description) { + String registryType = registryProperties.stringArrayRegistryType(k, prop, description); + if (registryType != null) { + return registryType; + } + if (SchemaKeyedTypes.isKeyed(t.type())) { + return enums.addEnumList(prop, description, t, SchemaKeyedTypes.values(t.type()), Function.identity()); + } + if (t.type().isEnum()) { + return enums.addEnumList(prop, description, t, SchemaEnums.enumNames(t.type()), Function.identity()); + } + return "List of Text"; + } + + private String unexpectedType(String type, Field k, Class cl) { + builder.warnings.add("Unexpected Schema Type: " + type + " for field " + k.getName() + " (" + k.getType().getSimpleName() + ") in class " + cl.getSimpleName()); + return "Unknown Type"; + } + + private JSONObject describeProperty(Field k, Class cl, JSONObject prop, String type, String fancyType, KList description) { + KList d = new KList<>(); + d.add("" + k.getName() + ""); + d.add(builder.getFieldDescription(k) + "
"); + d.add("" + fancyType + ""); + String typeDesc = builder.getDescription(k.getType()); + boolean present = !typeDesc.isBlank(); + if (present) { + d.add(typeDesc); + } + + present = appendSnippetHint(k, d, present); + appendDefaultValue(k, cl, d, present); + + description.forEach((g) -> d.add(g.trim())); + String desc = d.toString("\n") + .replace("
", "\n") + .replace("", "") + .replace("", ""); + String hDesc = d.toString("
"); + prop.put("type", type); + prop.put("description", desc); + prop.put("x-intellij-html-description", hDesc); + return builder.buildSnippet(prop, k.getType()); + } + + private boolean appendSnippetHint(Field k, KList d, boolean present) { + Snippet snippet = k.getType().getDeclaredAnnotation(Snippet.class); + if (snippet == null) { + ArrayType array = k.getType().getDeclaredAnnotation(ArrayType.class); + if (array != null) { + snippet = array.type().getDeclaredAnnotation(Snippet.class); + } + } + + if (snippet == null) { + return present; + } + + String sm = snippet.value(); + if (present) { + d.add(" "); + } + d.add("You can instead specify \"snippet/" + sm + "/some-name.json\" to use a snippet file instead of specifying it here."); + return false; + } + + private void appendDefaultValue(Field k, Class cl, KList d, boolean present) { + Object instance; + try { + instance = cl.getDeclaredConstructor().newInstance(); + } catch (ReflectiveOperationException | LinkageError e) { + return; + } + + try { + k.setAccessible(true); + Object value = k.get(instance); + + if (value != null) { + if (present) { + d.add(" "); + } + if (value instanceof List) { + d.add(SchemaBuilder.SYMBOL_LIMIT__N + " Default Value is an empty list"); + } else if (!k.getType().isPrimitive() && !(value instanceof Number) && !(value instanceof String) && !(value instanceof Enum) && !SchemaKeyedTypes.isKeyed(k.getType())) { + d.add(SchemaBuilder.SYMBOL_LIMIT__N + " Default Value is a default object (create this object to see default properties)"); + } else { + d.add(SchemaBuilder.SYMBOL_LIMIT__N + " Default Value is " + value); + } + } + } catch (Throwable e) { + IrisLogging.reportError("Schema default value unavailable for " + + cl.getCanonicalName() + "." + k.getName(), e); + } + } +} diff --git a/core/src/main/java/art/arcane/iris/core/project/SchemaRegistryCatalog.java b/core/src/main/java/art/arcane/iris/core/project/SchemaRegistryCatalog.java new file mode 100644 index 000000000..706c6fc24 --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/project/SchemaRegistryCatalog.java @@ -0,0 +1,252 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.project; + +import art.arcane.iris.spi.CapabilityProbe; +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.volmlib.util.json.JSONArray; +import art.arcane.volmlib.util.json.JSONObject; + +import java.awt.GraphicsEnvironment; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.function.Supplier; + +final class SchemaRegistryCatalog { + /** Namespaced key or family/namespace prefix: "minecraft:village_plains", "minecraft:village", "nova_structures:". */ + static final String VANILLA_STRUCTURE_PREFIX_PATTERN = "^[a-z0-9_.-]+:[a-z0-9_./-]*$"; + private static final String MINECRAFT_NAMESPACE = "minecraft:"; + private static volatile JSONArray fontTypes; + private final SchemaBuilder builder; + private JSONArray potionTypes; + private JSONArray enchantTypes; + + SchemaRegistryCatalog(SchemaBuilder builder) { + this.builder = builder; + } + + /** + * Font families are only used for schema completion. Enumerating them touches AWT, which can fail outright on a + * headless or mod-loader JVM - a failure degrades to no completion, never to a broken schema. + */ + static JSONArray fontTypes() { + JSONArray cached = fontTypes; + if (cached != null) { + return cached; + } + JSONArray built = CapabilityProbe.attempt("java.awt font families", + () -> new JSONArray(GraphicsEnvironment.getLocalGraphicsEnvironment().getAvailableFontFamilyNames()), + new JSONArray()); + fontTypes = built; + return built; + } + + JSONArray potionTypes() { + if (potionTypes == null) { + potionTypes = registryKeyForms(IrisPlatforms.get().registries().potionEffectKeys(), true); + } + return potionTypes; + } + + JSONArray enchantTypes() { + if (enchantTypes == null) { + enchantTypes = registryKeyForms(IrisPlatforms.get().registries().enchantmentKeys(), false); + } + return enchantTypes; + } + + /** + * Emits the full namespaced key for every registry entry, so mod and datapack content is addressable + * unambiguously, plus the legacy short form for the vanilla namespace so existing packs stay valid. + */ + private static JSONArray registryKeyForms(List keys, boolean upperCaseLegacy) { + JSONArray a = new JSONArray(); + Set seen = new LinkedHashSet<>(); + if (keys != null) { + for (String key : keys) { + if (key == null || key.isBlank()) { + continue; + } + seen.add(key); + if (key.startsWith(MINECRAFT_NAMESPACE) || key.indexOf(':') < 0) { + String path = stripNamespace(key); + seen.add(upperCaseLegacy ? path.toUpperCase(Locale.ROOT).replace(' ', '_') : path); + } + } + } + for (String key : seen) { + a.put(key); + } + return a; + } + + /** + * Biome derivatives resolve through NamespacedKey.fromString, which accepts a full key or a bare vanilla path, + * so both forms are offered. + */ + JSONArray biomeTypes() { + return registryKeyForms(IrisPlatforms.get().registries().biomeKeys(), false); + } + + JSONArray entityTypes() { + return keysAsArray(IrisPlatforms.get().registries().entityKeys()); + } + + JSONArray specialEntityTypes() { + return keysAsArray(IrisPlatforms.get().registries().specialEntityKeys()); + } + + JSONArray vanillaStructures() { + return keysAsArray(IrisPlatforms.get().registries().structureKeys()); + } + + JSONArray vanillaStructureSets() { + if (IrisPlatforms.get().structureHooks() == null) { + return new JSONArray(); + } + List keys = IrisPlatforms.get().structureHooks().structureSetKeys(); + return keysAsArray(keys == null ? List.of() : keys); + } + + JSONArray nativeJigsawPools() { + return keysAsArray(templatePoolKeys()); + } + + private static JSONArray keysAsArray(List keys) { + JSONArray a = new JSONArray(); + if (keys != null) { + for (String key : keys) { + if (key != null && !key.isBlank()) { + a.put(key); + } + } + } + return a; + } + + /** + * Registers a registry-backed enum definition under {@code definitionKey} and points {@code target} at it. + *

+ * An empty key list means the registry has nothing to offer yet - the server is still booting, a modded registry + * has not been frozen, or the host simply does not expose that catalog. Emitting {@code "enum": []} in that case + * writes a schema that rejects every value the author could possibly type, turning a missing autocomplete list + * into a pack that reads as broken in the editor. The reference is omitted instead, leaving the field + * unconstrained, and the values are only computed when the definition does not exist yet. + */ + void putRegistryEnumRef(JSONObject target, String definitionKey, Supplier values) { + if (!builder.definitions.containsKey(definitionKey)) { + JSONArray built = values.get(); + if (built == null || built.length() == 0) { + IrisLogging.debug("Schema enum '" + definitionKey + "' omitted: the registry returned no keys"); + return; + } + JSONObject definition = new JSONObject(); + definition.put("enum", built); + builder.definitions.put(definitionKey, definition); + } + target.put("$ref", "#/definitions/" + definitionKey); + } + + /** + * {@link #putRegistryEnumRef(JSONObject, String, Supplier)} for a list-typed property. When the enum is omitted no + * {@code items} schema is written at all, which is valid and simply means "any element". + */ + void putRegistryEnumItems(JSONObject prop, String definitionKey, Supplier values) { + JSONObject items = new JSONObject(); + putRegistryEnumRef(items, definitionKey, values); + if (items.has("$ref")) { + prop.put("items", items); + } + } + + /** + * A registry enum that ALSO accepts family/namespace prefixes ("minecraft:village", + * "nova_structures:") — the runtime prefix-matching contract of importedStructures.disabled and + * adjustments[].match. Emitted as anyOf(enum, pattern) so autocomplete still offers registered + * keys while prefix entries validate instead of being rejected. + */ + void putRegistryEnumOrPrefixRef(JSONObject target, String definitionKey, + String enumDefinitionKey, Supplier values, + String pattern) { + if (!builder.definitions.containsKey(definitionKey)) { + JSONArray anyOf = new JSONArray(); + JSONObject enumRef = new JSONObject(); + try { + putRegistryEnumRef(enumRef, enumDefinitionKey, values); + } catch (RuntimeException e) { + IrisLogging.reportError("Schema enum '" + enumDefinitionKey + + "' unavailable; emitting prefix pattern only", e); + } + if (enumRef.has("$ref")) { + anyOf.put(enumRef); + } + JSONObject prefix = new JSONObject(); + prefix.put("type", "string"); + prefix.put("pattern", pattern); + anyOf.put(prefix); + JSONObject definition = new JSONObject(); + definition.put("anyOf", anyOf); + builder.definitions.put(definitionKey, definition); + } + target.put("$ref", "#/definitions/" + definitionKey); + } + + void putRegistryEnumOrPrefixItems(JSONObject prop, String definitionKey, + String enumDefinitionKey, Supplier values, + String pattern) { + JSONObject items = new JSONObject(); + putRegistryEnumOrPrefixRef(items, definitionKey, enumDefinitionKey, values, pattern); + prop.put("items", items); + } + + JSONArray itemTypes() { + JSONArray a = new JSONArray(); + for (String key : IrisPlatforms.get().registries().itemKeys()) { + a.put(key.startsWith(MINECRAFT_NAMESPACE) ? key.substring(MINECRAFT_NAMESPACE.length()) : key); + } + return a; + } + + JSONArray blockTypes() { + JSONArray a = new JSONArray(); + for (String i : builder.data.getBlockLoader().getPossibleKeys()) { + a.put(i); + } + for (String i : IrisPlatforms.get().registries().blockTypeKeys()) { + a.put(i); + } + return a; + } + + private static String stripNamespace(String key) { + int idx = key.indexOf(':'); + return idx < 0 ? key : key.substring(idx + 1); + } + + private List templatePoolKeys() { + if (IrisPlatforms.get().structureHooks() == null) { + return List.of(); + } + List keys = IrisPlatforms.get().structureHooks().templatePoolKeys(); + return keys == null ? List.of() : keys; + } +} diff --git a/core/src/main/java/art/arcane/iris/core/project/SchemaRegistryProperties.java b/core/src/main/java/art/arcane/iris/core/project/SchemaRegistryProperties.java new file mode 100644 index 000000000..9b445a51c --- /dev/null +++ b/core/src/main/java/art/arcane/iris/core/project/SchemaRegistryProperties.java @@ -0,0 +1,288 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.project; + +import art.arcane.iris.spi.IrisLogging; +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.iris.core.structure.StructureSchemaKeys; +import art.arcane.iris.engine.framework.ListFunction; +import art.arcane.iris.engine.object.annotations.RegistryListBiome; +import art.arcane.iris.engine.object.annotations.RegistryListBlockType; +import art.arcane.iris.engine.object.annotations.RegistryListEnchantment; +import art.arcane.iris.engine.object.annotations.RegistryListEntityType; +import art.arcane.iris.engine.object.annotations.RegistryListFont; +import art.arcane.iris.engine.object.annotations.RegistryListFunction; +import art.arcane.iris.engine.object.annotations.RegistryListItemType; +import art.arcane.iris.engine.object.annotations.RegistryListNativeJigsawPool; +import art.arcane.iris.engine.object.annotations.RegistryListPotionEffect; +import art.arcane.iris.engine.object.annotations.RegistryListResource; +import art.arcane.iris.engine.object.annotations.RegistryListSpecialEntity; +import art.arcane.iris.engine.object.annotations.RegistryListStructure; +import art.arcane.iris.engine.object.annotations.RegistryListVanillaStructure; +import art.arcane.iris.engine.object.annotations.RegistryListVanillaStructureSet; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.json.JSONArray; +import art.arcane.volmlib.util.json.JSONObject; + +import java.lang.reflect.Field; +import java.util.Arrays; + +final class SchemaRegistryProperties { + private static final String IRIS_STRUCTURE_DEFINITION = "enum-iris-structure-placement"; + private final SchemaBuilder builder; + private final SchemaRegistryCatalog catalog; + + SchemaRegistryProperties(SchemaBuilder builder, SchemaRegistryCatalog catalog) { + this.builder = builder; + this.catalog = catalog; + } + + String stringRegistryType(Field k, JSONObject prop, KList description) { + if (k.isAnnotationPresent(RegistryListResource.class)) { + return resourceReference(k, prop, description); + } + if (k.isAnnotationPresent(RegistryListStructure.class)) { + putIrisStructureDefinition(); + prop.put("$ref", "#/definitions/" + IRIS_STRUCTURE_DEFINITION); + description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid vanilla, datapack, or imported Iris structure (use ctrl+space for auto complete!)"); + return "Structure"; + } + if (k.isAnnotationPresent(RegistryListBlockType.class)) { + catalog.putRegistryEnumRef(prop, "enum-block-type", catalog::blockTypes); + description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid Block Type (use ctrl+space for auto complete!)"); + return "Block Type"; + } + if (k.isAnnotationPresent(RegistryListNativeJigsawPool.class)) { + catalog.putRegistryEnumRef(prop, "enum-native-jigsaw-pool", catalog::nativeJigsawPools); + description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a registered vanilla, datapack, or modded template pool key (use ctrl+space for auto complete!)"); + return "Native Jigsaw Pool"; + } + if (k.isAnnotationPresent(RegistryListVanillaStructure.class)) { + if (k.getAnnotation(RegistryListVanillaStructure.class).prefixes()) { + catalog.putRegistryEnumOrPrefixRef(prop, "enum-vanilla-structure-or-prefix", + "enum-vanilla-structure", catalog::vanillaStructures, SchemaRegistryCatalog.VANILLA_STRUCTURE_PREFIX_PATTERN); + description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a vanilla/datapack structure key or a family/namespace prefix like 'minecraft:village' or 'nova_structures:' (use ctrl+space for auto complete!)"); + } else { + catalog.putRegistryEnumRef(prop, "enum-vanilla-structure", catalog::vanillaStructures); + description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid vanilla/datapack structure key (use ctrl+space for auto complete!)"); + } + return "Vanilla Structure"; + } + if (k.isAnnotationPresent(RegistryListVanillaStructureSet.class)) { + catalog.putRegistryEnumRef(prop, "enum-vanilla-structure-set", catalog::vanillaStructureSets); + description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid vanilla/datapack structure SET key (use ctrl+space for auto complete!)"); + return "Vanilla Structure Set"; + } + if (k.isAnnotationPresent(RegistryListItemType.class)) { + catalog.putRegistryEnumRef(prop, "enum-item-type", catalog::itemTypes); + description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid Item Type (use ctrl+space for auto complete!)"); + return "Item Type"; + } + if (k.isAnnotationPresent(RegistryListEntityType.class)) { + catalog.putRegistryEnumRef(prop, "enum-entity-type", catalog::entityTypes); + description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid Entity Type (use ctrl+space for auto complete!)"); + return "Entity Type"; + } + if (k.isAnnotationPresent(RegistryListBiome.class)) { + catalog.putRegistryEnumRef(prop, "enum-biome-type", catalog::biomeTypes); + description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid vanilla, datapack, or mod biome key (use ctrl+space for auto complete!)"); + return "Biome Type"; + } + if (k.isAnnotationPresent(RegistryListSpecialEntity.class)) { + catalog.putRegistryEnumRef(prop, "enum-reg-specialentity", catalog::specialEntityTypes); + description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid Custom Mob Type (use ctrl+space for auto complete!)"); + return "Custom Mob Type"; + } + if (k.isAnnotationPresent(RegistryListFont.class)) { + catalog.putRegistryEnumRef(prop, "enum-font", SchemaRegistryCatalog::fontTypes); + description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid Font Family (use ctrl+space for auto complete!)"); + return "Font Family"; + } + if (k.isAnnotationPresent(RegistryListEnchantment.class)) { + catalog.putRegistryEnumRef(prop, "enum-enchantment", catalog::enchantTypes); + description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid Enchantment Type (use ctrl+space for auto complete!)"); + return "Enchantment Type"; + } + if (k.isAnnotationPresent(RegistryListPotionEffect.class)) { + catalog.putRegistryEnumRef(prop, "enum-potion-effect-type", catalog::potionTypes); + description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid Potion Effect Type (use ctrl+space for auto complete!)"); + return "Potion Effect Type"; + } + if (k.isAnnotationPresent(RegistryListFunction.class)) { + return functionReference(k, prop, description, "Text", false); + } + return null; + } + + String stringArrayRegistryType(Field k, JSONObject prop, KList description) { + if (k.isAnnotationPresent(RegistryListResource.class)) { + return resourceItems(k, prop, description); + } + if (k.isAnnotationPresent(RegistryListStructure.class)) { + putIrisStructureDefinition(); + putItemsReference(prop, IRIS_STRUCTURE_DEFINITION); + description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid vanilla, datapack, or imported Iris structure (use ctrl+space for auto complete!)"); + return "List"; + } + if (k.isAnnotationPresent(RegistryListNativeJigsawPool.class)) { + catalog.putRegistryEnumItems(prop, "enum-native-jigsaw-pool", catalog::nativeJigsawPools); + description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a registered vanilla, datapack, or modded template pool key (use ctrl+space for auto complete!)"); + return "List"; + } + if (k.isAnnotationPresent(RegistryListVanillaStructure.class)) { + if (k.getAnnotation(RegistryListVanillaStructure.class).prefixes()) { + catalog.putRegistryEnumOrPrefixItems(prop, "enum-vanilla-structure-or-prefix", + "enum-vanilla-structure", catalog::vanillaStructures, SchemaRegistryCatalog.VANILLA_STRUCTURE_PREFIX_PATTERN); + description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a vanilla/datapack structure key or a family/namespace prefix like 'minecraft:village' or 'nova_structures:' (use ctrl+space for auto complete!)"); + } else { + catalog.putRegistryEnumItems(prop, "enum-vanilla-structure", catalog::vanillaStructures); + description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid vanilla/datapack structure key (use ctrl+space for auto complete!)"); + } + return "List"; + } + if (k.isAnnotationPresent(RegistryListVanillaStructureSet.class)) { + catalog.putRegistryEnumItems(prop, "enum-vanilla-structure-set", catalog::vanillaStructureSets); + description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid vanilla/datapack structure set key (use ctrl+space for auto complete!)"); + return "List"; + } + if (k.isAnnotationPresent(RegistryListBlockType.class)) { + catalog.putRegistryEnumItems(prop, "enum-block-type", catalog::blockTypes); + description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid Block Type (use ctrl+space for auto complete!)"); + return "List of Block Types"; + } + if (k.isAnnotationPresent(RegistryListItemType.class)) { + catalog.putRegistryEnumItems(prop, "enum-item-type", catalog::itemTypes); + description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid Item Type (use ctrl+space for auto complete!)"); + return "List of Item Types"; + } + if (k.isAnnotationPresent(RegistryListEntityType.class)) { + catalog.putRegistryEnumItems(prop, "enum-entity-type", catalog::entityTypes); + description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid Entity Type (use ctrl+space for auto complete!)"); + return "List of Entity Types"; + } + if (k.isAnnotationPresent(RegistryListBiome.class)) { + catalog.putRegistryEnumItems(prop, "enum-biome-type", catalog::biomeTypes); + description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid vanilla, datapack, or mod biome key (use ctrl+space for auto complete!)"); + return "List of Biome Types"; + } + if (k.isAnnotationPresent(RegistryListFont.class)) { + catalog.putRegistryEnumItems(prop, "enum-font", SchemaRegistryCatalog::fontTypes); + description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid Font Family (use ctrl+space for auto complete!)"); + return "List of Font Families"; + } + if (k.isAnnotationPresent(RegistryListEnchantment.class)) { + catalog.putRegistryEnumItems(prop, "enum-enchantment", catalog::enchantTypes); + description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid Enchantment Type (use ctrl+space for auto complete!)"); + return "List of Enchantment Types"; + } + if (k.isAnnotationPresent(RegistryListPotionEffect.class)) { + catalog.putRegistryEnumItems(prop, "enum-potion-effect-type", catalog::potionTypes); + description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid Potion Effect Type (use ctrl+space for auto complete!)"); + return "List of Potion Effect Types"; + } + if (k.isAnnotationPresent(RegistryListFunction.class)) { + return functionReference(k, prop, description, "List of Text", true); + } + return null; + } + + private String resourceReference(Field k, JSONObject prop, KList description) { + RegistryListResource rr = k.getDeclaredAnnotation(RegistryListResource.class); + ResourceLoader loader = builder.data.getLoaders().get(rr.value()); + + if (loader == null) { + IrisLogging.error("Cannot find Registry Loader for type " + rr.value() + " used in " + k.getDeclaringClass().getCanonicalName() + " in field " + k.getName()); + return "Text"; + } + + String key = "erz" + loader.getFolderName(); + putResourceDefinition(key, loader); + prop.put("$ref", "#/definitions/" + key); + description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid " + loader.getFolderName() + " (use ctrl+space for auto complete!)"); + return "Iris " + loader.getResourceTypeName(); + } + + private String resourceItems(Field k, JSONObject prop, KList description) { + RegistryListResource rr = k.getDeclaredAnnotation(RegistryListResource.class); + ResourceLoader loader = builder.data.getLoaders().get(rr.value()); + + if (loader == null) { + IrisLogging.error("Cannot find Registry Loader for type (list schema) " + rr.value() + " used in " + k.getDeclaringClass().getCanonicalName() + " in field " + k.getName()); + return "List of Text"; + } + + String key = "erz" + loader.getFolderName(); + putResourceDefinition(key, loader); + putItemsReference(prop, key); + description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid " + loader.getResourceTypeName() + " (use ctrl+space for auto complete!)"); + return "List<" + loader.getResourceTypeName() + ">"; + } + + private String functionReference(Field k, JSONObject prop, KList description, String fallbackType, boolean asItems) { + Class>> functionClass = k.getDeclaredAnnotation(RegistryListFunction.class).value(); + String fancyType = fallbackType; + + try { + ListFunction> instance = functionClass.getDeclaredConstructor().newInstance(); + String key = instance.key(); + fancyType = instance.fancyName(); + + if (!builder.definitions.containsKey(key)) { + JSONObject j = new JSONObject(); + j.put("enum", instance.apply(builder.data)); + builder.definitions.put(key, j); + } + + if (asItems) { + putItemsReference(prop, key); + } else { + prop.put("$ref", "#/definitions/" + key); + } + description.add(SchemaBuilder.SYMBOL_TYPE__N + " Must be a valid " + fancyType + " (use ctrl+space for auto complete!)"); + } catch (Throwable e) { + IrisLogging.reportError("Could not execute apply method in " + functionClass.getName(), e); + } + + return fancyType; + } + + private void putResourceDefinition(String key, ResourceLoader loader) { + if (!builder.definitions.containsKey(key)) { + JSONObject j = new JSONObject(); + j.put("enum", new JSONArray(loader.getPossibleKeys())); + builder.definitions.put(key, j); + } + } + + private void putIrisStructureDefinition() { + if (!builder.definitions.containsKey(IRIS_STRUCTURE_DEFINITION)) { + JSONObject j = new JSONObject(); + j.put("enum", new JSONArray(StructureSchemaKeys.collect( + Arrays.asList(builder.data.getStructureLoader().getPossibleKeys()), + Arrays.asList(builder.data.getJigsawPieceLoader().getPossibleKeys())).toArray(new String[0]))); + builder.definitions.put(IRIS_STRUCTURE_DEFINITION, j); + } + } + + private void putItemsReference(JSONObject prop, String key) { + JSONObject items = new JSONObject(); + items.put("$ref", "#/definitions/" + key); + prop.put("items", items); + } +} diff --git a/core/src/test/java/art/arcane/iris/core/project/SchemaBuilderIdentityTest.java b/core/src/test/java/art/arcane/iris/core/project/SchemaBuilderIdentityTest.java new file mode 100644 index 000000000..93dda171a --- /dev/null +++ b/core/src/test/java/art/arcane/iris/core/project/SchemaBuilderIdentityTest.java @@ -0,0 +1,395 @@ +/* + * Iris is a World Generator for Minecraft Servers + * Copyright (c) 2026 Arcane Arts (Volmit Software) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package art.arcane.iris.core.project; + +import art.arcane.iris.core.loader.IrisData; +import art.arcane.iris.core.loader.IrisRegistrant; +import art.arcane.iris.core.loader.ResourceLoader; +import art.arcane.iris.engine.object.IrisBiome; +import art.arcane.iris.engine.object.IrisBlockData; +import art.arcane.iris.engine.object.IrisDimension; +import art.arcane.iris.engine.object.IrisEntity; +import art.arcane.iris.engine.object.IrisExpression; +import art.arcane.iris.engine.object.IrisGenerator; +import art.arcane.iris.engine.object.IrisHydrology; +import art.arcane.iris.engine.object.IrisImageMap; +import art.arcane.iris.engine.object.IrisJigsawPiece; +import art.arcane.iris.engine.object.IrisLootTable; +import art.arcane.iris.engine.object.IrisObjectPlacement; +import art.arcane.iris.engine.object.IrisRegion; +import art.arcane.iris.engine.object.IrisStructure; +import art.arcane.iris.engine.object.IrisStructurePlacement; +import art.arcane.iris.engine.object.IrisVanillaStructureAdjustment; +import art.arcane.iris.spi.IrisPlatform; +import art.arcane.iris.spi.IrisPlatforms; +import art.arcane.iris.spi.LogLevel; +import art.arcane.iris.spi.PlatformBiome; +import art.arcane.iris.spi.PlatformBiomeWriter; +import art.arcane.iris.spi.PlatformBlockProperty; +import art.arcane.iris.spi.PlatformBlockState; +import art.arcane.iris.spi.PlatformEntityType; +import art.arcane.iris.spi.PlatformItem; +import art.arcane.iris.spi.PlatformNumericRange; +import art.arcane.iris.spi.PlatformRegistries; +import art.arcane.iris.spi.PlatformScheduler; +import art.arcane.iris.spi.PlatformStructureHooks; +import art.arcane.iris.spi.PlatformWorld; +import art.arcane.iris.testsupport.PlatformLeakGuard; +import art.arcane.volmlib.util.collection.KList; +import art.arcane.volmlib.util.collection.KMap; +import art.arcane.volmlib.util.json.JSONArray; +import art.arcane.volmlib.util.json.JSONObject; +import org.junit.After; +import org.junit.Before; +import org.junit.ClassRule; +import org.junit.Test; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeSet; + +import static org.junit.Assert.assertEquals; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class SchemaBuilderIdentityTest { + @ClassRule + public static final PlatformLeakGuard PLATFORM_GUARD = PlatformLeakGuard.clean(); + + private static final String EXPECTED_SCHEMA_DIGEST = "375be26228878d948d5dd4ccd4593fc3285c9a5a1dd1943a38e289d7c932c711"; + + private static final List> SCHEMA_ROOTS = List.of( + IrisDimension.class, + IrisRegion.class, + IrisBiome.class, + IrisGenerator.class, + IrisHydrology.class, + IrisObjectPlacement.class, + IrisStructure.class, + IrisStructurePlacement.class, + IrisJigsawPiece.class, + IrisVanillaStructureAdjustment.class, + IrisEntity.class, + IrisLootTable.class, + IrisBlockData.class, + IrisImageMap.class, + IrisExpression.class); + + private IrisPlatform previous; + + @Before + public void bindFakePlatform() { + previous = IrisPlatforms.isBound() ? IrisPlatforms.get() : null; + if (previous != null) { + IrisPlatforms.unbind(); + } + IrisPlatforms.bind(new FakePlatform()); + } + + @After + public void restorePlatform() { + IrisPlatforms.unbind(); + if (previous != null) { + IrisPlatforms.bind(previous); + } + } + + @Test + public void studioSchemaOutputIsByteIdentical() { + StringBuilder canonical = new StringBuilder(); + for (Class root : SCHEMA_ROOTS) { + canonical.append(root.getName()).append('\n'); + canonical.append(canonicalize(new SchemaBuilder(root, schemaData()).construct())); + canonical.append('\n'); + } + assertEquals(EXPECTED_SCHEMA_DIGEST, digest(canonical.toString())); + } + + private static String digest(String value) { + MessageDigest sha256; + try { + sha256 = MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + byte[] hash = sha256.digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(hash.length * 2); + for (byte b : hash) { + hex.append(Character.forDigit((b >> 4) & 0xF, 16)); + hex.append(Character.forDigit(b & 0xF, 16)); + } + return hex.toString(); + } + + private static String canonicalize(Object value) { + StringBuilder out = new StringBuilder(); + appendCanonical(out, value); + return out.toString(); + } + + private static void appendCanonical(StringBuilder out, Object value) { + if (value instanceof JSONObject object) { + out.append('{'); + boolean first = true; + for (String key : new TreeSet<>(object.keySet())) { + if (!first) { + out.append(','); + } + first = false; + out.append('"').append(key).append("\":"); + appendCanonical(out, object.get(key)); + } + out.append('}'); + return; + } + if (value instanceof JSONArray array) { + out.append('['); + for (int index = 0; index < array.length(); index++) { + if (index > 0) { + out.append(','); + } + appendCanonical(out, array.get(index)); + } + out.append(']'); + return; + } + out.append('<').append(value == null ? "null" : value.getClass().getSimpleName()).append(':'); + out.append(String.valueOf(value)).append('>'); + } + + private static IrisData schemaData() { + IrisData data = mock(IrisData.class); + ResourceLoader structureLoader = mock(ResourceLoader.class); + ResourceLoader pieceLoader = mock(ResourceLoader.class); + ResourceLoader expressionLoader = mock(ResourceLoader.class); + ResourceLoader blockLoader = mock(ResourceLoader.class); + KMap, ResourceLoader> loaders = new KMap<>(); + loaders.put(IrisExpression.class, expressionLoader); + when(data.getStructureLoader()).thenReturn(structureLoader); + when(data.getJigsawPieceLoader()).thenReturn(pieceLoader); + when(data.getBlockLoader()).thenReturn(blockLoader); + when(data.getLoaders()).thenReturn(loaders); + when(data.getPossibleSnippets(anyString())).thenReturn(new KList().qadd("alpha").qadd("beta")); + when(structureLoader.getPossibleKeys()).thenReturn(new String[]{"pack/tower", "pack/keep"}); + when(pieceLoader.getPossibleKeys()).thenReturn(new String[]{"pack/tower-base"}); + when(blockLoader.getPossibleKeys()).thenReturn(new String[]{"pack/mossy_stone"}); + when(expressionLoader.getPossibleKeys()).thenReturn(new String[]{"pack/height"}); + when(expressionLoader.getFolderName()).thenReturn("expressions"); + when(expressionLoader.getResourceTypeName()).thenReturn("Expression"); + return data; + } + + private static final class FakeRegistries implements PlatformRegistries { + private static final List STONE_PROPERTIES = List.of( + new PlatformBlockProperty("waterlogged", "boolean", Boolean.FALSE, List.of(Boolean.TRUE, Boolean.FALSE), null), + new PlatformBlockProperty("level", "integer", 0, List.of(0, 1, 2), + new PlatformNumericRange(0D, 2D, false, false))); + private static final List LOG_PROPERTIES = List.of( + new PlatformBlockProperty("axis", "string", "y", List.of("x", "y", "z"), null)); + + @Override + public PlatformBlockState block(String key) { + return null; + } + + @Override + public PlatformBlockState blockOrNull(String key) { + return null; + } + + @Override + public PlatformBlockState blockOrNull(String key, boolean warn) { + return null; + } + + @Override + public PlatformBlockState air() { + return null; + } + + @Override + public PlatformBlockState deepSlateOre(PlatformBlockState block, PlatformBlockState ore) { + return null; + } + + @Override + public PlatformBiome biome(String key) { + return null; + } + + @Override + public PlatformItem item(String key) { + return null; + } + + @Override + public PlatformEntityType entity(String key) { + return null; + } + + @Override + public List blockKeys() { + return List.of("minecraft:stone", "minecraft:oak_log"); + } + + @Override + public List biomeKeys() { + return List.of("minecraft:plains", "minecraft:desert", "cool_mod:sky_meadow"); + } + + @Override + public List specialEntityKeys() { + return List.of("mythicmobs:skeleton_king"); + } + + @Override + public List structureKeys() { + return List.of("minecraft:monument", "minecraft:stronghold", "cool_mod:sky_temple"); + } + + @Override + public List itemKeys() { + return List.of("minecraft:stone", "minecraft:diamond_sword", "cool_mod:ruby"); + } + + @Override + public List entityKeys() { + return List.of("minecraft:zombie", "cool_mod:grizzly_bear"); + } + + @Override + public List blockTypeKeys() { + return List.of("minecraft:stone", "minecraft:oak_log"); + } + + @Override + public List enchantmentKeys() { + return List.of("minecraft:sharpness", "cool_mod:vorpal"); + } + + @Override + public List potionEffectKeys() { + return List.of("minecraft:speed", "minecraft:slow_falling", "sniffer_mod:mega_boost"); + } + + @Override + public List lootTableKeys() { + return List.of("minecraft:chests/simple_dungeon", "cool_mod:chests/sky_temple"); + } + + @Override + public Map> blockStateProperties() { + Map> properties = new LinkedHashMap<>(); + properties.put("minecraft:stone", STONE_PROPERTIES); + properties.put("minecraft:andesite", STONE_PROPERTIES); + properties.put("minecraft:oak_log", LOG_PROPERTIES); + return properties; + } + } + + private static final class FakePlatform implements IrisPlatform { + private final PlatformRegistries registries = new FakeRegistries(); + + @Override + public String platformName() { + return "fake"; + } + + @Override + public String minecraftVersion() { + return "0.0.0"; + } + + @Override + public PlatformRegistries registries() { + return registries; + } + + @Override + public PlatformScheduler scheduler() { + return null; + } + + @Override + public PlatformStructureHooks structureHooks() { + return null; + } + + @Override + public PlatformBiomeWriter biomeWriter() { + return null; + } + + @Override + public File dataFolder() { + return new File("."); + } + + @Override + public File dataFile(String... path) { + return new File("."); + } + + @Override + public File pluginJar() { + return new File("."); + } + + @Override + public int irisVersionNumber() { + return 0; + } + + @Override + public int minecraftVersionNumber() { + return 0; + } + + @Override + public void callEvent(Object event) { + } + + @Override + public void dispatchConsoleCommand(String command) { + } + + @Override + public boolean spawnEntity(PlatformWorld world, String entityKey, double x, double y, double z) { + return false; + } + + @Override + public void log(LogLevel level, String message) { + } + + @Override + public void msg(String message) { + } + + @Override + public void reportError(Throwable error) { + } + } +}