diff --git a/api/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserDefineCustomItemsEvent.java b/api/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserDefineCustomItemsEvent.java index bd14aaf43c7..b6de281217c 100644 --- a/api/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserDefineCustomItemsEvent.java +++ b/api/src/main/java/org/geysermc/geyser/api/event/lifecycle/GeyserDefineCustomItemsEvent.java @@ -27,8 +27,10 @@ import org.checkerframework.checker.nullness.qual.NonNull; import org.geysermc.event.Event; +import org.geysermc.geyser.api.exception.CustomItemDefinitionRegisterException; import org.geysermc.geyser.api.item.custom.CustomItemData; import org.geysermc.geyser.api.item.custom.NonVanillaCustomItemData; +import org.geysermc.geyser.api.item.custom.v2.CustomItemDefinition; import java.util.Collection; import java.util.List; @@ -40,14 +42,25 @@ * This event will not be called if the "add non-Bedrock items" setting is disabled in the Geyser config. */ public interface GeyserDefineCustomItemsEvent extends Event { + /** - * Gets a multimap of all the already registered custom items indexed by the item's extended java item's identifier. + * Gets a multimap of all the already registered (using the deprecated method) custom items indexed by the item's extended java item's identifier. * - * @return a multimap of all the already registered custom items + * @deprecated use {@link GeyserDefineCustomItemsEvent#getExistingCustomItemDefinitions()} + * @return a multimap of all the already custom items registered using {@link GeyserDefineCustomItemsEvent#register(String, CustomItemData)} */ + @Deprecated @NonNull Map> getExistingCustomItems(); + /** + * Gets a multimap of all the already registered custom item definitions indexed by the item's extended java item's identifier. + * + * @return a multimap of all the already registered custom item definitions + */ + @NonNull + Map> getExistingCustomItemDefinitions(); + /** * Gets the list of the already registered non-vanilla custom items. * @@ -58,14 +71,26 @@ public interface GeyserDefineCustomItemsEvent extends Event { /** * Registers a custom item with a base Java item. This is used to register items with custom textures and properties - * based on NBT data. + * based on NBT data. This method should not be used anymore, {@link CustomItemDefinition}s are preferred now and this method will convert {@code CustomItemData} to {@code CustomItemDefinition} internally. * + * @deprecated use {@link GeyserDefineCustomItemsEvent#register(String, CustomItemDefinition)} * @param identifier the base (java) item * @param customItemData the custom item data to register * @return if the item was registered */ + @Deprecated boolean register(@NonNull String identifier, @NonNull CustomItemData customItemData); + /** + * Registers a custom item with a base Java item. This is used to register items with custom textures and properties + * based on NBT data. + * + * @param identifier of the Java edition base item + * @param customItemDefinition the custom item definition to register + * @throws CustomItemDefinitionRegisterException when an error occurred while registering the item. + */ + void register(@NonNull String identifier, @NonNull CustomItemDefinition customItemDefinition) throws CustomItemDefinitionRegisterException; + /** * Registers a custom item with no base item. This is used for mods. * @@ -73,4 +98,4 @@ public interface GeyserDefineCustomItemsEvent extends Event { * @return if the item was registered */ boolean register(@NonNull NonVanillaCustomItemData customItemData); -} \ No newline at end of file +} diff --git a/api/src/main/java/org/geysermc/geyser/api/exception/CustomItemDefinitionRegisterException.java b/api/src/main/java/org/geysermc/geyser/api/exception/CustomItemDefinitionRegisterException.java new file mode 100644 index 00000000000..c553bc1d4ed --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/exception/CustomItemDefinitionRegisterException.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.api.exception; + +public class CustomItemDefinitionRegisterException extends Exception { + + public CustomItemDefinitionRegisterException(String message) { + super(message); + } +} diff --git a/api/src/main/java/org/geysermc/geyser/api/item/custom/CustomItemData.java b/api/src/main/java/org/geysermc/geyser/api/item/custom/CustomItemData.java index 3b871cd74c5..caae5d058d1 100644 --- a/api/src/main/java/org/geysermc/geyser/api/item/custom/CustomItemData.java +++ b/api/src/main/java/org/geysermc/geyser/api/item/custom/CustomItemData.java @@ -28,13 +28,25 @@ import org.checkerframework.checker.nullness.qual.NonNull; import org.checkerframework.checker.nullness.qual.Nullable; import org.geysermc.geyser.api.GeyserApi; - +import org.geysermc.geyser.api.item.custom.v2.CustomItemBedrockOptions; +import org.geysermc.geyser.api.item.custom.v2.CustomItemDefinition; +import org.geysermc.geyser.api.item.custom.v2.predicate.CustomItemPredicate; +import org.geysermc.geyser.api.item.custom.v2.predicate.RangeDispatchPredicateProperty; +import org.geysermc.geyser.api.item.custom.v2.predicate.condition.ConditionPredicateProperty; +import org.geysermc.geyser.api.util.CreativeCategory; +import org.geysermc.geyser.api.util.Identifier; +import org.geysermc.geyser.api.util.TriState; + +import java.util.Objects; import java.util.OptionalInt; import java.util.Set; /** * This is used to store data for a custom item. + * + * @deprecated use the new {@link org.geysermc.geyser.api.item.custom.v2.CustomItemDefinition} */ +@Deprecated public interface CustomItemData { /** * Gets the item's name. @@ -118,6 +130,33 @@ static CustomItemData.Builder builder() { return GeyserApi.api().provider(CustomItemData.Builder.class); } + default CustomItemDefinition.Builder toDefinition(Identifier javaItem) { + // TODO non vanilla + CustomItemDefinition.Builder definition = CustomItemDefinition.builder(javaItem, javaItem) + .displayName(displayName()) + .bedrockOptions(CustomItemBedrockOptions.builder() + .icon(icon()) + .allowOffhand(allowOffhand()) + .displayHandheld(displayHandheld()) + .creativeCategory(creativeCategory().isEmpty() ? CreativeCategory.NONE : CreativeCategory.values()[creativeCategory().getAsInt()]) + .creativeGroup(creativeGroup()) + .tags(tags()) + ); + + CustomItemOptions options = customItemOptions(); + if (options.customModelData().isPresent()) { + definition.predicate(CustomItemPredicate.rangeDispatch(RangeDispatchPredicateProperty.CUSTOM_MODEL_DATA, options.customModelData().getAsInt())); + } + if (options.damagePredicate().isPresent()) { + definition.predicate(CustomItemPredicate.rangeDispatch(RangeDispatchPredicateProperty.DAMAGE, options.damagePredicate().getAsInt())); + } + if (options.unbreakable() != TriState.NOT_SET) { + definition.predicate(CustomItemPredicate.condition(ConditionPredicateProperty.HAS_COMPONENT, + Objects.requireNonNull(options.unbreakable().toBoolean()), new Identifier("minecraft", "unbreakable"))); + } + return definition; + } + interface Builder { /** * Will also set the display name and icon to the provided parameter, if it is currently not set. diff --git a/api/src/main/java/org/geysermc/geyser/api/item/custom/CustomItemOptions.java b/api/src/main/java/org/geysermc/geyser/api/item/custom/CustomItemOptions.java index 2ca19e20e56..cc9496cb331 100644 --- a/api/src/main/java/org/geysermc/geyser/api/item/custom/CustomItemOptions.java +++ b/api/src/main/java/org/geysermc/geyser/api/item/custom/CustomItemOptions.java @@ -33,7 +33,10 @@ /** * This class represents the different ways you can register custom items + * + * @deprecated use the new {@link org.geysermc.geyser.api.item.custom.v2.CustomItemDefinition}. */ +@Deprecated public interface CustomItemOptions { /** * Gets if the item should be unbreakable. diff --git a/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/CustomItemBedrockOptions.java b/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/CustomItemBedrockOptions.java new file mode 100644 index 00000000000..3f43d7720fe --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/CustomItemBedrockOptions.java @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2024 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.api.item.custom.v2; + +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.geysermc.geyser.api.GeyserApi; +import org.geysermc.geyser.api.item.custom.CustomRenderOffsets; +import org.geysermc.geyser.api.util.CreativeCategory; + +import java.util.Set; + +/** + * This is used to store options for a custom item defintion that can't be described using item components. + */ +public interface CustomItemBedrockOptions { + + /** + * Gets the item's icon. When not present, the item's Bedrock identifier is used. + * + * @return the item's icon + * @see CustomItemDefinition#icon() + */ + @Nullable + String icon(); + + /** + * If the item is allowed to be put into the offhand. Defaults to true. + * + * @return true if the item is allowed to be used in the offhand, false otherwise + */ + boolean allowOffhand(); + + /** + * If the item should be displayed as handheld, like a tool. + * + * @return true if the item should be displayed as handheld, false otherwise + */ + boolean displayHandheld(); + + /** + * Since Bedrock doesn't properly support setting item armour values over attributes, this value + * determines how many armour points should be shown when this item is worn. This is purely visual. + * + *

Only has an effect when the item is equippable, and defaults to 0.

+ * + * @return the item's protection value. Purely visual and for Bedrock only. + */ + int protectionValue(); + + /** + * The item's creative category. Defaults to {@code NONE}. + * + * @return the item's creative category + */ + @NonNull + CreativeCategory creativeCategory(); + + /** + * Gets the item's creative group. + * + * @return the item's creative group + */ + @Nullable + String creativeGroup(); + + /** + * Gets the item's set of tags that can be used in Molang. + * Equivalent to "tag:some_tag" + * + * @return the item's tags, if they exist + */ + @NonNull + Set tags(); + + static Builder builder() { + return GeyserApi.api().provider(Builder.class); + } + + interface Builder { + + Builder icon(@Nullable String icon); + + Builder allowOffhand(boolean allowOffhand); + + Builder displayHandheld(boolean displayHandheld); + + Builder protectionValue(int protectionValue); + + Builder creativeCategory(CreativeCategory creativeCategory); + + Builder creativeGroup(@Nullable String creativeGroup); + + Builder tags(@Nullable Set tags); + + CustomItemBedrockOptions build(); + } +} diff --git a/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/CustomItemDefinition.java b/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/CustomItemDefinition.java new file mode 100644 index 00000000000..5f71dc38acc --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/CustomItemDefinition.java @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2024 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.api.item.custom.v2; + +import org.checkerframework.checker.nullness.qual.NonNull; +import org.geysermc.geyser.api.GeyserApi; +import org.geysermc.geyser.api.item.custom.v2.component.DataComponentMap; +import org.geysermc.geyser.api.item.custom.v2.component.DataComponent; +import org.geysermc.geyser.api.item.custom.v2.predicate.CustomItemPredicate; +import org.geysermc.geyser.api.item.custom.v2.predicate.PredicateStrategy; +import org.geysermc.geyser.api.util.Identifier; + +import java.util.List; + +/** + * This is used to define a custom item and its properties for a specific Java item and item model definition combination. + * + *

A custom item definition will be used for all item stacks that match the Java item and item model this item is for. + * Additionally, predicates can be added that allow fine-grained control as to when to use this custom item. These predicates are similar + * to the predicates available in Java item model definitions.

+ * + *

In Geyser, all registered custom item definitions for a Java item model will be checked in a specific order:

+ * + *
    + *
  1. First by checking their priority values, higher priority values going first.
  2. + *
  3. Then by checking if they both have a similar range dispatch predicate, the one with the highest threshold going first.
  4. + *
  5. Lastly by the amount of predicates, from most to least.
  6. + *
+ * + *

This ensures predicates will be checked in the correct order. In most cases, specifying a priority value isn't necessary, but it can be added to ensure the intended order.

+ */ +public interface CustomItemDefinition { + + /** + * The Bedrock identifier for this custom item. It cannot be in the {@code minecraft} namespace. + */ + @NonNull Identifier bedrockIdentifier(); + + /** + * The display name of the item. If none is set, the display name is taken from the item's Bedrock identifier. + */ + @NonNull String displayName(); + + /** + * The item model this definition is for. If the model is in the {@code minecraft} namespace, then the definition must have at least one predicate. + * + *

If multiple item definitions for a model are registered, then only one can have no predicate.

+ */ + @NonNull Identifier model(); + + /** + * The icon used for this item. + * + *

If none is set in the item's Bedrock options, then the item's Bedrock identifier is used, + * the namespace separator ({@code :}) replaced with {@code .} and the path separators ({@code /}) replaced with {@code _}. For example:

+ * + *

{@code my_datapack:my_custom_item} => {@code my_datapack.my_custom_item}

+ *

{@code my_datapack:cool_items/cool_item_1} => {@code my_datapack.cool_items_cool_item_1}

+ */ + default @NonNull String icon() { + return bedrockOptions().icon() == null ? bedrockIdentifier().toString().replaceAll(":", ".").replaceAll("/", "_") : bedrockOptions().icon(); + } + + /** + * The predicates that have to match for this item definition to be used. These predicates are similar to the Java item model predicates. + */ + @NonNull List predicates(); + + /** + * The predicate strategy to be used. Determines if one of, or all of the predicates have to pass for this item definition to be used. Defaults to {@link PredicateStrategy#AND}. + */ + PredicateStrategy predicateStrategy(); + + /** + * The priority of this definition. For all definitions for a single Java item model, definitions with a higher priority will be matched first. Defaults to 0. + */ + int priority(); + + /** + * The item's Bedrock options. These describe item properties that can't be described in item components, e.g. item texture size and if the item is allowed in the off-hand. + */ + @NonNull CustomItemBedrockOptions bedrockOptions(); + + /** + * The item's data components. It is expected that the item always has these components on the server. If the components mismatch, bugs will occur. + * + *

Currently, the following components are (somewhat) supported:

+ * + *
    + *
  • {@code minecraft:consumable} ({@link DataComponent#CONSUMABLE})
  • + *
  • {@code minecraft:equippable} ({@link DataComponent#EQUIPPABLE})
  • + *
  • {@code minecraft:food} ({@link DataComponent#FOOD})
  • + *
  • {@code minecraft:max_damage} ({@link DataComponent#MAX_DAMAGE})
  • + *
  • {@code minecraft:max_stack_size} ({@link DataComponent#MAX_STACK_SIZE})
  • + *
  • {@code minecraft:use_cooldown} ({@link DataComponent#USE_COOLDOWN})
  • + *
  • {@code minecraft:enchantable} ({@link DataComponent#ENCHANTABLE})
  • + *
  • {@code minecraft:tool} ({@link DataComponent#TOOL})
  • + *
  • {@code minecraft:repairable} ({@link DataComponent#REPAIRABLE})
  • + *
+ * + *

Note: some components, for example {@code minecraft:rarity}, {@code minecraft:enchantment_glint_override}, and {@code minecraft:attribute_modifiers} are translated automatically, + * and do not have to be specified here.

+ * + * @see DataComponent + */ + @NonNull DataComponentMap components(); + + static Builder builder(Identifier identifier, Identifier itemModel) { + return GeyserApi.api().provider(Builder.class, identifier, itemModel); + } + + interface Builder { + + Builder displayName(String displayName); + + Builder priority(int priority); + + Builder bedrockOptions(CustomItemBedrockOptions.@NonNull Builder options); + + Builder predicate(@NonNull CustomItemPredicate predicate); + + Builder predicateStrategy(@NonNull PredicateStrategy strategy); + + Builder component(@NonNull DataComponent component, @NonNull T value); + + CustomItemDefinition build(); + } +} diff --git a/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/component/Consumable.java b/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/component/Consumable.java new file mode 100644 index 00000000000..561b2dc7c42 --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/component/Consumable.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.api.item.custom.v2.component; + +import org.checkerframework.checker.index.qual.Positive; + +public record Consumable(@Positive float consumeSeconds, Animation animation) { + + public Consumable { + if (consumeSeconds <= 0.0F) { + throw new IllegalArgumentException("Consume seconds must be above 0"); + } + } + + /** + * Not all animations work perfectly on bedrock. Bedrock behaviour is noted per animation. The {@code toot_horn} animation doesn't exist on bedrock, and is therefore not listed here. + */ + public enum Animation { + /** + * Does nothing in 1st person, appears as eating in 3rd person. + */ + NONE, + /** + * Appears to look correctly. + */ + EAT, + /** + * Appears to look correctly. + */ + DRINK, + /** + * Does nothing in 1st person, eating in 3rd person. + */ + BLOCK, + /** + * Does nothing in 1st person, eating in 3rd person. + */ + BOW, + /** + * Does nothing in 1st person, but looks like spear in 3rd person. + */ + SPEAR, + /** + * Does nothing in 1st person, eating in 3rd person. + */ + CROSSBOW, + /** + * Does nothing in 1st person, but looks like spyglass in 3rd person. + */ + SPYGLASS, + /** + * Brush in 1st and 3rd person. Will look weird when not displayed handheld. + */ + BRUSH + } +} diff --git a/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/component/DataComponent.java b/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/component/DataComponent.java new file mode 100644 index 00000000000..041210e724d --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/component/DataComponent.java @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2025 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.api.item.custom.v2.component; + +import org.geysermc.geyser.api.item.custom.v2.CustomItemDefinition; +import org.geysermc.geyser.api.util.Identifier; + +import java.util.function.Predicate; + +/** + * Data components used to indicate item behaviour of custom items. It is expected that any components set on a {@link CustomItemDefinition} are always present on the item server-side. + * + * @see CustomItemDefinition#components() + */ +public final class DataComponent { + /** + * Marks the item as consumable. Of this component, only {@code consume_seconds} and {@code animation} properties are translated. Consume effects are done server side, + * and consume sounds and particles aren't possible. + * + *

Note that due to a bug on Bedrock, not all consume animations appear perfectly. See {@link Consumable.Animation}

+ * + * @see Consumable + */ + public static final DataComponent CONSUMABLE = create("consumable"); + /** + * Marks the item as equippable. Of this component, only the {@code slot} property is translated. Other properties are done server-side, are done differently on Bedrock (e.g. {@code asset_id} is done via attachables), + * or are not possible on Bedrock at all (e.g. {@code camera_overlay}). + * + *

Note that on Bedrock, equippables can't have a stack size above 1.

+ * + * @see Equippable + */ + public static final DataComponent EQUIPPABLE = create("equippable"); + /** + * Food properties of the item. All properties properly translate over to Bedrock. + * + * @see FoodProperties + */ + public static final DataComponent FOOD = create("food"); + /** + * Max damage value of the item. Must be at or above 0. Items with a max damage value above 0 can't have a stack size above 1. + */ + public static final DataComponent MAX_DAMAGE = create("max_damage", i -> i >= 0); + /** + * Max stack size of the item. Must be between 1 and 99. Items with a max stack size value above 1 can't have a max damage value above 0. + */ + public static final DataComponent MAX_STACK_SIZE = create("max_stack_size", i -> i >= 1 && i <= 99); // Reverse lambda + /** + * Marks the item to have a use cooldown. To properly function, the item must be able to be used: it must be consumable or have some other kind of use logic. + * + * @see UseCooldown + */ + public static final DataComponent USE_COOLDOWN = create("use_cooldown"); + /** + * Marks the item to be enchantable. Must be at or above 0. + * + *

This component does not translate over perfectly, due to the way enchantments work on Bedrock. The component will be mapped to the {@code minecraft:enchantable} bedrock component with {@code slot=all}. + * This should, but does not guarantee, allow for compatibility with vanilla enchantments. Non-vanilla enchantments are unlikely to work.

+ */ + public static final DataComponent ENCHANTABLE = create("enchantable", i -> i >= 0); + /** + * This component is only used for the {@link ToolProperties#canDestroyBlocksInCreative()} option, which will be a feature in Java 1.21.5, but is already in use in Geyser. + * + *

Like other components, when not set this will fall back to the default value.

+ * + * @see ToolProperties + */ + public static final DataComponent TOOL = create("tool"); + /** + * Marks which items can be used to repair the item. + * + * @see Repairable + */ + public static final DataComponent REPAIRABLE = create("repairable"); + + private final Identifier identifier; + private final Predicate validator; + + private DataComponent(Identifier identifier, Predicate validator) { + this.identifier = identifier; + this.validator = validator; + } + + private static DataComponent create(String name) { + return new DataComponent<>(new Identifier(Identifier.DEFAULT_NAMESPACE, name), t -> true); + } + + private static DataComponent create(String name, Predicate validator) { + return new DataComponent<>(new Identifier(Identifier.DEFAULT_NAMESPACE, name), validator); + } + + public boolean validate(T value) { + return validator.test(value); + } + + @Override + public String toString() { + return "data component " + identifier.toString(); + } +} diff --git a/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/component/DataComponentMap.java b/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/component/DataComponentMap.java new file mode 100644 index 00000000000..2f9808442df --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/component/DataComponentMap.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.api.item.custom.v2.component; + +import java.util.Set; + +/** + * A map of data components to their values. Mainly used internally when mapping custom items. + */ +public interface DataComponentMap { + + /** + * @return the value of the given component, or null if it is not in the map. + */ + T get(DataComponent type); + + /** + * @return all data components in this map. + */ + Set> keySet(); +} diff --git a/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/component/Equippable.java b/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/component/Equippable.java new file mode 100644 index 00000000000..7646209dc14 --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/component/Equippable.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.api.item.custom.v2.component; + +public record Equippable(EquipmentSlot slot) { + + public enum EquipmentSlot { + HEAD, + CHEST, + LEGS, + FEET + } +} diff --git a/core/src/main/java/org/geysermc/geyser/item/components/WearableSlot.java b/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/component/FoodProperties.java similarity index 71% rename from core/src/main/java/org/geysermc/geyser/item/components/WearableSlot.java rename to api/src/main/java/org/geysermc/geyser/api/item/custom/v2/component/FoodProperties.java index 7ce488a512f..aa1229fa230 100644 --- a/core/src/main/java/org/geysermc/geyser/item/components/WearableSlot.java +++ b/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/component/FoodProperties.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019-2022 GeyserMC. http://geysermc.org + * Copyright (c) 2025 GeyserMC. http://geysermc.org * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal @@ -23,25 +23,15 @@ * @link https://github.com/GeyserMC/Geyser */ -package org.geysermc.geyser.item.components; +package org.geysermc.geyser.api.item.custom.v2.component; -import org.cloudburstmc.nbt.NbtMap; +import org.checkerframework.checker.index.qual.NonNegative; -import java.util.Locale; +public record FoodProperties(@NonNegative int nutrition, @NonNegative float saturation, boolean canAlwaysEat) { -public enum WearableSlot { - HEAD, - CHEST, - LEGS, - FEET; - - private final NbtMap slotNbt; - - WearableSlot() { - this.slotNbt = NbtMap.builder().putString("slot", "slot.armor." + this.name().toLowerCase(Locale.ROOT)).build(); - } - - public NbtMap getSlotNbt() { - return slotNbt; + public FoodProperties { + if (nutrition < 0 || saturation < 0.0F) { + throw new IllegalArgumentException("Nutrition and saturation must be at or above 0"); + } } } diff --git a/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/component/Repairable.java b/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/component/Repairable.java new file mode 100644 index 00000000000..9a7c01d0696 --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/component/Repairable.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.api.item.custom.v2.component; + +import org.geysermc.geyser.api.util.Identifier; + +public record Repairable(Identifier... items) { +} diff --git a/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/component/ToolProperties.java b/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/component/ToolProperties.java new file mode 100644 index 00000000000..eb04e09daab --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/component/ToolProperties.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.api.item.custom.v2.component; + +public record ToolProperties(boolean canDestroyBlocksInCreative) { +} diff --git a/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/component/UseCooldown.java b/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/component/UseCooldown.java new file mode 100644 index 00000000000..05e01a412f3 --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/component/UseCooldown.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.api.item.custom.v2.component; + +import org.checkerframework.checker.index.qual.Positive; +import org.geysermc.geyser.api.util.Identifier; + +public record UseCooldown(@Positive float seconds, Identifier cooldownGroup) { + + public UseCooldown { + if (seconds <= 0.0F) { + throw new IllegalArgumentException("Cooldown seconds must be above 0"); + } + } +} diff --git a/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/predicate/ConditionItemPredicate.java b/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/predicate/ConditionItemPredicate.java new file mode 100644 index 00000000000..6ceb0d68b8c --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/predicate/ConditionItemPredicate.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.api.item.custom.v2.predicate; + +import org.geysermc.geyser.api.item.custom.v2.predicate.condition.ConditionPredicateProperty; + +/** + * @see org.geysermc.geyser.api.item.custom.v2.predicate.CustomItemPredicate#condition(ConditionPredicateProperty, boolean, Object) + */ +public interface ConditionItemPredicate extends CustomItemPredicate { + + ConditionPredicateProperty property(); + + boolean expected(); + + T data(); +} diff --git a/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/predicate/CustomItemPredicate.java b/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/predicate/CustomItemPredicate.java new file mode 100644 index 00000000000..3a1747d7a13 --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/predicate/CustomItemPredicate.java @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2024 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.api.item.custom.v2.predicate; + +import org.geysermc.geyser.api.GeyserApi; +import org.geysermc.geyser.api.item.custom.v2.predicate.condition.ConditionPredicateProperty; +import org.geysermc.geyser.api.item.custom.v2.predicate.match.MatchPredicateProperty; + +public interface CustomItemPredicate { + + static ConditionItemPredicate condition(ConditionPredicateProperty property) { + return condition(property, true); + } + + static ConditionItemPredicate condition(ConditionPredicateProperty property, T data) { + return condition(property, true, data); + } + + static ConditionItemPredicate condition(ConditionPredicateProperty property, boolean expected) { + return condition(property, expected, null); + } + + /** + * A predicate that checks for a certain boolean property of the item stack and returns true if it matches the expected value. + * + * @param property the property to check. + * @param expected whether the property should be true or false. Defaults to true. + * @param data the data used by the predicate. Only used by some predicates, defaults to null. + */ + static ConditionItemPredicate condition(ConditionPredicateProperty property, boolean expected, T data) { + return GeyserApi.api().provider(ConditionItemPredicate.class, property, expected, data); + } + + /** + * A predicate that matches a property of the item stack and returns true if it matches the expected value. + * + * @param property the property to check for. + * @param data the value expected. + */ + static MatchItemPredicate match(MatchPredicateProperty property, T data) { + return GeyserApi.api().provider(MatchItemPredicate.class, property, data); + } + + static RangeDispatchItemPredicate rangeDispatch(RangeDispatchPredicateProperty property, double threshold) { + return rangeDispatch(property, threshold, 1.0); + } + + static RangeDispatchItemPredicate rangeDispatch(RangeDispatchPredicateProperty property, double threshold, double scale) { + return rangeDispatch(property, threshold, scale, false, 0); + } + + static RangeDispatchItemPredicate rangeDispatch(RangeDispatchPredicateProperty property, double threshold, boolean normalizeIfPossible) { + return rangeDispatch(property, threshold, 1.0, normalizeIfPossible, 0); + } + + static RangeDispatchItemPredicate rangeDispatch(RangeDispatchPredicateProperty property, double threshold, double scale, boolean normalizeIfPossible) { + return rangeDispatch(property, threshold, scale, normalizeIfPossible, 0); + } + + /** + * A predicate that checks for a certain numeric property of the item stack and returns true if it is above the specified threshold. + * + * @param property the property to check. + * @param threshold the threshold the property should be above. + * @param scale factor to multiply the property value with before comparing it with the threshold. Defaults to 1.0. + * @param normalizeIfPossible if the property value should be normalised to a value between 0.0 and 1.0 before scaling and comparing. Defaults to false. Only works for certain properties. + * @param index only used for the {@link RangeDispatchPredicateProperty#CUSTOM_MODEL_DATA} property, determines which float of the item's custom model data to check. Defaults to 0. + */ + static RangeDispatchItemPredicate rangeDispatch(RangeDispatchPredicateProperty property, double threshold, double scale, boolean normalizeIfPossible, int index) { + return GeyserApi.api().provider(RangeDispatchItemPredicate.class, property, threshold, scale, normalizeIfPossible, index); + } +} diff --git a/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/predicate/MatchItemPredicate.java b/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/predicate/MatchItemPredicate.java new file mode 100644 index 00000000000..6b099cd24a6 --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/predicate/MatchItemPredicate.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.api.item.custom.v2.predicate; + +import org.geysermc.geyser.api.item.custom.v2.predicate.match.MatchPredicateProperty; + +/** + * @see org.geysermc.geyser.api.item.custom.v2.predicate.CustomItemPredicate#match(MatchPredicateProperty, Object) + */ +public interface MatchItemPredicate extends CustomItemPredicate { + + MatchPredicateProperty property(); + + T data(); +} diff --git a/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/predicate/PredicateStrategy.java b/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/predicate/PredicateStrategy.java new file mode 100644 index 00000000000..6168112bb56 --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/predicate/PredicateStrategy.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.api.item.custom.v2.predicate; + +public enum PredicateStrategy { + /** + * Require all predicates to pass for the item to be used. + */ + AND, + /** + * Require only one of the predicates to pass for the item to be used. + */ + OR +} diff --git a/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/predicate/RangeDispatchItemPredicate.java b/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/predicate/RangeDispatchItemPredicate.java new file mode 100644 index 00000000000..1b7c228c290 --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/predicate/RangeDispatchItemPredicate.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.api.item.custom.v2.predicate; + +/** + * @see org.geysermc.geyser.api.item.custom.v2.predicate.CustomItemPredicate#rangeDispatch(RangeDispatchPredicateProperty, double, double, boolean, int) + */ +public interface RangeDispatchItemPredicate extends CustomItemPredicate { + + RangeDispatchPredicateProperty property(); + + double threshold(); + + double scale(); + + boolean normalizeIfPossible(); + + int index(); +} diff --git a/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/predicate/RangeDispatchPredicateProperty.java b/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/predicate/RangeDispatchPredicateProperty.java new file mode 100644 index 00000000000..5315d5bcd93 --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/predicate/RangeDispatchPredicateProperty.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.api.item.custom.v2.predicate; + +public enum RangeDispatchPredicateProperty { + /** + * Checks the item's bundle fullness. Returns the total stack count of all the items in a bundle. + * + *

Usually used with bundles, but works for any item with the {@code minecraft:bundle_contents} component.

+ */ + BUNDLE_FULLNESS, + /** + * Checks the item's damage value. Can be normalised. + */ + DAMAGE, + /** + * Checks the item's stack count. Can be normalised. + */ + COUNT, + /** + * Checks one of the item's custom model data floats, defaults to 0.0. + */ + CUSTOM_MODEL_DATA +} diff --git a/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/predicate/condition/ConditionPredicateProperty.java b/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/predicate/condition/ConditionPredicateProperty.java new file mode 100644 index 00000000000..81eb7f92585 --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/predicate/condition/ConditionPredicateProperty.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.api.item.custom.v2.predicate.condition; + +import org.geysermc.geyser.api.util.Identifier; + +public final class ConditionPredicateProperty { + + /** + * Checks if the item is broken (has 1 durability point left). + */ + public static final ConditionPredicateProperty BROKEN = createNoData(); + /** + * Checks if the item is damaged (has non-full durability). + */ + public static final ConditionPredicateProperty DAMAGED = createNoData(); + /** + * Returns one of the item's custom model data flags, defaults to false. Data in the predicate is an integer that sets the index of the flags to check. + */ + public static final ConditionPredicateProperty CUSTOM_MODEL_DATA = create(); + /** + * Returns true if the item stack has a component with the identifier set in the predicate data. + */ + public static final ConditionPredicateProperty HAS_COMPONENT = create(); + + public final boolean requiresData; + + private ConditionPredicateProperty(boolean requiresData) { + this.requiresData = requiresData; + } + + private static ConditionPredicateProperty create() { + return new ConditionPredicateProperty<>(true); + } + + private static ConditionPredicateProperty createNoData() { + return new ConditionPredicateProperty<>(false); + } +} diff --git a/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/predicate/match/ChargeType.java b/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/predicate/match/ChargeType.java new file mode 100644 index 00000000000..a9a70c47987 --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/predicate/match/ChargeType.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2024 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.api.item.custom.v2.predicate.match; + +/** + * Values returned by the {@link MatchPredicateProperty#CHARGE_TYPE} predicate property. + */ +public enum ChargeType { + /** + * Returned if there are no projectiles loaded in the crossbow. + */ + NONE, + /** + * Returned if there are any projectiles (except fireworks) loaded in the crossbow. + */ + ARROW, + /** + * Returned if there are firework rocket projectiles loaded in the crossbow. + */ + ROCKET +} diff --git a/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/predicate/match/CustomModelDataString.java b/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/predicate/match/CustomModelDataString.java new file mode 100644 index 00000000000..c4020105893 --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/predicate/match/CustomModelDataString.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2024 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.api.item.custom.v2.predicate.match; + +public record CustomModelDataString(String value, int index) { +} diff --git a/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/predicate/match/MatchPredicateProperty.java b/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/predicate/match/MatchPredicateProperty.java new file mode 100644 index 00000000000..e9d8041cc8a --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/item/custom/v2/predicate/match/MatchPredicateProperty.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2024 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.api.item.custom.v2.predicate.match; + +import org.geysermc.geyser.api.util.Identifier; + +public final class MatchPredicateProperty { + + /** + * Matches for the item's charged projectile. Usually used with crossbows, but checks any item with the {@code minecraft:charged_projectiles} component. + */ + public static final MatchPredicateProperty CHARGE_TYPE = create(); + /** + * Matches the item's trim material identifier. Works for any item with the {@code minecraft:trim} component. + */ + public static final MatchPredicateProperty TRIM_MATERIAL = create(); + /** + * Matches the dimension identifier the Bedrock session player is currently in. + */ + public static final MatchPredicateProperty CONTEXT_DIMENSION = create(); + /** + * Matches a string of the item's custom model data strings. + */ + public static final MatchPredicateProperty CUSTOM_MODEL_DATA = create(); + + private MatchPredicateProperty() {} + + private static MatchPredicateProperty create() { + return new MatchPredicateProperty<>(); + } +} diff --git a/api/src/main/java/org/geysermc/geyser/api/util/CreativeCategory.java b/api/src/main/java/org/geysermc/geyser/api/util/CreativeCategory.java index 245eb9bc271..430b580340c 100644 --- a/api/src/main/java/org/geysermc/geyser/api/util/CreativeCategory.java +++ b/api/src/main/java/org/geysermc/geyser/api/util/CreativeCategory.java @@ -26,6 +26,7 @@ package org.geysermc.geyser.api.util; import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; /** * Represents the creative menu categories or tabs. @@ -37,21 +38,21 @@ public enum CreativeCategory { ITEMS("items", 4), NONE("none", 6); - private final String internalName; + private final String bedrockName; private final int id; - CreativeCategory(String internalName, int id) { - this.internalName = internalName; + CreativeCategory(String bedrockName, int id) { + this.bedrockName = bedrockName; this.id = id; } /** - * Gets the internal name of the category. + * Gets the bedrock name (used in behaviour packs) of the category. * * @return the name of the category */ - public @NonNull String internalName() { - return internalName; + public @NonNull String bedrockName() { + return bedrockName; } /** @@ -62,4 +63,18 @@ public enum CreativeCategory { public int id() { return id; } + + /** + * Gets the creative category from its bedrock name. + * + * @return the creative category, or null if not found. + */ + public static @Nullable CreativeCategory fromName(String name) { + for (CreativeCategory category : values()) { + if (category.bedrockName.equals(name)) { + return category; + } + } + return null; + } } diff --git a/api/src/main/java/org/geysermc/geyser/api/util/Identifier.java b/api/src/main/java/org/geysermc/geyser/api/util/Identifier.java new file mode 100644 index 00000000000..d74c9ed63c9 --- /dev/null +++ b/api/src/main/java/org/geysermc/geyser/api/util/Identifier.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2024 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.api.util; + +import java.util.Objects; +import java.util.function.Function; + +public final class Identifier { + public static final String DEFAULT_NAMESPACE = "minecraft"; + private final String namespace; + private final String path; + + public Identifier(String namespace, String path) { + this.namespace = namespace; + this.path = path; + validate(); + } + + public Identifier(String identifier) { + String[] split = identifier.split(":"); + if (split.length == 1) { + namespace = DEFAULT_NAMESPACE; + path = split[0]; + } else if (split.length == 2) { + namespace = split[0]; + path = split[1]; + } else { + throw new IllegalArgumentException("':' in identifier path: " + identifier); + } + validate(); + } + + private void validate() { + checkString(namespace, "namespace", Identifier::allowedInNamespace); + checkString(path, "path", Identifier::allowedInPath); + } + + public String namespace() { + return namespace; + } + + public String path() { + return path; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) { + return false; + } + Identifier other = (Identifier) o; + return Objects.equals(namespace, other.namespace) && Objects.equals(path, other.path); + } + + @Override + public int hashCode() { + return Objects.hash(namespace, path); + } + + @Override + public String toString() { + return namespace + ":" + path; + } + + private static void checkString(String string, String type, Function characterChecker) { + for (int i = 0; i < string.length(); i++) { + if (!characterChecker.apply(string.charAt(i))) { + throw new IllegalArgumentException("Illegal character in " + type + " " + string); + } + } + } + + private static boolean allowedInNamespace(char character) { + return character == '_' || character == '-' || character >= 'a' && character <= 'z' || character >= '0' && character <= '9' || character == '.'; + } + + private static boolean allowedInPath(char character) { + return character == '_' || character == '-' || character >= 'a' && character <= 'z' || character >= '0' && character <= '9' || character == '.' || character == '/'; + } +} diff --git a/core/src/main/java/org/geysermc/geyser/event/type/GeyserDefineCustomItemsEventImpl.java b/core/src/main/java/org/geysermc/geyser/event/type/GeyserDefineCustomItemsEventImpl.java index b9a059f19ac..8fb0806fa8f 100644 --- a/core/src/main/java/org/geysermc/geyser/event/type/GeyserDefineCustomItemsEventImpl.java +++ b/core/src/main/java/org/geysermc/geyser/event/type/GeyserDefineCustomItemsEventImpl.java @@ -26,10 +26,14 @@ package org.geysermc.geyser.event.type; import com.google.common.collect.Multimap; +import com.google.common.collect.MultimapBuilder; import org.checkerframework.checker.nullness.qual.NonNull; import org.geysermc.geyser.api.event.lifecycle.GeyserDefineCustomItemsEvent; +import org.geysermc.geyser.api.exception.CustomItemDefinitionRegisterException; import org.geysermc.geyser.api.item.custom.CustomItemData; import org.geysermc.geyser.api.item.custom.NonVanillaCustomItemData; +import org.geysermc.geyser.api.item.custom.v2.CustomItemDefinition; +import org.geysermc.geyser.api.util.Identifier; import java.util.Collection; import java.util.Collections; @@ -37,49 +41,40 @@ import java.util.Map; public abstract class GeyserDefineCustomItemsEventImpl implements GeyserDefineCustomItemsEvent { - private final Multimap customItems; + private final Multimap deprecatedCustomItems = MultimapBuilder.hashKeys().arrayListValues().build(); + private final Multimap customItems; private final List nonVanillaCustomItems; - public GeyserDefineCustomItemsEventImpl(Multimap customItems, List nonVanillaCustomItems) { + public GeyserDefineCustomItemsEventImpl(Multimap customItems, List nonVanillaCustomItems) { this.customItems = customItems; this.nonVanillaCustomItems = nonVanillaCustomItems; } - /** - * Gets a multimap of all the already registered custom items indexed by the item's extended java item's identifier. - * - * @return a multimap of all the already registered custom items - */ @Override + @Deprecated public @NonNull Map> getExistingCustomItems() { - return Collections.unmodifiableMap(this.customItems.asMap()); + return Collections.unmodifiableMap(deprecatedCustomItems.asMap()); + } + + @Override + public @NonNull Map> getExistingCustomItemDefinitions() { + return Collections.unmodifiableMap(customItems.asMap()); } - /** - * Gets the list of the already registered non-vanilla custom items. - * - * @return the list of the already registered non-vanilla custom items - */ @Override public @NonNull List getExistingNonVanillaCustomItems() { return Collections.unmodifiableList(this.nonVanillaCustomItems); } - /** - * Registers a custom item with a base Java item. This is used to register items with custom textures and properties - * based on NBT data. - * - * @param identifier the base (java) item - * @param customItemData the custom item data to register - * @return if the item was registered - */ - public abstract boolean register(@NonNull String identifier, @NonNull CustomItemData customItemData); - - /** - * Registers a custom item with no base item. This is used for mods. - * - * @param customItemData the custom item data to register - * @return if the item was registered - */ - public abstract boolean register(@NonNull NonVanillaCustomItemData customItemData); + @Override + @Deprecated + public boolean register(@NonNull String identifier, @NonNull CustomItemData customItemData) { + try { + register(identifier, customItemData.toDefinition(new Identifier(identifier)).build()); + deprecatedCustomItems.put(identifier, customItemData); + return true; + } catch (CustomItemDefinitionRegisterException exception) { + return false; + } + } } diff --git a/core/src/main/java/org/geysermc/geyser/item/GeyserCustomMappingData.java b/core/src/main/java/org/geysermc/geyser/item/GeyserCustomMappingData.java index d71f2e54863..9f8d10b8d27 100644 --- a/core/src/main/java/org/geysermc/geyser/item/GeyserCustomMappingData.java +++ b/core/src/main/java/org/geysermc/geyser/item/GeyserCustomMappingData.java @@ -27,6 +27,7 @@ import org.cloudburstmc.protocol.bedrock.data.definitions.ItemDefinition; import org.cloudburstmc.protocol.bedrock.data.inventory.ComponentItemData; +import org.geysermc.geyser.api.item.custom.v2.CustomItemDefinition; -public record GeyserCustomMappingData(ComponentItemData componentItemData, ItemDefinition itemDefinition, String stringId, int integerId) { +public record GeyserCustomMappingData(CustomItemDefinition definition, ComponentItemData componentItemData, ItemDefinition itemDefinition, int integerId) { } diff --git a/core/src/main/java/org/geysermc/geyser/item/custom/ComponentConverters.java b/core/src/main/java/org/geysermc/geyser/item/custom/ComponentConverters.java new file mode 100644 index 00000000000..5830e07d3f2 --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/item/custom/ComponentConverters.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2025 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.item.custom; + +import org.cloudburstmc.nbt.NbtMapBuilder; +import org.geysermc.geyser.api.item.custom.v2.component.DataComponent; +import org.geysermc.geyser.api.item.custom.v2.component.DataComponentMap; +import org.geysermc.geyser.api.item.custom.v2.component.Repairable; +import org.geysermc.geyser.util.MinecraftKey; +import org.geysermc.mcprotocollib.protocol.data.game.entity.EquipmentSlot; +import org.geysermc.mcprotocollib.protocol.data.game.item.component.Consumable; +import org.geysermc.mcprotocollib.protocol.data.game.item.component.DataComponentType; +import org.geysermc.mcprotocollib.protocol.data.game.item.component.DataComponents; +import org.geysermc.mcprotocollib.protocol.data.game.item.component.Equippable; +import org.geysermc.mcprotocollib.protocol.data.game.item.component.FoodProperties; +import org.geysermc.mcprotocollib.protocol.data.game.item.component.UseCooldown; +import org.geysermc.mcprotocollib.protocol.data.game.level.sound.BuiltinSound; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +// Why is this coded so weirdly, you ask? +// Why, it's because of two reasons! +// First of all, Java generics are a bit limited :( +// Second, the API module has its own set of component classes, because MCPL can't be used in there. +// However, those component classes have the same names as the MCPL ones, which causes some issues when they both have to be used in the same file. +// One can't be imported, and as such its full qualifier (e.g. org.geysermc.mcprotocollib.protocol.data.game.item.component.Consumable) would have to be used. +// That would be a mess to code in, and as such this code here was carefully designed to only require one set of component classes by name (the MCPL ones). +// +// It is VERY IMPORTANT to note that for every component in the API, a converter to MCPL must be put here (there are some exceptions as noted in the Javadoc, better solutions are welcome). +/** + * This class is used to convert components from the API module to MCPL ones. + * + *

Most components convert over nicely, and it is very much preferred to have every API component have a converter in here. However, this is not always possible. At the moment, there are 2 exceptions: + *

    + *
  • The {@link DataComponent#TOOL} component doesn't convert over to its MCPL counterpart as the only reason it's in the API as of right now is the {@code canDestroyInCreative} property. This is a 1.21.5 property, + * and once Geyser for 1.21.5 releases, this component should have a converter in here.
  • + *
  • The MCPL counterpart of the {@link DataComponent#REPAIRABLE} component is just an ID holder set, which can't be used in the custom item registry populator. + * Also see {@link org.geysermc.geyser.registry.populator.CustomItemRegistryPopulator#computeRepairableProperties(Repairable, NbtMapBuilder)}.
  • + *
+ * For both of these cases proper accommodations have been made in the {@link org.geysermc.geyser.registry.populator.CustomItemRegistryPopulator}. + */ +public class ComponentConverters { + private static final Map, ComponentConverter> converters = new HashMap<>(); + + static { + registerConverter(DataComponent.CONSUMABLE, (itemMap, value) -> { + Consumable.ItemUseAnimation convertedAnimation = switch (value.animation()) { + case NONE -> Consumable.ItemUseAnimation.NONE; + case EAT -> Consumable.ItemUseAnimation.EAT; + case DRINK -> Consumable.ItemUseAnimation.DRINK; + case BLOCK -> Consumable.ItemUseAnimation.BLOCK; + case BOW -> Consumable.ItemUseAnimation.BOW; + case SPEAR -> Consumable.ItemUseAnimation.SPEAR; + case CROSSBOW -> Consumable.ItemUseAnimation.CROSSBOW; + case SPYGLASS -> Consumable.ItemUseAnimation.SPYGLASS; + case BRUSH -> Consumable.ItemUseAnimation.BRUSH; + }; + itemMap.put(DataComponentType.CONSUMABLE, new Consumable(value.consumeSeconds(), convertedAnimation, BuiltinSound.ENTITY_GENERIC_EAT, + true, List.of())); + }); + + registerConverter(DataComponent.EQUIPPABLE, (itemMap, value) -> { + EquipmentSlot convertedSlot = switch (value.slot()) { + case HEAD -> EquipmentSlot.HELMET; + case CHEST -> EquipmentSlot.CHESTPLATE; + case LEGS -> EquipmentSlot.LEGGINGS; + case FEET -> EquipmentSlot.BOOTS; + }; + itemMap.put(DataComponentType.EQUIPPABLE, new Equippable(convertedSlot, BuiltinSound.ITEM_ARMOR_EQUIP_GENERIC, + null, null, null, false, false, false)); + }); + + registerConverter(DataComponent.FOOD, (itemMap, value) -> itemMap.put(DataComponentType.FOOD, + new FoodProperties(value.nutrition(), value.saturation(), value.canAlwaysEat()))); + + registerConverter(DataComponent.MAX_DAMAGE, (itemMap, value) -> itemMap.put(DataComponentType.MAX_DAMAGE, value)); + registerConverter(DataComponent.MAX_STACK_SIZE, (itemMap, value) -> itemMap.put(DataComponentType.MAX_STACK_SIZE, value)); + + registerConverter(DataComponent.USE_COOLDOWN, (itemMap, value) -> itemMap.put(DataComponentType.USE_COOLDOWN, + new UseCooldown(value.seconds(), MinecraftKey.identifierToKey(value.cooldownGroup())))); + + registerConverter(DataComponent.ENCHANTABLE, (itemMap, value) -> itemMap.put(DataComponentType.ENCHANTABLE, value)); + } + + private static void registerConverter(DataComponent component, ComponentConverter converter) { + converters.put(component, converter); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + public static void convertAndPutComponents(DataComponents itemMap, DataComponentMap customDefinitionMap) { + for (DataComponent component : customDefinitionMap.keySet()) { + ComponentConverter converter = converters.get(component); + if (converter != null) { + Object value = customDefinitionMap.get(component); + converter.convertAndPut(itemMap, value); + } + } + } + + @FunctionalInterface + public interface ComponentConverter { + + void convertAndPut(DataComponents itemMap, T value); + } +} diff --git a/core/src/main/java/org/geysermc/geyser/item/custom/GeyserCustomItemBedrockOptions.java b/core/src/main/java/org/geysermc/geyser/item/custom/GeyserCustomItemBedrockOptions.java new file mode 100644 index 00000000000..3119f9381e7 --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/item/custom/GeyserCustomItemBedrockOptions.java @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2024 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.item.custom; + +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.geysermc.geyser.api.item.custom.CustomRenderOffsets; +import org.geysermc.geyser.api.item.custom.v2.CustomItemBedrockOptions; +import org.geysermc.geyser.api.util.CreativeCategory; + +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; + +public record GeyserCustomItemBedrockOptions(@Nullable String icon, boolean allowOffhand, boolean displayHandheld, int protectionValue, + @NonNull CreativeCategory creativeCategory, @Nullable String creativeGroup, @NonNull Set tags) implements CustomItemBedrockOptions { + + public static class Builder implements CustomItemBedrockOptions.Builder { + private String icon = null; + private boolean allowOffhand = true; + private boolean displayHandheld = false; + private int protectionValue = 0; + private CreativeCategory creativeCategory = CreativeCategory.NONE; + private String creativeGroup = null; + private Set tags = new HashSet<>(); + + @Override + public Builder icon(@Nullable String icon) { + this.icon = icon; + return this; + } + + @Override + public Builder allowOffhand(boolean allowOffhand) { + this.allowOffhand = allowOffhand; + return this; + } + + @Override + public Builder displayHandheld(boolean displayHandheld) { + this.displayHandheld = displayHandheld; + return this; + } + + @Override + public Builder protectionValue(int protectionValue) { + this.protectionValue = protectionValue; + return this; + } + + @Override + public Builder creativeCategory(CreativeCategory creativeCategory) { + this.creativeCategory = creativeCategory; + return this; + } + + @Override + public Builder creativeGroup(@Nullable String creativeGroup) { + this.creativeGroup = creativeGroup; + return this; + } + + @Override + public Builder tags(@Nullable Set tags) { + this.tags = Objects.requireNonNullElseGet(tags, Set::of); + return this; + } + + @Override + public CustomItemBedrockOptions build() { + return new GeyserCustomItemBedrockOptions(icon, allowOffhand, displayHandheld, protectionValue, + creativeCategory, creativeGroup, tags); + } + } +} diff --git a/core/src/main/java/org/geysermc/geyser/item/custom/GeyserCustomItemDefinition.java b/core/src/main/java/org/geysermc/geyser/item/custom/GeyserCustomItemDefinition.java new file mode 100644 index 00000000000..dc160a6ec0d --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/item/custom/GeyserCustomItemDefinition.java @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2024 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.item.custom; + +import it.unimi.dsi.fastutil.objects.Reference2ObjectMap; +import it.unimi.dsi.fastutil.objects.Reference2ObjectOpenHashMap; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.geysermc.geyser.api.item.custom.v2.CustomItemBedrockOptions; +import org.geysermc.geyser.api.item.custom.v2.CustomItemDefinition; +import org.geysermc.geyser.api.item.custom.v2.component.DataComponentMap; +import org.geysermc.geyser.api.item.custom.v2.component.DataComponent; +import org.geysermc.geyser.api.item.custom.v2.predicate.CustomItemPredicate; +import org.geysermc.geyser.api.item.custom.v2.predicate.PredicateStrategy; +import org.geysermc.geyser.api.util.Identifier; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +public record GeyserCustomItemDefinition(@NonNull Identifier bedrockIdentifier, String displayName, @NonNull Identifier model, @NonNull List predicates, + PredicateStrategy predicateStrategy, + int priority, @NonNull CustomItemBedrockOptions bedrockOptions, @NonNull DataComponentMap components) implements CustomItemDefinition { + + public static class Builder implements CustomItemDefinition.Builder { + private final Identifier bedrockIdentifier; + private final Identifier model; + private final List predicates = new ArrayList<>(); + private final Reference2ObjectMap, Object> components = new Reference2ObjectOpenHashMap<>(); + + private String displayName; + private int priority = 0; + private CustomItemBedrockOptions bedrockOptions = CustomItemBedrockOptions.builder().build(); + private PredicateStrategy predicateStrategy = PredicateStrategy.AND; + + public Builder(Identifier bedrockIdentifier, Identifier model) { + this.bedrockIdentifier = bedrockIdentifier; + this.displayName = bedrockIdentifier.toString(); + this.model = model; + } + + @Override + public CustomItemDefinition.Builder displayName(String displayName) { + this.displayName = displayName; + return this; + } + + @Override + public CustomItemDefinition.Builder priority(int priority) { + this.priority = priority; + return this; + } + + @Override + public CustomItemDefinition.Builder bedrockOptions(CustomItemBedrockOptions.@NonNull Builder options) { + this.bedrockOptions = options.build(); + return this; + } + + @Override + public CustomItemDefinition.Builder predicate(@NonNull CustomItemPredicate predicate) { + predicates.add(predicate); + return this; + } + + @Override + public CustomItemDefinition.Builder predicateStrategy(@NonNull PredicateStrategy strategy) { + predicateStrategy = strategy; + return this; + } + + @Override + public CustomItemDefinition.Builder component(@NonNull DataComponent component, @NonNull T value) { + if (!component.validate(value)) { + throw new IllegalArgumentException("Value " + value + " is invalid for " + component); + } + components.put(component, value); + return this; + } + + @Override + public CustomItemDefinition build() { + return new GeyserCustomItemDefinition(bedrockIdentifier, displayName, model, List.copyOf(predicates), predicateStrategy, priority, bedrockOptions, + new GeyserCustomItemDefinition.ComponentMap(components)); + } + } + + private record ComponentMap(Reference2ObjectMap, Object> components) implements DataComponentMap { + + @Override + public T get(DataComponent type) { + return (T) components.get(type); + } + + @Override + public Set> keySet() { + return components.keySet(); + } + } +} diff --git a/core/src/main/java/org/geysermc/geyser/item/custom/predicate/ConditionPredicate.java b/core/src/main/java/org/geysermc/geyser/item/custom/predicate/ConditionPredicate.java new file mode 100644 index 00000000000..8474f38cc54 --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/item/custom/predicate/ConditionPredicate.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2024-2025 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.item.custom.predicate; + +import org.geysermc.geyser.api.item.custom.v2.predicate.ConditionItemPredicate; +import org.geysermc.geyser.api.item.custom.v2.predicate.condition.ConditionPredicateProperty; + +public record ConditionPredicate(ConditionPredicateProperty property, boolean expected, T data) implements ConditionItemPredicate { +} diff --git a/core/src/main/java/org/geysermc/geyser/item/custom/predicate/MatchPredicate.java b/core/src/main/java/org/geysermc/geyser/item/custom/predicate/MatchPredicate.java new file mode 100644 index 00000000000..01095f3949b --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/item/custom/predicate/MatchPredicate.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2024-2025 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.item.custom.predicate; + +import org.geysermc.geyser.api.item.custom.v2.predicate.MatchItemPredicate; +import org.geysermc.geyser.api.item.custom.v2.predicate.match.MatchPredicateProperty; + +public record MatchPredicate(MatchPredicateProperty property, T data) implements MatchItemPredicate { +} diff --git a/core/src/main/java/org/geysermc/geyser/item/custom/predicate/RangeDispatchPredicate.java b/core/src/main/java/org/geysermc/geyser/item/custom/predicate/RangeDispatchPredicate.java new file mode 100644 index 00000000000..1a5958ff34a --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/item/custom/predicate/RangeDispatchPredicate.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2024-2025 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.item.custom.predicate; + +import org.geysermc.geyser.api.item.custom.v2.predicate.RangeDispatchItemPredicate; +import org.geysermc.geyser.api.item.custom.v2.predicate.RangeDispatchPredicateProperty; + +public record RangeDispatchPredicate(RangeDispatchPredicateProperty property, double threshold, double scale, boolean normalizeIfPossible, int index) implements RangeDispatchItemPredicate { +} diff --git a/core/src/main/java/org/geysermc/geyser/item/exception/InvalidCustomMappingsFileException.java b/core/src/main/java/org/geysermc/geyser/item/exception/InvalidCustomMappingsFileException.java index 99ca463d761..b2452fe0d2e 100644 --- a/core/src/main/java/org/geysermc/geyser/item/exception/InvalidCustomMappingsFileException.java +++ b/core/src/main/java/org/geysermc/geyser/item/exception/InvalidCustomMappingsFileException.java @@ -35,4 +35,8 @@ public class InvalidCustomMappingsFileException extends Exception { public InvalidCustomMappingsFileException(String message) { super(message); } + + public InvalidCustomMappingsFileException(String task, String error, String... context) { + this("While " + task + " in " + String.join(" in ", context) + ": " + error); + } } diff --git a/core/src/main/java/org/geysermc/geyser/item/exception/InvalidItemComponentsException.java b/core/src/main/java/org/geysermc/geyser/item/exception/InvalidItemComponentsException.java new file mode 100644 index 00000000000..c2109dd8cfd --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/item/exception/InvalidItemComponentsException.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2024 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.item.exception; + +public class InvalidItemComponentsException extends Exception { + + public InvalidItemComponentsException(String message) { + super(message); + } +} diff --git a/core/src/main/java/org/geysermc/geyser/item/type/PotionItem.java b/core/src/main/java/org/geysermc/geyser/item/type/PotionItem.java index 89e60b32506..03508262bda 100644 --- a/core/src/main/java/org/geysermc/geyser/item/type/PotionItem.java +++ b/core/src/main/java/org/geysermc/geyser/item/type/PotionItem.java @@ -49,7 +49,7 @@ public ItemData.Builder translateToBedrock(GeyserSession session, int count, Dat if (components == null) return super.translateToBedrock(session, count, components, mapping, mappings); PotionContents potionContents = components.get(DataComponentType.POTION_CONTENTS); if (potionContents != null) { - ItemDefinition customItemDefinition = CustomItemTranslator.getCustomItem(components, mapping); + ItemDefinition customItemDefinition = CustomItemTranslator.getCustomItem(session, count, components, mapping); if (customItemDefinition == null) { Potion potion = Potion.getByJavaId(potionContents.getPotionId()); if (potion != null) { diff --git a/core/src/main/java/org/geysermc/geyser/item/type/ShulkerBoxItem.java b/core/src/main/java/org/geysermc/geyser/item/type/ShulkerBoxItem.java index 5d14f748ce3..b086a7f5b62 100644 --- a/core/src/main/java/org/geysermc/geyser/item/type/ShulkerBoxItem.java +++ b/core/src/main/java/org/geysermc/geyser/item/type/ShulkerBoxItem.java @@ -75,7 +75,7 @@ public void translateComponentsToBedrock(@NonNull GeyserSession session, @NonNul if (boxComponents != null) { // Check for custom items - ItemDefinition customItemDefinition = CustomItemTranslator.getCustomItem(boxComponents, boxMapping); + ItemDefinition customItemDefinition = CustomItemTranslator.getCustomItem(session, item.getAmount(), boxComponents, boxMapping); if (customItemDefinition != null) { bedrockIdentifier = customItemDefinition.getIdentifier(); bedrockData = 0; diff --git a/core/src/main/java/org/geysermc/geyser/registry/loader/ProviderRegistryLoader.java b/core/src/main/java/org/geysermc/geyser/registry/loader/ProviderRegistryLoader.java index 94de0c29858..2fb7ab39b05 100644 --- a/core/src/main/java/org/geysermc/geyser/registry/loader/ProviderRegistryLoader.java +++ b/core/src/main/java/org/geysermc/geyser/registry/loader/ProviderRegistryLoader.java @@ -39,7 +39,16 @@ import org.geysermc.geyser.api.item.custom.CustomItemData; import org.geysermc.geyser.api.item.custom.CustomItemOptions; import org.geysermc.geyser.api.item.custom.NonVanillaCustomItemData; +import org.geysermc.geyser.api.item.custom.v2.CustomItemBedrockOptions; +import org.geysermc.geyser.api.item.custom.v2.CustomItemDefinition; +import org.geysermc.geyser.api.item.custom.v2.predicate.ConditionItemPredicate; +import org.geysermc.geyser.api.item.custom.v2.predicate.MatchItemPredicate; +import org.geysermc.geyser.api.item.custom.v2.predicate.RangeDispatchItemPredicate; +import org.geysermc.geyser.api.item.custom.v2.predicate.RangeDispatchPredicateProperty; +import org.geysermc.geyser.api.item.custom.v2.predicate.condition.ConditionPredicateProperty; +import org.geysermc.geyser.api.item.custom.v2.predicate.match.MatchPredicateProperty; import org.geysermc.geyser.api.pack.PathPackCodec; +import org.geysermc.geyser.api.util.Identifier; import org.geysermc.geyser.impl.camera.GeyserCameraFade; import org.geysermc.geyser.impl.camera.GeyserCameraPosition; import org.geysermc.geyser.event.GeyserEventRegistrar; @@ -47,6 +56,11 @@ import org.geysermc.geyser.item.GeyserCustomItemData; import org.geysermc.geyser.item.GeyserCustomItemOptions; import org.geysermc.geyser.item.GeyserNonVanillaCustomItemData; +import org.geysermc.geyser.item.custom.GeyserCustomItemBedrockOptions; +import org.geysermc.geyser.item.custom.GeyserCustomItemDefinition; +import org.geysermc.geyser.item.custom.predicate.ConditionPredicate; +import org.geysermc.geyser.item.custom.predicate.MatchPredicate; +import org.geysermc.geyser.item.custom.predicate.RangeDispatchPredicate; import org.geysermc.geyser.level.block.GeyserCustomBlockComponents; import org.geysermc.geyser.level.block.GeyserCustomBlockData; import org.geysermc.geyser.level.block.GeyserGeometryComponent; @@ -84,6 +98,13 @@ public Map, ProviderSupplier> load(Map, ProviderSupplier> prov providers.put(CustomItemOptions.Builder.class, args -> new GeyserCustomItemOptions.Builder()); providers.put(NonVanillaCustomItemData.Builder.class, args -> new GeyserNonVanillaCustomItemData.Builder()); + // items v2 + providers.put(CustomItemDefinition.Builder.class, args -> new GeyserCustomItemDefinition.Builder((Identifier) args[0], (Identifier) args[1])); + providers.put(CustomItemBedrockOptions.Builder.class, args -> new GeyserCustomItemBedrockOptions.Builder()); + providers.put(ConditionItemPredicate.class, args -> new ConditionPredicate<>((ConditionPredicateProperty) args[0], (boolean) args[1], args[2])); + providers.put(MatchItemPredicate.class, args -> new MatchPredicate<>((MatchPredicateProperty) args[0], args[1])); + providers.put(RangeDispatchItemPredicate.class, args -> new RangeDispatchPredicate((RangeDispatchPredicateProperty) args[0], (double) args[1], (double) args[2], (boolean) args[3], (int) args[4])); + // cameras providers.put(CameraFade.Builder.class, args -> new GeyserCameraFade.Builder()); providers.put(CameraPosition.Builder.class, args -> new GeyserCameraPosition.Builder()); diff --git a/core/src/main/java/org/geysermc/geyser/registry/mappings/MappingsConfigReader.java b/core/src/main/java/org/geysermc/geyser/registry/mappings/MappingsConfigReader.java index d09e0b5a1e1..58eaa507ede 100644 --- a/core/src/main/java/org/geysermc/geyser/registry/mappings/MappingsConfigReader.java +++ b/core/src/main/java/org/geysermc/geyser/registry/mappings/MappingsConfigReader.java @@ -25,16 +25,19 @@ package org.geysermc.geyser.registry.mappings; -import com.fasterxml.jackson.databind.JsonNode; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; import it.unimi.dsi.fastutil.ints.Int2ObjectMap; import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; import org.checkerframework.checker.nullness.qual.Nullable; import org.geysermc.geyser.GeyserImpl; -import org.geysermc.geyser.api.item.custom.CustomItemData; +import org.geysermc.geyser.api.item.custom.v2.CustomItemDefinition; import org.geysermc.geyser.registry.mappings.util.CustomBlockMapping; import org.geysermc.geyser.registry.mappings.versions.MappingsReader; import org.geysermc.geyser.registry.mappings.versions.MappingsReader_v1; +import org.geysermc.geyser.registry.mappings.versions.MappingsReader_v2; +import java.io.FileReader; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -46,13 +49,14 @@ public class MappingsConfigReader { public MappingsConfigReader() { this.mappingReaders.put(1, new MappingsReader_v1()); + this.mappingReaders.put(2, new MappingsReader_v2()); } public Path[] getCustomMappingsFiles() { try { return Files.walk(this.customMappingsDirectory) - .filter(child -> child.toString().endsWith(".json")) - .toArray(Path[]::new); + .filter(child -> child.toString().endsWith(".json")) + .toArray(Path[]::new); } catch (IOException e) { return new Path[0]; } @@ -73,7 +77,7 @@ public boolean ensureMappingsDirectory(Path mappingsDirectory) { return true; } - public void loadItemMappingsFromJson(BiConsumer consumer) { + public void loadItemMappingsFromJson(BiConsumer consumer) { if (!ensureMappingsDirectory(this.customMappingsDirectory)) { return; } @@ -95,10 +99,10 @@ public void loadBlockMappingsFromJson(BiConsumer con } } - public @Nullable JsonNode getMappingsRoot(Path file) { - JsonNode mappingsRoot; - try { - mappingsRoot = GeyserImpl.JSON_MAPPER.readTree(file.toFile()); + public @Nullable JsonObject getMappingsRoot(Path file) { + JsonObject mappingsRoot; + try (FileReader reader = new FileReader(file.toFile())) { + mappingsRoot = (JsonObject) new JsonParser().parse(reader); } catch (IOException e) { GeyserImpl.getInstance().getLogger().error("Failed to read custom mapping file: " + file, e); return null; @@ -112,8 +116,8 @@ public void loadBlockMappingsFromJson(BiConsumer con return mappingsRoot; } - public int getFormatVersion(JsonNode mappingsRoot, Path file) { - int formatVersion = mappingsRoot.get("format_version").asInt(); + public int getFormatVersion(JsonObject mappingsRoot, Path file) { + int formatVersion = mappingsRoot.get("format_version").getAsInt(); if (!this.mappingReaders.containsKey(formatVersion)) { GeyserImpl.getInstance().getLogger().error("Mappings file " + file + " has an unknown format version: " + formatVersion); return -1; @@ -121,8 +125,8 @@ public int getFormatVersion(JsonNode mappingsRoot, Path file) { return formatVersion; } - public void readItemMappingsFromJson(Path file, BiConsumer consumer) { - JsonNode mappingsRoot = getMappingsRoot(file); + public void readItemMappingsFromJson(Path file, BiConsumer consumer) { + JsonObject mappingsRoot = getMappingsRoot(file); if (mappingsRoot == null) { return; @@ -138,7 +142,7 @@ public void readItemMappingsFromJson(Path file, BiConsumer consumer) { - JsonNode mappingsRoot = getMappingsRoot(file); + JsonObject mappingsRoot = getMappingsRoot(file); if (mappingsRoot == null) { return; diff --git a/core/src/main/java/org/geysermc/geyser/registry/mappings/components/DataComponentReader.java b/core/src/main/java/org/geysermc/geyser/registry/mappings/components/DataComponentReader.java new file mode 100644 index 00000000000..853b44a32c0 --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/registry/mappings/components/DataComponentReader.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2024 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.registry.mappings.components; + +import com.google.gson.JsonElement; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.geysermc.geyser.api.item.custom.v2.CustomItemDefinition; +import org.geysermc.geyser.api.item.custom.v2.component.DataComponent; +import org.geysermc.geyser.item.exception.InvalidCustomMappingsFileException; + +public abstract class DataComponentReader { + private final DataComponent type; + + protected DataComponentReader(DataComponent type) { + this.type = type; + } + + protected abstract V readDataComponent(@NonNull JsonElement element, String... context) throws InvalidCustomMappingsFileException; + + void read(CustomItemDefinition.Builder builder, JsonElement element, String... context) throws InvalidCustomMappingsFileException { + builder.component(type, readDataComponent(element, context)); + } +} diff --git a/core/src/main/java/org/geysermc/geyser/registry/mappings/components/DataComponentReaders.java b/core/src/main/java/org/geysermc/geyser/registry/mappings/components/DataComponentReaders.java new file mode 100644 index 00000000000..2069056dc25 --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/registry/mappings/components/DataComponentReaders.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2024 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.registry.mappings.components; + +import com.google.gson.JsonElement; +import net.kyori.adventure.key.Key; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.geysermc.geyser.api.item.custom.v2.CustomItemDefinition; +import org.geysermc.geyser.api.item.custom.v2.component.DataComponent; +import org.geysermc.geyser.item.exception.InvalidCustomMappingsFileException; +import org.geysermc.geyser.registry.mappings.components.readers.ConsumableReader; +import org.geysermc.geyser.registry.mappings.components.readers.EnchantableReader; +import org.geysermc.geyser.registry.mappings.components.readers.EquippableReader; +import org.geysermc.geyser.registry.mappings.components.readers.FoodPropertiesReader; +import org.geysermc.geyser.registry.mappings.components.readers.IntComponentReader; +import org.geysermc.geyser.registry.mappings.components.readers.RepairableReader; +import org.geysermc.geyser.registry.mappings.components.readers.ToolPropertiesReader; +import org.geysermc.geyser.registry.mappings.components.readers.UseCooldownReader; +import org.geysermc.geyser.util.MinecraftKey; + +import java.util.HashMap; +import java.util.Map; + +public class DataComponentReaders { + private static final Map> READERS = new HashMap<>(); + + public static void readDataComponent(CustomItemDefinition.Builder builder, Key key, @NonNull JsonElement element, String baseContext) throws InvalidCustomMappingsFileException { + DataComponentReader reader = READERS.get(key); + if (reader == null) { + throw new InvalidCustomMappingsFileException("reading data components", "unknown data component " + key, baseContext); + } + reader.read(builder, element, "component " + key, baseContext); + } + + static { + READERS.put(MinecraftKey.key("consumable"), new ConsumableReader()); + READERS.put(MinecraftKey.key("equippable"), new EquippableReader()); + READERS.put(MinecraftKey.key("food"), new FoodPropertiesReader()); + READERS.put(MinecraftKey.key("max_damage"), new IntComponentReader(DataComponent.MAX_DAMAGE, 0)); + READERS.put(MinecraftKey.key("max_stack_size"), new IntComponentReader(DataComponent.MAX_STACK_SIZE, 1, 99)); + READERS.put(MinecraftKey.key("use_cooldown"), new UseCooldownReader()); + READERS.put(MinecraftKey.key("enchantable"), new EnchantableReader()); + READERS.put(MinecraftKey.key("tool"), new ToolPropertiesReader()); + READERS.put(MinecraftKey.key("repairable"), new RepairableReader()); + } +} diff --git a/core/src/main/java/org/geysermc/geyser/registry/mappings/components/readers/ConsumableReader.java b/core/src/main/java/org/geysermc/geyser/registry/mappings/components/readers/ConsumableReader.java new file mode 100644 index 00000000000..077094d7e23 --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/registry/mappings/components/readers/ConsumableReader.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2024 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.registry.mappings.components.readers; + +import com.google.gson.JsonElement; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.geysermc.geyser.api.item.custom.v2.component.Consumable; +import org.geysermc.geyser.api.item.custom.v2.component.DataComponent; +import org.geysermc.geyser.item.exception.InvalidCustomMappingsFileException; +import org.geysermc.geyser.registry.mappings.components.DataComponentReader; +import org.geysermc.geyser.registry.mappings.util.MappingsUtil; +import org.geysermc.geyser.registry.mappings.util.NodeReader; + +public class ConsumableReader extends DataComponentReader { + + public ConsumableReader() { + super(DataComponent.CONSUMABLE); + } + + @Override + protected Consumable readDataComponent(@NonNull JsonElement element, String... context) throws InvalidCustomMappingsFileException { + float consumeSeconds = MappingsUtil.readOrDefault(element, "consume_seconds", NodeReader.POSITIVE_DOUBLE.andThen(Double::floatValue), 1.6F, context); + Consumable.Animation animation = MappingsUtil.readOrDefault(element, "animation", NodeReader.CONSUMABLE_ANIMATION, Consumable.Animation.EAT, context); + + return new Consumable(consumeSeconds, animation); + } +} diff --git a/core/src/main/java/org/geysermc/geyser/registry/mappings/components/readers/EnchantableReader.java b/core/src/main/java/org/geysermc/geyser/registry/mappings/components/readers/EnchantableReader.java new file mode 100644 index 00000000000..ed6b05108fd --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/registry/mappings/components/readers/EnchantableReader.java @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2025 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.registry.mappings.components.readers; + +import com.google.gson.JsonElement; +import com.google.gson.JsonPrimitive; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.geysermc.geyser.api.item.custom.v2.component.DataComponent; +import org.geysermc.geyser.item.exception.InvalidCustomMappingsFileException; +import org.geysermc.geyser.registry.mappings.components.DataComponentReader; +import org.geysermc.geyser.registry.mappings.util.MappingsUtil; +import org.geysermc.geyser.registry.mappings.util.NodeReader; + +/** + * For some reason, Minecraft Java uses + * + *
+ *     {@code
+ *     {
+ *       "minecraft:enchantable: {
+ *         "value": 4
+ *       }
+ *     }
+ *     }
+ * 
+ * + * instead of + * + *
+ *     {@code
+ *     {
+ *         "minecraft:enchantable": 4
+ *     }
+ *     }
+ * 
+ * + * This reader allows both styles. + */ +public class EnchantableReader extends DataComponentReader { + + public EnchantableReader() { + super(DataComponent.ENCHANTABLE); + } + + @Override + protected Integer readDataComponent(@NonNull JsonElement element, String... context) throws InvalidCustomMappingsFileException { + try { + if (element instanceof JsonPrimitive primitive) { + return NodeReader.NON_NEGATIVE_INT.read(primitive, "reading component", context); + } + } catch (InvalidCustomMappingsFileException ignored) {} + return MappingsUtil.readOrThrow(element, "value", NodeReader.NON_NEGATIVE_INT); + } +} diff --git a/core/src/main/java/org/geysermc/geyser/registry/mappings/components/readers/EquippableReader.java b/core/src/main/java/org/geysermc/geyser/registry/mappings/components/readers/EquippableReader.java new file mode 100644 index 00000000000..cfdf2cac029 --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/registry/mappings/components/readers/EquippableReader.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2024 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.registry.mappings.components.readers; + +import com.google.gson.JsonElement; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.geysermc.geyser.api.item.custom.v2.component.DataComponent; +import org.geysermc.geyser.api.item.custom.v2.component.Equippable; +import org.geysermc.geyser.item.exception.InvalidCustomMappingsFileException; +import org.geysermc.geyser.registry.mappings.components.DataComponentReader; +import org.geysermc.geyser.registry.mappings.util.MappingsUtil; +import org.geysermc.geyser.registry.mappings.util.NodeReader; + +public class EquippableReader extends DataComponentReader { + + public EquippableReader() { + super(DataComponent.EQUIPPABLE); + } + + @Override + protected Equippable readDataComponent(@NonNull JsonElement element, String... context) throws InvalidCustomMappingsFileException { + Equippable.EquipmentSlot slot = MappingsUtil.readOrThrow(element, "slot", NodeReader.EQUIPMENT_SLOT, context); + return new Equippable(slot); + } +} diff --git a/core/src/main/java/org/geysermc/geyser/registry/mappings/components/readers/FoodPropertiesReader.java b/core/src/main/java/org/geysermc/geyser/registry/mappings/components/readers/FoodPropertiesReader.java new file mode 100644 index 00000000000..e07c41a43ec --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/registry/mappings/components/readers/FoodPropertiesReader.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2024 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.registry.mappings.components.readers; + +import com.google.gson.JsonElement; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.geysermc.geyser.api.item.custom.v2.component.DataComponent; +import org.geysermc.geyser.api.item.custom.v2.component.FoodProperties; +import org.geysermc.geyser.item.exception.InvalidCustomMappingsFileException; +import org.geysermc.geyser.registry.mappings.components.DataComponentReader; +import org.geysermc.geyser.registry.mappings.util.MappingsUtil; +import org.geysermc.geyser.registry.mappings.util.NodeReader; + +public class FoodPropertiesReader extends DataComponentReader { + + public FoodPropertiesReader() { + super(DataComponent.FOOD); + } + + @Override + protected FoodProperties readDataComponent(@NonNull JsonElement element, String... context) throws InvalidCustomMappingsFileException { + int nutrition = MappingsUtil.readOrDefault(element, "nutrition", NodeReader.NON_NEGATIVE_INT, 0, context); + float saturation = MappingsUtil.readOrDefault(element, "saturation", NodeReader.NON_NEGATIVE_DOUBLE.andThen(Double::floatValue), 0.0F, context); + boolean canAlwaysEat = MappingsUtil.readOrDefault(element, "can_always_eat", NodeReader.BOOLEAN, false, context); + + return new FoodProperties(nutrition, saturation, canAlwaysEat); + } +} diff --git a/core/src/main/java/org/geysermc/geyser/registry/mappings/components/readers/IntComponentReader.java b/core/src/main/java/org/geysermc/geyser/registry/mappings/components/readers/IntComponentReader.java new file mode 100644 index 00000000000..09be7e79129 --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/registry/mappings/components/readers/IntComponentReader.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2024 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.registry.mappings.components.readers; + +import com.google.gson.JsonElement; +import com.google.gson.JsonPrimitive; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.geysermc.geyser.api.item.custom.v2.component.DataComponent; +import org.geysermc.geyser.item.exception.InvalidCustomMappingsFileException; +import org.geysermc.geyser.registry.mappings.components.DataComponentReader; +import org.geysermc.geyser.registry.mappings.util.NodeReader; + +public class IntComponentReader extends DataComponentReader { + private final int minimum; + private final int maximum; + + public IntComponentReader(DataComponent type, int minimum, int maximum) { + super(type); + this.minimum = minimum; + this.maximum = maximum; + } + + public IntComponentReader(DataComponent type, int minimum) { + this(type, minimum, Integer.MAX_VALUE); + } + + @Override + protected Integer readDataComponent(@NonNull JsonElement element, String... context) throws InvalidCustomMappingsFileException { + if (!element.isJsonPrimitive()) { + throw new InvalidCustomMappingsFileException("reading component", "value must be a primitive", context); + } + return NodeReader.boundedInt(minimum, maximum).read((JsonPrimitive) element, "reading component", context); + } +} diff --git a/core/src/main/java/org/geysermc/geyser/registry/mappings/components/readers/RepairableReader.java b/core/src/main/java/org/geysermc/geyser/registry/mappings/components/readers/RepairableReader.java new file mode 100644 index 00000000000..79ee41d64d0 --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/registry/mappings/components/readers/RepairableReader.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.registry.mappings.components.readers; + +import com.google.gson.JsonElement; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.geysermc.geyser.api.item.custom.v2.component.DataComponent; +import org.geysermc.geyser.api.item.custom.v2.component.Repairable; +import org.geysermc.geyser.api.util.Identifier; +import org.geysermc.geyser.item.exception.InvalidCustomMappingsFileException; +import org.geysermc.geyser.registry.mappings.components.DataComponentReader; +import org.geysermc.geyser.registry.mappings.util.MappingsUtil; +import org.geysermc.geyser.registry.mappings.util.NodeReader; + +import java.util.List; + +public class RepairableReader extends DataComponentReader { + + public RepairableReader() { + super(DataComponent.REPAIRABLE); + } + + @Override + protected Repairable readDataComponent(@NonNull JsonElement node, String... context) throws InvalidCustomMappingsFileException { + try { + Identifier item = MappingsUtil.readOrThrow(node, "items", NodeReader.IDENTIFIER, context); + return new Repairable(item); + } catch (InvalidCustomMappingsFileException exception) { + List items = MappingsUtil.readArrayOrThrow(node, "items", NodeReader.IDENTIFIER, context); + return new Repairable(items.toArray(Identifier[]::new)); + } + } +} diff --git a/core/src/main/java/org/geysermc/geyser/registry/mappings/components/readers/ToolPropertiesReader.java b/core/src/main/java/org/geysermc/geyser/registry/mappings/components/readers/ToolPropertiesReader.java new file mode 100644 index 00000000000..3c8a08f8707 --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/registry/mappings/components/readers/ToolPropertiesReader.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.registry.mappings.components.readers; + +import com.google.gson.JsonElement; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.geysermc.geyser.api.item.custom.v2.component.DataComponent; +import org.geysermc.geyser.api.item.custom.v2.component.ToolProperties; +import org.geysermc.geyser.item.exception.InvalidCustomMappingsFileException; +import org.geysermc.geyser.registry.mappings.components.DataComponentReader; +import org.geysermc.geyser.registry.mappings.util.MappingsUtil; +import org.geysermc.geyser.registry.mappings.util.NodeReader; + +public class ToolPropertiesReader extends DataComponentReader { + + public ToolPropertiesReader() { + super(DataComponent.TOOL); + } + + @Override + protected ToolProperties readDataComponent(@NonNull JsonElement element, String... context) throws InvalidCustomMappingsFileException { + return new ToolProperties(MappingsUtil.readOrDefault(element, "can_destroy_blocks_in_creative", NodeReader.BOOLEAN, true, context)); + } +} diff --git a/core/src/main/java/org/geysermc/geyser/registry/mappings/components/readers/UseCooldownReader.java b/core/src/main/java/org/geysermc/geyser/registry/mappings/components/readers/UseCooldownReader.java new file mode 100644 index 00000000000..85197b4ffa2 --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/registry/mappings/components/readers/UseCooldownReader.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2024 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.registry.mappings.components.readers; + +import com.google.gson.JsonElement; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.geysermc.geyser.api.item.custom.v2.component.DataComponent; +import org.geysermc.geyser.api.item.custom.v2.component.UseCooldown; +import org.geysermc.geyser.api.util.Identifier; +import org.geysermc.geyser.item.exception.InvalidCustomMappingsFileException; +import org.geysermc.geyser.registry.mappings.components.DataComponentReader; +import org.geysermc.geyser.registry.mappings.util.MappingsUtil; +import org.geysermc.geyser.registry.mappings.util.NodeReader; + +public class UseCooldownReader extends DataComponentReader { + + public UseCooldownReader() { + super(DataComponent.USE_COOLDOWN); + } + + @Override + protected UseCooldown readDataComponent(@NonNull JsonElement element, String... context) throws InvalidCustomMappingsFileException { + float seconds = MappingsUtil.readOrThrow(element, "seconds", NodeReader.POSITIVE_DOUBLE.andThen(Double::floatValue), context); + Identifier cooldownGroup = MappingsUtil.readOrThrow(element, "cooldown_group", NodeReader.IDENTIFIER, context); + + return new UseCooldown(seconds, cooldownGroup); + } +} diff --git a/core/src/main/java/org/geysermc/geyser/registry/mappings/util/MappingsUtil.java b/core/src/main/java/org/geysermc/geyser/registry/mappings/util/MappingsUtil.java new file mode 100644 index 00000000000..8eb78b1e636 --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/registry/mappings/util/MappingsUtil.java @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2025 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.registry.mappings.util; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonPrimitive; +import org.geysermc.geyser.item.exception.InvalidCustomMappingsFileException; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +public class MappingsUtil { + private static final String OBJECT_ERROR = "element was not an object"; + private static final String REQUIRED_ERROR = "key is required but was not present"; + private static final String PRIMITIVE_ERROR = "key must be a primitive"; + + public static T readOrThrow(JsonElement object, String name, NodeReader converter, String... context) throws InvalidCustomMappingsFileException { + JsonElement element = getJsonElement(object, name, context); + if (element == null) { + throw new InvalidCustomMappingsFileException(formatTask(name), REQUIRED_ERROR, context); + } else if (!element.isJsonPrimitive()) { + throw new InvalidCustomMappingsFileException(formatTask(name), PRIMITIVE_ERROR, context); + } + return converter.read((JsonPrimitive) element, formatTask(name), context); + } + + public static T readOrDefault(JsonElement object, String name, NodeReader converter, T defaultValue, String... context) throws InvalidCustomMappingsFileException { + JsonElement element = getJsonElement(object, name, context); + if (element == null) { + return defaultValue; + } else if (!element.isJsonPrimitive()) { + throw new InvalidCustomMappingsFileException(formatTask(name), PRIMITIVE_ERROR, context); + } + return converter.read((JsonPrimitive) element, formatTask(name), context); + } + + public static List readArrayOrThrow(JsonElement object, String name, NodeReader converter, String... context) throws InvalidCustomMappingsFileException { + JsonElement element = getJsonElement(object, name, context); + if (element == null) { + throw new InvalidCustomMappingsFileException(formatTask(name), REQUIRED_ERROR, context); + } else if (!element.isJsonArray()) { + throw new InvalidCustomMappingsFileException(formatTask(name), "key must be an array", context); + } + + JsonArray array = element.getAsJsonArray(); + List objects = new ArrayList<>(); + for (int i = 0; i < array.size(); i++) { + JsonElement item = array.get(i); + String task = "reading object " + i + " in key \"" + name + "\""; + + if (!item.isJsonPrimitive()) { + throw new InvalidCustomMappingsFileException(task, PRIMITIVE_ERROR, context); + } + objects.add(converter.read((JsonPrimitive) item, task, context)); + } + return objects; + } + + public static void readIfPresent(JsonElement node, String name, Consumer consumer, NodeReader converter, String... context) throws InvalidCustomMappingsFileException { + JsonElement element = getJsonElement(node, name, context); + if (element != null) { + if (!element.isJsonPrimitive()) { + throw new InvalidCustomMappingsFileException(formatTask(name), PRIMITIVE_ERROR, context); + } + consumer.accept(converter.read((JsonPrimitive) element, formatTask(name), context)); + } + } + + private static JsonElement getJsonElement(JsonElement element, String name, String... context) throws InvalidCustomMappingsFileException { + if (!element.isJsonObject()) { + throw new InvalidCustomMappingsFileException(formatTask(name), OBJECT_ERROR, context); + } + return element.getAsJsonObject().get(name); + } + + private static String formatTask(String name) { + return "reading key \"" + name + "\""; + } +} diff --git a/core/src/main/java/org/geysermc/geyser/registry/mappings/util/NodeReader.java b/core/src/main/java/org/geysermc/geyser/registry/mappings/util/NodeReader.java new file mode 100644 index 00000000000..a67f3404b24 --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/registry/mappings/util/NodeReader.java @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2025 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.registry.mappings.util; + +import com.google.gson.JsonPrimitive; +import org.geysermc.geyser.api.item.custom.v2.component.Consumable; +import org.geysermc.geyser.api.item.custom.v2.component.Equippable; +import org.geysermc.geyser.api.item.custom.v2.predicate.PredicateStrategy; +import org.geysermc.geyser.api.item.custom.v2.predicate.RangeDispatchPredicateProperty; +import org.geysermc.geyser.api.item.custom.v2.predicate.condition.ConditionPredicateProperty; +import org.geysermc.geyser.api.item.custom.v2.predicate.match.ChargeType; +import org.geysermc.geyser.api.item.custom.v2.predicate.match.MatchPredicateProperty; +import org.geysermc.geyser.api.util.CreativeCategory; +import org.geysermc.geyser.api.util.Identifier; +import org.geysermc.geyser.item.exception.InvalidCustomMappingsFileException; + +import java.util.Arrays; +import java.util.Map; +import java.util.Objects; +import java.util.function.Predicate; + +@FunctionalInterface +public interface NodeReader { + + NodeReader INT = node -> { + double i = node.getAsDouble(); + if (i == (int) i) { // Make sure the number is round + return (int) i; + } + throw new InvalidCustomMappingsFileException("expected node to be an integer"); + }; + + NodeReader NON_NEGATIVE_INT = INT.validate(i -> i >= 0, "integer must be non-negative"); + + NodeReader POSITIVE_INT = INT.validate(i -> i > 0, "integer must be positive"); + + NodeReader DOUBLE = JsonPrimitive::getAsDouble; + + NodeReader NON_NEGATIVE_DOUBLE = DOUBLE.validate(d -> d >= 0, "number must be non-negative"); + + NodeReader POSITIVE_DOUBLE = DOUBLE.validate(d -> d > 0, "number must be positive"); + + NodeReader BOOLEAN = node -> { + // Not directly using getAsBoolean here since that doesn't convert integers and doesn't throw an error when the string is not "true" or "false" + if (node.isString()) { + String s = node.getAsString(); + if (s.equals("true")) { + return true; + } else if (s.equals("false")) { + return false; + } + } else if (node.isNumber() && node.getAsNumber() instanceof Integer i) { + if (i == 1) { + return true; + } else if (i == 0) { + return false; + } + } else if (node.isBoolean()) { + return node.getAsBoolean(); + } + throw new InvalidCustomMappingsFileException("expected node to be a boolean"); + }; + + NodeReader STRING = JsonPrimitive::getAsString; + + NodeReader NON_EMPTY_STRING = STRING.validate(s -> !s.isEmpty(), "string must not be empty"); + + NodeReader IDENTIFIER = NON_EMPTY_STRING.andThen(Identifier::new); + + NodeReader CREATIVE_CATEGORY = NON_EMPTY_STRING.andThen(CreativeCategory::fromName).validate(Objects::nonNull, "unknown creative category"); + + NodeReader PREDICATE_STRATEGY = ofEnum(PredicateStrategy.class); + + NodeReader> CONDITION_PREDICATE_PROPERTY = ofMap( + Map.of( + "broken", ConditionPredicateProperty.BROKEN, + "damaged", ConditionPredicateProperty.DAMAGED, + "custom_model_data", ConditionPredicateProperty.CUSTOM_MODEL_DATA, + "has_component", ConditionPredicateProperty.HAS_COMPONENT + ) + ); + + NodeReader> MATCH_PREDICATE_PROPERTY = ofMap( + Map.of( + "charge_type", MatchPredicateProperty.CHARGE_TYPE, + "trim_material", MatchPredicateProperty.TRIM_MATERIAL, + "context_dimension", MatchPredicateProperty.CONTEXT_DIMENSION, + "custom_model_data", MatchPredicateProperty.CUSTOM_MODEL_DATA + ) + ); + + NodeReader CHARGE_TYPE = ofEnum(ChargeType.class); + + NodeReader RANGE_DISPATCH_PREDICATE_PROPERTY = ofEnum(RangeDispatchPredicateProperty.class); + + NodeReader CONSUMABLE_ANIMATION = ofEnum(Consumable.Animation.class); + + NodeReader EQUIPMENT_SLOT = ofEnum(Equippable.EquipmentSlot.class); + + static > NodeReader ofEnum(Class clazz) { + return NON_EMPTY_STRING.andThen(String::toUpperCase).andThen(s -> { + try { + return Enum.valueOf(clazz, s); + } catch (IllegalArgumentException exception) { + throw new InvalidCustomMappingsFileException("unknown element, must be one of [" + + String.join(", ", Arrays.stream(clazz.getEnumConstants()).map(E::toString).toArray(String[]::new)).toLowerCase() + "]"); + } + }); + } + + static NodeReader ofMap(Map map) { + return NON_EMPTY_STRING.andThen(String::toLowerCase).andThen(s -> { + T value = map.get(s); + if (value == null) { + throw new InvalidCustomMappingsFileException("unknown element, must be one of [" + + String.join(", ", map.keySet()).toLowerCase() + "]"); + } + return value; + }); + } + + static NodeReader boundedInt(int min, int max) { + return INT.validate(i -> i >= min && i <= max, "integer must be in range [" + min + ", " + max + "]"); + } + + /** + * {@link NodeReader#read(JsonPrimitive, String, String...)} is preferably used as that properly formats the error when one is thrown. + */ + T read(JsonPrimitive node) throws InvalidCustomMappingsFileException; + + default T read(JsonPrimitive node, String task, String... context) throws InvalidCustomMappingsFileException { + try { + return read(node); + } catch (Exception exception) { + throw new InvalidCustomMappingsFileException(task, exception.getMessage() + " (node was " + node.toString() + ")", context); + } + } + + default NodeReader andThen(After after) { + return node -> after.apply(read(node)); + } + + default NodeReader validate(Predicate validator, String error) { + return andThen(v -> { + if (!validator.test(v)) { + throw new InvalidCustomMappingsFileException(error); + } + return v; + }); + } + + @FunctionalInterface + interface After { + + T apply(V value) throws InvalidCustomMappingsFileException; + } +} diff --git a/core/src/main/java/org/geysermc/geyser/registry/mappings/versions/MappingsReader.java b/core/src/main/java/org/geysermc/geyser/registry/mappings/versions/MappingsReader.java index b2bdd5a0130..3e03cc6e3a6 100644 --- a/core/src/main/java/org/geysermc/geyser/registry/mappings/versions/MappingsReader.java +++ b/core/src/main/java/org/geysermc/geyser/registry/mappings/versions/MappingsReader.java @@ -25,10 +25,12 @@ package org.geysermc.geyser.registry.mappings.versions; -import com.fasterxml.jackson.databind.JsonNode; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; import org.checkerframework.checker.nullness.qual.Nullable; -import org.geysermc.geyser.api.item.custom.CustomItemData; import org.geysermc.geyser.api.item.custom.CustomRenderOffsets; +import org.geysermc.geyser.api.item.custom.v2.CustomItemDefinition; +import org.geysermc.geyser.api.util.Identifier; import org.geysermc.geyser.item.exception.InvalidCustomMappingsFileException; import org.geysermc.geyser.registry.mappings.util.CustomBlockMapping; @@ -36,51 +38,48 @@ import java.util.function.BiConsumer; public abstract class MappingsReader { - public abstract void readItemMappings(Path file, JsonNode mappingsRoot, BiConsumer consumer); - public abstract void readBlockMappings(Path file, JsonNode mappingsRoot, BiConsumer consumer); + public abstract void readItemMappings(Path file, JsonObject mappingsRoot, BiConsumer consumer); + public abstract void readBlockMappings(Path file, JsonObject mappingsRoot, BiConsumer consumer); - public abstract CustomItemData readItemMappingEntry(JsonNode node) throws InvalidCustomMappingsFileException; - public abstract CustomBlockMapping readBlockMappingEntry(String identifier, JsonNode node) throws InvalidCustomMappingsFileException; + public abstract CustomItemDefinition readItemMappingEntry(Identifier identifier, JsonElement node) throws InvalidCustomMappingsFileException; + public abstract CustomBlockMapping readBlockMappingEntry(String identifier, JsonElement node) throws InvalidCustomMappingsFileException; - protected @Nullable CustomRenderOffsets fromJsonNode(JsonNode node) { - if (node == null || !node.isObject()) { + protected @Nullable CustomRenderOffsets fromJsonObject(JsonObject node) { + if (node == null) { return null; } return new CustomRenderOffsets( - getHandOffsets(node, "main_hand"), - getHandOffsets(node, "off_hand") + getHandOffsets(node, "main_hand"), + getHandOffsets(node, "off_hand") ); } - protected CustomRenderOffsets.@Nullable Hand getHandOffsets(JsonNode node, String hand) { - JsonNode tmpNode = node.get(hand); - if (tmpNode == null || !tmpNode.isObject()) { + protected CustomRenderOffsets.@Nullable Hand getHandOffsets(JsonObject node, String hand) { + if (!(node.get(hand) instanceof JsonObject tmpNode)) { return null; } return new CustomRenderOffsets.Hand( - getPerspectiveOffsets(tmpNode, "first_person"), - getPerspectiveOffsets(tmpNode, "third_person") + getPerspectiveOffsets(tmpNode, "first_person"), + getPerspectiveOffsets(tmpNode, "third_person") ); } - protected CustomRenderOffsets.@Nullable Offset getPerspectiveOffsets(JsonNode node, String perspective) { - JsonNode tmpNode = node.get(perspective); - if (tmpNode == null || !tmpNode.isObject()) { + protected CustomRenderOffsets.@Nullable Offset getPerspectiveOffsets(JsonObject node, String perspective) { + if (!(node.get(perspective) instanceof JsonObject tmpNode)) { return null; } return new CustomRenderOffsets.Offset( - getOffsetXYZ(tmpNode, "position"), - getOffsetXYZ(tmpNode, "rotation"), - getOffsetXYZ(tmpNode, "scale") + getOffsetXYZ(tmpNode, "position"), + getOffsetXYZ(tmpNode, "rotation"), + getOffsetXYZ(tmpNode, "scale") ); } - protected CustomRenderOffsets.@Nullable OffsetXYZ getOffsetXYZ(JsonNode node, String offsetType) { - JsonNode tmpNode = node.get(offsetType); - if (tmpNode == null || !tmpNode.isObject()) { + protected CustomRenderOffsets.@Nullable OffsetXYZ getOffsetXYZ(JsonObject node, String offsetType) { + if (!(node.get(offsetType) instanceof JsonObject tmpNode)) { return null; } @@ -89,9 +88,9 @@ public abstract class MappingsReader { } return new CustomRenderOffsets.OffsetXYZ( - tmpNode.get("x").floatValue(), - tmpNode.get("y").floatValue(), - tmpNode.get("z").floatValue() + tmpNode.get("x").getAsFloat(), + tmpNode.get("y").getAsFloat(), + tmpNode.get("z").getAsFloat() ); } } diff --git a/core/src/main/java/org/geysermc/geyser/registry/mappings/versions/MappingsReader_v1.java b/core/src/main/java/org/geysermc/geyser/registry/mappings/versions/MappingsReader_v1.java index b5e25a4ba5c..dc6962a1c40 100644 --- a/core/src/main/java/org/geysermc/geyser/registry/mappings/versions/MappingsReader_v1.java +++ b/core/src/main/java/org/geysermc/geyser/registry/mappings/versions/MappingsReader_v1.java @@ -25,8 +25,10 @@ package org.geysermc.geyser.registry.mappings.versions; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.node.ArrayNode; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; import it.unimi.dsi.fastutil.objects.ObjectOpenHashSet; import org.checkerframework.checker.nullness.qual.Nullable; @@ -34,12 +36,19 @@ import org.geysermc.geyser.api.block.custom.CustomBlockData; import org.geysermc.geyser.api.block.custom.CustomBlockPermutation; import org.geysermc.geyser.api.block.custom.CustomBlockState; -import org.geysermc.geyser.api.block.custom.component.*; +import org.geysermc.geyser.api.block.custom.component.BoxComponent; +import org.geysermc.geyser.api.block.custom.component.CustomBlockComponents; +import org.geysermc.geyser.api.block.custom.component.GeometryComponent; +import org.geysermc.geyser.api.block.custom.component.MaterialInstance; +import org.geysermc.geyser.api.block.custom.component.PlacementConditions; import org.geysermc.geyser.api.block.custom.component.PlacementConditions.BlockFilterType; import org.geysermc.geyser.api.block.custom.component.PlacementConditions.Face; +import org.geysermc.geyser.api.block.custom.component.TransformationComponent; import org.geysermc.geyser.api.item.custom.CustomItemData; import org.geysermc.geyser.api.item.custom.CustomItemOptions; +import org.geysermc.geyser.api.item.custom.v2.CustomItemDefinition; import org.geysermc.geyser.api.util.CreativeCategory; +import org.geysermc.geyser.api.util.Identifier; import org.geysermc.geyser.item.exception.InvalidCustomMappingsFileException; import org.geysermc.geyser.level.block.GeyserCustomBlockComponents; import org.geysermc.geyser.level.block.GeyserCustomBlockData; @@ -57,7 +66,13 @@ import org.geysermc.geyser.util.MinecraftKey; import java.nio.file.Path; -import java.util.*; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.function.BiConsumer; import java.util.function.Function; import java.util.function.Predicate; @@ -68,32 +83,32 @@ */ public class MappingsReader_v1 extends MappingsReader { @Override - public void readItemMappings(Path file, JsonNode mappingsRoot, BiConsumer consumer) { + public void readItemMappings(Path file, JsonObject mappingsRoot, BiConsumer consumer) { this.readItemMappingsV1(file, mappingsRoot, consumer); } /** * Read item block from a JSON node - * + * * @param file The path to the file - * @param mappingsRoot The {@link JsonNode} containing the mappings + * @param mappingsRoot The {@link JsonObject} containing the mappings * @param consumer The consumer to accept the mappings - * @see #readBlockMappingsV1(Path, JsonNode, BiConsumer) + * @see #readBlockMappingsV1(Path, JsonObject, BiConsumer) */ @Override - public void readBlockMappings(Path file, JsonNode mappingsRoot, BiConsumer consumer) { + public void readBlockMappings(Path file, JsonObject mappingsRoot, BiConsumer consumer) { this.readBlockMappingsV1(file, mappingsRoot, consumer); } - public void readItemMappingsV1(Path file, JsonNode mappingsRoot, BiConsumer consumer) { - JsonNode itemsNode = mappingsRoot.get("items"); + public void readItemMappingsV1(Path file, JsonObject mappingsRoot, BiConsumer consumer) { + JsonObject itemsNode = mappingsRoot.getAsJsonObject("items"); - if (itemsNode != null && itemsNode.isObject()) { - itemsNode.fields().forEachRemaining(entry -> { - if (entry.getValue().isArray()) { - entry.getValue().forEach(data -> { + if (itemsNode != null) { + itemsNode.entrySet().forEach(entry -> { + if (entry.getValue() instanceof JsonArray array) { + array.forEach(data -> { try { - CustomItemData customItemData = this.readItemMappingEntry(data); + CustomItemDefinition customItemData = this.readItemMappingEntry(new Identifier(entry.getKey()), (JsonObject) data); consumer.accept(entry.getKey(), customItemData); } catch (InvalidCustomMappingsFileException e) { GeyserImpl.getInstance().getLogger().error("Error in registering items for custom mapping file: " + file.toString(), e); @@ -106,21 +121,19 @@ public void readItemMappingsV1(Path file, JsonNode mappingsRoot, BiConsumer consumer) { - JsonNode blocksNode = mappingsRoot.get("blocks"); - - if (blocksNode != null && blocksNode.isObject()) { - blocksNode.fields().forEachRemaining(entry -> { - if (entry.getValue().isObject()) { + public void readBlockMappingsV1(Path file, JsonObject mappingsRoot, BiConsumer consumer) { + if (mappingsRoot.get("blocks") instanceof JsonObject blocksNode) { + blocksNode.entrySet().forEach(entry -> { + if (entry.getValue() instanceof JsonObject jsonObject) { try { String identifier = MinecraftKey.key(entry.getKey()).asString(); - CustomBlockMapping customBlockMapping = this.readBlockMappingEntry(identifier, entry.getValue()); + CustomBlockMapping customBlockMapping = this.readBlockMappingEntry(identifier, jsonObject); consumer.accept(identifier, customBlockMapping); } catch (Exception e) { GeyserImpl.getInstance().getLogger().error("Error in registering blocks for custom mapping file: " + file.toString()); @@ -131,115 +144,117 @@ public void readBlockMappingsV1(Path file, JsonNode mappingsRoot, BiConsumer tagsSet = new ObjectOpenHashSet<>(); - tags.forEach(tag -> tagsSet.add(tag.asText())); + tags.forEach(tag -> tagsSet.add(tag.getAsString())); customItemData.tags(tagsSet); } - return customItemData.build(); + return customItemData.build().toDefinition(identifier).build(); } /** * Read a block mapping entry from a JSON node and Java identifier - * + * * @param identifier The Java identifier of the block - * @param node The {@link JsonNode} containing the block mapping entry + * @param element The {@link JsonObject} containing the block mapping entry * @return The {@link CustomBlockMapping} record to be read by {@link org.geysermc.geyser.registry.populator.CustomBlockRegistryPopulator} * @throws InvalidCustomMappingsFileException If the JSON node is invalid */ @Override - public CustomBlockMapping readBlockMappingEntry(String identifier, JsonNode node) throws InvalidCustomMappingsFileException { - if (node == null || !node.isObject()) { - throw new InvalidCustomMappingsFileException("Invalid block mappings entry:" + node); + public CustomBlockMapping readBlockMappingEntry(String identifier, JsonElement element) throws InvalidCustomMappingsFileException { + if (element == null || !element.isJsonObject()) { + throw new InvalidCustomMappingsFileException("Invalid block mappings entry:" + element); } + JsonObject object = element.getAsJsonObject(); - String name = node.get("name").asText(); + String name = object.get("name").getAsString(); if (name == null || name.isEmpty()) { throw new InvalidCustomMappingsFileException("A block entry has no name"); } - boolean includedInCreativeInventory = node.has("included_in_creative_inventory") && node.get("included_in_creative_inventory").asBoolean(); + boolean includedInCreativeInventory = object.has("included_in_creative_inventory") && object.get("included_in_creative_inventory").getAsBoolean(); CreativeCategory creativeCategory = CreativeCategory.NONE; - if (node.has("creative_category")) { - String categoryName = node.get("creative_category").asText(); + if (object.has("creative_category")) { + String categoryName = object.get("creative_category").getAsString(); try { creativeCategory = CreativeCategory.valueOf(categoryName.toUpperCase()); } catch (IllegalArgumentException e) { @@ -248,37 +263,34 @@ public CustomBlockMapping readBlockMappingEntry(String identifier, JsonNode node } String creativeGroup = ""; - if (node.has("creative_group")) { - creativeGroup = node.get("creative_group").asText(); + if (object.has("creative_group")) { + creativeGroup = object.get("creative_group").getAsString(); } // If this is true, we will only register the states the user has specified rather than all the possible block states - boolean onlyOverrideStates = node.has("only_override_states") && node.get("only_override_states").asBoolean(); + boolean onlyOverrideStates = object.has("only_override_states") && object.get("only_override_states").getAsBoolean(); // Create the data for the overall block CustomBlockData.Builder customBlockDataBuilder = new GeyserCustomBlockData.Builder() - .name(name) - .includedInCreativeInventory(includedInCreativeInventory) - .creativeCategory(creativeCategory) - .creativeGroup(creativeGroup); + .name(name) + .includedInCreativeInventory(includedInCreativeInventory) + .creativeCategory(creativeCategory) + .creativeGroup(creativeGroup); if (BlockRegistries.JAVA_IDENTIFIER_TO_ID.get().containsKey(identifier)) { // There is only one Java block state to override - CustomBlockComponentsMapping componentsMapping = createCustomBlockComponentsMapping(node, identifier, name); + CustomBlockComponentsMapping componentsMapping = createCustomBlockComponentsMapping(object, identifier, name); CustomBlockData blockData = customBlockDataBuilder - .components(componentsMapping.components()) - .build(); + .components(componentsMapping.components()) + .build(); return new CustomBlockMapping(blockData, Map.of(identifier, new CustomBlockStateMapping(blockData.defaultBlockState(), componentsMapping.extendedCollisionBox())), identifier, !onlyOverrideStates); } Map componentsMap = new LinkedHashMap<>(); - JsonNode stateOverrides = node.get("state_overrides"); - if (stateOverrides != null && stateOverrides.isObject()) { + if (object.get("state_overrides") instanceof JsonObject stateOverrides) { // Load components for specific Java block states - Iterator> fields = stateOverrides.fields(); - while (fields.hasNext()) { - Map.Entry overrideEntry = fields.next(); + for (Map.Entry overrideEntry : stateOverrides.entrySet()) { String state = identifier + "[" + overrideEntry.getKey() + "]"; if (!BlockRegistries.JAVA_IDENTIFIER_TO_ID.get().containsKey(state)) { throw new InvalidCustomMappingsFileException("Unknown Java block state: " + state + " for state_overrides."); @@ -293,10 +305,10 @@ public CustomBlockMapping readBlockMappingEntry(String identifier, JsonNode node if (!onlyOverrideStates) { // Create components for any remaining Java block states BlockRegistries.JAVA_IDENTIFIER_TO_ID.get().keySet() - .stream() - .filter(s -> s.startsWith(identifier + "[")) - .filter(Predicate.not(componentsMap::containsKey)) - .forEach(state -> componentsMap.put(state, createCustomBlockComponentsMapping(null, state, name))); + .stream() + .filter(s -> s.startsWith(identifier + "[")) + .filter(Predicate.not(componentsMap::containsKey)) + .forEach(state -> componentsMap.put(state, createCustomBlockComponentsMapping(null, state, name))); } if (componentsMap.isEmpty()) { @@ -306,7 +318,7 @@ public CustomBlockMapping readBlockMappingEntry(String identifier, JsonNode node // We pass in the first state and just use the hitbox from that as the default // Each state will have its own so this is fine String firstState = componentsMap.keySet().iterator().next(); - CustomBlockComponentsMapping firstComponentsMapping = createCustomBlockComponentsMapping(node, firstState, name); + CustomBlockComponentsMapping firstComponentsMapping = createCustomBlockComponentsMapping(object, firstState, name); customBlockDataBuilder.components(firstComponentsMapping.components()); return createCustomBlockMapping(customBlockDataBuilder, componentsMap, identifier, !onlyOverrideStates); @@ -333,7 +345,7 @@ private CustomBlockMapping createCustomBlockMapping(CustomBlockData.Builder cust String value = parts[1]; valuesMap.computeIfAbsent(property, k -> new LinkedHashSet<>()) - .add(value); + .add(value); conditions[i] = String.format("q.block_property('%s') == '%s'", property, value); blockStateBuilder = blockStateBuilder.andThen(builder -> builder.stringProperty(property, value)); @@ -346,8 +358,8 @@ private CustomBlockMapping createCustomBlockMapping(CustomBlockData.Builder cust valuesMap.forEach((key, value) -> customBlockDataBuilder.stringProperty(key, new ArrayList<>(value))); CustomBlockData customBlockData = customBlockDataBuilder - .permutations(permutations) - .build(); + .permutations(permutations) + .build(); // Build CustomBlockStates for each Java block state we wish to override Map states = blockStateBuilders.entrySet().stream() .collect(Collectors.toMap(Map.Entry::getKey, e -> new CustomBlockStateMapping(e.getValue().builder().apply(customBlockData.blockStateBuilder()), e.getValue().extendedCollisionBox()))); @@ -357,22 +369,22 @@ private CustomBlockMapping createCustomBlockMapping(CustomBlockData.Builder cust /** * Creates a {@link CustomBlockComponents} object for the passed state override or base block node, Java block state identifier, and custom block name - * - * @param node the state override or base block {@link JsonNode} + * + * @param element the state override or base block {@link JsonObject} * @param stateKey the Java block state identifier * @param name the name of the custom block * @return the {@link CustomBlockComponents} object */ - private CustomBlockComponentsMapping createCustomBlockComponentsMapping(JsonNode node, String stateKey, String name) { + private CustomBlockComponentsMapping createCustomBlockComponentsMapping(JsonElement element, String stateKey, String name) { // This is needed to find the correct selection box for the given block int id = BlockRegistries.JAVA_IDENTIFIER_TO_ID.getOrDefault(stateKey, -1); BoxComponent boxComponent = createBoxComponent(id); BoxComponent extendedBoxComponent = createExtendedBoxComponent(id); CustomBlockComponents.Builder builder = new GeyserCustomBlockComponents.Builder() - .collisionBox(boxComponent) - .selectionBox(boxComponent); + .collisionBox(boxComponent) + .selectionBox(boxComponent); - if (node == null) { + if (!(element instanceof JsonObject node)) { // No other components were defined return new CustomBlockComponentsMapping(builder.build(), extendedBoxComponent); } @@ -394,28 +406,28 @@ private CustomBlockComponentsMapping createCustomBlockComponentsMapping(JsonNode // We set this to max value by default so that we may dictate the correct destroy time ourselves float destructibleByMining = Float.MAX_VALUE; if (node.has("destructible_by_mining")) { - destructibleByMining = node.get("destructible_by_mining").floatValue(); + destructibleByMining = node.get("destructible_by_mining").getAsFloat(); } builder.destructibleByMining(destructibleByMining); if (node.has("geometry")) { - if (node.get("geometry").isTextual()) { + if (node.get("geometry").isJsonPrimitive()) { builder.geometry(new GeyserGeometryComponent.Builder() - .identifier(node.get("geometry").asText()) - .build()); + .identifier(node.get("geometry").getAsString()) + .build()); } else { - JsonNode geometry = node.get("geometry"); + JsonObject geometry = node.getAsJsonObject("geometry"); GeometryComponent.Builder geometryBuilder = new GeyserGeometryComponent.Builder(); if (geometry.has("identifier")) { - geometryBuilder.identifier(geometry.get("identifier").asText()); + geometryBuilder.identifier(geometry.get("identifier").getAsString()); } if (geometry.has("bone_visibility")) { - JsonNode boneVisibility = geometry.get("bone_visibility"); - if (boneVisibility.isObject()) { + if (geometry.get("bone_visibility") instanceof JsonObject boneVisibility) { Map boneVisibilityMap = new Object2ObjectOpenHashMap<>(); - boneVisibility.fields().forEachRemaining(entry -> { + boneVisibility.entrySet().forEach(entry -> { String key = entry.getKey(); - String value = entry.getValue().isBoolean() ? (entry.getValue().asBoolean() ? "1" : "0") : entry.getValue().asText(); + String value = entry.getValue() instanceof JsonPrimitive primitive && primitive.isBoolean() + ? (entry.getValue().getAsBoolean() ? "1" : "0") : entry.getValue().getAsString(); boneVisibilityMap.put(key, value); }); geometryBuilder.boneVisibility(boneVisibilityMap); @@ -427,30 +439,30 @@ private CustomBlockComponentsMapping createCustomBlockComponentsMapping(JsonNode String displayName = name; if (node.has("display_name")) { - displayName = node.get("display_name").asText(); + displayName = node.get("display_name").getAsString(); } builder.displayName(displayName); if (node.has("friction")) { - builder.friction(node.get("friction").floatValue()); + builder.friction(node.get("friction").getAsFloat()); } if (node.has("light_emission")) { - builder.lightEmission(node.get("light_emission").asInt()); + builder.lightEmission(node.get("light_emission").getAsInt()); } if (node.has("light_dampening")) { - builder.lightDampening(node.get("light_dampening").asInt()); + builder.lightDampening(node.get("light_dampening").getAsInt()); } boolean placeAir = true; if (node.has("place_air")) { - placeAir = node.get("place_air").asBoolean(); + placeAir = node.get("place_air").getAsBoolean(); } builder.placeAir(placeAir); if (node.has("transformation")) { - JsonNode transformation = node.get("transformation"); + JsonObject transformation = node.getAsJsonObject("transformation"); int rotationX = 0; int rotationY = 0; @@ -463,22 +475,22 @@ private CustomBlockComponentsMapping createCustomBlockComponentsMapping(JsonNode float transformZ = 0; if (transformation.has("rotation")) { - JsonNode rotation = transformation.get("rotation"); - rotationX = rotation.get(0).asInt(); - rotationY = rotation.get(1).asInt(); - rotationZ = rotation.get(2).asInt(); + JsonArray rotation = transformation.getAsJsonArray("rotation"); + rotationX = rotation.get(0).getAsInt(); + rotationY = rotation.get(1).getAsInt(); + rotationZ = rotation.get(2).getAsInt(); } if (transformation.has("scale")) { - JsonNode scale = transformation.get("scale"); - scaleX = scale.get(0).floatValue(); - scaleY = scale.get(1).floatValue(); - scaleZ = scale.get(2).floatValue(); + JsonArray scale = transformation.getAsJsonArray("scale"); + scaleX = scale.get(0).getAsFloat(); + scaleY = scale.get(1).getAsFloat(); + scaleZ = scale.get(2).getAsFloat(); } if (transformation.has("translation")) { - JsonNode translation = transformation.get("translation"); - transformX = translation.get(0).floatValue(); - transformY = translation.get(1).floatValue(); - transformZ = translation.get(2).floatValue(); + JsonArray translation = transformation.getAsJsonArray("translation"); + transformX = translation.get(0).getAsFloat(); + transformY = translation.get(1).getAsFloat(); + transformZ = translation.get(2).getAsFloat(); } builder.transformation(new TransformationComponent(rotationX, rotationY, rotationZ, scaleX, scaleY, scaleZ, transformX, transformY, transformZ)); } @@ -490,12 +502,10 @@ private CustomBlockComponentsMapping createCustomBlockComponentsMapping(JsonNode } if (node.has("material_instances")) { - JsonNode materialInstances = node.get("material_instances"); - if (materialInstances.isObject()) { - materialInstances.fields().forEachRemaining(entry -> { + if (node.get("material_instances") instanceof JsonObject materialInstances) { + materialInstances.entrySet().forEach(entry -> { String key = entry.getKey(); - JsonNode value = entry.getValue(); - if (value.isObject()) { + if (entry.getValue() instanceof JsonObject value) { MaterialInstance materialInstance = createMaterialInstanceComponent(value); builder.materialInstance(key, materialInstance); } @@ -503,16 +513,10 @@ private CustomBlockComponentsMapping createCustomBlockComponentsMapping(JsonNode } } - if (node.has("placement_filter")) { - JsonNode placementFilter = node.get("placement_filter"); - if (placementFilter.isObject()) { - if (placementFilter.has("conditions")) { - JsonNode conditions = placementFilter.get("conditions"); - if (conditions.isArray()) { - List filter = createPlacementFilterComponent(conditions); - builder.placementFilter(filter); - } - } + if (node.get("placement_filter") instanceof JsonObject placementFilter) { + if (placementFilter.get("conditions") instanceof JsonArray conditions) { + List filter = createPlacementFilterComponent(conditions); + builder.placementFilter(filter); } } @@ -521,9 +525,9 @@ private CustomBlockComponentsMapping createCustomBlockComponentsMapping(JsonNode // Ideally we could programmatically extract the tags here https://wiki.bedrock.dev/blocks/block-tags.html // This would let us automatically apply the correct vanilla tags to blocks // However, its worth noting that vanilla tools do not currently honor these tags anyway - if (node.get("tags") instanceof ArrayNode tags) { + if (node.get("tags") instanceof JsonArray tags) { Set tagsSet = new ObjectOpenHashSet<>(); - tags.forEach(tag -> tagsSet.add(tag.asText())); + tags.forEach(tag -> tagsSet.add(tag.getAsString())); builder.tags(tagsSet); } @@ -532,7 +536,7 @@ private CustomBlockComponentsMapping createCustomBlockComponentsMapping(JsonNode /** * Creates a {@link BoxComponent} based on a Java block's collision with provided bounds and offsets - * + * * @param javaId the block's Java ID * @param heightTranslation the height translation of the box * @return the {@link BoxComponent} @@ -571,18 +575,18 @@ private BoxComponent createBoxComponent(int javaId, float heightTranslation) { maxZ = MathUtils.clamp(maxZ, 0, 1); return new BoxComponent( - 16 * (1 - maxX) - 8, // For some odd reason X is mirrored on Bedrock - 16 * minY, - 16 * minZ - 8, - 16 * (maxX - minX), - 16 * (maxY - minY), - 16 * (maxZ - minZ) + 16 * (1 - maxX) - 8, // For some odd reason X is mirrored on Bedrock + 16 * minY, + 16 * minZ - 8, + 16 * (maxX - minX), + 16 * (maxY - minY), + 16 * (maxZ - minZ) ); } /** * Creates a {@link BoxComponent} based on a Java block's collision - * + * * @param javaId the block's Java ID * @return the {@link BoxComponent} */ @@ -592,7 +596,7 @@ private BoxComponent createBoxComponent(int javaId) { /** * Creates the {@link BoxComponent} for an extended collision box based on a Java block's collision - * + * * @param javaId the block's Java ID * @return the {@link BoxComponent} or null if the block's collision box would not exceed 16 y units */ @@ -612,22 +616,22 @@ private BoxComponent createBoxComponent(int javaId) { /** * Creates a {@link BoxComponent} from a JSON Node - * - * @param node the JSON node + * + * @param element the JSON node * @return the {@link BoxComponent} */ - private @Nullable BoxComponent createBoxComponent(JsonNode node) { - if (node != null && node.isObject()) { + private @Nullable BoxComponent createBoxComponent(JsonElement element) { + if (element instanceof JsonObject node) { if (node.has("origin") && node.has("size")) { - JsonNode origin = node.get("origin"); - float originX = origin.get(0).floatValue(); - float originY = origin.get(1).floatValue(); - float originZ = origin.get(2).floatValue(); + JsonArray origin = node.getAsJsonArray("origin"); + float originX = origin.get(0).getAsFloat(); + float originY = origin.get(1).getAsFloat(); + float originZ = origin.get(2).getAsFloat(); - JsonNode size = node.get("size"); - float sizeX = size.get(0).floatValue(); - float sizeY = size.get(1).floatValue(); - float sizeZ = size.get(2).floatValue(); + JsonArray size = node.getAsJsonArray("size"); + float sizeX = size.get(0).getAsFloat(); + float sizeY = size.get(1).getAsFloat(); + float sizeZ = size.get(2).getAsFloat(); return new BoxComponent(originX, originY, originZ, sizeX, sizeY, sizeZ); } @@ -638,72 +642,73 @@ private BoxComponent createBoxComponent(int javaId) { /** * Creates the {@link MaterialInstance} for the passed material instance node and custom block name * The name is used as a fallback if no texture is provided by the node - * + * * @param node the material instance node * @return the {@link MaterialInstance} */ - private MaterialInstance createMaterialInstanceComponent(JsonNode node) { + private MaterialInstance createMaterialInstanceComponent(JsonObject node) { // Set default values, and use what the user provides if they have provided something String texture = null; if (node.has("texture")) { - texture = node.get("texture").asText(); + texture = node.get("texture").getAsString(); } String renderMethod = "opaque"; if (node.has("render_method")) { - renderMethod = node.get("render_method").asText(); + renderMethod = node.get("render_method").getAsString(); } boolean faceDimming = true; if (node.has("face_dimming")) { - faceDimming = node.get("face_dimming").asBoolean(); + faceDimming = node.get("face_dimming").getAsBoolean(); } boolean ambientOcclusion = true; if (node.has("ambient_occlusion")) { - ambientOcclusion = node.get("ambient_occlusion").asBoolean(); + ambientOcclusion = node.get("ambient_occlusion").getAsBoolean(); } return new GeyserMaterialInstance.Builder() - .texture(texture) - .renderMethod(renderMethod) - .faceDimming(faceDimming) - .ambientOcclusion(ambientOcclusion) - .build(); + .texture(texture) + .renderMethod(renderMethod) + .faceDimming(faceDimming) + .ambientOcclusion(ambientOcclusion) + .build(); } /** * Creates the list of {@link PlacementConditions} for the passed conditions node - * + * * @param node the conditions node * @return the list of {@link PlacementConditions} */ - private List createPlacementFilterComponent(JsonNode node) { + private List createPlacementFilterComponent(JsonArray node) { List conditions = new ArrayList<>(); // The structure of the placement filter component is the most complex of the current components // Each condition effectively separated into two arrays: one of allowed faces, and one of blocks/block Molang queries - node.forEach(condition -> { + node.forEach(json -> { + if (!(json instanceof JsonObject condition)) { + return; + } Set faces = EnumSet.noneOf(Face.class); if (condition.has("allowed_faces")) { - JsonNode allowedFaces = condition.get("allowed_faces"); - if (allowedFaces.isArray()) { - allowedFaces.forEach(face -> faces.add(Face.valueOf(face.asText().toUpperCase()))); + if (condition.get("allowed_faces") instanceof JsonArray allowedFaces) { + allowedFaces.forEach(face -> faces.add(Face.valueOf(face.getAsString().toUpperCase()))); } } LinkedHashMap blockFilters = new LinkedHashMap<>(); if (condition.has("block_filter")) { - JsonNode blockFilter = condition.get("block_filter"); - if (blockFilter.isArray()) { + if (condition.get("block_filter") instanceof JsonArray blockFilter) { blockFilter.forEach(filter -> { - if (filter.isObject()) { - if (filter.has("tags")) { - JsonNode tags = filter.get("tags"); - blockFilters.put(tags.asText(), BlockFilterType.TAG); + if (filter instanceof JsonObject jsonObject) { + if (jsonObject.has("tags")) { + JsonElement tags = jsonObject.get("tags"); + blockFilters.put(tags.getAsString(), BlockFilterType.TAG); } - } else if (filter.isTextual()) { - blockFilters.put(filter.asText(), BlockFilterType.BLOCK); + } else if (filter instanceof JsonPrimitive primitive && primitive.isString()) { + blockFilters.put(filter.getAsString(), BlockFilterType.BLOCK); } }); } @@ -717,7 +722,7 @@ private List createPlacementFilterComponent(JsonNode node) /** * Splits the given java state identifier into an array of property=value pairs - * + * * @param state the java state identifier * @return the array of property=value pairs */ diff --git a/core/src/main/java/org/geysermc/geyser/registry/mappings/versions/MappingsReader_v2.java b/core/src/main/java/org/geysermc/geyser/registry/mappings/versions/MappingsReader_v2.java new file mode 100644 index 00000000000..271b5f10710 --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/registry/mappings/versions/MappingsReader_v2.java @@ -0,0 +1,262 @@ +/* + * Copyright (c) 2024 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.registry.mappings.versions; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import it.unimi.dsi.fastutil.objects.ObjectOpenHashSet; +import net.kyori.adventure.key.Key; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.geysermc.geyser.Constants; +import org.geysermc.geyser.GeyserImpl; +import org.geysermc.geyser.api.item.custom.v2.CustomItemBedrockOptions; +import org.geysermc.geyser.api.item.custom.v2.CustomItemDefinition; +import org.geysermc.geyser.api.item.custom.v2.predicate.CustomItemPredicate; +import org.geysermc.geyser.api.item.custom.v2.predicate.RangeDispatchPredicateProperty; +import org.geysermc.geyser.api.item.custom.v2.predicate.condition.ConditionPredicateProperty; +import org.geysermc.geyser.api.item.custom.v2.predicate.match.CustomModelDataString; +import org.geysermc.geyser.api.item.custom.v2.predicate.match.MatchPredicateProperty; +import org.geysermc.geyser.api.util.Identifier; +import org.geysermc.geyser.item.exception.InvalidCustomMappingsFileException; +import org.geysermc.geyser.registry.mappings.components.DataComponentReaders; +import org.geysermc.geyser.registry.mappings.util.CustomBlockMapping; +import org.geysermc.geyser.registry.mappings.util.MappingsUtil; +import org.geysermc.geyser.registry.mappings.util.NodeReader; +import org.geysermc.geyser.util.MinecraftKey; + +import java.nio.file.Path; +import java.util.Map; +import java.util.Set; +import java.util.function.BiConsumer; + +public class MappingsReader_v2 extends MappingsReader { + + @Override + public void readItemMappings(Path file, JsonObject mappingsRoot, BiConsumer consumer) { + readItemMappingsV2(file, mappingsRoot, consumer); + } + + public void readItemMappingsV2(Path file, JsonObject mappingsRoot, BiConsumer consumer) { + JsonObject items = mappingsRoot.getAsJsonObject("items"); + + if (items != null) { + items.entrySet().forEach(entry -> { + if (entry.getValue() instanceof JsonArray array) { + array.forEach(definition -> { + try { + readItemDefinitionEntry(definition, entry.getKey(), null, consumer); + } catch (InvalidCustomMappingsFileException exception) { + GeyserImpl.getInstance().getLogger().error( + "Error reading definition for item " + entry.getKey() + " in custom mappings file: " + file.toString(), exception); + } + }); + } else { + GeyserImpl.getInstance().getLogger().error("Item definitions key " + entry.getKey() + " was not an array!"); + } + }); + } + } + + @Override + public void readBlockMappings(Path file, JsonObject mappingsRoot, BiConsumer consumer) { + JsonElement blocks = mappingsRoot.get("blocks"); + if (blocks != null) { + throw new UnsupportedOperationException("Unimplemented; use the v1 format of block mappings"); + } + } + + private void readItemDefinitionEntry(JsonElement data, String itemIdentifier, Identifier model, + BiConsumer definitionConsumer) throws InvalidCustomMappingsFileException { + String context = "item definition(s) for Java item " + itemIdentifier; + + String type = MappingsUtil.readOrDefault(data, "type", NodeReader.NON_EMPTY_STRING, "definition", context); + if (type.equals("group")) { + // Read model of group if it's present, or default to the model of the parent group, if that's present + // If the parent group model is not present (or there is no parent group), and this group also doesn't have a model, then it is expected the definitions supply their model themselves + Identifier groupModel = MappingsUtil.readOrDefault(data, "model", NodeReader.IDENTIFIER, model, context); + + // The method above should have already thrown a properly formatted error if data is not a JSON object + JsonElement definitions = data.getAsJsonObject().get("definitions"); + + if (definitions == null || !definitions.isJsonArray()) { + throw new InvalidCustomMappingsFileException("reading item definitions in group", "group has no definitions key, or it wasn't an array", context); + } else { + for (JsonElement definition : definitions.getAsJsonArray()) { + // Recursively read all the entries in the group - they can be more groups or definitions + readItemDefinitionEntry(definition, itemIdentifier, groupModel, definitionConsumer); + } + } + } else if (type.equals("definition")) { + CustomItemDefinition customItemDefinition = readItemMappingEntry(model, data); + definitionConsumer.accept(itemIdentifier, customItemDefinition); + } else { + throw new InvalidCustomMappingsFileException("reading item definition", "unknown definition type " + type, context); + } + } + + @Override + public CustomItemDefinition readItemMappingEntry(Identifier parentModel, JsonElement element) throws InvalidCustomMappingsFileException { + Identifier bedrockIdentifier = MappingsUtil.readOrThrow(element, "bedrock_identifier", NodeReader.IDENTIFIER, "item definition"); + // We now know the Bedrock identifier, make a base context so that the error can be easily located in the JSON file + String context = "item definition (bedrock identifier=" + bedrockIdentifier + ")"; + + Identifier model = MappingsUtil.readOrDefault(element, "model", NodeReader.IDENTIFIER, parentModel, context); + + if (model == null) { + throw new InvalidCustomMappingsFileException("reading item model", "no model present", context); + } + + if (bedrockIdentifier.namespace().equals(Key.MINECRAFT_NAMESPACE)) { + bedrockIdentifier = new Identifier(Constants.GEYSER_CUSTOM_NAMESPACE, bedrockIdentifier.path()); // Use geyser_custom namespace when no namespace or the minecraft namespace was given + } + CustomItemDefinition.Builder builder = CustomItemDefinition.builder(bedrockIdentifier, model); + + MappingsUtil.readIfPresent(element, "display_name", builder::displayName, NodeReader.NON_EMPTY_STRING, context); + MappingsUtil.readIfPresent(element, "priority", builder::priority, NodeReader.INT, context); + + // Mappings util methods used above already threw a properly formatted error if the element is not a JSON object + readPredicates(builder, element.getAsJsonObject().get("predicate"), context); + MappingsUtil.readIfPresent(element, "predicate_strategy", builder::predicateStrategy, NodeReader.PREDICATE_STRATEGY, context); + + builder.bedrockOptions(readBedrockOptions(element.getAsJsonObject().get("bedrock_options"), context)); + + JsonElement componentsElement = element.getAsJsonObject().get("components"); + if (componentsElement != null) { + if (componentsElement instanceof JsonObject components) { + for (Map.Entry entry : components.entrySet()) { + DataComponentReaders.readDataComponent(builder, MinecraftKey.key(entry.getKey()), entry.getValue(), context); + } + } else { + throw new InvalidCustomMappingsFileException("reading components", "components key must be an object", context); + } + } + + return builder.build(); + } + + private CustomItemBedrockOptions.Builder readBedrockOptions(JsonElement element, String baseContext) throws InvalidCustomMappingsFileException { + CustomItemBedrockOptions.Builder builder = CustomItemBedrockOptions.builder(); + if (element == null) { + return builder; + } + + String[] context = {"bedrock options", baseContext}; + MappingsUtil.readIfPresent(element, "icon", builder::icon, NodeReader.NON_EMPTY_STRING, context); + MappingsUtil.readIfPresent(element, "allow_offhand", builder::allowOffhand, NodeReader.BOOLEAN, context); + MappingsUtil.readIfPresent(element, "display_handheld", builder::displayHandheld, NodeReader.BOOLEAN, context); + MappingsUtil.readIfPresent(element, "protection_value", builder::protectionValue, NodeReader.NON_NEGATIVE_INT, context); + MappingsUtil.readIfPresent(element, "creative_category", builder::creativeCategory, NodeReader.CREATIVE_CATEGORY, context); + MappingsUtil.readIfPresent(element, "creative_group", builder::creativeGroup, NodeReader.NON_EMPTY_STRING, context); + + if (element.getAsJsonObject().get("tags") instanceof JsonArray tags) { + Set tagsSet = new ObjectOpenHashSet<>(); + for (JsonElement tag : tags) { + if (!tag.isJsonPrimitive()) { + throw new InvalidCustomMappingsFileException("reading tag", "tag must be a string", context); + } + tagsSet.add(NodeReader.NON_EMPTY_STRING.read((JsonPrimitive) tag, "reading tag", context)); + } + builder.tags(tagsSet); + } + + return builder; + } + + private void readPredicates(CustomItemDefinition.Builder builder, JsonElement element, String context) throws InvalidCustomMappingsFileException { + if (element == null) { + return; + } + + if (element.isJsonObject()) { + readPredicate(builder, element, context); + } else if (element.isJsonArray()) { + for (JsonElement predicate : element.getAsJsonArray()) { + readPredicate(builder, predicate, context); + } + } else { + throw new InvalidCustomMappingsFileException("reading predicates", "expected predicate key to be a list of predicates or a predicate", context); + } + } + + private void readPredicate(CustomItemDefinition.Builder builder, @NonNull JsonElement element, String baseContext) throws InvalidCustomMappingsFileException { + String type = MappingsUtil.readOrThrow(element, "type", NodeReader.NON_EMPTY_STRING, "predicate", baseContext); + String[] context = {type + " predicate", baseContext}; + + switch (type) { + case "condition" -> { + ConditionPredicateProperty conditionProperty = MappingsUtil.readOrThrow(element, "property", NodeReader.CONDITION_PREDICATE_PROPERTY, context); + boolean expected = MappingsUtil.readOrDefault(element, "expected", NodeReader.BOOLEAN, true, context); + + if (!conditionProperty.requiresData) { + builder.predicate(CustomItemPredicate.condition((ConditionPredicateProperty) conditionProperty, expected)); + } else if (conditionProperty == ConditionPredicateProperty.CUSTOM_MODEL_DATA) { + int index = MappingsUtil.readOrDefault(element, "index", NodeReader.NON_NEGATIVE_INT, 0, context); + builder.predicate(CustomItemPredicate.condition(ConditionPredicateProperty.CUSTOM_MODEL_DATA, expected, index)); + } else if (conditionProperty == ConditionPredicateProperty.HAS_COMPONENT) { + Identifier component = MappingsUtil.readOrThrow(element, "component", NodeReader.IDENTIFIER, context); + builder.predicate(CustomItemPredicate.condition(ConditionPredicateProperty.HAS_COMPONENT, expected, component)); + } else { + throw new InvalidCustomMappingsFileException("reading condition predicate", "unimplemented reading of condition predicate property!", context); + } + } + case "match" -> { + MatchPredicateProperty property = MappingsUtil.readOrThrow(element, "property", NodeReader.MATCH_PREDICATE_PROPERTY, context); + + if (property == MatchPredicateProperty.CHARGE_TYPE) { + builder.predicate(CustomItemPredicate.match(MatchPredicateProperty.CHARGE_TYPE, + MappingsUtil.readOrThrow(element, "value", NodeReader.CHARGE_TYPE, context))); + } else if (property == MatchPredicateProperty.TRIM_MATERIAL || property == MatchPredicateProperty.CONTEXT_DIMENSION) { + builder.predicate(CustomItemPredicate.match((MatchPredicateProperty) property, + MappingsUtil.readOrThrow(element, "value", NodeReader.IDENTIFIER, context))); + } else if (property == MatchPredicateProperty.CUSTOM_MODEL_DATA) { + builder.predicate(CustomItemPredicate.match(MatchPredicateProperty.CUSTOM_MODEL_DATA, + new CustomModelDataString(MappingsUtil.readOrThrow(element, "value", NodeReader.STRING, context), + MappingsUtil.readOrDefault(element, "index", NodeReader.NON_NEGATIVE_INT, 0, context)))); + } else { + throw new InvalidCustomMappingsFileException("reading match predicate", "unimplemented reading of match predicate property!", context); + } + } + case "range_dispatch" -> { + RangeDispatchPredicateProperty property = MappingsUtil.readOrThrow(element, "property", NodeReader.RANGE_DISPATCH_PREDICATE_PROPERTY, context); + + double threshold = MappingsUtil.readOrThrow(element, "threshold", NodeReader.DOUBLE, context); + double scale = MappingsUtil.readOrDefault(element, "scale", NodeReader.DOUBLE, 1.0, context); + boolean normalizeIfPossible = MappingsUtil.readOrDefault(element, "normalize", NodeReader.BOOLEAN, false, context); + int index = MappingsUtil.readOrDefault(element, "index", NodeReader.NON_NEGATIVE_INT, 0, context); + + builder.predicate(CustomItemPredicate.rangeDispatch(property, threshold, scale, normalizeIfPossible, index)); + } + default -> throw new InvalidCustomMappingsFileException("reading predicate", "unknown predicate type " + type, context); + } + } + + @Override + public CustomBlockMapping readBlockMappingEntry(String identifier, JsonElement node) throws InvalidCustomMappingsFileException { + throw new InvalidCustomMappingsFileException("Unimplemented; use the v1 format of block mappings"); + } +} diff --git a/core/src/main/java/org/geysermc/geyser/registry/populator/CustomBlockRegistryPopulator.java b/core/src/main/java/org/geysermc/geyser/registry/populator/CustomBlockRegistryPopulator.java index a43df3f52cf..a6382a9ab3c 100644 --- a/core/src/main/java/org/geysermc/geyser/registry/populator/CustomBlockRegistryPopulator.java +++ b/core/src/main/java/org/geysermc/geyser/registry/populator/CustomBlockRegistryPopulator.java @@ -317,7 +317,7 @@ static BlockPropertyData generateBlockPropertyData(CustomBlockData customBlock, // in the future, this can be used to replace items in the creative inventory // this would require us to map https://wiki.bedrock.dev/documentation/creative-categories.html#for-blocks programatically .putCompound("menu_category", NbtMap.builder() - .putString("category", creativeCategory.internalName()) + .putString("category", creativeCategory.bedrockName()) .putString("group", creativeGroup) .putBoolean("is_hidden_in_commands", false) .build()) diff --git a/core/src/main/java/org/geysermc/geyser/registry/populator/CustomItemRegistryPopulator.java b/core/src/main/java/org/geysermc/geyser/registry/populator/CustomItemRegistryPopulator.java index ec1e16e795f..5b630ef0182 100644 --- a/core/src/main/java/org/geysermc/geyser/registry/populator/CustomItemRegistryPopulator.java +++ b/core/src/main/java/org/geysermc/geyser/registry/populator/CustomItemRegistryPopulator.java @@ -26,6 +26,7 @@ package org.geysermc.geyser.registry.populator; import com.google.common.collect.Multimap; +import net.kyori.adventure.key.Key; import org.checkerframework.checker.nullness.qual.NonNull; import org.checkerframework.checker.nullness.qual.Nullable; import org.cloudburstmc.nbt.NbtMap; @@ -35,24 +36,37 @@ import org.cloudburstmc.protocol.bedrock.data.definitions.SimpleItemDefinition; import org.cloudburstmc.protocol.bedrock.data.inventory.ComponentItemData; import org.geysermc.geyser.GeyserImpl; -import org.geysermc.geyser.api.item.custom.CustomItemData; +import org.geysermc.geyser.api.exception.CustomItemDefinitionRegisterException; import org.geysermc.geyser.api.item.custom.CustomRenderOffsets; import org.geysermc.geyser.api.item.custom.NonVanillaCustomItemData; -import org.geysermc.geyser.api.util.TriState; +import org.geysermc.geyser.api.item.custom.v2.CustomItemBedrockOptions; +import org.geysermc.geyser.api.item.custom.v2.CustomItemDefinition; +import org.geysermc.geyser.api.item.custom.v2.component.DataComponent; +import org.geysermc.geyser.api.item.custom.v2.component.Repairable; +import org.geysermc.geyser.api.item.custom.v2.component.ToolProperties; +import org.geysermc.geyser.api.item.custom.v2.predicate.CustomItemPredicate; +import org.geysermc.geyser.api.item.custom.v2.predicate.condition.ConditionPredicateProperty; +import org.geysermc.geyser.api.util.CreativeCategory; +import org.geysermc.geyser.api.util.Identifier; import org.geysermc.geyser.event.type.GeyserDefineCustomItemsEventImpl; import org.geysermc.geyser.item.GeyserCustomMappingData; -import org.geysermc.geyser.item.Items; -import org.geysermc.geyser.item.components.WearableSlot; +import org.geysermc.geyser.item.custom.ComponentConverters; +import org.geysermc.geyser.item.custom.predicate.ConditionPredicate; +import org.geysermc.geyser.item.exception.InvalidItemComponentsException; import org.geysermc.geyser.item.type.Item; +import org.geysermc.geyser.registry.Registries; import org.geysermc.geyser.registry.mappings.MappingsConfigReader; import org.geysermc.geyser.registry.type.GeyserMappingItem; -import org.geysermc.geyser.registry.type.ItemMapping; import org.geysermc.geyser.registry.type.NonVanillaItemRegistration; +import org.geysermc.mcprotocollib.protocol.data.game.item.component.Consumable; import org.geysermc.mcprotocollib.protocol.data.game.item.component.DataComponentType; import org.geysermc.mcprotocollib.protocol.data.game.item.component.DataComponents; +import org.geysermc.mcprotocollib.protocol.data.game.item.component.Equippable; +import org.geysermc.mcprotocollib.protocol.data.game.item.component.FoodProperties; +import org.geysermc.mcprotocollib.protocol.data.game.item.component.UseCooldown; import java.util.ArrayList; -import java.util.Collections; +import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -60,42 +74,50 @@ import java.util.Set; public class CustomItemRegistryPopulator { - public static void populate(Map items, Multimap customItems, List nonVanillaCustomItems) { + private static final Identifier UNBREAKABLE_COMPONENT = new Identifier("minecraft", "unbreakable"); + + // In behaviour packs and Java components this is set to a text value, such as "eat" or "drink"; over Bedrock network it's sent as an int. + // These don't all work correctly on Bedrock - see the Consumable.Animation Javadoc in the API + private static final Map BEDROCK_ANIMATIONS = Map.of( + Consumable.ItemUseAnimation.NONE, 0, + Consumable.ItemUseAnimation.EAT, 1, + Consumable.ItemUseAnimation.DRINK, 2, + Consumable.ItemUseAnimation.BLOCK, 3, + Consumable.ItemUseAnimation.BOW, 4, + Consumable.ItemUseAnimation.SPEAR, 6, + Consumable.ItemUseAnimation.CROSSBOW, 9, + Consumable.ItemUseAnimation.SPYGLASS, 10, + Consumable.ItemUseAnimation.BRUSH, 12 + ); + + public static void populate(Map items, Multimap customItems, List nonVanillaCustomItems) { MappingsConfigReader mappingsConfigReader = new MappingsConfigReader(); // Load custom items from mappings files - mappingsConfigReader.loadItemMappingsFromJson((key, item) -> { - if (CustomItemRegistryPopulator.initialCheck(key, item, items)) { - customItems.get(key).add(item); + mappingsConfigReader.loadItemMappingsFromJson((identifier, item) -> { + String error = validate(identifier, item, customItems, items); + if (error == null) { + customItems.get(identifier).add(item); + } else { + GeyserImpl.getInstance().getLogger().error("Not registering custom item definition (bedrock identifier=" + item.bedrockIdentifier() + "): " + error); } }); GeyserImpl.getInstance().eventBus().fire(new GeyserDefineCustomItemsEventImpl(customItems, nonVanillaCustomItems) { + @Override - public boolean register(@NonNull String identifier, @NonNull CustomItemData customItemData) { - if (CustomItemRegistryPopulator.initialCheck(identifier, customItemData, items)) { - customItems.get(identifier).add(customItemData); - return true; + public void register(@NonNull String identifier, @NonNull CustomItemDefinition definition) throws CustomItemDefinitionRegisterException { + String error = validate(identifier, definition, customItems, items); + if (error == null) { + customItems.get(identifier).add(definition); + } else { + throw new CustomItemDefinitionRegisterException("Not registering custom item definition (bedrock identifier=" + definition.bedrockIdentifier() + "): " + error); } - return false; } @Override public boolean register(@NonNull NonVanillaCustomItemData customItemData) { - if (customItemData.identifier().startsWith("minecraft:")) { - GeyserImpl.getInstance().getLogger().error("The custom item " + customItemData.identifier() + - " is attempting to masquerade as a vanilla Minecraft item!"); - return false; - } - - if (customItemData.javaId() < items.size()) { - // Attempting to overwrite an item that already exists in the protocol - GeyserImpl.getInstance().getLogger().error("The custom item " + customItemData.identifier() + - " is attempting to overwrite a vanilla Minecraft item!"); - return false; - } - - nonVanillaCustomItems.add(customItemData); - return true; + // TODO + return false; } }); @@ -105,159 +127,163 @@ public boolean register(@NonNull NonVanillaCustomItemData customItemData) { } } - public static GeyserCustomMappingData registerCustomItem(String customItemName, Item javaItem, GeyserMappingItem mapping, CustomItemData customItemData, int bedrockId, int protocolVersion) { - ItemDefinition itemDefinition = new SimpleItemDefinition(customItemName, bedrockId, true); + public static GeyserCustomMappingData registerCustomItem(Item javaItem, GeyserMappingItem mapping, CustomItemDefinition customItem, + int bedrockId) throws InvalidItemComponentsException { + checkComponents(customItem, javaItem); + + ItemDefinition itemDefinition = new SimpleItemDefinition(customItem.bedrockIdentifier().toString(), bedrockId, true); - NbtMapBuilder builder = createComponentNbt(customItemData, javaItem, mapping, customItemName, bedrockId, protocolVersion); - ComponentItemData componentItemData = new ComponentItemData(customItemName, builder.build()); + NbtMapBuilder builder = createComponentNbt(customItem, javaItem, mapping, bedrockId); + ComponentItemData componentItemData = new ComponentItemData(customItem.bedrockIdentifier().toString(), builder.build()); - return new GeyserCustomMappingData(componentItemData, itemDefinition, customItemName, bedrockId); + return new GeyserCustomMappingData(customItem, componentItemData, itemDefinition, bedrockId); } - static boolean initialCheck(String identifier, CustomItemData item, Map mappings) { - if (!mappings.containsKey(identifier)) { - GeyserImpl.getInstance().getLogger().error("Could not find the Java item to add custom item properties to for " + item.name()); - return false; + /** + * @return null if there are no errors with the registration, and an error message if there are + */ + private static String validate(String vanillaIdentifier, CustomItemDefinition item, Multimap registered, Map mappings) { + if (!mappings.containsKey(vanillaIdentifier)) { + return "unknown Java item " + vanillaIdentifier; } - if (!item.customItemOptions().hasCustomItemOptions()) { - GeyserImpl.getInstance().getLogger().error("The custom item " + item.name() + " has no registration types"); + Identifier bedrockIdentifier = item.bedrockIdentifier(); + if (bedrockIdentifier.namespace().equals(Key.MINECRAFT_NAMESPACE)) { + return "custom item bedrock identifier namespace can't be minecraft"; + } else if (item.model().namespace().equals(Key.MINECRAFT_NAMESPACE) && item.predicates().isEmpty()) { + return "custom item definition model can't be in the minecraft namespace without a predicate"; } - String name = item.name(); - if (name.isEmpty()) { - GeyserImpl.getInstance().getLogger().warning("Custom item name is empty?"); - } else if (Character.isDigit(name.charAt(0))) { - // As of 1.19.31 - GeyserImpl.getInstance().getLogger().warning("Custom item name (" + name + ") begins with a digit. This may cause issues!"); + + for (Map.Entry entry : registered.entries()) { + if (entry.getValue().bedrockIdentifier().equals(item.bedrockIdentifier())) { + return "conflicts with another custom item definition with the same bedrock identifier"; + } + String error = checkPredicate(entry, vanillaIdentifier, item); + if (error != null) { + return "conflicts with custom item definition (bedrock identifier=" + entry.getValue().bedrockIdentifier() + "): " + error; + } } - return true; - } - public static NonVanillaItemRegistration registerCustomItem(NonVanillaCustomItemData customItemData, int customItemId, int protocolVersion) { - String customIdentifier = customItemData.identifier(); - - DataComponents components = new DataComponents(new HashMap<>()); - components.put(DataComponentType.MAX_STACK_SIZE, customItemData.stackSize()); - components.put(DataComponentType.MAX_DAMAGE, customItemData.maxDamage()); - - Item item = new Item(customIdentifier, Item.builder().components(components)); - Items.register(item, customItemData.javaId()); - - ItemMapping customItemMapping = ItemMapping.builder() - .bedrockIdentifier(customIdentifier) - .bedrockDefinition(new SimpleItemDefinition(customIdentifier, customItemId, true)) - .bedrockData(0) - .bedrockBlockDefinition(null) - .toolType(customItemData.toolType()) - .translationString(customItemData.translationString()) - .customItemOptions(Collections.emptyList()) - .javaItem(item) - .build(); - - NbtMapBuilder builder = createComponentNbt(customItemData, customItemData.identifier(), customItemId, - customItemData.isHat(), customItemData.displayHandheld(), protocolVersion); - ComponentItemData componentItemData = new ComponentItemData(customIdentifier, builder.build()); - - return new NonVanillaItemRegistration(componentItemData, item, customItemMapping); + return null; } - private static NbtMapBuilder createComponentNbt(CustomItemData customItemData, Item javaItem, GeyserMappingItem mapping, - String customItemName, int customItemId, int protocolVersion) { - NbtMapBuilder builder = NbtMap.builder(); - builder.putString("name", customItemName) - .putInt("id", customItemId); - - NbtMapBuilder itemProperties = NbtMap.builder(); - NbtMapBuilder componentBuilder = NbtMap.builder(); - - setupBasicItemInfo(javaItem.defaultMaxDamage(), javaItem.defaultMaxStackSize(), mapping.getToolType() != null || customItemData.displayHandheld(), customItemData, itemProperties, componentBuilder, protocolVersion); - - boolean canDestroyInCreative = true; - if (mapping.getToolType() != null) { // This is not using the isTool boolean because it is not just a render type here. - canDestroyInCreative = computeToolProperties(mapping.getToolType(), itemProperties, componentBuilder, javaItem.defaultAttackDamage()); - } - itemProperties.putBoolean("can_destroy_in_creative", canDestroyInCreative); - - if (mapping.getArmorType() != null) { - computeArmorProperties(mapping.getArmorType(), mapping.getProtectionValue(), itemProperties, componentBuilder); + /** + * @return an error message if there was a conflict, or null otherwise + */ + private static String checkPredicate(Map.Entry existing, String vanillaIdentifier, CustomItemDefinition newItem) { + // If the definitions are for different Java items or models then it doesn't matter + if (!vanillaIdentifier.equals(existing.getKey()) || !newItem.model().equals(existing.getValue().model())) { + return null; } - - if (mapping.getFirstBlockRuntimeId() != null) { - computeBlockItemProperties(mapping.getBedrockIdentifier(), componentBuilder); + // If they both don't have predicates they conflict + if (existing.getValue().predicates().isEmpty() && newItem.predicates().isEmpty()) { + return "both entries don't have predicates, one must have a predicate"; } - - if (mapping.isEdible()) { - computeConsumableProperties(itemProperties, componentBuilder, 1, false); + // If their predicates are equal then they also conflict + if (existing.getValue().predicates().size() == newItem.predicates().size()) { + boolean equal = true; + for (CustomItemPredicate predicate : existing.getValue().predicates()) { + if (!newItem.predicates().contains(predicate)) { + equal = false; + } + } + if (equal) { + return "both entries have the same predicates"; + } } - if (mapping.isEntityPlacer()) { - computeEntityPlacerProperties(componentBuilder); - } + return null; + } - switch (mapping.getBedrockIdentifier()) { - case "minecraft:fire_charge", "minecraft:flint_and_steel" -> computeBlockItemProperties("minecraft:fire", componentBuilder); - case "minecraft:bow", "minecraft:crossbow", "minecraft:trident" -> computeChargeableProperties(itemProperties, componentBuilder, mapping.getBedrockIdentifier(), protocolVersion); - case "minecraft:honey_bottle", "minecraft:milk_bucket", "minecraft:potion" -> computeConsumableProperties(itemProperties, componentBuilder, 2, true); - case "minecraft:experience_bottle", "minecraft:egg", "minecraft:ender_pearl", "minecraft:ender_eye", "minecraft:lingering_potion", "minecraft:snowball", "minecraft:splash_potion" -> - computeThrowableProperties(componentBuilder); + /** + * Check for illegal combinations of item components that can be specified in the custom item API, and validated components that can't be checked in the API, e.g. components that reference items. + * + *

Note that, component validation is preferred to occur early in the API module. This method should primarily check for illegal combinations of item components. + * It is expected that the values of the components separately have already been validated when possible (for example, it is expected that stack size is in the range [1, 99]).

+ */ + private static void checkComponents(CustomItemDefinition definition, Item javaItem) throws InvalidItemComponentsException { + DataComponents components = patchDataComponents(javaItem, definition); + int stackSize = components.getOrDefault(DataComponentType.MAX_STACK_SIZE, 0); + int maxDamage = components.getOrDefault(DataComponentType.MAX_DAMAGE, 0); + + if (components.get(DataComponentType.EQUIPPABLE) != null && stackSize > 1) { + throw new InvalidItemComponentsException("Bedrock doesn't support equippable items with a stack size above 1"); + } else if (stackSize > 1 && maxDamage > 0) { + throw new InvalidItemComponentsException("Stack size must be 1 when max damage is above 0"); + } + + Repairable repairable = definition.components().get(DataComponent.REPAIRABLE); + if (repairable != null) { + for (Identifier item : repairable.items()) { + if (Registries.JAVA_ITEM_IDENTIFIERS.get(item.toString()) == null) { + throw new InvalidItemComponentsException("Unknown repair item " + item + " in minecraft:repairable component"); + } + } } + } - // Hardcoded on Java, and should extend to the custom item - boolean isHat = (javaItem.equals(Items.SKELETON_SKULL) || javaItem.equals(Items.WITHER_SKELETON_SKULL) - || javaItem.equals(Items.CARVED_PUMPKIN) || javaItem.equals(Items.ZOMBIE_HEAD) - || javaItem.equals(Items.PIGLIN_HEAD) || javaItem.equals(Items.DRAGON_HEAD) - || javaItem.equals(Items.CREEPER_HEAD) || javaItem.equals(Items.PLAYER_HEAD) - ); - computeRenderOffsets(isHat, customItemData, componentBuilder); - - componentBuilder.putCompound("item_properties", itemProperties.build()); - builder.putCompound("components", componentBuilder.build()); - - return builder; + public static NonVanillaItemRegistration registerCustomItem(NonVanillaCustomItemData customItemData, int customItemId, int protocolVersion) { + // TODO + return null; } - private static NbtMapBuilder createComponentNbt(NonVanillaCustomItemData customItemData, String customItemName, - int customItemId, boolean isHat, boolean displayHandheld, int protocolVersion) { - NbtMapBuilder builder = NbtMap.builder(); - builder.putString("name", customItemName) - .putInt("id", customItemId); + private static NbtMapBuilder createComponentNbt(CustomItemDefinition customItemDefinition, Item vanillaJavaItem, GeyserMappingItem vanillaMapping, int customItemId) { + NbtMapBuilder builder = NbtMap.builder() + .putString("name", customItemDefinition.bedrockIdentifier().toString()) + .putInt("id", customItemId); NbtMapBuilder itemProperties = NbtMap.builder(); NbtMapBuilder componentBuilder = NbtMap.builder(); - setupBasicItemInfo(customItemData.maxDamage(), customItemData.stackSize(), displayHandheld, customItemData, itemProperties, componentBuilder, protocolVersion); + DataComponents components = patchDataComponents(vanillaJavaItem, customItemDefinition); + setupBasicItemInfo(customItemDefinition, components, itemProperties, componentBuilder); + + computeToolProperties(itemProperties, componentBuilder); + + // Temporary workaround: when 1.21.5 releases, this value will be mapped to an MCPL tool component, and this code will look nicer + // since we can get the value from the vanilla item component instead of using the vanilla mapping. + ToolProperties toolProperties = customItemDefinition.components().get(DataComponent.TOOL); + boolean canDestroyInCreative = toolProperties == null ? !"sword".equals(vanillaMapping.getToolType()) : toolProperties.canDestroyBlocksInCreative(); + computeCreativeDestroyProperties(canDestroyInCreative, itemProperties, componentBuilder); - boolean canDestroyInCreative = true; - if (customItemData.toolType() != null) { // This is not using the isTool boolean because it is not just a render type here. - canDestroyInCreative = computeToolProperties(Objects.requireNonNull(customItemData.toolType()), itemProperties, componentBuilder, customItemData.attackDamage()); + switch (vanillaMapping.getBedrockIdentifier()) { + case "minecraft:fire_charge", "minecraft:flint_and_steel" -> computeBlockItemProperties("minecraft:fire", componentBuilder); + case "minecraft:bow", "minecraft:crossbow", "minecraft:trident" -> computeChargeableProperties(itemProperties, componentBuilder, vanillaMapping.getBedrockIdentifier()); + case "minecraft:experience_bottle", "minecraft:egg", "minecraft:ender_pearl", "minecraft:ender_eye", "minecraft:lingering_potion", "minecraft:snowball", "minecraft:splash_potion" -> computeThrowableProperties(componentBuilder); } - itemProperties.putBoolean("can_destroy_in_creative", canDestroyInCreative); - String armorType = customItemData.armorType(); - if (armorType != null) { - computeArmorProperties(armorType, customItemData.protectionValue(), itemProperties, componentBuilder); + // Using API component here because MCPL one is just an ID holder set + Repairable repairable = customItemDefinition.components().get(DataComponent.REPAIRABLE); + if (repairable != null) { + computeRepairableProperties(repairable, componentBuilder); } - if (customItemData.isEdible()) { - computeConsumableProperties(itemProperties, componentBuilder, 1, customItemData.canAlwaysEat()); + Equippable equippable = components.get(DataComponentType.EQUIPPABLE); + if (equippable != null) { + computeArmorProperties(equippable, customItemDefinition.bedrockOptions().protectionValue(), componentBuilder); } - if (customItemData.isChargeable()) { - String tooltype = customItemData.toolType(); - if (tooltype == null) { - throw new IllegalArgumentException("tool type must be set if the custom item is chargeable!"); - } - computeChargeableProperties(itemProperties, componentBuilder, "minecraft:" + tooltype, protocolVersion); + Integer enchantmentValue = components.get(DataComponentType.ENCHANTABLE); + if (enchantmentValue != null) { + computeEnchantableProperties(enchantmentValue, itemProperties, componentBuilder); } - computeRenderOffsets(isHat, customItemData, componentBuilder); + if (vanillaMapping.getFirstBlockRuntimeId() != null) { + computeBlockItemProperties(vanillaMapping.getBedrockIdentifier(), componentBuilder); + } - if (customItemData.isFoil()) { - itemProperties.putBoolean("foil", true); + Consumable consumable = components.get(DataComponentType.CONSUMABLE); + if (consumable != null) { + FoodProperties foodProperties = components.get(DataComponentType.FOOD); + computeConsumableProperties(consumable, foodProperties, itemProperties, componentBuilder); } - String block = customItemData.block(); - if (block != null) { - computeBlockItemProperties(block, componentBuilder); + if (vanillaMapping.isEntityPlacer()) { + computeEntityPlacerProperties(componentBuilder); + } + + UseCooldown useCooldown = components.get(DataComponentType.USE_COOLDOWN); + if (useCooldown != null) { + computeUseCooldownProperties(useCooldown, componentBuilder); } componentBuilder.putCompound("item_properties", itemProperties.build()); @@ -266,270 +292,270 @@ private static NbtMapBuilder createComponentNbt(NonVanillaCustomItemData customI return builder; } - private static void setupBasicItemInfo(int maxDamage, int stackSize, boolean displayHandheld, CustomItemData customItemData, NbtMapBuilder itemProperties, NbtMapBuilder componentBuilder, int protocolVersion) { + private static NbtMapBuilder createComponentNbt(NonVanillaCustomItemData customItemData, String customItemName, + int customItemId, boolean isHat, boolean displayHandheld, int protocolVersion) { + // TODO; + return null; + } + + private static void setupBasicItemInfo(CustomItemDefinition definition, DataComponents components, NbtMapBuilder itemProperties, NbtMapBuilder componentBuilder) { + CustomItemBedrockOptions options = definition.bedrockOptions(); NbtMap iconMap = NbtMap.builder() .putCompound("textures", NbtMap.builder() - .putString("default", customItemData.icon()) - .build()) + .putString("default", definition.icon()) + .build()) .build(); itemProperties.putCompound("minecraft:icon", iconMap); - if (customItemData.creativeCategory().isPresent()) { - itemProperties.putInt("creative_category", customItemData.creativeCategory().getAsInt()); + if (options.creativeCategory() != CreativeCategory.NONE) { + itemProperties.putInt("creative_category", options.creativeCategory().id()); - if (customItemData.creativeGroup() != null) { - itemProperties.putString("creative_group", customItemData.creativeGroup()); + if (options.creativeGroup() != null) { + itemProperties.putString("creative_group", options.creativeGroup()); } } - componentBuilder.putCompound("minecraft:display_name", NbtMap.builder().putString("value", customItemData.displayName()).build()); + componentBuilder.putCompound("minecraft:display_name", NbtMap.builder().putString("value", definition.displayName()).build()); // Add a Geyser tag to the item, allowing Molang queries addItemTag(componentBuilder, "geyser:is_custom"); // Add other defined tags to the item - Set tags = customItemData.tags(); + Set tags = options.tags(); for (String tag : tags) { if (tag != null && !tag.isBlank()) { addItemTag(componentBuilder, tag); } } - itemProperties.putBoolean("allow_off_hand", customItemData.allowOffhand()); - itemProperties.putBoolean("hand_equipped", displayHandheld); + itemProperties.putBoolean("allow_off_hand", options.allowOffhand()); + itemProperties.putBoolean("hand_equipped", options.displayHandheld()); + + int maxDamage = components.getOrDefault(DataComponentType.MAX_DAMAGE, 0); + Equippable equippable = components.get(DataComponentType.EQUIPPABLE); + // Java requires stack size to be 1 when max damage is above 0, and bedrock requires stack size to be 1 when the item can be equipped + int stackSize = maxDamage > 0 || equippable != null ? 1 : components.getOrDefault(DataComponentType.MAX_STACK_SIZE, 0); // This should never be 0 since we're patching components on top of the vanilla ones + itemProperties.putInt("max_stack_size", stackSize); - // Ignore durability if the item's predicate requires that it be unbreakable - if (maxDamage > 0 && customItemData.customItemOptions().unbreakable() != TriState.TRUE) { + if (maxDamage > 0 && !isUnbreakableItem(definition)) { componentBuilder.putCompound("minecraft:durability", NbtMap.builder() - .putCompound("damage_chance", NbtMap.builder() - .putInt("max", 1) - .putInt("min", 1) - .build()) - .putInt("max_durability", maxDamage) - .build()); - itemProperties.putBoolean("use_duration", true); + .putCompound("damage_chance", NbtMap.builder() + .putInt("max", 1) + .putInt("min", 1) + .build()) + .putInt("max_durability", maxDamage) + .build()); } } /** - * @return can destroy in creative + * Adds properties to make the Bedrock client unable to destroy any block with this custom item. + * This works because the molang '1' for tags will be true for all blocks and the speed will be 0. + * We want this since we calculate break speed server side in BedrockActionTranslator */ - private static boolean computeToolProperties(String toolType, NbtMapBuilder itemProperties, NbtMapBuilder componentBuilder, int attackDamage) { - boolean canDestroyInCreative = true; - float miningSpeed = 1.0f; - - // This means client side the tool can never destroy a block - // This works because the molang '1' for tags will be true for all blocks and the speed will be 0 - // We want this since we calculate break speed server side in BedrockActionTranslator + private static void computeToolProperties(NbtMapBuilder itemProperties, NbtMapBuilder componentBuilder) { List speed = new ArrayList<>(List.of( NbtMap.builder() .putCompound("block", NbtMap.builder() - .putString("tags", "1") - .build()) - .putCompound("on_dig", NbtMap.builder() - .putCompound("condition", NbtMap.builder() - .putString("expression", "") - .putInt("version", -1) - .build()) - .putString("event", "tool_durability") - .putString("target", "self") - .build()) + .putString("name", "") + .putCompound("states", NbtMap.EMPTY) + .putString("tags", "1") + .build()) .putInt("speed", 0) .build() )); - - componentBuilder.putCompound("minecraft:digger", - NbtMap.builder() - .putList("destroy_speeds", NbtType.COMPOUND, speed) - .putCompound("on_dig", NbtMap.builder() - .putCompound("condition", NbtMap.builder() - .putString("expression", "") - .putInt("version", -1) - .build()) - .putString("event", "tool_durability") - .putString("target", "self") - .build()) - .putBoolean("use_efficiency", true) - .build() - ); - - if (toolType.equals("sword")) { - miningSpeed = 1.5f; - canDestroyInCreative = false; - } - itemProperties.putBoolean("hand_equipped", true); - itemProperties.putFloat("mining_speed", miningSpeed); - - // This allows custom tools - shears, swords, shovels, axes etc to be enchanted or combined in the anvil - itemProperties.putInt("enchantable_value", 1); - itemProperties.putString("enchantable_slot", toolType); + componentBuilder.putCompound("minecraft:digger", NbtMap.builder() + .putList("destroy_speeds", NbtType.COMPOUND, speed) + .putBoolean("use_efficiency", false) + .build()); - // Adds a "attack damage" indicator. Purely visual! - if (attackDamage > 0) { - itemProperties.putInt("damage", attackDamage); - } + itemProperties.putFloat("mining_speed", 1.0F); + } - return canDestroyInCreative; + private static void computeCreativeDestroyProperties(boolean canDestroyInCreative, NbtMapBuilder itemProperties, NbtMapBuilder componentBuilder) { + itemProperties.putBoolean("can_destroy_in_creative", canDestroyInCreative); + componentBuilder.putCompound("minecraft:can_destroy_in_creative", NbtMap.builder() + .putBoolean("value", canDestroyInCreative) + .build()); } - private static void computeArmorProperties(String armorType, int protectionValue, NbtMapBuilder itemProperties, NbtMapBuilder componentBuilder) { - switch (armorType) { - case "boots" -> { - componentBuilder.putString("minecraft:render_offsets", "boots"); - componentBuilder.putCompound("minecraft:wearable", WearableSlot.FEET.getSlotNbt()); - componentBuilder.putCompound("minecraft:armor", NbtMap.builder().putInt("protection", protectionValue).build()); + /** + * Repairable component should already have been validated for valid Java items in {@link CustomItemRegistryPopulator#checkComponents(CustomItemDefinition, Item)}. + * + *

This method passes the Java identifiers straight to bedrock - which isn't perfect.

+ */ + private static void computeRepairableProperties(Repairable repairable, NbtMapBuilder componentBuilder) { + List items = Arrays.stream(repairable.items()) + .map(identifier -> NbtMap.builder() + .putString("name", identifier.toString()) + .build()).toList(); + + componentBuilder.putCompound("minecraft:repairable", NbtMap.builder() + .putList("repair_items", NbtType.COMPOUND, NbtMap.builder() + .putList("items", NbtType.COMPOUND, items) + .putFloat("repair_amount", 0.0F) + .build()) + .build()); + } - itemProperties.putString("enchantable_slot", "armor_feet"); - itemProperties.putInt("enchantable_value", 15); + private static void computeArmorProperties(Equippable equippable, int protectionValue, NbtMapBuilder componentBuilder) { + switch (equippable.slot()) { + case HELMET -> { + componentBuilder.putCompound("minecraft:wearable", NbtMap.builder() + .putString("slot", "slot.armor.head") + .putInt("protection", protectionValue) + .build()); } - case "chestplate" -> { - componentBuilder.putString("minecraft:render_offsets", "chestplates"); - componentBuilder.putCompound("minecraft:wearable", WearableSlot.CHEST.getSlotNbt()); - componentBuilder.putCompound("minecraft:armor", NbtMap.builder().putInt("protection", protectionValue).build()); - - itemProperties.putString("enchantable_slot", "armor_torso"); - itemProperties.putInt("enchantable_value", 15); + case CHESTPLATE -> { + componentBuilder.putCompound("minecraft:wearable", NbtMap.builder() + .putString("slot", "slot.armor.chest") + .putInt("protection", protectionValue) + .build()); } - case "leggings" -> { - componentBuilder.putString("minecraft:render_offsets", "leggings"); - componentBuilder.putCompound("minecraft:wearable", WearableSlot.LEGS.getSlotNbt()); - componentBuilder.putCompound("minecraft:armor", NbtMap.builder().putInt("protection", protectionValue).build()); - - itemProperties.putString("enchantable_slot", "armor_legs"); - itemProperties.putInt("enchantable_value", 15); + case LEGGINGS -> { + componentBuilder.putCompound("minecraft:wearable", NbtMap.builder() + .putString("slot", "slot.armor.legs") + .putInt("protection", protectionValue) + .build()); } - case "helmet" -> { - componentBuilder.putString("minecraft:render_offsets", "helmets"); - componentBuilder.putCompound("minecraft:wearable", WearableSlot.HEAD.getSlotNbt()); - componentBuilder.putCompound("minecraft:armor", NbtMap.builder().putInt("protection", protectionValue).build()); - - itemProperties.putString("enchantable_slot", "armor_head"); - itemProperties.putInt("enchantable_value", 15); + case BOOTS -> { + componentBuilder.putCompound("minecraft:wearable", NbtMap.builder() + .putString("slot", "slot.armor.feet") + .putInt("protection", protectionValue) + .build()); } } } + private static void computeEnchantableProperties(int enchantmentValue, NbtMapBuilder itemProperties, NbtMapBuilder componentBuilder) { + itemProperties.putString("enchantable_slot", "all"); + itemProperties.putInt("enchantable_value", enchantmentValue); + componentBuilder.putCompound("minecraft:enchantable", NbtMap.builder() + .putString("slot", "all") + .putByte("value", (byte) enchantmentValue) + .build()); + } + private static void computeBlockItemProperties(String blockItem, NbtMapBuilder componentBuilder) { // carved pumpkin should be able to be worn and for that we would need to add wearable and armor with protection 0 here // however this would have the side effect of preventing carved pumpkins from working as an attachable on the RP side outside the head slot - // it also causes the item to glitch when right clicked to "equip" so this should only be added here later if these issues can be overcome + // it also causes the item to glitch when right-clicked to "equip" so this should only be added here later if these issues can be overcome // all block items registered should be given this component to prevent double placement - componentBuilder.putCompound("minecraft:block_placer", NbtMap.builder().putString("block", blockItem).build()); + componentBuilder.putCompound("minecraft:block_placer", NbtMap.builder() + .putString("block", blockItem) + .putBoolean("canUseBlockAsIcon", false) + .putList("use_on", NbtType.STRING) + .build()); } - private static void computeChargeableProperties(NbtMapBuilder itemProperties, NbtMapBuilder componentBuilder, String mapping, int protocolVersion) { + private static void computeChargeableProperties(NbtMapBuilder itemProperties, NbtMapBuilder componentBuilder, String mapping) { // setting high use_duration prevents the consume animation from playing itemProperties.putInt("use_duration", Integer.MAX_VALUE); - // display item as tool (mainly for crossbow and bow) - itemProperties.putBoolean("hand_equipped", true); - // Make bows, tridents, and crossbows enchantable - itemProperties.putInt("enchantable_value", 1); componentBuilder.putCompound("minecraft:use_modifiers", NbtMap.builder() - .putFloat("use_duration", 100F) - .putFloat("movement_modifier", 0.35F) - .build()); + .putFloat("movement_modifier", 0.35F) + .putFloat("use_duration", 100.0F) + .build()); switch (mapping) { case "minecraft:bow" -> { - itemProperties.putString("enchantable_slot", "bow"); itemProperties.putInt("frame_count", 3); componentBuilder.putCompound("minecraft:shooter", NbtMap.builder() - .putList("ammunition", NbtType.COMPOUND, List.of( - NbtMap.builder() - .putCompound("item", NbtMap.builder() - .putString("name", "minecraft:arrow") - .build()) - .putBoolean("use_offhand", true) - .putBoolean("search_inventory", true) - .build() - )) - .putFloat("max_draw_duration", 0f) - .putBoolean("charge_on_draw", true) - .putBoolean("scale_power_by_draw_duration", true) - .build()); - componentBuilder.putInt("minecraft:use_duration", 999); - } - case "minecraft:trident" -> { - itemProperties.putString("enchantable_slot", "trident"); - componentBuilder.putInt("minecraft:use_duration", 999); + .putList("ammunition", NbtType.COMPOUND, List.of( + NbtMap.builder() + .putCompound("item", NbtMap.builder() + .putString("name", "minecraft:arrow") + .build()) + .putBoolean("search_inventory", true) + .putBoolean("use_in_creative", false) + .putBoolean("use_offhand", true) + .build() + )) + .putBoolean("charge_on_draw", true) + .putFloat("max_draw_duration", 0.0F) + .putBoolean("scale_power_by_draw_duration", true) + .build()); } + case "minecraft:trident" -> itemProperties.putInt("use_animation", BEDROCK_ANIMATIONS.get(Consumable.ItemUseAnimation.SPEAR)); case "minecraft:crossbow" -> { - itemProperties.putString("enchantable_slot", "crossbow"); itemProperties.putInt("frame_count", 10); componentBuilder.putCompound("minecraft:shooter", NbtMap.builder() - .putList("ammunition", NbtType.COMPOUND, List.of( - NbtMap.builder() - .putCompound("item", NbtMap.builder() - .putString("name", "minecraft:arrow") - .build()) - .putBoolean("use_offhand", true) - .putBoolean("search_inventory", true) - .build() - )) - .putFloat("max_draw_duration", 1f) - .putBoolean("charge_on_draw", true) - .putBoolean("scale_power_by_draw_duration", true) - .build()); - componentBuilder.putInt("minecraft:use_duration", 999); + .putList("ammunition", NbtType.COMPOUND, List.of( + NbtMap.builder() + .putCompound("item", NbtMap.builder() + .putString("name", "minecraft:arrow") + .build()) + .putBoolean("use_offhand", true) + .putBoolean("use_in_creative", false) + .putBoolean("search_inventory", true) + .build() + )) + .putBoolean("charge_on_draw", true) + .putFloat("max_draw_duration", 1.0F) + .putBoolean("scale_power_by_draw_duration", true) + .build()); } } } - private static void computeConsumableProperties(NbtMapBuilder itemProperties, NbtMapBuilder componentBuilder, int useAnimation, boolean canAlwaysEat) { + private static void computeConsumableProperties(Consumable consumable, @Nullable FoodProperties foodProperties, NbtMapBuilder itemProperties, NbtMapBuilder componentBuilder) { // this is the duration of the use animation in ticks; note that in behavior packs this is set as a float in seconds, but over the network it is an int in ticks - itemProperties.putInt("use_duration", 32); - // this dictates that the item will use the eat or drink animation (in the first person) and play eat or drink sounds - // note that in behavior packs this is set as the string "eat" or "drink", but over the network it as an int, with these values being 1 and 2 respectively - itemProperties.putInt("use_animation", useAnimation); - // this component is required to allow the eat animation to play - componentBuilder.putCompound("minecraft:food", NbtMap.builder().putBoolean("can_always_eat", canAlwaysEat).build()); + itemProperties.putInt("use_duration", (int) (consumable.consumeSeconds() * 20)); + + Integer animationId = BEDROCK_ANIMATIONS.get(consumable.animation()); + if (animationId != null) { + itemProperties.putInt("use_animation", animationId); + componentBuilder.putCompound("minecraft:use_animation", NbtMap.builder() + .putString("value", consumable.animation().toString().toLowerCase()) + .build()); + } + + int nutrition = foodProperties == null ? 0 : foodProperties.getNutrition(); + float saturationModifier = foodProperties == null ? 0.0F : foodProperties.getSaturationModifier(); + boolean canAlwaysEat = foodProperties == null || foodProperties.isCanAlwaysEat(); + componentBuilder.putCompound("minecraft:food", NbtMap.builder() + .putBoolean("can_always_eat", canAlwaysEat) + .putInt("nutrition", nutrition) + .putFloat("saturation_modifier", saturationModifier) + .putCompound("using_converts_to", NbtMap.EMPTY) + .build()); + + componentBuilder.putCompound("minecraft:use_modifiers", NbtMap.builder() + .putFloat("movement_modifier", 0.35F) + .putFloat("use_duration", consumable.consumeSeconds()) + .build()); } private static void computeEntityPlacerProperties(NbtMapBuilder componentBuilder) { // all items registered that place entities should be given this component to prevent double placement - // it is okay that the entity here does not match the actual one since we control what entity actually spawns - componentBuilder.putCompound("minecraft:entity_placer", NbtMap.builder().putString("entity", "minecraft:minecart").build()); + // it is okay that the entity here does not match the actual one since we control what entity actually spawns + componentBuilder.putCompound("minecraft:entity_placer", NbtMap.builder() + .putList("dispense_on", NbtType.STRING) + .putString("entity", "minecraft:minecart") + .putList("use_on", NbtType.STRING) + .build()); } private static void computeThrowableProperties(NbtMapBuilder componentBuilder) { // allows item to be thrown when holding down right click (individual presses are required w/o this component) componentBuilder.putCompound("minecraft:throwable", NbtMap.builder().putBoolean("do_swing_animation", true).build()); + // this must be set to something for the swing animation to play // it is okay that the projectile here does not match the actual one since we control what entity actually spawns componentBuilder.putCompound("minecraft:projectile", NbtMap.builder().putString("projectile_entity", "minecraft:snowball").build()); } - private static void computeRenderOffsets(boolean isHat, CustomItemData customItemData, NbtMapBuilder componentBuilder) { - if (isHat) { - componentBuilder.remove("minecraft:render_offsets"); - componentBuilder.putString("minecraft:render_offsets", "helmets"); - - componentBuilder.remove("minecraft:wearable"); - componentBuilder.putCompound("minecraft:wearable", WearableSlot.HEAD.getSlotNbt()); - } - - CustomRenderOffsets renderOffsets = customItemData.renderOffsets(); - if (renderOffsets != null) { - componentBuilder.remove("minecraft:render_offsets"); - componentBuilder.putCompound("minecraft:render_offsets", toNbtMap(renderOffsets)); - } else if (customItemData.textureSize() != 16 && !componentBuilder.containsKey("minecraft:render_offsets")) { - float scale1 = (float) (0.075 / (customItemData.textureSize() / 16f)); - float scale2 = (float) (0.125 / (customItemData.textureSize() / 16f)); - float scale3 = (float) (0.075 / (customItemData.textureSize() / 16f * 2.4f)); - - componentBuilder.putCompound("minecraft:render_offsets", - NbtMap.builder().putCompound("main_hand", NbtMap.builder() - .putCompound("first_person", xyzToScaleList(scale3, scale3, scale3)) - .putCompound("third_person", xyzToScaleList(scale1, scale2, scale1)).build()) - .putCompound("off_hand", NbtMap.builder() - .putCompound("first_person", xyzToScaleList(scale1, scale2, scale1)) - .putCompound("third_person", xyzToScaleList(scale1, scale2, scale1)).build()).build()); - } + private static void computeUseCooldownProperties(UseCooldown cooldown, NbtMapBuilder componentBuilder) { + Objects.requireNonNull(cooldown.cooldownGroup(), "Cooldown group can't be null"); + componentBuilder.putCompound("minecraft:cooldown", NbtMap.builder() + .putString("category", cooldown.cooldownGroup().asString()) + .putFloat("duration", cooldown.seconds()) + .build() + ); } private static NbtMap toNbtMap(CustomRenderOffsets renderOffsets) { @@ -603,6 +629,35 @@ private static List toList(CustomRenderOffsets.OffsetXYZ xyz) { return List.of(xyz.x(), xyz.y(), xyz.z()); } + private static NbtMap xyzToScaleList(float x, float y, float z) { + return NbtMap.builder().putList("scale", NbtType.FLOAT, List.of(x, y, z)).build(); + } + + private static boolean isUnbreakableItem(CustomItemDefinition definition) { + for (CustomItemPredicate predicate : definition.predicates()) { + if (predicate instanceof ConditionPredicate condition && condition.property() == ConditionPredicateProperty.HAS_COMPONENT && condition.expected()) { + Identifier component = (Identifier) condition.data(); + if (UNBREAKABLE_COMPONENT.equals(component)) { + return true; + } + } + } + return false; + } + + /** + * Converts the API components to MCPL ones using the converters in {@link ComponentConverters}, and applies these on top of the default item components. + * + *

Note that not every API component has a converter in {@link ComponentConverters}. See the documentation there.

+ * + * @see ComponentConverters + */ + private static DataComponents patchDataComponents(Item javaItem, CustomItemDefinition definition) { + DataComponents convertedComponents = new DataComponents(new HashMap<>()); + ComponentConverters.convertAndPutComponents(convertedComponents, definition.components()); + return javaItem.gatherComponents(convertedComponents); + } + @SuppressWarnings("unchecked") private static void addItemTag(NbtMapBuilder builder, String tag) { List tagList = (List) builder.get("item_tags"); @@ -617,8 +672,4 @@ private static void addItemTag(NbtMapBuilder builder, String tag) { } } } - - private static NbtMap xyzToScaleList(float x, float y, float z) { - return NbtMap.builder().putList("scale", NbtType.FLOAT, List.of(x, y, z)).build(); - } } diff --git a/core/src/main/java/org/geysermc/geyser/registry/populator/ItemRegistryPopulator.java b/core/src/main/java/org/geysermc/geyser/registry/populator/ItemRegistryPopulator.java index f7b45ba1ddb..5ee756c3a93 100644 --- a/core/src/main/java/org/geysermc/geyser/registry/populator/ItemRegistryPopulator.java +++ b/core/src/main/java/org/geysermc/geyser/registry/populator/ItemRegistryPopulator.java @@ -28,7 +28,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.google.common.collect.Multimap; import com.google.common.collect.MultimapBuilder; -import it.unimi.dsi.fastutil.Pair; +import com.google.common.collect.SortedSetMultimap; import it.unimi.dsi.fastutil.ints.Int2ObjectMap; import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; import it.unimi.dsi.fastutil.ints.IntOpenHashSet; @@ -40,6 +40,7 @@ import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap; import it.unimi.dsi.fastutil.objects.ObjectArrayList; import it.unimi.dsi.fastutil.objects.ObjectOpenHashSet; +import net.kyori.adventure.key.Key; import org.checkerframework.checker.nullness.qual.NonNull; import org.cloudburstmc.nbt.NbtMap; import org.cloudburstmc.nbt.NbtMapBuilder; @@ -52,19 +53,22 @@ import org.cloudburstmc.protocol.bedrock.data.definitions.SimpleItemDefinition; import org.cloudburstmc.protocol.bedrock.data.inventory.ComponentItemData; import org.cloudburstmc.protocol.bedrock.data.inventory.ItemData; -import org.geysermc.geyser.Constants; import org.geysermc.geyser.GeyserBootstrap; import org.geysermc.geyser.GeyserImpl; import org.geysermc.geyser.api.block.custom.CustomBlockData; import org.geysermc.geyser.api.block.custom.CustomBlockState; import org.geysermc.geyser.api.block.custom.NonVanillaCustomBlockData; -import org.geysermc.geyser.api.item.custom.CustomItemData; -import org.geysermc.geyser.api.item.custom.CustomItemOptions; import org.geysermc.geyser.api.item.custom.NonVanillaCustomItemData; +import org.geysermc.geyser.api.item.custom.v2.CustomItemDefinition; +import org.geysermc.geyser.api.item.custom.v2.predicate.CustomItemPredicate; +import org.geysermc.geyser.item.custom.predicate.RangeDispatchPredicate; +import org.geysermc.geyser.api.util.CreativeCategory; +import org.geysermc.geyser.api.util.Identifier; import org.geysermc.geyser.inventory.item.StoredItemMappings; import org.geysermc.geyser.item.GeyserCustomMappingData; import org.geysermc.geyser.item.Items; import org.geysermc.geyser.item.components.Rarity; +import org.geysermc.geyser.item.exception.InvalidItemComponentsException; import org.geysermc.geyser.item.type.BlockItem; import org.geysermc.geyser.item.type.Item; import org.geysermc.geyser.level.block.property.Properties; @@ -77,15 +81,18 @@ import org.geysermc.geyser.registry.type.ItemMappings; import org.geysermc.geyser.registry.type.NonVanillaItemRegistration; import org.geysermc.geyser.registry.type.PaletteItem; +import org.geysermc.geyser.util.MinecraftKey; import java.io.InputStream; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.Set; import java.util.concurrent.atomic.AtomicInteger; @@ -169,10 +176,7 @@ public static void populate() { boolean customItemsAllowed = GeyserImpl.getInstance().getConfig().isAddNonBedrockItems(); - // List values here is important compared to HashSet - we need to preserve the order of what's given to us - // (as of 1.19.2 Java) to replicate some edge cases in Java predicate behavior where it checks from the bottom - // of the list first, then ascends. - Multimap customItems = MultimapBuilder.hashKeys().arrayListValues().build(); + Multimap customItems = MultimapBuilder.hashKeys().arrayListValues().build(); List nonVanillaCustomItems = customItemsAllowed ? new ObjectArrayList<>() : Collections.emptyList(); if (customItemsAllowed) { @@ -267,7 +271,7 @@ public static void populate() { javaOnlyItems.addAll(palette.javaOnlyItems().keySet()); Int2ObjectMap customIdMappings = new Int2ObjectOpenHashMap<>(); - Set registeredItemNames = new ObjectOpenHashSet<>(); // This is used to check for duplicate item names + Set registeredCustomItems = new ObjectOpenHashSet<>(); // This is used to check for duplicate item names for (Map.Entry entry : items.entrySet()) { Item javaItem = Registries.JAVA_ITEM_IDENTIFIERS.get(entry.getKey()); @@ -473,49 +477,50 @@ public static void populate() { } // Add the custom item properties, if applicable - List> customItemOptions; - Collection customItemsToLoad = customItems.get(javaItem.javaIdentifier()); + SortedSetMultimap customItemDefinitions; + Collection customItemsToLoad = customItems.get(javaItem.javaIdentifier()); if (customItemsAllowed && !customItemsToLoad.isEmpty()) { - customItemOptions = new ObjectArrayList<>(customItemsToLoad.size()); + customItemDefinitions = MultimapBuilder.hashKeys(customItemsToLoad.size()).treeSetValues(new CustomItemDefinitionComparator()).build(); - for (CustomItemData customItem : customItemsToLoad) { + for (CustomItemDefinition customItem : customItemsToLoad) { int customProtocolId = nextFreeBedrockId++; - String customItemName = customItem instanceof NonVanillaCustomItemData nonVanillaItem ? nonVanillaItem.identifier() : Constants.GEYSER_CUSTOM_NAMESPACE + ":" + customItem.name(); - if (!registeredItemNames.add(customItemName)) { + Identifier customItemIdentifier = customItem.bedrockIdentifier(); // TODO don't forget to check if this works for non vanilla too, it probably does + if (!registeredCustomItems.add(customItemIdentifier)) { if (firstMappingsPass) { - GeyserImpl.getInstance().getLogger().error("Custom item name '" + customItemName + "' already exists and was registered again! Skipping..."); + GeyserImpl.getInstance().getLogger().error("Custom item '" + customItemIdentifier + "' already exists and was registered again! Skipping..."); } continue; } - GeyserCustomMappingData customMapping = CustomItemRegistryPopulator.registerCustomItem( - customItemName, javaItem, mappingItem, customItem, customProtocolId, palette.protocolVersion - ); + try { + GeyserCustomMappingData customMapping = CustomItemRegistryPopulator.registerCustomItem(javaItem, mappingItem, customItem, customProtocolId); - if (customItem.creativeCategory().isPresent()) { - creativeItems.add(ItemData.builder() + if (customItem.bedrockOptions().creativeCategory() != CreativeCategory.NONE) { + creativeItems.add(ItemData.builder() .netId(creativeNetId.incrementAndGet()) .definition(customMapping.itemDefinition()) .blockDefinition(null) .count(1) .build()); - } + } - // ComponentItemData - used to register some custom properties - componentItemData.add(customMapping.componentItemData()); - customItemOptions.add(Pair.of(customItem.customItemOptions(), customMapping.itemDefinition())); - registry.put(customMapping.integerId(), customMapping.itemDefinition()); + // ComponentItemData - used to register some custom properties + componentItemData.add(customMapping.componentItemData()); + customItemDefinitions.put(MinecraftKey.identifierToKey(customItem.model()), customMapping); + registry.put(customMapping.integerId(), customMapping.itemDefinition()); - customIdMappings.put(customMapping.integerId(), customMapping.stringId()); + customIdMappings.put(customMapping.integerId(), customItemIdentifier.toString()); + } catch (InvalidItemComponentsException exception) { + if (firstMappingsPass) { + GeyserImpl.getInstance().getLogger().error("Not registering custom item " + customItem.bedrockIdentifier() + "!", exception); + } + } } - - // Important for later to find the best match and accurately replicate Java behavior - Collections.reverse(customItemOptions); } else { - customItemOptions = Collections.emptyList(); + customItemDefinitions = null; } - mappingBuilder.customItemOptions(customItemOptions); + mappingBuilder.customItemDefinitions(customItemDefinitions); ItemMapping mapping = mappingBuilder.build(); @@ -542,7 +547,7 @@ public static void populate() { .bedrockDefinition(lightBlock) .bedrockData(0) .bedrockBlockDefinition(null) - .customItemOptions(Collections.emptyList()) + .customItemDefinitions(null) .build(); lightBlocks.put(lightBlock.getRuntimeId(), lightBlockEntry); } @@ -559,7 +564,7 @@ public static void populate() { .bedrockDefinition(lodestoneCompass) .bedrockData(0) .bedrockBlockDefinition(null) - .customItemOptions(Collections.emptyList()) + .customItemDefinitions(null) .build(); if (customItemsAllowed) { @@ -574,7 +579,7 @@ public static void populate() { .bedrockDefinition(definition) .bedrockData(0) .bedrockBlockDefinition(null) - .customItemOptions(Collections.emptyList()) // TODO check for custom items with furnace minecart + .customItemDefinitions(null) // TODO check for custom items with furnace minecart .build()); creativeItems.add(ItemData.builder() @@ -586,6 +591,7 @@ public static void populate() { registerFurnaceMinecart(nextFreeBedrockId++, componentItemData, palette.protocolVersion); // Register any completely custom items given to us + // TODO broken as of right now IntSet registeredJavaIds = new IntOpenHashSet(); // Used to check for duplicate item java ids for (NonVanillaCustomItemData customItem : nonVanillaCustomItems) { if (!registeredJavaIds.add(customItem.javaId())) { @@ -729,4 +735,56 @@ private static void registerFurnaceMinecart(int nextFreeBedrockId, List + *
  • First by checking their priority values, higher priority values going first.
  • + *
  • Then by checking if they both have a similar range dispatch predicate, the one with the highest threshold going first.
  • + *
  • Lastly by the amount of predicates, from most to least.
  • + * + * + *

    This comparator regards 2 custom item definitions as the same if their model differs, since it is only checking for predicates, and those + * don't matter if their models are different.

    + */ + private static class CustomItemDefinitionComparator implements Comparator { + + @Override + public int compare(GeyserCustomMappingData firstData, GeyserCustomMappingData secondData) { + CustomItemDefinition first = firstData.definition(); + CustomItemDefinition second = secondData.definition(); + if (first.equals(second) || !first.model().equals(second.model())) { + return 0; + } + + if (first.priority() != second.priority()) { + return second.priority() - first.priority(); + } + + if (first.predicates().isEmpty() || second.predicates().isEmpty()) { + return second.predicates().size() - first.predicates().size(); // No need checking for range predicates if either one has no predicates + } + + for (CustomItemPredicate predicate : first.predicates()) { + if (predicate instanceof RangeDispatchPredicate rangeDispatch) { + Optional other = second.predicates().stream() + .filter(otherPredicate -> otherPredicate instanceof RangeDispatchPredicate otherDispatch && otherDispatch.property() == rangeDispatch.property()) + .map(otherPredicate -> (RangeDispatchPredicate) otherPredicate) + .findFirst(); + if (other.isPresent()) { + double otherScaledThreshold = other.get().threshold() / other.get().scale(); + double thisThreshold = rangeDispatch.threshold() / rangeDispatch.scale(); + return (int) (otherScaledThreshold - thisThreshold); + } + } // TODO not a fan of how this looks + } + + if (first.predicates().size() == second.predicates().size()) { + return -1; // If there's no preferred range predicate order and they both have the same amount of predicates, prefer the first + } + + return second.predicates().size() - first.predicates().size(); + } + } } diff --git a/core/src/main/java/org/geysermc/geyser/registry/type/ItemMapping.java b/core/src/main/java/org/geysermc/geyser/registry/type/ItemMapping.java index d940db6e0bd..e8713bfeeae 100644 --- a/core/src/main/java/org/geysermc/geyser/registry/type/ItemMapping.java +++ b/core/src/main/java/org/geysermc/geyser/registry/type/ItemMapping.java @@ -25,21 +25,19 @@ package org.geysermc.geyser.registry.type; -import it.unimi.dsi.fastutil.Pair; +import com.google.common.collect.SortedSetMultimap; import lombok.Builder; import lombok.EqualsAndHashCode; import lombok.ToString; import lombok.Value; +import net.kyori.adventure.key.Key; import org.checkerframework.checker.nullness.qual.NonNull; import org.cloudburstmc.protocol.bedrock.data.definitions.BlockDefinition; import org.cloudburstmc.protocol.bedrock.data.definitions.ItemDefinition; -import org.geysermc.geyser.api.item.custom.CustomItemOptions; +import org.geysermc.geyser.item.GeyserCustomMappingData; import org.geysermc.geyser.item.Items; import org.geysermc.geyser.item.type.Item; -import java.util.Collections; -import java.util.List; - @Value @Builder @EqualsAndHashCode @@ -52,7 +50,7 @@ public class ItemMapping { null, // Air is never sent in full over the network for this to serialize. null, null, - Collections.emptyList(), + null, Items.AIR ); @@ -70,8 +68,10 @@ public class ItemMapping { String translationString; - @NonNull - List> customItemOptions; + /** + * A map of item models and all of their custom items, sorted from most definition predicates to least, which is important when matching predicates. + */ + SortedSetMultimap customItemDefinitions; @NonNull Item javaItem; diff --git a/core/src/main/java/org/geysermc/geyser/session/cache/RegistryCache.java b/core/src/main/java/org/geysermc/geyser/session/cache/RegistryCache.java index ecd293bff26..1f0de4444a5 100644 --- a/core/src/main/java/org/geysermc/geyser/session/cache/RegistryCache.java +++ b/core/src/main/java/org/geysermc/geyser/session/cache/RegistryCache.java @@ -51,6 +51,7 @@ import org.geysermc.geyser.session.cache.registry.JavaRegistry; import org.geysermc.geyser.session.cache.registry.JavaRegistryKey; import org.geysermc.geyser.session.cache.registry.RegistryEntryContext; +import org.geysermc.geyser.session.cache.registry.RegistryEntryData; import org.geysermc.geyser.session.cache.registry.SimpleJavaRegistry; import org.geysermc.geyser.text.ChatDecoration; import org.geysermc.geyser.translator.level.BiomeTranslator; @@ -189,7 +190,7 @@ private static void register(Key registry, Function builder = new ArrayList<>(entries.size()); + List> builder = new ArrayList<>(entries.size()); for (int i = 0; i < entries.size(); i++) { RegistryEntry entry = entries.get(i); // If the data is null, that's the server telling us we need to use our default values. @@ -203,7 +204,7 @@ private static void register(Key registry, Function(entry.getId(), cacheEntry)); } localCache.reset(builder); }); diff --git a/core/src/main/java/org/geysermc/geyser/session/cache/registry/JavaRegistry.java b/core/src/main/java/org/geysermc/geyser/session/cache/registry/JavaRegistry.java index d7c7782eadf..66f2531f556 100644 --- a/core/src/main/java/org/geysermc/geyser/session/cache/registry/JavaRegistry.java +++ b/core/src/main/java/org/geysermc/geyser/session/cache/registry/JavaRegistry.java @@ -39,15 +39,25 @@ public interface JavaRegistry { */ T byId(@NonNegative int id); + /** + * Looks up a registry entry by its ID, and returns it wrapped in {@link RegistryEntryData} so that its registered key is also known. The object can be null, or not present. + */ + RegistryEntryData entryById(@NonNegative int id); + /** * Reverse looks-up an object to return its network ID, or -1. */ int byValue(T value); + /** + * Reverse looks-up an object to return it wrapped in {@link RegistryEntryData}, or null. + */ + RegistryEntryData entryByValue(T value); + /** * Resets the objects by these IDs. */ - void reset(List values); + void reset(List> values); /** * All values of this registry, as a list. diff --git a/core/src/main/java/org/geysermc/geyser/session/cache/registry/RegistryEntryData.java b/core/src/main/java/org/geysermc/geyser/session/cache/registry/RegistryEntryData.java new file mode 100644 index 00000000000..91b04fd2eb0 --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/session/cache/registry/RegistryEntryData.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2024 GeyserMC. http://geysermc.org + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * @author GeyserMC + * @link https://github.com/GeyserMC/Geyser + */ + +package org.geysermc.geyser.session.cache.registry; + +import net.kyori.adventure.key.Key; + +public record RegistryEntryData(Key key, T data) { +} diff --git a/core/src/main/java/org/geysermc/geyser/session/cache/registry/SimpleJavaRegistry.java b/core/src/main/java/org/geysermc/geyser/session/cache/registry/SimpleJavaRegistry.java index 7b79a40be03..92495525b88 100644 --- a/core/src/main/java/org/geysermc/geyser/session/cache/registry/SimpleJavaRegistry.java +++ b/core/src/main/java/org/geysermc/geyser/session/cache/registry/SimpleJavaRegistry.java @@ -31,10 +31,18 @@ import java.util.List; public class SimpleJavaRegistry implements JavaRegistry { - protected final ObjectArrayList values = new ObjectArrayList<>(); + protected final ObjectArrayList> values = new ObjectArrayList<>(); @Override public T byId(@NonNegative int id) { + if (id < 0 || id >= this.values.size()) { + return null; + } + return this.values.get(id).data(); + } + + @Override + public RegistryEntryData entryById(@NonNegative int id) { if (id < 0 || id >= this.values.size()) { return null; } @@ -43,11 +51,26 @@ public T byId(@NonNegative int id) { @Override public int byValue(T value) { - return this.values.indexOf(value); + for (int i = 0; i < this.values.size(); i++) { + if (values.get(i).data().equals(value)) { + return i; + } + } + return -1; + } + + @Override + public RegistryEntryData entryByValue(T value) { + for (RegistryEntryData entry : this.values) { + if (entry.data().equals(value)) { + return entry; + } + } + return null; } @Override - public void reset(List values) { + public void reset(List> values) { this.values.clear(); this.values.addAll(values); this.values.trim(); @@ -55,7 +78,7 @@ public void reset(List values) { @Override public List values() { - return this.values; + return this.values.stream().map(RegistryEntryData::data).toList(); } @Override diff --git a/core/src/main/java/org/geysermc/geyser/translator/item/CustomItemTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/item/CustomItemTranslator.java index c6318c461bb..f22e486edc1 100644 --- a/core/src/main/java/org/geysermc/geyser/translator/item/CustomItemTranslator.java +++ b/core/src/main/java/org/geysermc/geyser/translator/item/CustomItemTranslator.java @@ -25,101 +25,210 @@ package org.geysermc.geyser.translator.item; +import com.google.common.collect.Multimap; +import it.unimi.dsi.fastutil.objects.Object2BooleanMap; +import it.unimi.dsi.fastutil.objects.Object2BooleanOpenHashMap; +import net.kyori.adventure.key.Key; +import org.cloudburstmc.protocol.bedrock.data.TrimMaterial; +import org.geysermc.geyser.api.item.custom.v2.predicate.CustomItemPredicate; +import org.geysermc.geyser.api.item.custom.v2.predicate.PredicateStrategy; +import org.geysermc.geyser.api.item.custom.v2.predicate.condition.ConditionPredicateProperty; +import org.geysermc.geyser.item.custom.predicate.ConditionPredicate; +import org.geysermc.geyser.item.custom.predicate.RangeDispatchPredicate; +import org.geysermc.geyser.api.item.custom.v2.predicate.match.ChargeType; +import org.geysermc.geyser.item.custom.predicate.MatchPredicate; +import org.geysermc.geyser.api.item.custom.v2.predicate.match.CustomModelDataString; +import org.geysermc.geyser.api.item.custom.v2.predicate.match.MatchPredicateProperty; +import org.geysermc.geyser.api.util.Identifier; +import org.geysermc.geyser.item.GeyserCustomMappingData; +import org.geysermc.geyser.item.Items; +import org.geysermc.geyser.level.JavaDimension; +import org.geysermc.geyser.session.GeyserSession; +import org.geysermc.geyser.session.cache.registry.RegistryEntryData; +import org.geysermc.geyser.util.MinecraftKey; +import org.geysermc.mcprotocollib.protocol.data.game.item.ItemStack; +import org.geysermc.mcprotocollib.protocol.data.game.item.component.ArmorTrim; import org.geysermc.mcprotocollib.protocol.data.game.item.component.CustomModelData; import org.geysermc.mcprotocollib.protocol.data.game.item.component.DataComponents; import org.geysermc.mcprotocollib.protocol.data.game.item.component.DataComponentType; -import it.unimi.dsi.fastutil.Pair; import org.checkerframework.checker.nullness.qual.Nullable; import org.cloudburstmc.protocol.bedrock.data.definitions.ItemDefinition; -import org.geysermc.geyser.api.item.custom.CustomItemOptions; -import org.geysermc.geyser.api.util.TriState; import org.geysermc.geyser.registry.type.ItemMapping; +import java.util.Collection; import java.util.List; -import java.util.OptionalInt; +import java.util.function.Function; /** * This is only a separate class for testing purposes so we don't have to load in GeyserImpl in ItemTranslator. */ public final class CustomItemTranslator { + private static final Key FALLBACK_MODEL = MinecraftKey.key("air"); @Nullable - public static ItemDefinition getCustomItem(DataComponents components, ItemMapping mapping) { + public static ItemDefinition getCustomItem(GeyserSession session, int stackSize, DataComponents components, ItemMapping mapping) { if (components == null) { return null; } - List> customMappings = mapping.getCustomItemOptions(); - if (customMappings.isEmpty()) { + + Multimap allCustomItems = mapping.getCustomItemDefinitions(); + if (allCustomItems == null) { return null; } - // TODO 1.21.4 - float customModelDataInt = 0; - CustomModelData customModelData = components.get(DataComponentType.CUSTOM_MODEL_DATA); - if (customModelData != null) { - if (!customModelData.floats().isEmpty()) { - customModelDataInt = customModelData.floats().get(0); - } + Key itemModel = components.getOrDefault(DataComponentType.ITEM_MODEL, FALLBACK_MODEL); + Collection customItems = allCustomItems.get(itemModel); + if (customItems.isEmpty()) { + return null; } - boolean checkDamage = mapping.getJavaItem().defaultMaxDamage() > 0; - int damage = !checkDamage ? 0 : components.getOrDefault(DataComponentType.DAMAGE, 0); - boolean unbreakable = checkDamage && !isDamaged(components, damage); - - for (Pair mappingTypes : customMappings) { - CustomItemOptions options = mappingTypes.key(); - - // Code note: there may be two or more conditions that a custom item must follow, hence the "continues" - // here with the return at the end. - - // Implementation details: Java's predicate system works exclusively on comparing float numbers. - // A value doesn't necessarily have to match 100%; it just has to be the first to meet all predicate conditions. - // This is also why the order of iteration is important as the first to match will be the chosen display item. - // For example, if CustomModelData is set to 2f as the requirement, then the NBT can be any number greater or equal (2, 3, 4...) - // The same behavior exists for Damage (in fraction form instead of whole numbers), - // and Damaged/Unbreakable handles no damage as 0f and damaged as 1f. - - if (checkDamage) { - if (unbreakable && options.unbreakable() == TriState.FALSE) { - continue; + // Cache predicate values so they're not recalculated every time when there are multiple item definitions using the same predicates + Object2BooleanMap calculatedPredicates = new Object2BooleanOpenHashMap<>(); + for (GeyserCustomMappingData customMapping : customItems) { + boolean needsOnlyOneMatch = customMapping.definition().predicateStrategy() == PredicateStrategy.OR; + boolean allMatch = true; + + for (CustomItemPredicate predicate : customMapping.definition().predicates()) { + boolean value = calculatedPredicates.computeIfAbsent(predicate, x -> predicateMatches(session, predicate, stackSize, components)); + if (!value) { + allMatch = false; + break; + } else if (needsOnlyOneMatch) { + break; } + } + if (allMatch) { + return customMapping.itemDefinition(); + } + } + return null; + } - OptionalInt damagePredicate = options.damagePredicate(); - if (damagePredicate.isPresent() && damage < damagePredicate.getAsInt()) { - continue; + private static boolean predicateMatches(GeyserSession session, CustomItemPredicate predicate, int stackSize, DataComponents components) { + if (predicate instanceof ConditionPredicate condition) { + ConditionPredicateProperty property = condition.property(); + boolean expected = condition.expected(); + if (property == ConditionPredicateProperty.BROKEN) { + return nextDamageWillBreak(components) == expected; + } else if (property == ConditionPredicateProperty.DAMAGED) { + return isDamaged(components) == expected; + } else if (property == ConditionPredicateProperty.CUSTOM_MODEL_DATA) { + Integer index = (Integer) condition.data(); + return getCustomBoolean(components, index) == expected; + } else if (property == ConditionPredicateProperty.HAS_COMPONENT) { + Identifier identifier = (Identifier) condition.data(); + if (identifier == null) { + return false; } - } else { - if (options.unbreakable() != TriState.NOT_SET || options.damagePredicate().isPresent()) { - // These will never match on this item. 1.19.2 behavior - // Maybe move this to CustomItemRegistryPopulator since it'll be the same for every item? If so, add a test. - continue; + Key component = MinecraftKey.identifierToKey(identifier); + for (DataComponentType componentType : components.getDataComponents().keySet()) { + if (componentType.getKey().equals(component)) { + return expected; + } } + return !expected; } - - OptionalInt customModelDataOption = options.customModelData(); - if (customModelDataOption.isPresent() && customModelDataInt < customModelDataOption.getAsInt()) { - continue; + } else if (predicate instanceof MatchPredicate match) { // TODO not much of a fan of the casts here, find a solution for the types? + if (match.property() == MatchPredicateProperty.CHARGE_TYPE) { + ChargeType expected = (ChargeType) match.data(); + List charged = components.get(DataComponentType.CHARGED_PROJECTILES); + if (charged == null || charged.isEmpty()) { + return expected == ChargeType.NONE; + } else if (expected == ChargeType.ROCKET) { + for (ItemStack projectile : charged) { + if (projectile.getId() == Items.FIREWORK_ROCKET.javaId()) { + return true; + } + } + return false; + } + return true; + } else if (match.property() == MatchPredicateProperty.TRIM_MATERIAL) { + Identifier material = (Identifier) match.data(); + ArmorTrim trim = components.get(DataComponentType.TRIM); + if (trim == null || trim.material().isCustom()) { + return false; + } + RegistryEntryData registered = session.getRegistryCache().trimMaterials().entryById(trim.material().id()); + return MinecraftKey.identifierToKey(material).equals(registered.key()); + } else if (match.property() == MatchPredicateProperty.CONTEXT_DIMENSION) { + Identifier dimension = (Identifier) match.data(); + RegistryEntryData registered = session.getRegistryCache().dimensions().entryByValue(session.getDimensionType()); + return MinecraftKey.identifierToKey(dimension).equals(registered.key()); + } else if (match.property() == MatchPredicateProperty.CUSTOM_MODEL_DATA) { + CustomModelDataString expected = (CustomModelDataString) match.data(); + return expected.value().equals(getSafeCustomModelData(components, CustomModelData::strings, expected.index())); } + } else if (predicate instanceof RangeDispatchPredicate rangeDispatch) { + double propertyValue = switch (rangeDispatch.property()) { + case BUNDLE_FULLNESS -> { + List stacks = components.get(DataComponentType.BUNDLE_CONTENTS); + if (stacks == null) { + yield 0; + } + int bundleWeight = 0; + for (ItemStack stack : stacks) { + bundleWeight += stack.getAmount(); + } + yield bundleWeight; + } + case DAMAGE -> tryNormalize(rangeDispatch, components.get(DataComponentType.DAMAGE), components.get(DataComponentType.MAX_DAMAGE)); + case COUNT -> tryNormalize(rangeDispatch, stackSize, components.get(DataComponentType.MAX_STACK_SIZE)); + case CUSTOM_MODEL_DATA -> getCustomFloat(components, rangeDispatch.index()); + } * rangeDispatch.scale(); + return propertyValue >= rangeDispatch.threshold(); + } - if (options.defaultItem()) { - return null; - } + throw new IllegalStateException("Unimplemented predicate type: " + predicate); + } - return mappingTypes.value(); + private static boolean getCustomBoolean(DataComponents components, Integer index) { + if (index == null) { + return false; } + Boolean b = getSafeCustomModelData(components, CustomModelData::flags, index); + return b != null && b; + } + + private static float getCustomFloat(DataComponents components, int index) { + Float f = getSafeCustomModelData(components, CustomModelData::floats, index); + return f == null ? 0.0F : f; + } + private static T getSafeCustomModelData(DataComponents components, Function> listGetter, int index) { + CustomModelData modelData = components.get(DataComponentType.CUSTOM_MODEL_DATA); + if (modelData == null || index < 0) { + return null; + } + List list = listGetter.apply(modelData); + if (index < list.size()) { + return list.get(index); + } return null; } - /* These two functions are based off their Mojmap equivalents from 1.19.2 */ + private static double tryNormalize(RangeDispatchPredicate predicate, @Nullable Integer value, @Nullable Integer max) { + if (value == null) { + return 0.0; + } else if (max == null) { + return value; + } else if (!predicate.normalizeIfPossible()) { + return Math.min(value, max); + } + return Math.max(0.0, Math.min(1.0, (double) value / max)); + } + + /* These three functions are based off their Mojmap equivalents from 1.21.3 */ + private static boolean nextDamageWillBreak(DataComponents components) { + return isDamageableItem(components) && components.getOrDefault(DataComponentType.DAMAGE, 0) >= components.getOrDefault(DataComponentType.MAX_DAMAGE, 0) - 1; + } - private static boolean isDamaged(DataComponents components, int damage) { - return isDamagableItem(components) && damage > 0; + private static boolean isDamaged(DataComponents components) { + return isDamageableItem(components) && components.getOrDefault(DataComponentType.DAMAGE, 0) > 0; } - private static boolean isDamagableItem(DataComponents components) { - // mapping.getMaxDamage > 0 should also be checked (return false if not true) but we already check prior to this function - return components.get(DataComponentType.UNBREAKABLE) == null; + private static boolean isDamageableItem(DataComponents components) { + return components.get(DataComponentType.UNBREAKABLE) == null && components.getOrDefault(DataComponentType.MAX_DAMAGE, 0) > 0; } private CustomItemTranslator() { diff --git a/core/src/main/java/org/geysermc/geyser/translator/item/ItemTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/item/ItemTranslator.java index 3f9bf744691..0f3ba6d72c3 100644 --- a/core/src/main/java/org/geysermc/geyser/translator/item/ItemTranslator.java +++ b/core/src/main/java/org/geysermc/geyser/translator/item/ItemTranslator.java @@ -222,7 +222,7 @@ public static ItemData translateToBedrock(GeyserSession session, ItemStack stack translatePlayerHead(session, components.get(DataComponentType.PROFILE), builder); } - translateCustomItem(components, builder, bedrockItem); + translateCustomItem(session, count, components, builder, bedrockItem); // Translate the canDestroy and canPlaceOn Java components AdventureModePredicate canDestroy = components.get(DataComponentType.CAN_BREAK); @@ -522,7 +522,7 @@ public static ItemDefinition getBedrockItemDefinition(GeyserSession session, @No } } - ItemDefinition definition = CustomItemTranslator.getCustomItem(itemStack.getComponents(), mapping); + ItemDefinition definition = CustomItemTranslator.getCustomItem(session, itemStack.getAmount(), itemStack.getComponents(), mapping); if (definition == null) { // No custom item return itemDefinition; @@ -579,8 +579,8 @@ public static String getCustomName(GeyserSession session, DataComponents compone /** * Translates the custom model data of an item */ - public static void translateCustomItem(DataComponents components, ItemData.Builder builder, ItemMapping mapping) { - ItemDefinition definition = CustomItemTranslator.getCustomItem(components, mapping); + public static void translateCustomItem(GeyserSession session, int stackSize, DataComponents components, ItemData.Builder builder, ItemMapping mapping) { + ItemDefinition definition = CustomItemTranslator.getCustomItem(session, stackSize, components, mapping); if (definition != null) { builder.definition(definition); builder.blockDefinition(null); diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/entity/player/input/BedrockBlockActions.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/entity/player/input/BedrockBlockActions.java index c604f5be1ef..21d72e3d501 100644 --- a/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/entity/player/input/BedrockBlockActions.java +++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/bedrock/entity/player/input/BedrockBlockActions.java @@ -94,7 +94,7 @@ private static void handle(GeyserSession session, PlayerBlockActionData blockAct // If the block is custom or the breaking item is custom, we must keep track of break time ourselves GeyserItemStack item = session.getPlayerInventory().getItemInHand(); ItemMapping mapping = item.getMapping(session); - ItemDefinition customItem = mapping.isTool() ? CustomItemTranslator.getCustomItem(item.getComponents(), mapping) : null; + ItemDefinition customItem = mapping.isTool() ? CustomItemTranslator.getCustomItem(session, item.getAmount(), item.getComponents(), mapping) : null; CustomBlockState blockStateOverride = BlockRegistries.CUSTOM_BLOCK_STATE_OVERRIDES.get(blockState); SkullCache.Skull skull = session.getSkullCache().getSkulls().get(vector); diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/level/JavaCooldownTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/level/JavaCooldownTranslator.java index 4097f5b7833..b8c56c88092 100644 --- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/level/JavaCooldownTranslator.java +++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/level/JavaCooldownTranslator.java @@ -45,23 +45,20 @@ public void translate(GeyserSession session, ClientboundCooldownPacket packet) { Key cooldownGroup = packet.getCooldownGroup(); Item item = Registries.JAVA_ITEM_IDENTIFIERS.get(cooldownGroup.asString()); - // Not every item, as of 1.19, appears to be server-driven. Just these two. + // Custom items can define an item cooldown using a custom cooldown group, which will be sent to the client if there's not a vanilla cooldown group + String cooldownCategory = cooldownGroup.asString(); + // Not every vanilla item, as of 1.19, appears to be server-driven. Just these two. // Use a map here if it gets too big. - String cooldownCategory; if (item == Items.GOAT_HORN) { cooldownCategory = "goat_horn"; } else if (item == Items.SHIELD) { cooldownCategory = "shield"; - } else { - cooldownCategory = null; } - if (cooldownCategory != null) { - PlayerStartItemCooldownPacket bedrockPacket = new PlayerStartItemCooldownPacket(); - bedrockPacket.setItemCategory(cooldownCategory); - bedrockPacket.setCooldownDuration(Math.round(packet.getCooldownTicks() * (session.getMillisecondsPerTick() / 50))); - session.sendUpstreamPacket(bedrockPacket); - } + PlayerStartItemCooldownPacket bedrockPacket = new PlayerStartItemCooldownPacket(); + bedrockPacket.setItemCategory(cooldownCategory); + bedrockPacket.setCooldownDuration(Math.round(packet.getCooldownTicks() * (session.getMillisecondsPerTick() / 50))); + session.sendUpstreamPacket(bedrockPacket); session.getWorldCache().setCooldown(cooldownGroup, packet.getCooldownTicks()); } diff --git a/core/src/main/java/org/geysermc/geyser/util/MinecraftKey.java b/core/src/main/java/org/geysermc/geyser/util/MinecraftKey.java index 5f1c8e084e7..d2d54f579d0 100644 --- a/core/src/main/java/org/geysermc/geyser/util/MinecraftKey.java +++ b/core/src/main/java/org/geysermc/geyser/util/MinecraftKey.java @@ -26,6 +26,7 @@ package org.geysermc.geyser.util; import net.kyori.adventure.key.Key; +import org.geysermc.geyser.api.util.Identifier; import org.intellij.lang.annotations.Subst; public final class MinecraftKey { @@ -36,4 +37,8 @@ public final class MinecraftKey { public static Key key(@Subst("empty") String s) { return Key.key(s); } + + public static Key identifierToKey(Identifier identifier) { + return Key.key(identifier.namespace(), identifier.path()); + } } diff --git a/core/src/main/resources/mappings b/core/src/main/resources/mappings index 6808d0e16a8..452312f8831 160000 --- a/core/src/main/resources/mappings +++ b/core/src/main/resources/mappings @@ -1 +1 @@ -Subproject commit 6808d0e16a85e5e569d9d7f89ace59c73196c1f4 +Subproject commit 452312f88317cce019b8f336f485ffa7b2c19557 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e5e8a3faa46..f685416bf7c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -71,6 +71,8 @@ fastutil-int-byte-maps = { group = "com.nukkitx.fastutil", name = "fastutil-int- fastutil-int-boolean-maps = { group = "com.nukkitx.fastutil", name = "fastutil-int-boolean-maps", version.ref = "fastutil" } fastutil-object-int-maps = { group = "com.nukkitx.fastutil", name = "fastutil-object-int-maps", version.ref = "fastutil" } fastutil-object-object-maps = { group = "com.nukkitx.fastutil", name = "fastutil-object-object-maps", version.ref = "fastutil" } +fastutil-object-boolean-maps = { group = "com.nukkitx.fastutil", name = "fastutil-object-boolean-maps", version.ref = "fastutil" } +fastutil-reference-object-maps = { group = "com.nukkitx.fastutil", name = "fastutil-reference-object-maps", version.ref = "fastutil" } adventure-text-serializer-gson = { group = "net.kyori", name = "adventure-text-serializer-gson", version.ref = "adventure" } # Remove when we remove our Adventure bump adventure-text-serializer-legacy = { group = "net.kyori", name = "adventure-text-serializer-legacy", version.ref = "adventure" } @@ -153,7 +155,7 @@ blossom = { id = "net.kyori.blossom", version.ref = "blossom" } [bundles] jackson = [ "jackson-annotations", "jackson-databind", "jackson-dataformat-yaml" ] -fastutil = [ "fastutil-int-int-maps", "fastutil-int-long-maps", "fastutil-int-byte-maps", "fastutil-int-boolean-maps", "fastutil-object-int-maps", "fastutil-object-object-maps" ] +fastutil = [ "fastutil-int-int-maps", "fastutil-int-long-maps", "fastutil-int-byte-maps", "fastutil-int-boolean-maps", "fastutil-object-int-maps", "fastutil-object-object-maps", "fastutil-object-boolean-maps", "fastutil-reference-object-maps" ] adventure = [ "adventure-text-serializer-gson", "adventure-text-serializer-legacy", "adventure-text-serializer-plain" ] log4j = [ "log4j-api", "log4j-core", "log4j-slf4j2-impl" ] jline = [ "jline-terminal", "jline-terminal-jna", "jline-reader" ]