diff --git a/connector/pom.xml b/connector/pom.xml index 1b6e9fc4e..131e6e48b 100644 --- a/connector/pom.xml +++ b/connector/pom.xml @@ -233,6 +233,12 @@ <version>${adventure.version}</version> <scope>compile</scope> </dependency> + <dependency> + <groupId>net.kyori</groupId> + <artifactId>adventure-text-serializer-plain</artifactId> + <version>${adventure.version}</version> + <scope>compile</scope> + </dependency> <!-- Other --> <dependency> <groupId>junit</groupId> diff --git a/connector/src/main/java/org/geysermc/connector/inventory/AnvilContainer.java b/connector/src/main/java/org/geysermc/connector/inventory/AnvilContainer.java index cc9ebb2e8..b4f5a6d36 100644 --- a/connector/src/main/java/org/geysermc/connector/inventory/AnvilContainer.java +++ b/connector/src/main/java/org/geysermc/connector/inventory/AnvilContainer.java @@ -26,12 +26,48 @@ package org.geysermc.connector.inventory; import com.github.steveice10.mc.protocol.data.game.inventory.ContainerType; +import lombok.Getter; +import lombok.Setter; /** - * Used to determine if rename packets should be sent. + * Used to determine if rename packets should be sent and stores + * the expected level cost for AnvilInventoryUpdater */ +@Getter @Setter public class AnvilContainer extends Container { + /** + * Stores the level cost received as a window property from Java + */ + private int javaLevelCost = 0; + /** + * A flag to specify whether javaLevelCost can be used as it can + * be outdated or not sent at all. + */ + private boolean useJavaLevelCost = false; + + /** + * The new name of the item as received from Bedrock + */ + private String newName = null; + + private GeyserItemStack lastInput = GeyserItemStack.EMPTY; + private GeyserItemStack lastMaterial = GeyserItemStack.EMPTY; + + private int lastTargetSlot = -1; + public AnvilContainer(String title, int id, int size, ContainerType containerType, PlayerInventory playerInventory) { super(title, id, size, containerType, playerInventory); } + + public GeyserItemStack getInput() { + return getItem(0); + } + + public GeyserItemStack getMaterial() { + return getItem(1); + } + + public GeyserItemStack getResult() { + return getItem(2); + } } diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/BedrockFilterTextTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/BedrockFilterTextTranslator.java index 8abff259c..23f2ba293 100644 --- a/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/BedrockFilterTextTranslator.java +++ b/connector/src/main/java/org/geysermc/connector/network/translators/bedrock/BedrockFilterTextTranslator.java @@ -32,6 +32,8 @@ import org.geysermc.connector.inventory.CartographyContainer; import org.geysermc.connector.network.session.GeyserSession; import org.geysermc.connector.network.translators.PacketTranslator; import org.geysermc.connector.network.translators.Translator; +import org.geysermc.connector.network.translators.chat.MessageTranslator; +import org.geysermc.connector.utils.ItemUtils; /** * Used to send strings to the server and filter out unwanted words. @@ -47,12 +49,31 @@ public class BedrockFilterTextTranslator extends PacketTranslator<FilterTextPack return; } packet.setFromServer(true); - session.sendUpstreamPacket(packet); + if (session.getOpenInventory() instanceof AnvilContainer anvilContainer) { + anvilContainer.setNewName(packet.getText()); - if (session.getOpenInventory() instanceof AnvilContainer) { - // Java Edition sends a packet every time an item is renamed even slightly in GUI. Fortunately, this works out for us now - ServerboundRenameItemPacket renameItemPacket = new ServerboundRenameItemPacket(packet.getText()); - session.sendDownstreamPacket(renameItemPacket); + String originalName = ItemUtils.getCustomName(anvilContainer.getInput().getNbt()); + + String plainOriginalName = MessageTranslator.convertToPlainText(originalName, session.getLocale()); + String plainNewName = MessageTranslator.convertToPlainText(packet.getText(), session.getLocale()); + if (!plainOriginalName.equals(plainNewName)) { + // Strip out formatting since Java Edition does not allow it + packet.setText(plainNewName); + // Java Edition sends a packet every time an item is renamed even slightly in GUI. Fortunately, this works out for us now + ServerboundRenameItemPacket renameItemPacket = new ServerboundRenameItemPacket(plainNewName); + session.sendDownstreamPacket(renameItemPacket); + } else { + // Restore formatting for item since we're not renaming + packet.setText(MessageTranslator.convertMessageLenient(originalName)); + // Java Edition sends the original custom name when not renaming, + // if there isn't a custom name an empty string is sent + ServerboundRenameItemPacket renameItemPacket = new ServerboundRenameItemPacket(plainOriginalName); + session.sendDownstreamPacket(renameItemPacket); + } + + anvilContainer.setUseJavaLevelCost(false); + session.getInventoryTranslator().updateSlot(session, anvilContainer, 1); } + session.sendUpstreamPacket(packet); } } diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/chat/MessageTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/chat/MessageTranslator.java index 520e27455..55087de0f 100644 --- a/connector/src/main/java/org/geysermc/connector/network/translators/chat/MessageTranslator.java +++ b/connector/src/main/java/org/geysermc/connector/network/translators/chat/MessageTranslator.java @@ -31,6 +31,7 @@ import net.kyori.adventure.text.Component; import net.kyori.adventure.text.renderer.TranslatableComponentRenderer; import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer; import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import org.geysermc.connector.GeyserConnector; import org.geysermc.connector.network.session.GeyserSession; import org.geysermc.connector.utils.LanguageUtils; @@ -179,6 +180,33 @@ public class MessageTranslator { return GSON_SERIALIZER.serialize(component); } + /** + * Convert JSON and legacy format message to plain text + * + * @param message Message to convert + * @param locale Locale to use for translation strings + * @return The plain text of the message + */ + public static String convertToPlainText(String message, String locale) { + if (message == null) { + return ""; + } + Component messageComponent = null; + if (message.startsWith("{") && message.endsWith("}")) { + // Message is a JSON object + try { + messageComponent = GSON_SERIALIZER.deserialize(message); + // Translate any components that require it + messageComponent = RENDERER.render(messageComponent, locale); + } catch (Exception ignored) { + } + } + if (messageComponent == null) { + messageComponent = LegacyComponentSerializer.legacySection().deserialize(message); + } + return PlainTextComponentSerializer.plainText().serialize(messageComponent); + } + /** * Convert a team color to a chat color * diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/AnvilInventoryTranslator.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/AnvilInventoryTranslator.java index c4d7195c6..3fa9ebdd7 100644 --- a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/AnvilInventoryTranslator.java +++ b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/translators/AnvilInventoryTranslator.java @@ -31,12 +31,13 @@ import com.nukkitx.protocol.bedrock.data.inventory.StackRequestSlotInfoData; import org.geysermc.connector.inventory.AnvilContainer; import org.geysermc.connector.inventory.Inventory; import org.geysermc.connector.inventory.PlayerInventory; +import org.geysermc.connector.network.session.GeyserSession; import org.geysermc.connector.network.translators.inventory.BedrockContainerSlot; -import org.geysermc.connector.network.translators.inventory.updater.UIInventoryUpdater; +import org.geysermc.connector.network.translators.inventory.updater.AnvilInventoryUpdater; public class AnvilInventoryTranslator extends AbstractBlockInventoryTranslator { public AnvilInventoryTranslator() { - super(3, "minecraft:anvil[facing=north]", com.nukkitx.protocol.bedrock.data.inventory.ContainerType.ANVIL, UIInventoryUpdater.INSTANCE, + super(3, "minecraft:anvil[facing=north]", com.nukkitx.protocol.bedrock.data.inventory.ContainerType.ANVIL, AnvilInventoryUpdater.INSTANCE, "minecraft:chipped_anvil", "minecraft:damaged_anvil"); } @@ -74,4 +75,14 @@ public class AnvilInventoryTranslator extends AbstractBlockInventoryTranslator { public Inventory createInventory(String name, int windowId, ContainerType containerType, PlayerInventory playerInventory) { return new AnvilContainer(name, windowId, this.size, containerType, playerInventory); } + + @Override + public void updateProperty(GeyserSession session, Inventory inventory, int key, int value) { + // The only property sent by Java is key 0 which is the level cost + if (key != 0) return; + AnvilContainer anvilContainer = (AnvilContainer) inventory; + anvilContainer.setJavaLevelCost(value); + anvilContainer.setUseJavaLevelCost(true); + updateSlot(session, anvilContainer, 1); + } } diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/inventory/updater/AnvilInventoryUpdater.java b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/updater/AnvilInventoryUpdater.java new file mode 100644 index 000000000..6dfd60b8b --- /dev/null +++ b/connector/src/main/java/org/geysermc/connector/network/translators/inventory/updater/AnvilInventoryUpdater.java @@ -0,0 +1,460 @@ +/* + * Copyright (c) 2019-2021 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.connector.network.translators.inventory.updater; + +import com.github.steveice10.mc.protocol.data.game.entity.player.GameMode; +import com.github.steveice10.mc.protocol.packet.ingame.serverbound.inventory.ServerboundRenameItemPacket; +import com.github.steveice10.opennbt.tag.builtin.*; +import com.nukkitx.nbt.NbtMap; +import com.nukkitx.nbt.NbtMapBuilder; +import com.nukkitx.protocol.bedrock.data.inventory.ContainerId; +import com.nukkitx.protocol.bedrock.data.inventory.ItemData; +import com.nukkitx.protocol.bedrock.packet.InventorySlotPacket; +import it.unimi.dsi.fastutil.objects.Object2IntMap; +import it.unimi.dsi.fastutil.objects.Object2IntMaps; +import it.unimi.dsi.fastutil.objects.Object2IntOpenHashMap; +import org.geysermc.connector.GeyserConnector; +import org.geysermc.connector.inventory.AnvilContainer; +import org.geysermc.connector.inventory.GeyserItemStack; +import org.geysermc.connector.inventory.Inventory; +import org.geysermc.connector.network.session.GeyserSession; +import org.geysermc.connector.network.translators.chat.MessageTranslator; +import org.geysermc.connector.network.translators.inventory.InventoryTranslator; +import org.geysermc.connector.network.translators.item.Enchantment.JavaEnchantment; +import org.geysermc.connector.registry.Registries; +import org.geysermc.connector.registry.type.EnchantmentData; +import org.geysermc.connector.utils.ItemUtils; + +import java.util.Objects; +import java.util.Set; + +public class AnvilInventoryUpdater extends InventoryUpdater { + public static final AnvilInventoryUpdater INSTANCE = new AnvilInventoryUpdater(); + + private static final int MAX_LEVEL_COST = 40; + + @Override + public void updateInventory(InventoryTranslator translator, GeyserSession session, Inventory inventory) { + super.updateInventory(translator, session, inventory); + AnvilContainer anvilContainer = (AnvilContainer) inventory; + updateInventoryState(session, anvilContainer); + int targetSlot = getTargetSlot(session, anvilContainer); + for (int i = 0; i < translator.size; i++) { + final int bedrockSlot = translator.javaSlotToBedrock(i); + if (bedrockSlot == 50) + continue; + if (i == targetSlot) { + updateTargetSlot(translator, session, anvilContainer, targetSlot); + } else { + InventorySlotPacket slotPacket = new InventorySlotPacket(); + slotPacket.setContainerId(ContainerId.UI); + slotPacket.setSlot(bedrockSlot); + slotPacket.setItem(inventory.getItem(i).getItemData(session)); + session.sendUpstreamPacket(slotPacket); + } + } + } + + @Override + public boolean updateSlot(InventoryTranslator translator, GeyserSession session, Inventory inventory, int javaSlot) { + if (super.updateSlot(translator, session, inventory, javaSlot)) + return true; + AnvilContainer anvilContainer = (AnvilContainer) inventory; + updateInventoryState(session, anvilContainer); + + int lastTargetSlot = anvilContainer.getLastTargetSlot(); + int targetSlot = getTargetSlot(session, anvilContainer); + if (targetSlot != javaSlot) { + // Update the requested slot + InventorySlotPacket slotPacket = new InventorySlotPacket(); + slotPacket.setContainerId(ContainerId.UI); + slotPacket.setSlot(translator.javaSlotToBedrock(javaSlot)); + slotPacket.setItem(inventory.getItem(javaSlot).getItemData(session)); + session.sendUpstreamPacket(slotPacket); + } else if (lastTargetSlot != javaSlot) { + // Update the previous target slot to remove repair cost changes + InventorySlotPacket slotPacket = new InventorySlotPacket(); + slotPacket.setContainerId(ContainerId.UI); + slotPacket.setSlot(translator.javaSlotToBedrock(lastTargetSlot)); + slotPacket.setItem(inventory.getItem(lastTargetSlot).getItemData(session)); + session.sendUpstreamPacket(slotPacket); + } + + updateTargetSlot(translator, session, anvilContainer, targetSlot); + return true; + } + + private void updateInventoryState(GeyserSession session, AnvilContainer anvilContainer) { + GeyserItemStack input = anvilContainer.getInput(); + if (!input.equals(anvilContainer.getLastInput())) { + anvilContainer.setLastInput(input.copy()); + anvilContainer.setUseJavaLevelCost(false); + + // Changing the item in the input slot resets the name field on Bedrock, but + // does not result in a FilterTextPacket + String originalName = MessageTranslator.convertToPlainText(ItemUtils.getCustomName(input.getNbt()), session.getLocale()); + ServerboundRenameItemPacket renameItemPacket = new ServerboundRenameItemPacket(originalName); + session.sendDownstreamPacket(renameItemPacket); + + anvilContainer.setNewName(null); + } + + GeyserItemStack material = anvilContainer.getMaterial(); + if (!material.equals(anvilContainer.getLastMaterial())) { + anvilContainer.setLastMaterial(material.copy()); + anvilContainer.setUseJavaLevelCost(false); + } + } + + /** + * @param anvilContainer the anvil inventory + * @return the slot to change the repair cost + */ + private int getTargetSlot(GeyserSession session, AnvilContainer anvilContainer) { + GeyserItemStack input = anvilContainer.getInput(); + GeyserItemStack material = anvilContainer.getMaterial(); + + if (!material.isEmpty()) { + if (!input.isEmpty() && isRepairing(session, input, material)) { + // Changing the repair cost on the material item makes it non-stackable + return 0; + } + // Prefer changing the material item because it does not reset the name field + return 1; + } + return 0; + } + + private void updateTargetSlot(InventoryTranslator translator, GeyserSession session, AnvilContainer anvilContainer, int slot) { + ItemData itemData = anvilContainer.getItem(slot).getItemData(session); + itemData = hijackRepairCost(session, anvilContainer, itemData); + + if (slot == 0 && isRenaming(session, anvilContainer, true)) { + // Can't change the RepairCost because it resets the name field on Bedrock + return; + } + + anvilContainer.setLastTargetSlot(slot); + + InventorySlotPacket slotPacket = new InventorySlotPacket(); + slotPacket.setContainerId(ContainerId.UI); + slotPacket.setSlot(translator.javaSlotToBedrock(slot)); + slotPacket.setItem(itemData); + session.sendUpstreamPacket(slotPacket); + } + + private ItemData hijackRepairCost(GeyserSession session, AnvilContainer anvilContainer, ItemData itemData) { + if (itemData.isNull()) { + return itemData; + } + // Fix level count by adjusting repair cost + int newRepairCost; + if (anvilContainer.isUseJavaLevelCost()) { + newRepairCost = anvilContainer.getJavaLevelCost(); + } else { + // Did not receive a ServerWindowPropertyPacket with the level cost + newRepairCost = calcLevelCost(session, anvilContainer, false); + } + + int bedrockLevelCost = calcLevelCost(session, anvilContainer, true); + if (bedrockLevelCost == -1) { + // Bedrock is unable to combine/repair the items + return itemData; + } + + newRepairCost -= bedrockLevelCost; + if (newRepairCost == 0) { + // No change to the repair cost needed + return itemData; + } + + NbtMapBuilder tagBuilder = NbtMap.builder(); + if (itemData.getTag() != null) { + newRepairCost += itemData.getTag().getInt("RepairCost", 0); + tagBuilder.putAll(itemData.getTag()); + } + tagBuilder.put("RepairCost", newRepairCost); + return itemData.toBuilder().tag(tagBuilder.build()).build(); + } + + /** + * Calculate the number of levels needed to combine/rename an item + * + * @param session the geyser session + * @param anvilContainer the anvil container + * @param bedrock True to count enchantments like Bedrock + * @return the number of levels needed + */ + public int calcLevelCost(GeyserSession session, AnvilContainer anvilContainer, boolean bedrock) { + GeyserItemStack input = anvilContainer.getInput(); + GeyserItemStack material = anvilContainer.getMaterial(); + + if (input.isEmpty()) { + return 0; + } + int totalRepairCost = getRepairCost(input); + int cost = 0; + if (!material.isEmpty()) { + totalRepairCost += getRepairCost(material); + if (isCombining(session, input, material)) { + if (hasDurability(session, input) && input.getJavaId() == material.getJavaId()) { + cost += calcMergeRepairCost(session, input, material); + } + + int enchantmentLevelCost = calcMergeEnchantmentCost(session, input, material, bedrock); + if (enchantmentLevelCost != -1) { + cost += enchantmentLevelCost; + } else if (cost == 0) { + // Can't repair or merge enchantments + return -1; + } + } else if (hasDurability(session, input) && isRepairing(session, input, material)) { + cost = calcRepairLevelCost(session, input, material); + if (cost == -1) { + // No damage to repair + return -1; + } + } else { + return -1; + } + } + + int totalCost = totalRepairCost + cost; + if (isRenaming(session, anvilContainer, bedrock)) { + totalCost++; + if (cost == 0 && totalCost >= MAX_LEVEL_COST) { + // Items can still be renamed when the level cost for renaming exceeds 40 + totalCost = MAX_LEVEL_COST - 1; + } + } + return totalCost; + } + + /** + * Calculate the levels needed to repair an item with its repair material + * E.g. iron_sword + iron_ingot + * + * @param session Geyser session + * @param input an item with durability + * @param material the item's respective repair material + * @return the number of levels needed or 0 if it is not possible to repair any further + */ + private int calcRepairLevelCost(GeyserSession session, GeyserItemStack input, GeyserItemStack material) { + int newDamage = getDamage(input); + int unitRepair = Math.min(newDamage, input.getMapping(session).getMaxDamage() / 4); + if (unitRepair <= 0) { + // No damage to repair + return -1; + } + for (int i = 0; i < material.getAmount(); i++) { + newDamage -= unitRepair; + unitRepair = Math.min(newDamage, input.getMapping(session).getMaxDamage() / 4); + if (unitRepair <= 0) { + return i + 1; + } + } + return material.getAmount(); + } + + /** + * Calculate the levels cost for repairing items by combining two of the same item + * + * @param session Geyser session + * @param input an item with durability + * @param material a matching item + * @return the number of levels needed or 0 if it is not possible to repair any further + */ + private int calcMergeRepairCost(GeyserSession session, GeyserItemStack input, GeyserItemStack material) { + // If the material item is damaged 112% or more, then the input item will not be repaired + if (getDamage(input) > 0 && getDamage(material) < (material.getMapping(session).getMaxDamage() * 112 / 100)) { + return 2; + } + return 0; + } + + /** + * Calculate the levels needed for combining the enchantments of two items + * + * @param session Geyser session + * @param input an item with durability + * @param material a matching item + * @param bedrock True to count enchantments like Bedrock, False to count like Java + * @return the number of levels needed or -1 if no enchantments can be applied + */ + private int calcMergeEnchantmentCost(GeyserSession session, GeyserItemStack input, GeyserItemStack material, boolean bedrock) { + boolean hasCompatible = false; + Object2IntMap<JavaEnchantment> combinedEnchantments = getEnchantments(session, input, bedrock); + int cost = 0; + for (Object2IntMap.Entry<JavaEnchantment> entry : getEnchantments(session, material, bedrock).object2IntEntrySet()) { + JavaEnchantment enchantment = entry.getKey(); + EnchantmentData data = Registries.ENCHANTMENTS.get(enchantment); + if (data == null) { + GeyserConnector.getInstance().getLogger().debug("Java enchantment not in registry: " + enchantment); + continue; + } + + boolean canApply = isEnchantedBook(session, input) || data.validItems().contains(input.getJavaId()); + for (JavaEnchantment incompatible : data.incompatibleEnchantments()) { + if (combinedEnchantments.containsKey(incompatible)) { + canApply = false; + if (!bedrock) { + cost++; + } + } + } + + if (canApply || (!bedrock && session.getGameMode() == GameMode.CREATIVE)) { + int currentLevel = combinedEnchantments.getOrDefault(enchantment, 0); + int newLevel = entry.getIntValue(); + if (newLevel == currentLevel) { + newLevel++; + } + newLevel = Math.max(currentLevel, newLevel); + if (newLevel > data.maxLevel()) { + newLevel = data.maxLevel(); + } + combinedEnchantments.put(enchantment, newLevel); + + int rarityMultiplier = data.rarityMultiplier(); + if (isEnchantedBook(session, material) && rarityMultiplier > 1) { + rarityMultiplier /= 2; + } + if (bedrock) { + if (newLevel > currentLevel) { + hasCompatible = true; + } + if (enchantment == JavaEnchantment.IMPALING) { + // Multiplier is halved on Bedrock for some reason + rarityMultiplier /= 2; + } else if (enchantment == JavaEnchantment.SWEEPING) { + // Doesn't exist on Bedrock + rarityMultiplier = 0; + } + cost += rarityMultiplier * (newLevel - currentLevel); + } else { + hasCompatible = true; + cost += rarityMultiplier * newLevel; + } + } + } + + if (!hasCompatible) { + return -1; + } + return cost; + } + + private Object2IntMap<JavaEnchantment> getEnchantments(GeyserSession session, GeyserItemStack itemStack, boolean bedrock) { + if (itemStack.getNbt() == null) { + return Object2IntMaps.emptyMap(); + } + Object2IntMap<JavaEnchantment> enchantments = new Object2IntOpenHashMap<>(); + Tag enchantmentTag; + if (isEnchantedBook(session, itemStack)) { + enchantmentTag = itemStack.getNbt().get("StoredEnchantments"); + } else { + enchantmentTag = itemStack.getNbt().get("Enchantments"); + } + if (enchantmentTag instanceof ListTag listTag) { + for (Tag tag : listTag.getValue()) { + if (tag instanceof CompoundTag enchantTag) { + if (enchantTag.get("id") instanceof StringTag javaEnchId) { + JavaEnchantment enchantment = JavaEnchantment.getByJavaIdentifier(javaEnchId.getValue()); + if (enchantment == null) { + GeyserConnector.getInstance().getLogger().debug("Unknown java enchantment: " + javaEnchId.getValue()); + continue; + } + + Tag javaEnchLvl = enchantTag.get("lvl"); + if (!(javaEnchLvl instanceof ShortTag || javaEnchLvl instanceof IntTag)) + continue; + + // Handle duplicate enchantments + if (bedrock) { + enchantments.putIfAbsent(enchantment, ((Number) javaEnchLvl.getValue()).intValue()); + } else { + enchantments.mergeInt(enchantment, ((Number) javaEnchLvl.getValue()).intValue(), Math::max); + } + } + } + } + } + return enchantments; + } + + private boolean isEnchantedBook(GeyserSession session, GeyserItemStack itemStack) { + return itemStack.getJavaId() == session.getItemMappings().getStoredItems().enchantedBook().getJavaId(); + } + + private boolean isCombining(GeyserSession session, GeyserItemStack input, GeyserItemStack material) { + return isEnchantedBook(session, material) || (input.getJavaId() == material.getJavaId() && hasDurability(session, input)); + } + + private boolean isRepairing(GeyserSession session, GeyserItemStack input, GeyserItemStack material) { + Set<String> repairMaterials = input.getMapping(session).getRepairMaterials(); + return repairMaterials != null && repairMaterials.contains(material.getMapping(session).getJavaIdentifier()); + } + + private boolean isRenaming(GeyserSession session, AnvilContainer anvilContainer, boolean bedrock) { + if (anvilContainer.getResult().isEmpty()) { + return false; + } + // This should really check the name field in all cases, but that requires the localized name + // of the item which can change depending on NBT and Minecraft Edition + String originalName = ItemUtils.getCustomName(anvilContainer.getInput().getNbt()); + if (bedrock && originalName != null && anvilContainer.getNewName() != null) { + // Check text and formatting + String legacyOriginalName = MessageTranslator.convertMessageLenient(originalName, session.getLocale()); + return !legacyOriginalName.equals(anvilContainer.getNewName()); + } + return !Objects.equals(originalName, ItemUtils.getCustomName(anvilContainer.getResult().getNbt())); + } + + private int getTagIntValueOr(GeyserItemStack itemStack, String tagName, int defaultValue) { + if (itemStack.getNbt() != null) { + Tag tag = itemStack.getNbt().get(tagName); + if (tag != null && tag.getValue() instanceof Number value) { + return value.intValue(); + } + } + return defaultValue; + } + + private int getRepairCost(GeyserItemStack itemStack) { + return getTagIntValueOr(itemStack, "RepairCost", 0); + } + + private boolean hasDurability(GeyserSession session, GeyserItemStack itemStack) { + if (itemStack.getMapping(session).getMaxDamage() > 0) { + return getTagIntValueOr(itemStack, "Unbreakable", 0) == 0; + } + return false; + } + + private int getDamage(GeyserItemStack itemStack) { + return getTagIntValueOr(itemStack, "Damage", 0); + } +} diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/item/Enchantment.java b/connector/src/main/java/org/geysermc/connector/network/translators/item/Enchantment.java index 14b918a4f..947f9e6ed 100644 --- a/connector/src/main/java/org/geysermc/connector/network/translators/item/Enchantment.java +++ b/connector/src/main/java/org/geysermc/connector/network/translators/item/Enchantment.java @@ -147,6 +147,18 @@ public enum Enchantment { */ public static final String[] ALL_JAVA_IDENTIFIERS; + public static JavaEnchantment getByJavaIdentifier(String javaIdentifier) { + if (!javaIdentifier.startsWith("minecraft:")) { + javaIdentifier = "minecraft:" + javaIdentifier; + } + for (int i = 0; i < ALL_JAVA_IDENTIFIERS.length; i++) { + if (ALL_JAVA_IDENTIFIERS[i].equalsIgnoreCase(javaIdentifier)) { + return VALUES[i]; + } + } + return null; + } + static { ALL_JAVA_IDENTIFIERS = new String[VALUES.length]; for (int i = 0; i < ALL_JAVA_IDENTIFIERS.length; i++) { diff --git a/connector/src/main/java/org/geysermc/connector/network/translators/item/StoredItemMappings.java b/connector/src/main/java/org/geysermc/connector/network/translators/item/StoredItemMappings.java index 7f8456d6a..6bbdb7421 100644 --- a/connector/src/main/java/org/geysermc/connector/network/translators/item/StoredItemMappings.java +++ b/connector/src/main/java/org/geysermc/connector/network/translators/item/StoredItemMappings.java @@ -43,6 +43,7 @@ public class StoredItemMappings { private final ItemMapping barrier; private final ItemMapping compass; private final ItemMapping crossbow; + private final ItemMapping enchantedBook; private final ItemMapping fishingRod; private final ItemMapping lodestoneCompass; private final ItemMapping milkBucket; @@ -58,6 +59,7 @@ public class StoredItemMappings { this.barrier = load(itemMappings, "barrier"); this.compass = load(itemMappings, "compass"); this.crossbow = load(itemMappings, "crossbow"); + this.enchantedBook = load(itemMappings, "enchanted_book"); this.fishingRod = load(itemMappings, "fishing_rod"); this.lodestoneCompass = load(itemMappings, "lodestone_compass"); this.milkBucket = load(itemMappings, "milk_bucket"); diff --git a/connector/src/main/java/org/geysermc/connector/registry/Registries.java b/connector/src/main/java/org/geysermc/connector/registry/Registries.java index 0d548c824..157d01ea3 100644 --- a/connector/src/main/java/org/geysermc/connector/registry/Registries.java +++ b/connector/src/main/java/org/geysermc/connector/registry/Registries.java @@ -39,12 +39,14 @@ import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; import it.unimi.dsi.fastutil.objects.Object2IntMap; import org.geysermc.connector.network.translators.collision.translators.BlockCollision; import org.geysermc.connector.network.translators.world.event.LevelEventTransformer; +import org.geysermc.connector.network.translators.item.Enchantment.JavaEnchantment; import org.geysermc.connector.network.translators.sound.SoundHandler; import org.geysermc.connector.network.translators.sound.SoundInteractionHandler; import org.geysermc.connector.network.translators.world.block.entity.BlockEntityTranslator; import org.geysermc.connector.registry.loader.*; import org.geysermc.connector.registry.populator.ItemRegistryPopulator; import org.geysermc.connector.registry.populator.RecipeRegistryPopulator; +import org.geysermc.connector.registry.type.EnchantmentData; import org.geysermc.connector.registry.type.ItemMappings; import org.geysermc.connector.registry.type.ParticleMapping; import org.geysermc.connector.registry.type.SoundMapping; @@ -82,6 +84,11 @@ public class Registries { */ public static final VersionedRegistry<Map<RecipeType, List<CraftingData>>> CRAFTING_DATA = VersionedRegistry.create(RegistryLoaders.empty(Int2ObjectOpenHashMap::new)); + /** + * A registry holding data of all the known enchantments. + */ + public static final SimpleMappedRegistry<JavaEnchantment, EnchantmentData> ENCHANTMENTS; + /** * A registry holding a CompoundTag of the known entity identifiers. */ @@ -140,5 +147,6 @@ public class Registries { // Create registries that require other registries to load first POTION_MIXES = SimpleRegistry.create(PotionMixRegistryLoader::new); + ENCHANTMENTS = SimpleMappedRegistry.create("mappings/enchantments.json", EnchantmentRegistryLoader::new); } } \ No newline at end of file diff --git a/connector/src/main/java/org/geysermc/connector/registry/loader/EnchantmentRegistryLoader.java b/connector/src/main/java/org/geysermc/connector/registry/loader/EnchantmentRegistryLoader.java new file mode 100644 index 000000000..a1a95fe1a --- /dev/null +++ b/connector/src/main/java/org/geysermc/connector/registry/loader/EnchantmentRegistryLoader.java @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2019-2021 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.connector.registry.loader; + +import com.fasterxml.jackson.databind.JsonNode; +import it.unimi.dsi.fastutil.ints.IntOpenHashSet; +import it.unimi.dsi.fastutil.ints.IntSet; +import org.geysermc.connector.GeyserConnector; +import org.geysermc.connector.network.MinecraftProtocol; +import org.geysermc.connector.network.translators.item.Enchantment.JavaEnchantment; +import org.geysermc.connector.registry.Registries; +import org.geysermc.connector.registry.type.EnchantmentData; +import org.geysermc.connector.registry.type.ItemMapping; +import org.geysermc.connector.utils.FileUtils; + +import java.io.InputStream; +import java.util.EnumMap; +import java.util.EnumSet; +import java.util.Iterator; +import java.util.Map; + +public class EnchantmentRegistryLoader implements RegistryLoader<String, Map<JavaEnchantment, EnchantmentData>> { + @Override + public Map<JavaEnchantment, EnchantmentData> load(String input) { + InputStream enchantmentsStream = FileUtils.getResource(input); + JsonNode enchantmentsNode; + try { + enchantmentsNode = GeyserConnector.JSON_MAPPER.readTree(enchantmentsStream); + } catch (Exception e) { + throw new AssertionError("Unable to load enchantment data", e); + } + + Map<JavaEnchantment, EnchantmentData> enchantments = new EnumMap<>(JavaEnchantment.class); + Iterator<Map.Entry<String, JsonNode>> it = enchantmentsNode.fields(); + while (it.hasNext()) { + Map.Entry<String, JsonNode> entry = it.next(); + JavaEnchantment key = JavaEnchantment.getByJavaIdentifier(entry.getKey()); + JsonNode node = entry.getValue(); + int rarityMultiplier = switch (node.get("rarity").textValue()) { + case "common" -> 1; + case "uncommon" -> 2; + case "rare" -> 4; + case "very_rare" -> 8; + default -> throw new IllegalStateException("Unexpected value: " + node.get("rarity").textValue()); + }; + int maxLevel = node.get("max_level").asInt(); + + EnumSet<JavaEnchantment> incompatibleEnchantments = EnumSet.noneOf(JavaEnchantment.class); + JsonNode incompatibleEnchantmentsNode = node.get("incompatible_enchantments"); + if (incompatibleEnchantmentsNode != null) { + for (JsonNode incompatibleNode : incompatibleEnchantmentsNode) { + incompatibleEnchantments.add(JavaEnchantment.getByJavaIdentifier(incompatibleNode.textValue())); + } + } + + IntSet validItems = new IntOpenHashSet(); + for (JsonNode itemNode : node.get("valid_items")) { + String javaIdentifier = itemNode.textValue(); + ItemMapping itemMapping = Registries.ITEMS.forVersion(MinecraftProtocol.DEFAULT_BEDROCK_CODEC.getProtocolVersion()).getMapping(javaIdentifier); + if (itemMapping != null) { + validItems.add(itemMapping.getJavaId()); + } else { + throw new NullPointerException("No item entry exists for java identifier: " + javaIdentifier); + } + } + + EnchantmentData enchantmentData = new EnchantmentData(rarityMultiplier, maxLevel, incompatibleEnchantments, validItems); + enchantments.put(key, enchantmentData); + } + return enchantments; + } +} diff --git a/connector/src/main/java/org/geysermc/connector/registry/populator/ItemRegistryPopulator.java b/connector/src/main/java/org/geysermc/connector/registry/populator/ItemRegistryPopulator.java index b413b3a80..8f3e87e53 100644 --- a/connector/src/main/java/org/geysermc/connector/registry/populator/ItemRegistryPopulator.java +++ b/connector/src/main/java/org/geysermc/connector/registry/populator/ItemRegistryPopulator.java @@ -37,10 +37,7 @@ import com.nukkitx.protocol.bedrock.data.inventory.ItemData; import com.nukkitx.protocol.bedrock.packet.StartGamePacket; import com.nukkitx.protocol.bedrock.v465.Bedrock_v465; import com.nukkitx.protocol.bedrock.v471.Bedrock_v471; -import it.unimi.dsi.fastutil.ints.Int2ObjectMap; -import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; -import it.unimi.dsi.fastutil.ints.IntArrayList; -import it.unimi.dsi.fastutil.ints.IntList; +import it.unimi.dsi.fastutil.ints.*; import it.unimi.dsi.fastutil.objects.*; import org.geysermc.connector.GeyserConnector; import org.geysermc.connector.network.translators.item.StoredItemMappings; @@ -365,7 +362,12 @@ public class ItemRegistryPopulator { .bedrockId(bedrockId) .bedrockData(mappingItem.getBedrockData()) .bedrockBlockId(bedrockBlockId) - .stackSize(stackSize); + .stackSize(stackSize) + .maxDamage(mappingItem.getMaxDamage()); + + if (mappingItem.getRepairMaterials() != null) { + mappingBuilder = mappingBuilder.repairMaterials(new ObjectOpenHashSet<>(mappingItem.getRepairMaterials())); + } if (mappingItem.getToolType() != null) { if (mappingItem.getToolTier() != null) { diff --git a/connector/src/main/java/org/geysermc/connector/registry/type/EnchantmentData.java b/connector/src/main/java/org/geysermc/connector/registry/type/EnchantmentData.java new file mode 100644 index 000000000..cd16a093a --- /dev/null +++ b/connector/src/main/java/org/geysermc/connector/registry/type/EnchantmentData.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2019-2021 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.connector.registry.type; + +import it.unimi.dsi.fastutil.ints.IntSet; +import org.geysermc.connector.network.translators.item.Enchantment.JavaEnchantment; + +import java.util.Set; + +public record EnchantmentData(int rarityMultiplier, int maxLevel, Set<JavaEnchantment> incompatibleEnchantments, + IntSet validItems) { +} diff --git a/connector/src/main/java/org/geysermc/connector/registry/type/GeyserMappingItem.java b/connector/src/main/java/org/geysermc/connector/registry/type/GeyserMappingItem.java index da91c412e..12e5544b7 100644 --- a/connector/src/main/java/org/geysermc/connector/registry/type/GeyserMappingItem.java +++ b/connector/src/main/java/org/geysermc/connector/registry/type/GeyserMappingItem.java @@ -28,6 +28,8 @@ package org.geysermc.connector.registry.type; import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; +import java.util.List; + /** * Represents Geyser's own serialized item information before being processed per-version */ @@ -40,4 +42,6 @@ public class GeyserMappingItem { @JsonProperty("stack_size") int stackSize = 64; @JsonProperty("tool_type") String toolType; @JsonProperty("tool_tier") String toolTier; + @JsonProperty("max_damage") int maxDamage = 0; + @JsonProperty("repair_materials") List<String> repairMaterials; } diff --git a/connector/src/main/java/org/geysermc/connector/registry/type/ItemMapping.java b/connector/src/main/java/org/geysermc/connector/registry/type/ItemMapping.java index 4da25893f..1a7714968 100644 --- a/connector/src/main/java/org/geysermc/connector/registry/type/ItemMapping.java +++ b/connector/src/main/java/org/geysermc/connector/registry/type/ItemMapping.java @@ -31,13 +31,15 @@ import lombok.Value; import org.geysermc.connector.network.MinecraftProtocol; import org.geysermc.connector.registry.BlockRegistries; +import java.util.Set; + @Value @Builder @EqualsAndHashCode public class ItemMapping { public static final ItemMapping AIR = new ItemMapping("minecraft:air", "minecraft:air", 0, 0, 0, BlockRegistries.BLOCKS.forVersion(MinecraftProtocol.DEFAULT_BEDROCK_CODEC.getProtocolVersion()).getBedrockAirId(), - 64, null, null, null); + 64, null, null, null, 0, null); String javaIdentifier; String bedrockIdentifier; @@ -57,6 +59,10 @@ public class ItemMapping { String translationString; + int maxDamage; + + Set<String> repairMaterials; + /** * Gets if this item is a block. * diff --git a/connector/src/main/java/org/geysermc/connector/utils/ItemUtils.java b/connector/src/main/java/org/geysermc/connector/utils/ItemUtils.java index db4e9e2e1..8bd2edfd1 100644 --- a/connector/src/main/java/org/geysermc/connector/utils/ItemUtils.java +++ b/connector/src/main/java/org/geysermc/connector/utils/ItemUtils.java @@ -58,4 +58,19 @@ public class ItemUtils { } return original; } + + /** + * @param itemTag the NBT tag of the item + * @return the custom name of the item + */ + public static String getCustomName(CompoundTag itemTag) { + if (itemTag != null) { + if (itemTag.get("display") instanceof CompoundTag displayTag) { + if (displayTag.get("Name") instanceof StringTag nameTag) { + return nameTag.getValue(); + } + } + } + return null; + } } diff --git a/connector/src/main/resources/mappings b/connector/src/main/resources/mappings index 7ff1b6567..5b6239f0a 160000 --- a/connector/src/main/resources/mappings +++ b/connector/src/main/resources/mappings @@ -1 +1 @@ -Subproject commit 7ff1b6567b56c7b0b8e28786b9bbc30abfaededf +Subproject commit 5b6239f0a43ec9a38d65ed53b8d1bfaf564c1c3b diff --git a/connector/src/test/java/org/geysermc/connector/network/translators/chat/MessageTranslatorTest.java b/connector/src/test/java/org/geysermc/connector/network/translators/chat/MessageTranslatorTest.java index 649e96425..cfb2bbc97 100644 --- a/connector/src/test/java/org/geysermc/connector/network/translators/chat/MessageTranslatorTest.java +++ b/connector/src/test/java/org/geysermc/connector/network/translators/chat/MessageTranslatorTest.java @@ -81,6 +81,16 @@ public class MessageTranslatorTest { Assert.assertEquals("Unimplemented formatting chars not stripped", "Bold Underline", MessageTranslator.convertMessageLenient("§m§nBold Underline")); } + @Test + public void convertToPlainText() { + Assert.assertEquals("JSON message is not handled properly", "Many colors here", MessageTranslator.convertToPlainText("{\"extra\":[{\"color\":\"red\",\"text\":\"M\"},{\"color\":\"gold\",\"text\":\"a\"},{\"color\":\"yellow\",\"text\":\"n\"},{\"color\":\"green\",\"text\":\"y \"},{\"color\":\"aqua\",\"text\":\"c\"},{\"color\":\"dark_purple\",\"text\":\"o\"},{\"color\":\"red\",\"text\":\"l\"},{\"color\":\"gold\",\"text\":\"o\"},{\"color\":\"yellow\",\"text\":\"r\"},{\"color\":\"green\",\"text\":\"s \"},{\"color\":\"aqua\",\"text\":\"h\"},{\"color\":\"dark_purple\",\"text\":\"e\"},{\"color\":\"red\",\"text\":\"r\"},{\"color\":\"gold\",\"text\":\"e\"}],\"text\":\"\"}", "en_US")); + Assert.assertEquals("Legacy formatted message is not handled properly (Colors)", "Many colors here", MessageTranslator.convertToPlainText("§cM§6a§en§ay §bc§5o§cl§6o§er§as §bh§5e§cr§6e", "en_US")); + Assert.assertEquals("Legacy formatted message is not handled properly (Style)", "Obf Bold Strikethrough Underline Italic Reset", MessageTranslator.convertToPlainText("§kObf §lBold §mStrikethrough §nUnderline §oItalic §rReset", "en_US")); + Assert.assertEquals("Valid lenient JSON is not handled properly", "Strange", MessageTranslator.convertToPlainText("§rStrange", "en_US")); + Assert.assertEquals("Empty message is not handled properly", "", MessageTranslator.convertToPlainText("", "en_US")); + Assert.assertEquals("Whitespace is not preserved", " ", MessageTranslator.convertToPlainText(" ", "en_US")); + } + @Test public void testNullTextPacket() { DefaultComponentSerializer.get().deserialize("null");