From 12e93ba1d83d1300e2cf06005680479bee86d0c7 Mon Sep 17 00:00:00 2001 From: Camotoy <20743703+Camotoy@users.noreply.github.com> Date: Mon, 11 Nov 2024 14:28:17 -0500 Subject: [PATCH 1/4] Bundle support --- .../geyser/inventory/GeyserItemStack.java | 64 +- .../geysermc/geyser/inventory/Inventory.java | 8 +- .../geyser/inventory/click/Click.java | 3 + .../geyser/inventory/click/ClickPlan.java | 108 ++- .../geysermc/geyser/item/type/BundleItem.java | 32 + .../geyser/session/GeyserSession.java | 3 + .../geyser/session/cache/BundleCache.java | 350 +++++++ .../geyser/session/cache/tags/ItemTag.java | 1 + .../inventory/BundleInventoryTranslator.java | 333 +++++++ .../inventory/InventoryTranslator.java | 27 +- .../inventory/PlayerInventoryTranslator.java | 9 + .../JavaContainerSetContentTranslator.java | 5 +- .../JavaContainerSetSlotTranslator.java | 1 + .../JavaSetCursorItemTranslator.java | 1 + .../JavaSetPlayerInventoryTranslator.java | 4 +- .../geysermc/geyser/util/InventoryUtils.java | 1 + .../geyser/util/thirdparty/Fraction.java | 911 ++++++++++++++++++ 17 files changed, 1840 insertions(+), 21 deletions(-) create mode 100644 core/src/main/java/org/geysermc/geyser/item/type/BundleItem.java create mode 100644 core/src/main/java/org/geysermc/geyser/session/cache/BundleCache.java create mode 100644 core/src/main/java/org/geysermc/geyser/translator/inventory/BundleInventoryTranslator.java create mode 100644 core/src/main/java/org/geysermc/geyser/util/thirdparty/Fraction.java diff --git a/core/src/main/java/org/geysermc/geyser/inventory/GeyserItemStack.java b/core/src/main/java/org/geysermc/geyser/inventory/GeyserItemStack.java index 256de779942..5e1fb844762 100644 --- a/core/src/main/java/org/geysermc/geyser/inventory/GeyserItemStack.java +++ b/core/src/main/java/org/geysermc/geyser/inventory/GeyserItemStack.java @@ -25,7 +25,11 @@ package org.geysermc.geyser.inventory; -import lombok.*; +import lombok.AccessLevel; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; import org.checkerframework.checker.nullness.qual.NonNull; import org.checkerframework.checker.nullness.qual.Nullable; import org.cloudburstmc.protocol.bedrock.data.inventory.ItemData; @@ -35,6 +39,7 @@ import org.geysermc.geyser.registry.Registries; import org.geysermc.geyser.registry.type.ItemMapping; import org.geysermc.geyser.session.GeyserSession; +import org.geysermc.geyser.session.cache.BundleCache; import org.geysermc.geyser.translator.item.ItemTranslator; import org.geysermc.mcprotocollib.protocol.data.game.item.ItemStack; import org.geysermc.mcprotocollib.protocol.data.game.item.component.DataComponentType; @@ -49,25 +54,30 @@ @Data public class GeyserItemStack { public static final GeyserItemStack EMPTY = new GeyserItemStack(Items.AIR_ID, 0, null); + public static final int UNSET_NET_ID = 1; private final int javaId; private int amount; private DataComponents components; private int netId; + @EqualsAndHashCode.Exclude + private BundleCache.BundleData bundleData; + @Getter(AccessLevel.NONE) @Setter(AccessLevel.NONE) @EqualsAndHashCode.Exclude private Item item; private GeyserItemStack(int javaId, int amount, DataComponents components) { - this(javaId, amount, components, 1); + this(javaId, amount, components, 1, null); } - private GeyserItemStack(int javaId, int amount, DataComponents components, int netId) { + private GeyserItemStack(int javaId, int amount, DataComponents components, int netId, BundleCache.BundleData bundleData) { this.javaId = javaId; this.amount = amount; this.components = components; this.netId = netId; + this.bundleData = bundleData; } public static @NonNull GeyserItemStack of(int javaId, int amount) { @@ -148,10 +158,35 @@ public int getComponent(@NonNull DataComponentType type, return def; } +// public boolean hasComponent(DataComponentType type) { +// if (components == null) { +// return false; +// } +// return components.getDataComponents().containsKey(type); +// } + public int getNetId() { return isEmpty() ? 0 : netId; } + public int getBundleId() { + if (isEmpty()) { + return -1; + } + + return bundleData == null ? -1 : bundleData.bundleId(); + } + + public void mergeBundleData(GeyserSession session, BundleCache.BundleData oldBundleData) { + if (oldBundleData != null && this.bundleData != null) { + // Old bundle; re-use old IDs + this.bundleData.updateNetIds(session, oldBundleData); + } else if (this.bundleData != null) { + // New bundle; allocate new ID + session.getBundleCache().markNewBundle(this.bundleData); + } + } + public void add(int add) { amount += add; } @@ -165,6 +200,24 @@ public ItemStack getItemStack() { } public @Nullable ItemStack getItemStack(int newAmount) { + if (isEmpty()) { + return null; + } + // Sync our updated bundle data to server, if applicable + // Not fresh from server? Then we have changes to apply!~ + if (bundleData != null && !bundleData.freshFromServer()) { + if (!bundleData.contents().isEmpty()) { + if (components == null) { + components = new DataComponents(new HashMap<>()); + } + components.put(DataComponentType.BUNDLE_CONTENTS, bundleData.toComponent()); + } else { + if (components != null) { + // Empty list = no component = should delete + components.getDataComponents().remove(DataComponentType.BUNDLE_CONTENTS); + } + } + } return isEmpty() ? null : new ItemStack(javaId, newAmount, components); } @@ -175,7 +228,8 @@ public ItemData getItemData(GeyserSession session) { ItemData.Builder itemData = ItemTranslator.translateToBedrock(session, javaId, amount, components); itemData.netId(getNetId()); itemData.usingNetId(true); - return itemData.build(); + + return session.getBundleCache().checkForBundle(this, itemData); } public ItemMapping getMapping(GeyserSession session) { @@ -208,6 +262,6 @@ public GeyserItemStack copy() { } public GeyserItemStack copy(int newAmount) { - return isEmpty() ? EMPTY : new GeyserItemStack(javaId, newAmount, components == null ? null : components.clone(), netId); + return isEmpty() ? EMPTY : new GeyserItemStack(javaId, newAmount, components == null ? null : components.clone(), netId, bundleData == null ? null : bundleData.copy()); } } diff --git a/core/src/main/java/org/geysermc/geyser/inventory/Inventory.java b/core/src/main/java/org/geysermc/geyser/inventory/Inventory.java index 09d04f17c79..c960ed1a204 100644 --- a/core/src/main/java/org/geysermc/geyser/inventory/Inventory.java +++ b/core/src/main/java/org/geysermc/geyser/inventory/Inventory.java @@ -142,15 +142,21 @@ public void setItem(int slot, @NonNull GeyserItemStack newItem, GeyserSession se } } - protected void updateItemNetId(GeyserItemStack oldItem, GeyserItemStack newItem, GeyserSession session) { + public static void updateItemNetId(GeyserItemStack oldItem, GeyserItemStack newItem, GeyserSession session) { if (!newItem.isEmpty()) { ItemDefinition oldMapping = ItemTranslator.getBedrockItemDefinition(session, oldItem); ItemDefinition newMapping = ItemTranslator.getBedrockItemDefinition(session, newItem); if (oldMapping.equals(newMapping)) { newItem.setNetId(oldItem.getNetId()); + newItem.mergeBundleData(session, oldItem.getBundleData()); } else { newItem.setNetId(session.getNextItemNetId()); + session.getBundleCache().markNewBundle(newItem.getBundleData()); + session.getBundleCache().onOldItemDelete(oldItem); } + } else { + // Empty item means no more bundle if one existed. + session.getBundleCache().onOldItemDelete(oldItem); } } diff --git a/core/src/main/java/org/geysermc/geyser/inventory/click/Click.java b/core/src/main/java/org/geysermc/geyser/inventory/click/Click.java index 6897786c139..cf16d0b6fdf 100644 --- a/core/src/main/java/org/geysermc/geyser/inventory/click/Click.java +++ b/core/src/main/java/org/geysermc/geyser/inventory/click/Click.java @@ -31,7 +31,10 @@ @AllArgsConstructor public enum Click { LEFT(ContainerActionType.CLICK_ITEM, ClickItemAction.LEFT_CLICK), + LEFT_BUNDLE(ContainerActionType.CLICK_ITEM, ClickItemAction.LEFT_CLICK), + LEFT_BUNDLE_FROM_CURSOR(ContainerActionType.CLICK_ITEM, ClickItemAction.LEFT_CLICK), RIGHT(ContainerActionType.CLICK_ITEM, ClickItemAction.RIGHT_CLICK), + RIGHT_BUNDLE(ContainerActionType.CLICK_ITEM, ClickItemAction.RIGHT_CLICK), LEFT_SHIFT(ContainerActionType.SHIFT_CLICK_ITEM, ShiftClickItemAction.LEFT_CLICK), DROP_ONE(ContainerActionType.DROP_ITEM, DropItemAction.DROP_FROM_SELECTED), DROP_ALL(ContainerActionType.DROP_ITEM, DropItemAction.DROP_SELECTED_STACK), diff --git a/core/src/main/java/org/geysermc/geyser/inventory/click/ClickPlan.java b/core/src/main/java/org/geysermc/geyser/inventory/click/ClickPlan.java index 9d6f4d3e3dc..15c46aed0e0 100644 --- a/core/src/main/java/org/geysermc/geyser/inventory/click/ClickPlan.java +++ b/core/src/main/java/org/geysermc/geyser/inventory/click/ClickPlan.java @@ -25,19 +25,26 @@ package org.geysermc.geyser.inventory.click; -import org.geysermc.mcprotocollib.protocol.data.game.item.ItemStack; -import org.geysermc.mcprotocollib.protocol.data.game.inventory.ContainerActionType; -import org.geysermc.mcprotocollib.protocol.data.game.inventory.ContainerType; -import org.geysermc.mcprotocollib.protocol.data.game.inventory.MoveToHotbarAction; -import org.geysermc.mcprotocollib.protocol.packet.ingame.serverbound.inventory.ServerboundContainerClickPacket; -import it.unimi.dsi.fastutil.ints.*; +import it.unimi.dsi.fastutil.ints.Int2ObjectMap; +import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap; +import it.unimi.dsi.fastutil.ints.IntOpenHashSet; +import it.unimi.dsi.fastutil.ints.IntSet; import org.geysermc.geyser.inventory.GeyserItemStack; import org.geysermc.geyser.inventory.Inventory; import org.geysermc.geyser.inventory.SlotType; import org.geysermc.geyser.session.GeyserSession; +import org.geysermc.geyser.translator.inventory.BundleInventoryTranslator; import org.geysermc.geyser.translator.inventory.CraftingInventoryTranslator; import org.geysermc.geyser.translator.inventory.InventoryTranslator; import org.geysermc.geyser.util.InventoryUtils; +import org.geysermc.geyser.util.thirdparty.Fraction; +import org.geysermc.mcprotocollib.protocol.data.game.inventory.ContainerActionType; +import org.geysermc.mcprotocollib.protocol.data.game.inventory.ContainerType; +import org.geysermc.mcprotocollib.protocol.data.game.inventory.MoveToHotbarAction; +import org.geysermc.mcprotocollib.protocol.data.game.item.ItemStack; +import org.geysermc.mcprotocollib.protocol.packet.ingame.serverbound.inventory.ServerboundContainerClickPacket; +import org.geysermc.mcprotocollib.protocol.packet.ingame.serverbound.inventory.ServerboundSelectBundleItemPacket; +import org.geysermc.mcprotocollib.protocol.packet.ingame.serverbound.inventory.ServerboundSetCreativeModeSlotPacket; import org.jetbrains.annotations.Contract; import java.util.ArrayList; @@ -52,7 +59,8 @@ public final class ClickPlan { */ private Int2ObjectMap changedItems; private GeyserItemStack simulatedCursor; - private boolean finished; + private int desiredBundleSlot; + private boolean executionBegan; private final GeyserSession session; private final InventoryTranslator translator; @@ -67,7 +75,7 @@ public ClickPlan(GeyserSession session, InventoryTranslator translator, Inventor this.simulatedItems = new Int2ObjectOpenHashMap<>(inventory.getSize()); this.changedItems = null; this.simulatedCursor = session.getPlayerInventory().getCursor().copy(); - this.finished = false; + this.executionBegan = false; gridSize = translator.getGridSize(); } @@ -82,7 +90,7 @@ public void add(Click click, int slot) { } public void add(Click click, int slot, boolean force) { - if (finished) + if (executionBegan) throw new UnsupportedOperationException("ClickPlan already executed"); if (click == Click.LEFT_OUTSIDE || click == Click.RIGHT_OUTSIDE) { @@ -97,6 +105,7 @@ public void add(Click click, int slot, boolean force) { } public void execute(boolean refresh) { + executionBegan = true; //update geyser inventory after simulation to avoid net id desync resetSimulation(); ListIterator planIter = plan.listIterator(); @@ -159,7 +168,25 @@ public void execute(boolean refresh) { for (Int2ObjectMap.Entry simulatedSlot : simulatedItems.int2ObjectEntrySet()) { inventory.setItem(simulatedSlot.getIntKey(), simulatedSlot.getValue(), session); } - finished = true; + } + + public void executeForCreativeMode() { + executionBegan = true; + //update geyser inventory after simulation to avoid net id desync + resetSimulation(); + changedItems = new Int2ObjectOpenHashMap<>(); + for (ClickAction action : plan) { + simulateAction(action); + } + session.getPlayerInventory().setCursor(simulatedCursor, session); + for (Int2ObjectMap.Entry simulatedSlot : simulatedItems.int2ObjectEntrySet()) { + inventory.setItem(simulatedSlot.getIntKey(), simulatedSlot.getValue(), session); + } + for (Int2ObjectMap.Entry changedSlot : changedItems.int2ObjectEntrySet()) { + session.sendDownstreamGamePacket( + new ServerboundSetCreativeModeSlotPacket((short) changedSlot.getIntKey(), changedSlot.getValue()) + ); + } } public Inventory getInventory() { @@ -187,6 +214,10 @@ public GeyserItemStack getItem(int slot) { return simulatedItems.computeIfAbsent(slot, k -> inventory.getItem(slot).copy()); } + public void setDesiredBundleSlot(int desiredBundleSlot) { + this.desiredBundleSlot = desiredBundleSlot; + } + public GeyserItemStack getCursor() { return simulatedCursor; } @@ -275,7 +306,59 @@ private void simulateAction(ClickAction action) { } else if (InventoryUtils.canStack(cursor, clicked)) { cursor.sub(1); add(action.slot, clicked, 1); + } else { + // Can't stack, but both the cursor and the slot have an item + // (Called for bundles) + setCursor(clicked); + setItem(action.slot, cursor); + } + break; + case LEFT_BUNDLE: + Fraction bundleWeight = BundleInventoryTranslator.calculateBundleWeight(clicked.getBundleData().contents()); + int amountToAddInBundle = Math.min(BundleInventoryTranslator.capacityForItemStack(bundleWeight, cursor), cursor.getAmount()); + GeyserItemStack toInsertInBundle = cursor.copy(amountToAddInBundle); + if (executionBegan) { + clicked.getBundleData().contents().add(0, toInsertInBundle); + session.getBundleCache().onItemAdded(clicked); // Must be run before onSlotItemChange as the latter exports an ItemStack from the bundle } + onSlotItemChange(action.slot, clicked); + cursor.sub(amountToAddInBundle); + break; + case LEFT_BUNDLE_FROM_CURSOR: + List contents = cursor.getBundleData().contents(); + bundleWeight = BundleInventoryTranslator.calculateBundleWeight(contents); + amountToAddInBundle = Math.min(BundleInventoryTranslator.capacityForItemStack(bundleWeight, clicked), clicked.getAmount()); + toInsertInBundle = clicked.copy(amountToAddInBundle); + if (executionBegan) { + cursor.getBundleData().contents().add(0, toInsertInBundle); + session.getBundleCache().onItemAdded(cursor); + } + sub(action.slot, clicked, amountToAddInBundle); + break; + case RIGHT_BUNDLE: + if (!cursor.isEmpty()) { + // Bundle should be in player's hand. + GeyserItemStack itemStack = cursor.getBundleData() + .contents() + .remove(0); + if (executionBegan) { + session.getBundleCache().onItemRemoved(cursor, 0); + } + setItem(action.slot, itemStack); + break; + } + + if (executionBegan) { + sendSelectedBundleSlot(action.slot); + } + GeyserItemStack itemStack = clicked.getBundleData() + .contents() + .remove(desiredBundleSlot); + if (executionBegan) { + session.getBundleCache().onItemRemoved(clicked, desiredBundleSlot); + } + onSlotItemChange(action.slot, clicked); + setCursor(itemStack); break; case SWAP_TO_HOTBAR_1: swap(action.slot, inventory.getOffsetForHotbar(0), clicked); @@ -319,6 +402,11 @@ private void simulateAction(ClickAction action) { } } + private void sendSelectedBundleSlot(int slot) { + // Looks like this is also technically sent in creative mode. + session.sendDownstreamGamePacket(new ServerboundSelectBundleItemPacket(slot, desiredBundleSlot)); + } + /** * Swap between two inventory slots without a cursor. This should only be used with {@link ContainerActionType#MOVE_TO_HOTBAR_SLOT} */ diff --git a/core/src/main/java/org/geysermc/geyser/item/type/BundleItem.java b/core/src/main/java/org/geysermc/geyser/item/type/BundleItem.java new file mode 100644 index 00000000000..9c06768b3d5 --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/item/type/BundleItem.java @@ -0,0 +1,32 @@ +/* + * 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.type; + +public class BundleItem extends Item { + public BundleItem(String javaIdentifier, Builder builder) { + super(javaIdentifier, builder); + } +} diff --git a/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java b/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java index ef6261ead56..08cfe7f347c 100644 --- a/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java +++ b/core/src/main/java/org/geysermc/geyser/session/GeyserSession.java @@ -159,6 +159,7 @@ import org.geysermc.geyser.session.auth.BedrockClientData; import org.geysermc.geyser.session.cache.AdvancementsCache; import org.geysermc.geyser.session.cache.BookEditCache; +import org.geysermc.geyser.session.cache.BundleCache; import org.geysermc.geyser.session.cache.ChunkCache; import org.geysermc.geyser.session.cache.EntityCache; import org.geysermc.geyser.session.cache.EntityEffectCache; @@ -275,6 +276,7 @@ public class GeyserSession implements GeyserConnection, GeyserCommandSource { private final AdvancementsCache advancementsCache; private final BookEditCache bookEditCache; + private final BundleCache bundleCache; private final ChunkCache chunkCache; private final EntityCache entityCache; private final EntityEffectCache effectCache; @@ -662,6 +664,7 @@ public GeyserSession(GeyserImpl geyser, BedrockServerSession bedrockServerSessio this.advancementsCache = new AdvancementsCache(this); this.bookEditCache = new BookEditCache(this); + this.bundleCache = new BundleCache(this); this.chunkCache = new ChunkCache(this); this.entityCache = new EntityCache(this); this.effectCache = new EntityEffectCache(); diff --git a/core/src/main/java/org/geysermc/geyser/session/cache/BundleCache.java b/core/src/main/java/org/geysermc/geyser/session/cache/BundleCache.java new file mode 100644 index 00000000000..a57c9bf1a17 --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/session/cache/BundleCache.java @@ -0,0 +1,350 @@ +/* + * 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; + +import org.checkerframework.checker.nullness.qual.Nullable; +import org.cloudburstmc.nbt.NbtMap; +import org.cloudburstmc.nbt.NbtMapBuilder; +import org.cloudburstmc.protocol.bedrock.data.inventory.ContainerSlotType; +import org.cloudburstmc.protocol.bedrock.data.inventory.FullContainerName; +import org.cloudburstmc.protocol.bedrock.data.inventory.ItemData; +import org.cloudburstmc.protocol.bedrock.packet.ContainerRegistryCleanupPacket; +import org.cloudburstmc.protocol.bedrock.packet.InventoryContentPacket; +import org.cloudburstmc.protocol.bedrock.packet.InventorySlotPacket; +import org.geysermc.geyser.inventory.GeyserItemStack; +import org.geysermc.geyser.inventory.Inventory; +import org.geysermc.geyser.inventory.PlayerInventory; +import org.geysermc.geyser.session.GeyserSession; +import org.geysermc.geyser.session.cache.tags.ItemTag; +import org.geysermc.geyser.util.InventoryUtils; +import org.geysermc.mcprotocollib.protocol.data.game.item.ItemStack; +import org.geysermc.mcprotocollib.protocol.data.game.item.component.DataComponentType; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public final class BundleCache { + private static final int BUNDLE_CONTAINER_ID = 125; // BDS 1.21.44 + private final GeyserSession session; + private int nextBundleId; + + public BundleCache(GeyserSession session) { + this.session = session; + } + + /** + * Checks to see if the given item from the server is a bundle. + * If so, we initialize our bundle cache. + */ + public void initialize(GeyserItemStack itemStack) { + if (session.getTagCache().is(ItemTag.BUNDLES, itemStack)) { // Can't check for BUNDLE_CONTENTS, which may be missing if the bundle is empty. + if (itemStack.getBundleData() != null) { + session.getGeyser().getLogger().warning("Stack has bundle data already! It should not!"); + } + + BundleData bundleData; + List rawContents = itemStack.getComponent(DataComponentType.BUNDLE_CONTENTS); + if (rawContents != null) { + // Use existing list and transform it to support net IDs + bundleData = new BundleData(session, rawContents); + } else { + // This is valid behavior (as of vanilla 1.21.2) if the bundle is empty. + // Create new list + bundleData = new BundleData(); + } + itemStack.setBundleData(bundleData); + } + } + + public void markNewBundle(@Nullable BundleData bundleData) { + if (bundleData == null) { + return; + } + if (bundleData.bundleId != -1) { + return; + } + bundleData.bundleId = nextBundleId++; + for (GeyserItemStack stack : bundleData.contents()) { + stack.setNetId(session.getNextItemNetId()); + session.getBundleCache().markNewBundle(stack.getBundleData()); + } + } + + public ItemData checkForBundle(GeyserItemStack itemStack, ItemData.Builder itemData) { + if (itemStack.getBundleData() == null) { + return itemData.build(); + } + // Not ideal, since Cloudburst NBT is immutable, but there isn't another ideal intersection between + // item instance tracking and item translation + // (Java just reads the contents of each item, while Bedrock kind of wants its own ID for each bundle item stack) + List contents = itemStack.getBundleData().contents(); + int containerId = itemStack.getBundleId(); + + if (containerId == -1) { + session.getGeyser().getLogger().warning("Bundle ID should not be -1!"); + } + + NbtMap nbt = itemData.build().getTag(); + NbtMapBuilder builder = nbt == null ? NbtMap.builder() : nbt.toBuilder(); + builder.putInt("bundle_id", containerId); + itemData.tag(builder.build()); + + // Now that the tag is updated... + ItemData finalItem = itemData.build(); + + if (!itemStack.getBundleData().triggerFullContentsUpdate) { + // We are probably in the middle of updating one slot. Let's save bandwidth! :) + return finalItem; + } + + // This is how BDS does it, so while it isn't pretty, it is accurate. + // Ensure that all bundle slots are cleared when we re-send data. + // Otherwise, if we don't indicate an item for a slot, Bedrock will think + // the old item still exists. + ItemData[] array = new ItemData[64]; + Arrays.fill(array, ItemData.AIR); + List bedrockItems = Arrays.asList(array); + // Reverse order to ensure contents line up with Java. + int j = 0; + for (int i = contents.size() - 1; i >= 0; i--) { + // Ensure item data can be tracked + bedrockItems.set(j++, contents.get(i).getItemData(session)); + } + InventoryContentPacket packet = new InventoryContentPacket(); + packet.setContainerId(BUNDLE_CONTAINER_ID); + packet.setContents(bedrockItems); + packet.setContainerNameData(BundleCache.createContainer(containerId)); + packet.setStorageItem(finalItem); + session.sendUpstreamPacket(packet); + + return finalItem; + } + + /* + * We need to send an InventorySlotPacket to the Bedrock client so it updates its changes and doesn't desync. + */ + + public void onItemAdded(GeyserItemStack bundle) { + BundleData data = bundle.getBundleData(); + data.freshFromServer = false; + data.triggerFullContentsUpdate = false; + + List contents = data.contents(); + int bedrockSlot = platformConvertSlot(contents.size(), 0); + ItemData bedrockContent = contents.get(0).getItemData(session); + + sendInventoryPacket(data.bundleId(), bedrockSlot, bedrockContent, bundle.getItemData(session)); + + data.triggerFullContentsUpdate = true; + } + + public void onItemRemoved(GeyserItemStack bundle, int slot) { + // Whatever item used to be in here should have been removed *before* this was triggered. + BundleData data = bundle.getBundleData(); + data.freshFromServer = false; + data.triggerFullContentsUpdate = false; + + List contents = data.contents(); + ItemData baseBundle = bundle.getItemData(session); + // This first slot is now blank! + sendInventoryPacket(data.bundleId(), platformConvertSlot(contents.size() + 1, 0), ItemData.AIR, baseBundle); + // Adjust the index of every item that came before this item. + for (int i = 0; i < slot; i++) { + sendInventoryPacket(data.bundleId(), platformConvertSlot(contents.size(), i), + contents.get(i).getItemData(session), baseBundle); + } + + data.triggerFullContentsUpdate = true; + } + + private void sendInventoryPacket(int bundleId, int bedrockSlot, ItemData bedrockContent, ItemData baseBundle) { + InventorySlotPacket packet = new InventorySlotPacket(); + packet.setContainerId(BUNDLE_CONTAINER_ID); + packet.setItem(bedrockContent); + packet.setSlot(bedrockSlot); + packet.setContainerNameData(createContainer(bundleId)); + packet.setStorageItem(baseBundle); + session.sendUpstreamPacket(packet); + } + + /** + * If a bundle is no longer present in the working inventory, delete the cache + * from the client. + */ + public void onOldItemDelete(GeyserItemStack itemStack) { + if (itemStack.getBundleId() != -1) { + // Clean up old container ID, to match BDS behavior. + ContainerRegistryCleanupPacket packet = new ContainerRegistryCleanupPacket(); + packet.getContainers().add(createContainer(itemStack.getBundleId())); + session.sendUpstreamPacket(packet); + } + } + + public void onInventoryClose(Inventory inventory) { + if (inventory instanceof PlayerInventory) { + // Don't bother; items are still here. + return; + } + + for (int i = 0; i < inventory.getSize(); i++) { + GeyserItemStack item = inventory.getItem(i); + onOldItemDelete(item); + } + } + + /** + * Bidirectional; works for both Bedrock and Java. + */ + public static int platformConvertSlot(int contentsSize, int rawSlot) { + return contentsSize - rawSlot - 1; + } + + public static FullContainerName createContainer(int id) { + return new FullContainerName(ContainerSlotType.DYNAMIC_CONTAINER, id); + } + + /** + * Primarily exists to support net IDs within bundles. + * Important to prevent accidental item deletion in creative mode. + */ + public static final class BundleData { + private final List contents; + /** + * Will be set to a positive integer after checking for existing bundle data. + */ + private int bundleId = -1; + /** + * If false, blocks a complete InventoryContentPacket being sent to the server. + */ + private boolean triggerFullContentsUpdate = true; + /** + * Sets whether data is accurate from the server; if so, any old bundle contents + * will be overwritten. + * This will be set to false if we are the most recent change-makers. + */ + private boolean freshFromServer = true; + + BundleData(GeyserSession session, List contents) { + this(); + for (ItemStack content : contents) { + GeyserItemStack itemStack = GeyserItemStack.from(content); + // Check recursively + session.getBundleCache().initialize(itemStack); + this.contents.add(itemStack); + } + } + + BundleData() { + this.contents = new ArrayList<>(); + } + + public int bundleId() { + return bundleId; + } + + public List contents() { + return contents; + } + + public boolean freshFromServer() { + return freshFromServer; + } + + public List toComponent() { + List component = new ArrayList<>(this.contents.size()); + for (GeyserItemStack content : this.contents) { + component.add(content.getItemStack()); + } + return component; + } + + /** + * Merge in changes from the server and re-use net IDs where possible. + */ + public void updateNetIds(GeyserSession session, BundleData oldData) { + List oldContents = oldData.contents(); + // Items can't exactly be rearranged in a bundle; they can only be removed at an index, or inserted. + int oldIndex = 0; + for (int newIndex = 0; newIndex < this.contents.size(); newIndex++) { + GeyserItemStack itemStack = this.contents.get(newIndex); + if (oldIndex >= oldContents.size()) { + // Assume new item if it goes out of bounds of our existing stack + if (this.freshFromServer) { + // Only update net IDs for new items if the data is fresh from server. + // Otherwise, we can update net IDs for something that already has + // net IDs allocated, which can cause desyncs. + Inventory.updateItemNetId(GeyserItemStack.EMPTY, itemStack, session); + session.getBundleCache().markNewBundle(itemStack.getBundleData()); + } + continue; + } + + GeyserItemStack oldItem = oldContents.get(oldIndex); + // If it stacks with the old item at this index, then + if (!InventoryUtils.canStack(oldItem, itemStack)) { + // New item? + boolean found = false; + if (oldIndex + 1 < oldContents.size()) { + oldItem = oldContents.get(oldIndex + 1); + if (InventoryUtils.canStack(oldItem, itemStack)) { + // Permanently increment and assume all contents shifted here + oldIndex++; + found = true; + } + } + if (!found && oldIndex - 1 >= 0) { + oldItem = oldContents.get(oldIndex - 1); + if (InventoryUtils.canStack(oldItem, itemStack)) { + // Permanently decrement and assume all contents shifted here + oldIndex--; + found = true; + } + } + if (!found) { + oldItem = GeyserItemStack.EMPTY; + } + } + + if (oldItem != GeyserItemStack.EMPTY || this.freshFromServer) { + Inventory.updateItemNetId(oldItem, itemStack, session); + } + oldIndex++; + } + this.bundleId = oldData.bundleId(); + } + + public BundleData copy() { + BundleData data = new BundleData(); + data.bundleId = this.bundleId; + for (GeyserItemStack content : this.contents) { + data.contents.add(content.copy()); + } + data.freshFromServer = this.freshFromServer; + return data; + } + } +} diff --git a/core/src/main/java/org/geysermc/geyser/session/cache/tags/ItemTag.java b/core/src/main/java/org/geysermc/geyser/session/cache/tags/ItemTag.java index 4f42f146a6d..5100625600a 100644 --- a/core/src/main/java/org/geysermc/geyser/session/cache/tags/ItemTag.java +++ b/core/src/main/java/org/geysermc/geyser/session/cache/tags/ItemTag.java @@ -181,6 +181,7 @@ public final class ItemTag { public static final Tag ENCHANTABLE_CROSSBOW = create("enchantable/crossbow"); public static final Tag ENCHANTABLE_VANISHING = create("enchantable/vanishing"); public static final Tag ENCHANTABLE_MACE = create("enchantable/mace"); + public static final Tag BUNDLES = create("bundles"); private ItemTag() {} diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/BundleInventoryTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/BundleInventoryTranslator.java new file mode 100644 index 00000000000..2d8e5770b33 --- /dev/null +++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/BundleInventoryTranslator.java @@ -0,0 +1,333 @@ +/* + * 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.translator.inventory; + +import it.unimi.dsi.fastutil.ints.IntOpenHashSet; +import it.unimi.dsi.fastutil.ints.IntSet; +import it.unimi.dsi.fastutil.ints.IntSets; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.cloudburstmc.protocol.bedrock.data.inventory.ContainerSlotType; +import org.cloudburstmc.protocol.bedrock.data.inventory.itemstack.request.ItemStackRequest; +import org.cloudburstmc.protocol.bedrock.data.inventory.itemstack.request.ItemStackRequestSlotData; +import org.cloudburstmc.protocol.bedrock.data.inventory.itemstack.request.action.ItemStackRequestAction; +import org.cloudburstmc.protocol.bedrock.data.inventory.itemstack.request.action.TransferItemStackRequestAction; +import org.cloudburstmc.protocol.bedrock.data.inventory.itemstack.response.ItemStackResponse; +import org.geysermc.geyser.inventory.GeyserItemStack; +import org.geysermc.geyser.inventory.Inventory; +import org.geysermc.geyser.inventory.click.Click; +import org.geysermc.geyser.inventory.click.ClickPlan; +import org.geysermc.geyser.session.GeyserSession; +import org.geysermc.geyser.session.cache.BundleCache; +import org.geysermc.geyser.session.cache.tags.ItemTag; +import org.geysermc.geyser.util.InventoryUtils; +import org.geysermc.geyser.util.thirdparty.Fraction; +import org.geysermc.mcprotocollib.protocol.data.game.item.component.DataComponentType; +import org.geysermc.mcprotocollib.protocol.data.game.item.component.DataComponents; + +import java.util.List; + +import static org.geysermc.geyser.translator.inventory.InventoryTranslator.*; + +public final class BundleInventoryTranslator { + /** + * @return a processed bundle interaction, or null to resume normal transaction handling. + */ + @Nullable + static ItemStackResponse handleBundle(GeyserSession session, InventoryTranslator translator, Inventory inventory, ItemStackRequest request, boolean sendCreativePackets) { + TransferItemStackRequestAction action = null; + for (ItemStackRequestAction requestAction : request.getActions()) { + if (!(requestAction instanceof TransferItemStackRequestAction transferAction)) { + // No known bundle action that does not use transfer actions + return null; + } + boolean sourceIsBundle = isBundle(transferAction.getSource()); + boolean destIsBundle = isBundle(transferAction.getDestination()); + if (sourceIsBundle && destIsBundle) { + // The client is rearranging the bundle inventory; we're going to ignore translating these actions. + continue; + } + if (sourceIsBundle || destIsBundle) { + // This action is moving to a bundle or moving out of a bundle. This is the one we want to track + action = transferAction; + } else { + // Neither location is a bundle location. We don't need to deal with this here. + return null; + } + } + if (action == null) { + return null; + } + + ClickPlan plan = new ClickPlan(session, translator, inventory); + if (isBundle(action.getDestination())) { + // Placing into bundle + var bundleSlotData = action.getDestination(); + var inventorySlotData = action.getSource(); + int bundleId = bundleSlotData.getContainerName().getDynamicId(); + GeyserItemStack cursor = session.getPlayerInventory().getCursor(); + + if (cursor.getBundleId() == bundleId) { + List contents = cursor.getBundleData().contents(); + // Placing items into bundles can mean their contents are empty + + // We are currently holding the bundle and trying to pick an item up. + int sourceSlot = translator.bedrockSlotToJava(inventorySlotData); + GeyserItemStack sourceItem = inventory.getItem(sourceSlot); + if (sourceItem.isEmpty()) { + // This would be treated as just... plumping the bundle down, + // and that should not be called here. + return rejectRequest(request); + } + if (inventorySlotData.getStackNetworkId() != sourceItem.getNetId()) { + return rejectRequest(request); + } + + // Note that this is also called in ClickPlan. Not ideal... + Fraction bundleWeight = calculateBundleWeight(contents); + int allowedCapacity = Math.min(capacityForItemStack(bundleWeight, sourceItem), sourceItem.getAmount()); + + if (action.getCount() != allowedCapacity) { + // Might trigger if bundle weight is different between Java and Bedrock (see calculateBundleWeight) + return rejectRequest(request); + } + + plan.add(Click.LEFT_BUNDLE_FROM_CURSOR, sourceSlot); + if (sendCreativePackets) { + plan.executeForCreativeMode(); + } else { + plan.execute(false); + } + return acceptRequest(request, translator.makeContainerEntries(session, inventory, IntSets.singleton(sourceSlot))); + } + + for (int javaSlot = 0; javaSlot < inventory.getSize(); javaSlot++) { + GeyserItemStack bundle = inventory.getItem(javaSlot); + if (bundle.getBundleId() != bundleId) { + continue; + } + + if (!translator.checkNetId(session, inventory, inventorySlotData)) { + return rejectRequest(request); + } + + // Placing items into bundles can mean their contents are empty + // Bundle slot does not matter; Java always appends an item to the beginning of a bundle inventory + + IntSet affectedSlots = new IntOpenHashSet(2); + affectedSlots.add(javaSlot); + + boolean slotIsInventory = !isCursor(inventorySlotData); + int sourceSlot; + // If source is cursor, logic lines up better with Java. + if (slotIsInventory) { + // Simulate picking up the item and adding it to our cursor, + // which is what Java would expect + sourceSlot = translator.bedrockSlotToJava(inventorySlotData); + plan.add(Click.LEFT, sourceSlot); + affectedSlots.add(sourceSlot); + } else { + sourceSlot = -1; + } + + Fraction bundleWeight = calculateBundleWeight(bundle.getBundleData().contents()); + // plan.getCursor() covers if we just picked up the item above from a slot + int allowedCapacity = Math.min(capacityForItemStack(bundleWeight, plan.getCursor()), plan.getCursor().getAmount()); + if (action.getCount() != allowedCapacity) { + // Might trigger if bundle weight is different between Java and Bedrock (see calculateBundleWeight) + return rejectRequest(request); + } + + plan.add(Click.LEFT_BUNDLE, javaSlot); + + if (slotIsInventory && allowedCapacity != plan.getCursor().getAmount()) { + // We will need to place the item back in its original slot. + plan.add(Click.LEFT, sourceSlot); + } + + if (sendCreativePackets) { + plan.executeForCreativeMode(); + } else { + plan.execute(false); + } + return acceptRequest(request, translator.makeContainerEntries(session, inventory, affectedSlots)); + } + + // Could not find bundle in inventory + + } else { + // Taking from bundle + var bundleSlotData = action.getSource(); + var inventorySlotData = action.getDestination(); + int bundleId = bundleSlotData.getContainerName().getDynamicId(); + GeyserItemStack cursor = session.getPlayerInventory().getCursor(); + if (cursor.getBundleId() == bundleId) { + // We are currently holding the bundle + List contents = cursor.getBundleData().contents(); + if (contents.isEmpty()) { + // Nothing would be ejected? + return rejectRequest(request); + } + + // Can't select bundle slots while holding bundle in any version; don't set desired bundle slot + + if (bundleSlotData.getStackNetworkId() != contents.get(0).getNetId()) { + // We're pulling out the first item; if something mismatches, wuh oh. + System.out.println(bundleSlotData.getStackNetworkId() + " " + contents.get(0).getNetId()); + return rejectRequest(request); + } + + int destSlot = translator.bedrockSlotToJava(inventorySlotData); + if (!inventory.getItem(destSlot).isEmpty()) { + // Illegal action to place an item down on an existing stack, even if + // the bundle contains the item. + return rejectRequest(request); + } + plan.add(Click.RIGHT_BUNDLE, destSlot); + if (sendCreativePackets) { + plan.executeForCreativeMode(); + } else { + plan.execute(false); + } + return acceptRequest(request, translator.makeContainerEntries(session, inventory, IntSets.singleton(destSlot))); + } + + // We need context of what slot the bundle is in. + for (int javaSlot = 0; javaSlot < inventory.getSize(); javaSlot++) { + GeyserItemStack bundle = inventory.getItem(javaSlot); + if (bundle.getBundleId() != bundleId) { + continue; + } + + List contents = bundle.getBundleData().contents(); + int rawSelectedSlot = bundleSlotData.getSlot(); + if (rawSelectedSlot >= contents.size()) { + // Illegal? + return rejectRequest(request); + } + + // Bedrock's indexes are flipped around - first item shown to it is the last index. + int slot = BundleCache.platformConvertSlot(contents.size(), rawSelectedSlot); + plan.setDesiredBundleSlot(slot); + + // We'll need it even if the final destination isn't the cursor. + // I can't think of a situation where we shouldn't reject it and use a temp slot, + // but we will see. + if (!cursor.isEmpty()) { + return rejectRequest(request); + } + + IntSet affectedSlots = new IntOpenHashSet(2); + affectedSlots.add(javaSlot); + GeyserItemStack bundledItem = contents.get(slot); + if (bundledItem.getNetId() != bundleSlotData.getStackNetworkId()) { + // !!! + return rejectRequest(request); + } + + plan.add(Click.RIGHT_BUNDLE, javaSlot); + // If false, simple logic that matches nicely with Java Edition + if (!isCursor(inventorySlotData)) { + // Alas, two-click time. + int destSlot = translator.bedrockSlotToJava(inventorySlotData); + GeyserItemStack existing = inventory.getItem(destSlot); + + // Empty slot is good, but otherwise let's just check that + // the two can stack... + if (!existing.isEmpty()) { + if (!InventoryUtils.canStack(bundledItem, existing)) { + return rejectRequest(request); + } + } + + // Copy the full stack to the new slot. + plan.add(Click.LEFT, destSlot); + affectedSlots.add(destSlot); + } + + if (sendCreativePackets) { + plan.executeForCreativeMode(); + } else { + plan.execute(false); + } + return acceptRequest(request, translator.makeContainerEntries(session, inventory, affectedSlots)); + } + + // Could not find bundle in inventory + } + return rejectRequest(request); + } + + private static final Fraction BUNDLE_IN_BUNDLE_WEIGHT = Fraction.getFraction(1, 16); + + public static Fraction calculateBundleWeight(List contents) { + Fraction fraction = Fraction.ZERO; + + for (GeyserItemStack content : contents) { + fraction = fraction.add(calculateWeight(content) + .multiplyBy(Fraction.getFraction(content.getAmount(), 1))); + } + + return fraction; + } + + private static Fraction calculateWeight(GeyserItemStack itemStack) { + if (itemStack.getBundleData() != null) { + return BUNDLE_IN_BUNDLE_WEIGHT.add(calculateBundleWeight(itemStack.getBundleData().contents())); + } + DataComponents components = itemStack.getComponents(); + if (components != null) { + // NOTE: this seems to be Java-only, so it can technically cause a bundle weight desync, + // but it'll be so rare we can probably ignore it. + List bees = components.get(DataComponentType.BEES); + if (bees != null && !bees.isEmpty()) { + // Bees be heavy, I guess. + return Fraction.ONE; + } + } + return Fraction.getFraction(1, itemStack.asItem().maxStackSize()); + } + + public static int capacityForItemStack(Fraction bundleWeight, GeyserItemStack itemStack) { + Fraction inverse = Fraction.ONE.subtract(bundleWeight); + return Math.max(inverse.divideBy(calculateWeight(itemStack)).intValue(), 0); + } + + static boolean isBundle(ItemStackRequestSlotData slotData) { + return slotData.getContainerName().getContainer() == ContainerSlotType.DYNAMIC_CONTAINER; + } + + static boolean isBundle(GeyserSession session, ClickPlan plan, int slot) { + return isBundle(session, plan.getItem(slot)); + } + + static boolean isBundle(GeyserSession session, GeyserItemStack stack) { + // Client as of 1.21.3 does use this, and + return session.getTagCache().is(ItemTag.BUNDLES, stack); + } + + private BundleInventoryTranslator() { + } +} diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/InventoryTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/InventoryTranslator.java index 15c19c542ad..6cbc81957fe 100644 --- a/core/src/main/java/org/geysermc/geyser/translator/inventory/InventoryTranslator.java +++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/InventoryTranslator.java @@ -87,6 +87,8 @@ import java.util.List; import java.util.Map; +import static org.geysermc.geyser.translator.inventory.BundleInventoryTranslator.isBundle; + @AllArgsConstructor public abstract class InventoryTranslator { @@ -242,6 +244,13 @@ public ItemStackResponse translateRequest(GeyserSession session, Inventory inven return rejectRequest(request); } + // Might be a bundle action... let's check. + ItemStackResponse bundleResponse = BundleInventoryTranslator.handleBundle(session, this, inventory, request, false); + if (bundleResponse != null) { + // We can simplify a lot of logic because we aren't expecting multi-slot interactions. + return bundleResponse; + } + int sourceSlot = bedrockSlotToJava(transferAction.getSource()); int destSlot = bedrockSlotToJava(transferAction.getDestination()); boolean isSourceCursor = isCursor(transferAction.getSource()); @@ -394,6 +403,7 @@ public ItemStackResponse translateRequest(GeyserSession session, Inventory inven break; } case SWAP: { + // TODO breaks with bundles SwapAction swapAction = (SwapAction) action; ItemStackRequestSlotData source = swapAction.getSource(); ItemStackRequestSlotData destination = swapAction.getDestination(); @@ -427,18 +437,24 @@ public ItemStackResponse translateRequest(GeyserSession session, Inventory inven } } + // A note on all the bundle checks for clicks... + // Left clicking in these contexts can count as using the bundle + // and adding the stack to the contents of the bundle. + // In these cases, we can safely use right-clicking while holding the bundle + // as its stack size is 1. + if (isSourceCursor && isDestCursor) { //??? return rejectRequest(request); } else if (isSourceCursor) { //swap cursor if (InventoryUtils.canStack(cursor, plan.getItem(destSlot))) { //TODO: cannot simply swap if cursor stacks with slot (temp slot) return rejectRequest(request); } - plan.add(Click.LEFT, destSlot); + plan.add(isBundle(session, plan, destSlot) || isBundle(session, cursor) ? Click.RIGHT : Click.LEFT, destSlot); } else if (isDestCursor) { //swap cursor if (InventoryUtils.canStack(cursor, plan.getItem(sourceSlot))) { //TODO return rejectRequest(request); } - plan.add(Click.LEFT, sourceSlot); + plan.add(isBundle(session, plan, sourceSlot) || isBundle(session, cursor) ? Click.RIGHT : Click.LEFT, sourceSlot); } else { if (!cursor.isEmpty()) { //TODO: (temp slot) return rejectRequest(request); @@ -450,7 +466,7 @@ public ItemStackResponse translateRequest(GeyserSession session, Inventory inven return rejectRequest(request); } plan.add(Click.LEFT, sourceSlot); //pickup source into cursor - plan.add(Click.LEFT, destSlot); //swap cursor with dest slot + plan.add(isBundle(session, plan, sourceSlot) || isBundle(session, plan, destSlot) ? Click.RIGHT : Click.LEFT, destSlot); //swap cursor with dest slot plan.add(Click.LEFT, sourceSlot); //release cursor onto source } break; @@ -916,6 +932,11 @@ protected void dumpStackRequestDetails(GeyserSession session, Inventory inventor } public boolean checkNetId(GeyserSession session, Inventory inventory, ItemStackRequestSlotData slotInfoData) { + if (BundleInventoryTranslator.isBundle(slotInfoData)) { + // Will thoroughly be investigated, if needed, in bundle checks. + return true; + } + int netId = slotInfoData.getStackNetworkId(); // "In my testing, sometimes the client thinks the netId of an item in the crafting grid is 1, even though we never said it was. // I think it only happens when we manually set the grid but that was my quick fix" diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/PlayerInventoryTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/PlayerInventoryTranslator.java index 8fd365d7f7f..7eefffa3e3e 100644 --- a/core/src/main/java/org/geysermc/geyser/translator/inventory/PlayerInventoryTranslator.java +++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/PlayerInventoryTranslator.java @@ -264,6 +264,15 @@ public ItemStackResponse translateRequest(GeyserSession session, Inventory inven return rejectRequest(request, false); } + // Might be a bundle action... let's check. + // If we're in creative mode, instead of replacing logic (more hassle for updates), + // let's just reuse as much logic as possible!! + ItemStackResponse bundleResponse = BundleInventoryTranslator.handleBundle(session, this, inventory, request, true); + if (bundleResponse != null) { + // We can simplify a lot of logic because we aren't expecting multi-slot interactions. + return bundleResponse; + } + int transferAmount = transferAction.getCount(); if (isCursor(transferAction.getDestination())) { int sourceSlot = bedrockSlotToJava(transferAction.getSource()); diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/inventory/JavaContainerSetContentTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/inventory/JavaContainerSetContentTranslator.java index 36d382d69d3..54c92857c82 100644 --- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/inventory/JavaContainerSetContentTranslator.java +++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/inventory/JavaContainerSetContentTranslator.java @@ -63,6 +63,7 @@ public void translate(GeyserSession session, ClientboundContainerSetContentPacke } GeyserItemStack newItem = GeyserItemStack.from(packet.getItems()[i]); + session.getBundleCache().initialize(newItem); inventory.setItem(i, newItem, session); } @@ -72,7 +73,9 @@ public void translate(GeyserSession session, ClientboundContainerSetContentPacke session.setEmulatePost1_16Logic(stateId > 0 || stateId != inventory.getStateId()); inventory.setStateId(stateId); - session.getPlayerInventory().setCursor(GeyserItemStack.from(packet.getCarriedItem()), session); + GeyserItemStack cursor = GeyserItemStack.from(packet.getCarriedItem()); + session.getBundleCache().initialize(cursor); + session.getPlayerInventory().setCursor(cursor, session); InventoryUtils.updateCursor(session); } diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/inventory/JavaContainerSetSlotTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/inventory/JavaContainerSetSlotTranslator.java index fa2e3a54461..b3bd91c5b38 100644 --- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/inventory/JavaContainerSetSlotTranslator.java +++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/inventory/JavaContainerSetSlotTranslator.java @@ -93,6 +93,7 @@ public void translate(GeyserSession session, ClientboundContainerSetSlotPacket p } GeyserItemStack newItem = GeyserItemStack.from(packet.getItem()); + session.getBundleCache().initialize(newItem); if (packet.getContainerId() == 0 && !(translator instanceof PlayerInventoryTranslator)) { // In rare cases, the window ID can still be 0 but Java treats it as valid // This behavior still exists as of Java Edition 1.21.2, despite the new packet diff --git a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/inventory/JavaSetCursorItemTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/inventory/JavaSetCursorItemTranslator.java index df11bf1af81..649da8ea4f9 100644 --- a/core/src/main/java/org/geysermc/geyser/translator/protocol/java/inventory/JavaSetCursorItemTranslator.java +++ b/core/src/main/java/org/geysermc/geyser/translator/protocol/java/inventory/JavaSetCursorItemTranslator.java @@ -38,6 +38,7 @@ public class JavaSetCursorItemTranslator extends PacketTranslatorThis class is immutable, and interoperable with most methods that accept + * a {@link Number}.

+ * + *

Note that this class is intended for common use cases, it is int + * based and thus suffers from various overflow issues. For a BigInteger based + * equivalent, please see the Commons Math BigFraction class.

+ * + * @since 2.0 + */ +// Geyser: Java Edition uses this for 1.21.3 bundle calculation. Rather than +// Reimplementing an open-source class or bringing in a whole library, +// the single class is used to ensure accuracy. +public final class Fraction extends Number implements Comparable { + + /** + * Required for serialization support. Lang version 2.0. + * + * @see java.io.Serializable + */ + private static final long serialVersionUID = 65382027393090L; + + /** + * {@link Fraction} representation of 0. + */ + public static final Fraction ZERO = new Fraction(0, 1); + /** + * {@link Fraction} representation of 1. + */ + public static final Fraction ONE = new Fraction(1, 1); + /** + * {@link Fraction} representation of 1/2. + */ + public static final Fraction ONE_HALF = new Fraction(1, 2); + /** + * {@link Fraction} representation of 1/3. + */ + public static final Fraction ONE_THIRD = new Fraction(1, 3); + /** + * {@link Fraction} representation of 2/3. + */ + public static final Fraction TWO_THIRDS = new Fraction(2, 3); + /** + * {@link Fraction} representation of 1/4. + */ + public static final Fraction ONE_QUARTER = new Fraction(1, 4); + /** + * {@link Fraction} representation of 2/4. + */ + public static final Fraction TWO_QUARTERS = new Fraction(2, 4); + /** + * {@link Fraction} representation of 3/4. + */ + public static final Fraction THREE_QUARTERS = new Fraction(3, 4); + /** + * {@link Fraction} representation of 1/5. + */ + public static final Fraction ONE_FIFTH = new Fraction(1, 5); + /** + * {@link Fraction} representation of 2/5. + */ + public static final Fraction TWO_FIFTHS = new Fraction(2, 5); + /** + * {@link Fraction} representation of 3/5. + */ + public static final Fraction THREE_FIFTHS = new Fraction(3, 5); + /** + * {@link Fraction} representation of 4/5. + */ + public static final Fraction FOUR_FIFTHS = new Fraction(4, 5); + + + /** + * Add two integers, checking for overflow. + * + * @param x an addend + * @param y an addend + * @return the sum {@code x+y} + * @throws ArithmeticException if the result can not be represented as + * an int + */ + private static int addAndCheck(final int x, final int y) { + final long s = (long) x + (long) y; + if (s < Integer.MIN_VALUE || s > Integer.MAX_VALUE) { + throw new ArithmeticException("overflow: add"); + } + return (int) s; + } + /** + * Creates a {@link Fraction} instance from a {@code double} value. + * + *

This method uses the + * continued fraction algorithm, computing a maximum of + * 25 convergents and bounding the denominator by 10,000.

+ * + * @param value the double value to convert + * @return a new fraction instance that is close to the value + * @throws ArithmeticException if {@code |value| > Integer.MAX_VALUE} + * or {@code value = NaN} + * @throws ArithmeticException if the calculated denominator is {@code zero} + * @throws ArithmeticException if the algorithm does not converge + */ + public static Fraction getFraction(double value) { + final int sign = value < 0 ? -1 : 1; + value = Math.abs(value); + if (value > Integer.MAX_VALUE || Double.isNaN(value)) { + throw new ArithmeticException("The value must not be greater than Integer.MAX_VALUE or NaN"); + } + final int wholeNumber = (int) value; + value -= wholeNumber; + + int numer0 = 0; // the pre-previous + int denom0 = 1; // the pre-previous + int numer1 = 1; // the previous + int denom1 = 0; // the previous + int numer2; // the current, setup in calculation + int denom2; // the current, setup in calculation + int a1 = (int) value; + int a2; + double x1 = 1; + double x2; + double y1 = value - a1; + double y2; + double delta1, delta2 = Double.MAX_VALUE; + double fraction; + int i = 1; + do { + delta1 = delta2; + a2 = (int) (x1 / y1); + x2 = y1; + y2 = x1 - a2 * y1; + numer2 = a1 * numer1 + numer0; + denom2 = a1 * denom1 + denom0; + fraction = (double) numer2 / (double) denom2; + delta2 = Math.abs(value - fraction); + a1 = a2; + x1 = x2; + y1 = y2; + numer0 = numer1; + denom0 = denom1; + numer1 = numer2; + denom1 = denom2; + i++; + } while (delta1 > delta2 && denom2 <= 10000 && denom2 > 0 && i < 25); + if (i == 25) { + throw new ArithmeticException("Unable to convert double to fraction"); + } + return getReducedFraction((numer0 + wholeNumber * denom0) * sign, denom0); + } + + /** + * Creates a {@link Fraction} instance with the 2 parts + * of a fraction Y/Z. + * + *

Any negative signs are resolved to be on the numerator.

+ * + * @param numerator the numerator, for example the three in 'three sevenths' + * @param denominator the denominator, for example the seven in 'three sevenths' + * @return a new fraction instance + * @throws ArithmeticException if the denominator is {@code zero} + * or the denominator is {@code negative} and the numerator is {@code Integer#MIN_VALUE} + */ + public static Fraction getFraction(int numerator, int denominator) { + if (denominator == 0) { + throw new ArithmeticException("The denominator must not be zero"); + } + if (denominator < 0) { + if (numerator == Integer.MIN_VALUE || denominator == Integer.MIN_VALUE) { + throw new ArithmeticException("overflow: can't negate"); + } + numerator = -numerator; + denominator = -denominator; + } + return new Fraction(numerator, denominator); + } + /** + * Creates a {@link Fraction} instance with the 3 parts + * of a fraction X Y/Z. + * + *

The negative sign must be passed in on the whole number part.

+ * + * @param whole the whole number, for example the one in 'one and three sevenths' + * @param numerator the numerator, for example the three in 'one and three sevenths' + * @param denominator the denominator, for example the seven in 'one and three sevenths' + * @return a new fraction instance + * @throws ArithmeticException if the denominator is {@code zero} + * @throws ArithmeticException if the denominator is negative + * @throws ArithmeticException if the numerator is negative + * @throws ArithmeticException if the resulting numerator exceeds + * {@code Integer.MAX_VALUE} + */ + public static Fraction getFraction(final int whole, final int numerator, final int denominator) { + if (denominator == 0) { + throw new ArithmeticException("The denominator must not be zero"); + } + if (denominator < 0) { + throw new ArithmeticException("The denominator must not be negative"); + } + if (numerator < 0) { + throw new ArithmeticException("The numerator must not be negative"); + } + final long numeratorValue; + if (whole < 0) { + numeratorValue = whole * (long) denominator - numerator; + } else { + numeratorValue = whole * (long) denominator + numerator; + } + if (numeratorValue < Integer.MIN_VALUE || numeratorValue > Integer.MAX_VALUE) { + throw new ArithmeticException("Numerator too large to represent as an Integer."); + } + return new Fraction((int) numeratorValue, denominator); + } + /** + * Creates a Fraction from a {@link String}. + * + *

The formats accepted are:

+ * + *
    + *
  1. {@code double} String containing a dot
  2. + *
  3. 'X Y/Z'
  4. + *
  5. 'Y/Z'
  6. + *
  7. 'X' (a simple whole number)
  8. + *
+ *

and a .

+ * + * @param str the string to parse, must not be {@code null} + * @return the new {@link Fraction} instance + * @throws NullPointerException if the string is {@code null} + * @throws NumberFormatException if the number format is invalid + */ + public static Fraction getFraction(String str) { + Objects.requireNonNull(str, "str"); + // parse double format + int pos = str.indexOf('.'); + if (pos >= 0) { + return getFraction(Double.parseDouble(str)); + } + + // parse X Y/Z format + pos = str.indexOf(' '); + if (pos > 0) { + final int whole = Integer.parseInt(str.substring(0, pos)); + str = str.substring(pos + 1); + pos = str.indexOf('/'); + if (pos < 0) { + throw new NumberFormatException("The fraction could not be parsed as the format X Y/Z"); + } + final int numer = Integer.parseInt(str.substring(0, pos)); + final int denom = Integer.parseInt(str.substring(pos + 1)); + return getFraction(whole, numer, denom); + } + + // parse Y/Z format + pos = str.indexOf('/'); + if (pos < 0) { + // simple whole number + return getFraction(Integer.parseInt(str), 1); + } + final int numer = Integer.parseInt(str.substring(0, pos)); + final int denom = Integer.parseInt(str.substring(pos + 1)); + return getFraction(numer, denom); + } + + /** + * Creates a reduced {@link Fraction} instance with the 2 parts + * of a fraction Y/Z. + * + *

For example, if the input parameters represent 2/4, then the created + * fraction will be 1/2.

+ * + *

Any negative signs are resolved to be on the numerator.

+ * + * @param numerator the numerator, for example the three in 'three sevenths' + * @param denominator the denominator, for example the seven in 'three sevenths' + * @return a new fraction instance, with the numerator and denominator reduced + * @throws ArithmeticException if the denominator is {@code zero} + */ + public static Fraction getReducedFraction(int numerator, int denominator) { + if (denominator == 0) { + throw new ArithmeticException("The denominator must not be zero"); + } + if (numerator == 0) { + return ZERO; // normalize zero. + } + // allow 2^k/-2^31 as a valid fraction (where k>0) + if (denominator == Integer.MIN_VALUE && (numerator & 1) == 0) { + numerator /= 2; + denominator /= 2; + } + if (denominator < 0) { + if (numerator == Integer.MIN_VALUE || denominator == Integer.MIN_VALUE) { + throw new ArithmeticException("overflow: can't negate"); + } + numerator = -numerator; + denominator = -denominator; + } + // simplify fraction. + final int gcd = greatestCommonDivisor(numerator, denominator); + numerator /= gcd; + denominator /= gcd; + return new Fraction(numerator, denominator); + } + + /** + * Gets the greatest common divisor of the absolute value of + * two numbers, using the "binary gcd" method which avoids + * division and modulo operations. See Knuth 4.5.2 algorithm B. + * This algorithm is due to Josef Stein (1961). + * + * @param u a non-zero number + * @param v a non-zero number + * @return the greatest common divisor, never zero + */ + private static int greatestCommonDivisor(int u, int v) { + // From Commons Math: + if (u == 0 || v == 0) { + if (u == Integer.MIN_VALUE || v == Integer.MIN_VALUE) { + throw new ArithmeticException("overflow: gcd is 2^31"); + } + return Math.abs(u) + Math.abs(v); + } + // if either operand is abs 1, return 1: + if (Math.abs(u) == 1 || Math.abs(v) == 1) { + return 1; + } + // keep u and v negative, as negative integers range down to + // -2^31, while positive numbers can only be as large as 2^31-1 + // (i.e. we can't necessarily negate a negative number without + // overflow) + if (u > 0) { + u = -u; + } // make u negative + if (v > 0) { + v = -v; + } // make v negative + // B1. [Find power of 2] + int k = 0; + while ((u & 1) == 0 && (v & 1) == 0 && k < 31) { // while u and v are both even... + u /= 2; + v /= 2; + k++; // cast out twos. + } + if (k == 31) { + throw new ArithmeticException("overflow: gcd is 2^31"); + } + // B2. Initialize: u and v have been divided by 2^k and at least + // one is odd. + int t = (u & 1) == 1 ? v : -(u / 2)/* B3 */; + // t negative: u was odd, v may be even (t replaces v) + // t positive: u was even, v is odd (t replaces u) + do { + /* assert u<0 && v<0; */ + // B4/B3: cast out twos from t. + while ((t & 1) == 0) { // while t is even. + t /= 2; // cast out twos + } + // B5 [reset max(u,v)] + if (t > 0) { + u = -t; + } else { + v = t; + } + // B6/B3. at this point both u and v should be odd. + t = (v - u) / 2; + // |u| larger: t positive (replace u) + // |v| larger: t negative (replace v) + } while (t != 0); + return -u * (1 << k); // gcd is u*2^k + } + + /** + * Multiply two integers, checking for overflow. + * + * @param x a factor + * @param y a factor + * @return the product {@code x*y} + * @throws ArithmeticException if the result can not be represented as + * an int + */ + private static int mulAndCheck(final int x, final int y) { + final long m = (long) x * (long) y; + if (m < Integer.MIN_VALUE || m > Integer.MAX_VALUE) { + throw new ArithmeticException("overflow: mul"); + } + return (int) m; + } + + /** + * Multiply two non-negative integers, checking for overflow. + * + * @param x a non-negative factor + * @param y a non-negative factor + * @return the product {@code x*y} + * @throws ArithmeticException if the result can not be represented as + * an int + */ + private static int mulPosAndCheck(final int x, final int y) { + /* assert x>=0 && y>=0; */ + final long m = (long) x * (long) y; + if (m > Integer.MAX_VALUE) { + throw new ArithmeticException("overflow: mulPos"); + } + return (int) m; + } + + /** + * Subtract two integers, checking for overflow. + * + * @param x the minuend + * @param y the subtrahend + * @return the difference {@code x-y} + * @throws ArithmeticException if the result can not be represented as + * an int + */ + private static int subAndCheck(final int x, final int y) { + final long s = (long) x - (long) y; + if (s < Integer.MIN_VALUE || s > Integer.MAX_VALUE) { + throw new ArithmeticException("overflow: add"); + } + return (int) s; + } + + /** + * The numerator number part of the fraction (the three in three sevenths). + */ + private final int numerator; + + /** + * The denominator number part of the fraction (the seven in three sevenths). + */ + private final int denominator; + + /** + * Cached output hashCode (class is immutable). + */ + private transient int hashCode; + + /** + * Cached output toString (class is immutable). + */ + private transient String toString; + + /** + * Cached output toProperString (class is immutable). + */ + private transient String toProperString; + + /** + * Constructs a {@link Fraction} instance with the 2 parts + * of a fraction Y/Z. + * + * @param numerator the numerator, for example the three in 'three sevenths' + * @param denominator the denominator, for example the seven in 'three sevenths' + */ + private Fraction(final int numerator, final int denominator) { + this.numerator = numerator; + this.denominator = denominator; + } + + /** + * Gets a fraction that is the positive equivalent of this one. + *

More precisely: {@code (fraction >= 0 ? this : -fraction)}

+ * + *

The returned fraction is not reduced.

+ * + * @return {@code this} if it is positive, or a new positive fraction + * instance with the opposite signed numerator + */ + public Fraction abs() { + if (numerator >= 0) { + return this; + } + return negate(); + } + + /** + * Adds the value of this fraction to another, returning the result in reduced form. + * The algorithm follows Knuth, 4.5.1. + * + * @param fraction the fraction to add, must not be {@code null} + * @return a {@link Fraction} instance with the resulting values + * @throws NullPointerException if the fraction is {@code null} + * @throws ArithmeticException if the resulting numerator or denominator exceeds + * {@code Integer.MAX_VALUE} + */ + public Fraction add(final Fraction fraction) { + return addSub(fraction, true /* add */); + } + + /** + * Implement add and subtract using algorithm described in Knuth 4.5.1. + * + * @param fraction the fraction to subtract, must not be {@code null} + * @param isAdd true to add, false to subtract + * @return a {@link Fraction} instance with the resulting values + * @throws IllegalArgumentException if the fraction is {@code null} + * @throws ArithmeticException if the resulting numerator or denominator + * cannot be represented in an {@code int}. + */ + private Fraction addSub(final Fraction fraction, final boolean isAdd) { + Objects.requireNonNull(fraction, "fraction"); + // zero is identity for addition. + if (numerator == 0) { + return isAdd ? fraction : fraction.negate(); + } + if (fraction.numerator == 0) { + return this; + } + // if denominators are randomly distributed, d1 will be 1 about 61% + // of the time. + final int d1 = greatestCommonDivisor(denominator, fraction.denominator); + if (d1 == 1) { + // result is ( (u*v' +/- u'v) / u'v') + final int uvp = mulAndCheck(numerator, fraction.denominator); + final int upv = mulAndCheck(fraction.numerator, denominator); + return new Fraction(isAdd ? addAndCheck(uvp, upv) : subAndCheck(uvp, upv), mulPosAndCheck(denominator, + fraction.denominator)); + } + // the quantity 't' requires 65 bits of precision; see knuth 4.5.1 + // exercise 7. we're going to use a BigInteger. + // t = u(v'/d1) +/- v(u'/d1) + final BigInteger uvp = BigInteger.valueOf(numerator).multiply(BigInteger.valueOf(fraction.denominator / d1)); + final BigInteger upv = BigInteger.valueOf(fraction.numerator).multiply(BigInteger.valueOf(denominator / d1)); + final BigInteger t = isAdd ? uvp.add(upv) : uvp.subtract(upv); + // but d2 doesn't need extra precision because + // d2 = gcd(t,d1) = gcd(t mod d1, d1) + final int tmodd1 = t.mod(BigInteger.valueOf(d1)).intValue(); + final int d2 = tmodd1 == 0 ? d1 : greatestCommonDivisor(tmodd1, d1); + + // result is (t/d2) / (u'/d1)(v'/d2) + final BigInteger w = t.divide(BigInteger.valueOf(d2)); + if (w.bitLength() > 31) { + throw new ArithmeticException("overflow: numerator too large after multiply"); + } + return new Fraction(w.intValue(), mulPosAndCheck(denominator / d1, fraction.denominator / d2)); + } + + /** + * Compares this object to another based on size. + * + *

Note: this class has a natural ordering that is inconsistent + * with equals, because, for example, equals treats 1/2 and 2/4 as + * different, whereas compareTo treats them as equal. + * + * @param other the object to compare to + * @return -1 if this is less, 0 if equal, +1 if greater + * @throws ClassCastException if the object is not a {@link Fraction} + * @throws NullPointerException if the object is {@code null} + */ + @Override + public int compareTo(final Fraction other) { + if (this == other) { + return 0; + } + if (numerator == other.numerator && denominator == other.denominator) { + return 0; + } + + // otherwise see which is less + final long first = (long) numerator * (long) other.denominator; + final long second = (long) other.numerator * (long) denominator; + return Long.compare(first, second); + } + + /** + * Divide the value of this fraction by another. + * + * @param fraction the fraction to divide by, must not be {@code null} + * @return a {@link Fraction} instance with the resulting values + * @throws NullPointerException if the fraction is {@code null} + * @throws ArithmeticException if the fraction to divide by is zero + * @throws ArithmeticException if the resulting numerator or denominator exceeds + * {@code Integer.MAX_VALUE} + */ + public Fraction divideBy(final Fraction fraction) { + Objects.requireNonNull(fraction, "fraction"); + if (fraction.numerator == 0) { + throw new ArithmeticException("The fraction to divide by must not be zero"); + } + return multiplyBy(fraction.invert()); + } + + /** + * Gets the fraction as a {@code double}. This calculates the fraction + * as the numerator divided by denominator. + * + * @return the fraction as a {@code double} + */ + @Override + public double doubleValue() { + return (double) numerator / (double) denominator; + } + + /** + * Compares this fraction to another object to test if they are equal.. + * + *

To be equal, both values must be equal. Thus 2/4 is not equal to 1/2.

+ * + * @param obj the reference object with which to compare + * @return {@code true} if this object is equal + */ + @Override + public boolean equals(final Object obj) { + if (obj == this) { + return true; + } + if (!(obj instanceof Fraction)) { + return false; + } + final Fraction other = (Fraction) obj; + return getNumerator() == other.getNumerator() && getDenominator() == other.getDenominator(); + } + + /** + * Gets the fraction as a {@code float}. This calculates the fraction + * as the numerator divided by denominator. + * + * @return the fraction as a {@code float} + */ + @Override + public float floatValue() { + return (float) numerator / (float) denominator; + } + + /** + * Gets the denominator part of the fraction. + * + * @return the denominator fraction part + */ + public int getDenominator() { + return denominator; + } + + /** + * Gets the numerator part of the fraction. + * + *

This method may return a value greater than the denominator, an + * improper fraction, such as the seven in 7/4.

+ * + * @return the numerator fraction part + */ + public int getNumerator() { + return numerator; + } + + /** + * Gets the proper numerator, always positive. + * + *

An improper fraction 7/4 can be resolved into a proper one, 1 3/4. + * This method returns the 3 from the proper fraction.

+ * + *

If the fraction is negative such as -7/4, it can be resolved into + * -1 3/4, so this method returns the positive proper numerator, 3.

+ * + * @return the numerator fraction part of a proper fraction, always positive + */ + public int getProperNumerator() { + return Math.abs(numerator % denominator); + } + + /** + * Gets the proper whole part of the fraction. + * + *

An improper fraction 7/4 can be resolved into a proper one, 1 3/4. + * This method returns the 1 from the proper fraction.

+ * + *

If the fraction is negative such as -7/4, it can be resolved into + * -1 3/4, so this method returns the positive whole part -1.

+ * + * @return the whole fraction part of a proper fraction, that includes the sign + */ + public int getProperWhole() { + return numerator / denominator; + } + + /** + * Gets a hashCode for the fraction. + * + * @return a hash code value for this object + */ + @Override + public int hashCode() { + if (hashCode == 0) { + // hash code update should be atomic. + hashCode = 37 * (37 * 17 + getNumerator()) + getDenominator(); + } + return hashCode; + } + + /** + * Gets the fraction as an {@code int}. This returns the whole number + * part of the fraction. + * + * @return the whole number fraction part + */ + @Override + public int intValue() { + return numerator / denominator; + } + + /** + * Gets a fraction that is the inverse (1/fraction) of this one. + * + *

The returned fraction is not reduced.

+ * + * @return a new fraction instance with the numerator and denominator + * inverted. + * @throws ArithmeticException if the fraction represents zero. + */ + public Fraction invert() { + if (numerator == 0) { + throw new ArithmeticException("Unable to invert zero."); + } + if (numerator==Integer.MIN_VALUE) { + throw new ArithmeticException("overflow: can't negate numerator"); + } + if (numerator<0) { + return new Fraction(-denominator, -numerator); + } + return new Fraction(denominator, numerator); + } + + /** + * Gets the fraction as a {@code long}. This returns the whole number + * part of the fraction. + * + * @return the whole number fraction part + */ + @Override + public long longValue() { + return (long) numerator / denominator; + } + + /** + * Multiplies the value of this fraction by another, returning the + * result in reduced form. + * + * @param fraction the fraction to multiply by, must not be {@code null} + * @return a {@link Fraction} instance with the resulting values + * @throws NullPointerException if the fraction is {@code null} + * @throws ArithmeticException if the resulting numerator or denominator exceeds + * {@code Integer.MAX_VALUE} + */ + public Fraction multiplyBy(final Fraction fraction) { + Objects.requireNonNull(fraction, "fraction"); + if (numerator == 0 || fraction.numerator == 0) { + return ZERO; + } + // knuth 4.5.1 + // make sure we don't overflow unless the result *must* overflow. + final int d1 = greatestCommonDivisor(numerator, fraction.denominator); + final int d2 = greatestCommonDivisor(fraction.numerator, denominator); + return getReducedFraction(mulAndCheck(numerator / d1, fraction.numerator / d2), + mulPosAndCheck(denominator / d2, fraction.denominator / d1)); + } + + /** + * Gets a fraction that is the negative (-fraction) of this one. + * + *

The returned fraction is not reduced.

+ * + * @return a new fraction instance with the opposite signed numerator + */ + public Fraction negate() { + // the positive range is one smaller than the negative range of an int. + if (numerator==Integer.MIN_VALUE) { + throw new ArithmeticException("overflow: too large to negate"); + } + return new Fraction(-numerator, denominator); + } + + /** + * Gets a fraction that is raised to the passed in power. + * + *

The returned fraction is in reduced form.

+ * + * @param power the power to raise the fraction to + * @return {@code this} if the power is one, {@link #ONE} if the power + * is zero (even if the fraction equals ZERO) or a new fraction instance + * raised to the appropriate power + * @throws ArithmeticException if the resulting numerator or denominator exceeds + * {@code Integer.MAX_VALUE} + */ + public Fraction pow(final int power) { + if (power == 1) { + return this; + } + if (power == 0) { + return ONE; + } + if (power < 0) { + if (power == Integer.MIN_VALUE) { // MIN_VALUE can't be negated. + return this.invert().pow(2).pow(-(power / 2)); + } + return this.invert().pow(-power); + } + final Fraction f = this.multiplyBy(this); + if (power % 2 == 0) { // if even... + return f.pow(power / 2); + } + return f.pow(power / 2).multiplyBy(this); + } + + /** + * Reduce the fraction to the smallest values for the numerator and + * denominator, returning the result. + * + *

For example, if this fraction represents 2/4, then the result + * will be 1/2.

+ * + * @return a new reduced fraction instance, or this if no simplification possible + */ + public Fraction reduce() { + if (numerator == 0) { + return equals(ZERO) ? this : ZERO; + } + final int gcd = greatestCommonDivisor(Math.abs(numerator), denominator); + if (gcd == 1) { + return this; + } + return getFraction(numerator / gcd, denominator / gcd); + } + + /** + * Subtracts the value of another fraction from the value of this one, + * returning the result in reduced form. + * + * @param fraction the fraction to subtract, must not be {@code null} + * @return a {@link Fraction} instance with the resulting values + * @throws NullPointerException if the fraction is {@code null} + * @throws ArithmeticException if the resulting numerator or denominator + * cannot be represented in an {@code int}. + */ + public Fraction subtract(final Fraction fraction) { + return addSub(fraction, false /* subtract */); + } + + /** + * Gets the fraction as a proper {@link String} in the format X Y/Z. + * + *

The format used in 'wholeNumber numerator/denominator'. + * If the whole number is zero it will be omitted. If the numerator is zero, + * only the whole number is returned.

+ * + * @return a {@link String} form of the fraction + */ + public String toProperString() { + if (toProperString == null) { + if (numerator == 0) { + toProperString = "0"; + } else if (numerator == denominator) { + toProperString = "1"; + } else if (numerator == -1 * denominator) { + toProperString = "-1"; + } else if ((numerator > 0 ? -numerator : numerator) < -denominator) { + // note that we do the magnitude comparison test above with + // NEGATIVE (not positive) numbers, since negative numbers + // have a larger range. otherwise numerator==Integer.MIN_VALUE + // is handled incorrectly. + final int properNumerator = getProperNumerator(); + if (properNumerator == 0) { + toProperString = Integer.toString(getProperWhole()); + } else { + toProperString = getProperWhole() + " " + properNumerator + "/" + getDenominator(); + } + } else { + toProperString = getNumerator() + "/" + getDenominator(); + } + } + return toProperString; + } + + /** + * Gets the fraction as a {@link String}. + * + *

The format used is 'numerator/denominator' always. + * + * @return a {@link String} form of the fraction + */ + @Override + public String toString() { + if (toString == null) { + toString = getNumerator() + "/" + getDenominator(); + } + return toString; + } +} From 415411e5fe88a70ce537862c7f3a9782def7249a Mon Sep 17 00:00:00 2001 From: Camotoy <20743703+Camotoy@users.noreply.github.com> Date: Mon, 11 Nov 2024 14:36:49 -0500 Subject: [PATCH 2/4] Touchups --- .../geyser/inventory/GeyserItemStack.java | 8 ----- .../geyser/inventory/click/ClickPlan.java | 4 ++- .../geysermc/geyser/item/type/BundleItem.java | 32 ------------------- .../inventory/BundleInventoryTranslator.java | 3 +- 4 files changed, 4 insertions(+), 43 deletions(-) delete mode 100644 core/src/main/java/org/geysermc/geyser/item/type/BundleItem.java diff --git a/core/src/main/java/org/geysermc/geyser/inventory/GeyserItemStack.java b/core/src/main/java/org/geysermc/geyser/inventory/GeyserItemStack.java index 5e1fb844762..4c82773416c 100644 --- a/core/src/main/java/org/geysermc/geyser/inventory/GeyserItemStack.java +++ b/core/src/main/java/org/geysermc/geyser/inventory/GeyserItemStack.java @@ -54,7 +54,6 @@ @Data public class GeyserItemStack { public static final GeyserItemStack EMPTY = new GeyserItemStack(Items.AIR_ID, 0, null); - public static final int UNSET_NET_ID = 1; private final int javaId; private int amount; @@ -158,13 +157,6 @@ public int getComponent(@NonNull DataComponentType type, return def; } -// public boolean hasComponent(DataComponentType type) { -// if (components == null) { -// return false; -// } -// return components.getDataComponents().containsKey(type); -// } - public int getNetId() { return isEmpty() ? 0 : netId; } diff --git a/core/src/main/java/org/geysermc/geyser/inventory/click/ClickPlan.java b/core/src/main/java/org/geysermc/geyser/inventory/click/ClickPlan.java index 15c46aed0e0..d4344f6e829 100644 --- a/core/src/main/java/org/geysermc/geyser/inventory/click/ClickPlan.java +++ b/core/src/main/java/org/geysermc/geyser/inventory/click/ClickPlan.java @@ -183,8 +183,10 @@ public void executeForCreativeMode() { inventory.setItem(simulatedSlot.getIntKey(), simulatedSlot.getValue(), session); } for (Int2ObjectMap.Entry changedSlot : changedItems.int2ObjectEntrySet()) { + ItemStack value = changedSlot.getValue(); + ItemStack toSend = InventoryUtils.isEmpty(value) ? new ItemStack(-1, 0, null) : value; session.sendDownstreamGamePacket( - new ServerboundSetCreativeModeSlotPacket((short) changedSlot.getIntKey(), changedSlot.getValue()) + new ServerboundSetCreativeModeSlotPacket((short) changedSlot.getIntKey(), toSend) ); } } diff --git a/core/src/main/java/org/geysermc/geyser/item/type/BundleItem.java b/core/src/main/java/org/geysermc/geyser/item/type/BundleItem.java deleted file mode 100644 index 9c06768b3d5..00000000000 --- a/core/src/main/java/org/geysermc/geyser/item/type/BundleItem.java +++ /dev/null @@ -1,32 +0,0 @@ -/* - * 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.type; - -public class BundleItem extends Item { - public BundleItem(String javaIdentifier, Builder builder) { - super(javaIdentifier, builder); - } -} diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/BundleInventoryTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/BundleInventoryTranslator.java index 2d8e5770b33..b281945aaab 100644 --- a/core/src/main/java/org/geysermc/geyser/translator/inventory/BundleInventoryTranslator.java +++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/BundleInventoryTranslator.java @@ -195,7 +195,6 @@ static ItemStackResponse handleBundle(GeyserSession session, InventoryTranslator if (bundleSlotData.getStackNetworkId() != contents.get(0).getNetId()) { // We're pulling out the first item; if something mismatches, wuh oh. - System.out.println(bundleSlotData.getStackNetworkId() + " " + contents.get(0).getNetId()); return rejectRequest(request); } @@ -324,7 +323,7 @@ static boolean isBundle(GeyserSession session, ClickPlan plan, int slot) { } static boolean isBundle(GeyserSession session, GeyserItemStack stack) { - // Client as of 1.21.3 does use this, and + // Client as of 1.21.3 does use this return session.getTagCache().is(ItemTag.BUNDLES, stack); } From cde0cae77681106be139cb978f307208db919df5 Mon Sep 17 00:00:00 2001 From: Camotoy <20743703+Camotoy@users.noreply.github.com> Date: Mon, 11 Nov 2024 14:53:00 -0500 Subject: [PATCH 3/4] Correct bundle mapping --- core/src/main/resources/mappings | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/src/main/resources/mappings b/core/src/main/resources/mappings index e1eafe2c530..57d2045675f 160000 --- a/core/src/main/resources/mappings +++ b/core/src/main/resources/mappings @@ -1 +1 @@ -Subproject commit e1eafe2c5304012d23acba80659459f7868fe2b1 +Subproject commit 57d2045675fc6f456729a89e39c0e676172510be From 3236712e31bc9052bea2077778d68296aa499957 Mon Sep 17 00:00:00 2001 From: Camotoy <20743703+Camotoy@users.noreply.github.com> Date: Tue, 12 Nov 2024 19:54:48 -0500 Subject: [PATCH 4/4] Grabbing a bundle from creative mode does work --- .../inventory/BundleInventoryTranslator.java | 10 ++++------ .../translator/inventory/InventoryTranslator.java | 6 +++--- .../inventory/PlayerInventoryTranslator.java | 11 ++++++++++- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/BundleInventoryTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/BundleInventoryTranslator.java index b281945aaab..44477945f96 100644 --- a/core/src/main/java/org/geysermc/geyser/translator/inventory/BundleInventoryTranslator.java +++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/BundleInventoryTranslator.java @@ -41,7 +41,6 @@ import org.geysermc.geyser.inventory.click.ClickPlan; import org.geysermc.geyser.session.GeyserSession; import org.geysermc.geyser.session.cache.BundleCache; -import org.geysermc.geyser.session.cache.tags.ItemTag; import org.geysermc.geyser.util.InventoryUtils; import org.geysermc.geyser.util.thirdparty.Fraction; import org.geysermc.mcprotocollib.protocol.data.game.item.component.DataComponentType; @@ -318,13 +317,12 @@ static boolean isBundle(ItemStackRequestSlotData slotData) { return slotData.getContainerName().getContainer() == ContainerSlotType.DYNAMIC_CONTAINER; } - static boolean isBundle(GeyserSession session, ClickPlan plan, int slot) { - return isBundle(session, plan.getItem(slot)); + static boolean isBundle(ClickPlan plan, int slot) { + return isBundle(plan.getItem(slot)); } - static boolean isBundle(GeyserSession session, GeyserItemStack stack) { - // Client as of 1.21.3 does use this - return session.getTagCache().is(ItemTag.BUNDLES, stack); + static boolean isBundle(GeyserItemStack stack) { + return stack.getBundleData() != null; } private BundleInventoryTranslator() { diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/InventoryTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/InventoryTranslator.java index 6cbc81957fe..16be81e121d 100644 --- a/core/src/main/java/org/geysermc/geyser/translator/inventory/InventoryTranslator.java +++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/InventoryTranslator.java @@ -449,12 +449,12 @@ public ItemStackResponse translateRequest(GeyserSession session, Inventory inven if (InventoryUtils.canStack(cursor, plan.getItem(destSlot))) { //TODO: cannot simply swap if cursor stacks with slot (temp slot) return rejectRequest(request); } - plan.add(isBundle(session, plan, destSlot) || isBundle(session, cursor) ? Click.RIGHT : Click.LEFT, destSlot); + plan.add(isBundle(plan, destSlot) || isBundle(cursor) ? Click.RIGHT : Click.LEFT, destSlot); } else if (isDestCursor) { //swap cursor if (InventoryUtils.canStack(cursor, plan.getItem(sourceSlot))) { //TODO return rejectRequest(request); } - plan.add(isBundle(session, plan, sourceSlot) || isBundle(session, cursor) ? Click.RIGHT : Click.LEFT, sourceSlot); + plan.add(isBundle(plan, sourceSlot) || isBundle(cursor) ? Click.RIGHT : Click.LEFT, sourceSlot); } else { if (!cursor.isEmpty()) { //TODO: (temp slot) return rejectRequest(request); @@ -466,7 +466,7 @@ public ItemStackResponse translateRequest(GeyserSession session, Inventory inven return rejectRequest(request); } plan.add(Click.LEFT, sourceSlot); //pickup source into cursor - plan.add(isBundle(session, plan, sourceSlot) || isBundle(session, plan, destSlot) ? Click.RIGHT : Click.LEFT, destSlot); //swap cursor with dest slot + plan.add(isBundle(plan, sourceSlot) || isBundle(plan, destSlot) ? Click.RIGHT : Click.LEFT, destSlot); //swap cursor with dest slot plan.add(Click.LEFT, sourceSlot); //release cursor onto source } break; diff --git a/core/src/main/java/org/geysermc/geyser/translator/inventory/PlayerInventoryTranslator.java b/core/src/main/java/org/geysermc/geyser/translator/inventory/PlayerInventoryTranslator.java index 7eefffa3e3e..e8548745e8f 100644 --- a/core/src/main/java/org/geysermc/geyser/translator/inventory/PlayerInventoryTranslator.java +++ b/core/src/main/java/org/geysermc/geyser/translator/inventory/PlayerInventoryTranslator.java @@ -423,6 +423,7 @@ public ItemStackResponse translateRequest(GeyserSession session, Inventory inven @Override protected ItemStackResponse translateCreativeRequest(GeyserSession session, Inventory inventory, ItemStackRequest request) { ItemStack javaCreativeItem = null; + boolean bundle = false; IntSet affectedSlots = new IntOpenHashSet(); CraftState craftState = CraftState.START; for (ItemStackRequestAction action : request.getActions()) { @@ -477,8 +478,10 @@ protected ItemStackResponse translateCreativeRequest(GeyserSession session, Inve if (isCursor(transferAction.getDestination())) { if (session.getPlayerInventory().getCursor().isEmpty()) { GeyserItemStack newItemStack = GeyserItemStack.from(javaCreativeItem); + session.getBundleCache().initialize(newItemStack); newItemStack.setAmount(transferAction.getCount()); session.getPlayerInventory().setCursor(newItemStack, session); + bundle = newItemStack.getBundleData() != null; } else { session.getPlayerInventory().getCursor().add(transferAction.getCount()); } @@ -487,8 +490,10 @@ protected ItemStackResponse translateCreativeRequest(GeyserSession session, Inve int destSlot = bedrockSlotToJava(transferAction.getDestination()); if (inventory.getItem(destSlot).isEmpty()) { GeyserItemStack newItemStack = GeyserItemStack.from(javaCreativeItem); + session.getBundleCache().initialize(newItemStack); newItemStack.setAmount(transferAction.getCount()); inventory.setItem(destSlot, newItemStack, session); + bundle = newItemStack.getBundleData() != null; } else { inventory.getItem(destSlot).add(transferAction.getCount()); } @@ -528,7 +533,11 @@ protected ItemStackResponse translateCreativeRequest(GeyserSession session, Inve int slot = it.nextInt(); sendCreativeAction(session, inventory, slot); } - return acceptRequest(request, makeContainerEntries(session, inventory, affectedSlots)); + // On the bundle check: + // We can also accept the request, but sending a bad request indicates to Geyser to refresh the inventory + // and we need to refresh the inventory to send the bundle ID/inventory to the client. + // It's not great, but I don't want to create a container class for request responses + return bundle ? rejectRequest(request, false) : acceptRequest(request, makeContainerEntries(session, inventory, affectedSlots)); } private static void sendCreativeAction(GeyserSession session, Inventory inventory, int slot) {