diff --git a/src/main/java/slimeknights/mantle/block/entity/InventoryBlockEntity.java b/src/main/java/slimeknights/mantle/block/entity/InventoryBlockEntity.java index 1185b6fb..00940962 100644 --- a/src/main/java/slimeknights/mantle/block/entity/InventoryBlockEntity.java +++ b/src/main/java/slimeknights/mantle/block/entity/InventoryBlockEntity.java @@ -1,7 +1,7 @@ package slimeknights.mantle.block.entity; +import io.github.fabricators_of_create.porting_lib.transfer.item.SlottedStackStorage; import lombok.Getter; -import net.fabricmc.fabric.api.transfer.v1.item.InventoryStorage; import net.fabricmc.fabric.api.transfer.v1.item.ItemVariant; import net.fabricmc.fabric.api.transfer.v1.storage.SlottedStorage; import net.fabricmc.fabric.api.transfer.v1.storage.Storage; @@ -22,6 +22,8 @@ import net.minecraft.world.level.block.entity.BlockEntityType; import net.minecraft.world.level.block.state.BlockState; import org.jetbrains.annotations.Nullable; +import slimeknights.mantle.fabric.transfer.IInventoryStorage; +import slimeknights.mantle.fabric.transfer.InventoryStorage; import slimeknights.mantle.util.ItemStackList; import javax.annotation.Nonnull; @@ -37,7 +39,7 @@ public abstract class InventoryBlockEntity extends NameableBlockEntity implement private final boolean saveSizeToNBT; protected int stackSizeLimit; @Getter - protected SlottedStorage itemHandler; + protected SlottedStackStorage itemHandler; /** * @param name Localization String for the inventory title. Can be overridden through setCustomName diff --git a/src/main/java/slimeknights/mantle/fabric/transfer/IInventoryStorage.java b/src/main/java/slimeknights/mantle/fabric/transfer/IInventoryStorage.java new file mode 100644 index 00000000..4455c61e --- /dev/null +++ b/src/main/java/slimeknights/mantle/fabric/transfer/IInventoryStorage.java @@ -0,0 +1,28 @@ +package slimeknights.mantle.fabric.transfer; + +import io.github.fabricators_of_create.porting_lib.transfer.item.SlottedStackStorage; +import net.fabricmc.fabric.api.transfer.v1.item.ItemVariant; +import net.fabricmc.fabric.api.transfer.v1.storage.base.SingleSlotStorage; +import org.jetbrains.annotations.UnmodifiableView; + +import java.util.List; + +public interface IInventoryStorage extends SlottedStackStorage { + /** + * Retrieve an unmodifiable list of the wrappers for the slots in this inventory. + * Each wrapper corresponds to a single slot in the inventory. + */ + @Override + @UnmodifiableView + List> getSlots(); + + @Override + default int getSlotCount() { + return getSlots().size(); + } + + @Override + default SingleSlotStorage getSlot(int slot) { + return getSlots().get(slot); + } +} diff --git a/src/main/java/slimeknights/mantle/fabric/transfer/InventorySlotWrapper.java b/src/main/java/slimeknights/mantle/fabric/transfer/InventorySlotWrapper.java new file mode 100644 index 00000000..c77c572c --- /dev/null +++ b/src/main/java/slimeknights/mantle/fabric/transfer/InventorySlotWrapper.java @@ -0,0 +1,155 @@ +package slimeknights.mantle.fabric.transfer; + +import net.fabricmc.fabric.api.transfer.v1.item.ItemVariant; +import net.fabricmc.fabric.api.transfer.v1.item.base.SingleStackStorage; +import net.fabricmc.fabric.api.transfer.v1.transaction.TransactionContext; +import net.fabricmc.fabric.impl.transfer.DebugMessages; +import net.fabricmc.fabric.impl.transfer.item.ItemVariantImpl; +import net.fabricmc.fabric.impl.transfer.item.SpecialLogicInventory; +import net.minecraft.core.BlockPos; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.block.ChestBlock; +import net.minecraft.world.level.block.entity.AbstractFurnaceBlockEntity; +import net.minecraft.world.level.block.entity.BrewingStandBlockEntity; +import net.minecraft.world.level.block.entity.ChestBlockEntity; +import net.minecraft.world.level.block.entity.ShulkerBoxBlockEntity; +import net.minecraft.world.level.block.state.properties.ChestType; + +/** + * A wrapper around a single slot of an inventory. + * We must ensure that only one instance of this class exists for every inventory slot, + * or the transaction logic will not work correctly. + * This is handled by the Map in InventoryStorageImpl. + */ +class InventorySlotWrapper extends SingleStackStorage { + /** + * The strong reference to the InventoryStorageImpl ensures that the weak value doesn't get GC'ed when individual slots are still being accessed. + */ + private final InventoryStorage storage; + final int slot; + private final SpecialLogicInventory specialInv; + private ItemStack lastReleasedSnapshot = null; + + InventorySlotWrapper(InventoryStorage storage, int slot) { + this.storage = storage; + this.slot = slot; + this.specialInv = storage.inventory instanceof SpecialLogicInventory specialInv ? specialInv : null; + } + + @Override + protected ItemStack getStack() { + return storage.inventory.getItem(slot); + } + + @Override + protected void setStack(ItemStack stack) { + if (specialInv == null) { + storage.inventory.setItem(slot, stack); + } else { + specialInv.fabric_setSuppress(true); + + try { + storage.inventory.setItem(slot, stack); + } finally { + specialInv.fabric_setSuppress(false); + } + } + } + + @Override + public long insert(ItemVariant insertedVariant, long maxAmount, TransactionContext transaction) { + if (!canInsert(slot, ((ItemVariantImpl) insertedVariant).getCachedStack())) { + return 0; + } + + long ret = super.insert(insertedVariant, maxAmount, transaction); + if (specialInv != null && ret > 0) specialInv.fabric_onTransfer(slot, transaction); + return ret; + } + + private boolean canInsert(int slot, ItemStack stack) { + if (storage.inventory instanceof ShulkerBoxBlockEntity shulker) { + // Shulkers override canInsert but not isValid. + return shulker.canPlaceItemThroughFace(slot, stack, null); + } else { + return storage.inventory.canPlaceItem(slot, stack); + } + } + + @Override + public long extract(ItemVariant variant, long maxAmount, TransactionContext transaction) { + long ret = super.extract(variant, maxAmount, transaction); + if (specialInv != null && ret > 0) specialInv.fabric_onTransfer(slot, transaction); + return ret; + } + + /** + * Special cases because vanilla checks the current stack in the following functions (which it shouldn't): + *
    + *
  • {@link AbstractFurnaceBlockEntity#canPlaceItem(int, ItemStack)}.
  • + *
  • {@link BrewingStandBlockEntity#canPlaceItem(int, ItemStack)}.
  • + *
+ */ + @Override + public int getCapacity(ItemVariant variant) { + // Special case to limit buckets to 1 in furnace fuel inputs. + if (storage.inventory instanceof AbstractFurnaceBlockEntity && slot == 1 && variant.isOf(Items.BUCKET)) { + return 1; + } + + // Special case to limit brewing stand "bottle inputs" to 1. + if (storage.inventory instanceof BrewingStandBlockEntity && slot < 3) { + return 1; + } + + return Math.min(storage.inventory.getMaxStackSize(), variant.getItem().getMaxStackSize()); + } + + // We override updateSnapshots to also schedule a markDirty call for the backing inventory. + @Override + public void updateSnapshots(TransactionContext transaction) { + storage.markDirtyParticipant.updateSnapshots(transaction); + super.updateSnapshots(transaction); + + // For chests: also schedule a markDirty call for the other half + if (storage.inventory instanceof ChestBlockEntity chest && chest.getBlockState().getValue(ChestBlock.TYPE) != ChestType.SINGLE) { + BlockPos otherChestPos = chest.getBlockPos().relative(ChestBlock.getConnectedDirection(chest.getBlockState())); + + if (chest.getLevel().getBlockEntity(otherChestPos) instanceof ChestBlockEntity otherChest) { + ((InventoryStorage) InventoryStorage.of(otherChest, null)).markDirtyParticipant.updateSnapshots(transaction); + } + } + } + + @Override + protected void releaseSnapshot(ItemStack snapshot) { + lastReleasedSnapshot = snapshot; + } + + @Override + protected void onFinalCommit() { + // Try to apply the change to the original stack + ItemStack original = lastReleasedSnapshot; + ItemStack currentStack = getStack(); + + if (storage.inventory instanceof SpecialLogicInventory specialLogicInv) { + specialLogicInv.fabric_onFinalCommit(slot, original, currentStack); + } + + if (!original.isEmpty() && original.getItem() == currentStack.getItem()) { + // None is empty and the items match: just update the amount and NBT, and reuse the original stack. + original.setCount(currentStack.getCount()); + original.setTag(currentStack.hasTag() ? currentStack.getTag().copy() : null); + setStack(original); + } else { + // Otherwise assume everything was taken from original so empty it. + original.setCount(0); + } + } + + @Override + public String toString() { + return "InventorySlotWrapper[%s#%d]".formatted(DebugMessages.forInventory(storage.inventory), slot); + } +} diff --git a/src/main/java/slimeknights/mantle/fabric/transfer/InventoryStorage.java b/src/main/java/slimeknights/mantle/fabric/transfer/InventoryStorage.java new file mode 100644 index 00000000..262fc2e3 --- /dev/null +++ b/src/main/java/slimeknights/mantle/fabric/transfer/InventoryStorage.java @@ -0,0 +1,135 @@ +package slimeknights.mantle.fabric.transfer; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import com.google.common.collect.MapMaker; +import io.github.fabricators_of_create.porting_lib.transfer.item.SlottedStackStorage; +import net.minecraft.world.item.ItemStack; +import org.jetbrains.annotations.Nullable; +import net.fabricmc.fabric.api.transfer.v1.item.ItemVariant; +import net.fabricmc.fabric.api.transfer.v1.storage.base.CombinedStorage; +import net.fabricmc.fabric.api.transfer.v1.storage.base.SingleSlotStorage; +import net.fabricmc.fabric.api.transfer.v1.transaction.base.SnapshotParticipant; +import net.fabricmc.fabric.impl.transfer.DebugMessages; +import net.minecraft.core.Direction; +import net.minecraft.world.Container; +import net.minecraft.world.WorldlyContainer; +import net.minecraft.world.entity.player.Inventory; + +/** + * Implementation of {@link InventoryStorage}. + * Note on thread-safety: we assume that Inventory's are inherently single-threaded, and no attempt is made at synchronization. + * However, the access to implementations can happen on multiple threads concurrently, which is why we use a thread-safe wrapper map. + */ +public class InventoryStorage extends CombinedStorage> implements IInventoryStorage { + /** + * Global wrapper concurrent map. + * + *

A note on GC: weak keys alone are not suitable as the InventoryStorage slots strongly reference the Inventory keys. + * Weak values are suitable, but we have to ensure that the InventoryStorageImpl remains strongly reachable as long as + * one of the slot wrappers refers to it, hence the {@code strongRef} field in {@link InventorySlotWrapper}. + */ + // TODO: look into promoting the weak reference to a soft reference if building the wrappers becomes a performance bottleneck. + // TODO: should have identity semantics? + private static final Map WRAPPERS = new MapMaker().weakValues().makeMap(); + + public static IInventoryStorage of(Container inventory, @Nullable Direction direction) { + InventoryStorage storage = WRAPPERS.computeIfAbsent(inventory, inv -> { + if (inv instanceof Inventory playerInventory) { + return new PlayerInventoryStorage(playerInventory); + } else { + return new InventoryStorage(inv); + } + }); + storage.resizeSlotList(); + return storage.getSidedWrapper(direction); + } + + final Container inventory; + /** + * This {@code backingList} is the real list of wrappers. + * The {@code parts} in the superclass is the public-facing unmodifiable sublist with exactly the right amount of slots. + */ + final List backingList; + /** + * This participant ensures that markDirty is only called once for the entire inventory. + */ + final MarkDirtyParticipant markDirtyParticipant = new MarkDirtyParticipant(); + + InventoryStorage(Container inventory) { + super(Collections.emptyList()); + this.inventory = inventory; + this.backingList = new ArrayList<>(); + } + + @Override + public List> getSlots() { + return parts; + } + + /** + * Resize slot list to match the current size of the inventory. + */ + private void resizeSlotList() { + int inventorySize = inventory.getContainerSize(); + + // If the public-facing list must change... + if (inventorySize != parts.size()) { + // Ensure we have enough wrappers in the backing list. + while (backingList.size() < inventorySize) { + backingList.add(new InventorySlotWrapper(this, backingList.size())); + } + + // Update the public-facing list. + parts = Collections.unmodifiableList(backingList.subList(0, inventorySize)); + } + } + + private IInventoryStorage getSidedWrapper(@Nullable Direction direction) { + if (inventory instanceof WorldlyContainer && direction != null) { + return new SidedInventoryStorage(this, direction); + } else { + return this; + } + } + + @Override + public String toString() { + return "InventoryStorage[" + DebugMessages.forInventory(inventory) + "]"; + } + + @Override + public ItemStack getStackInSlot(int slot) { + return inventory.getItem(slot); + } + + @Override + public void setStackInSlot(int slot, ItemStack stack) { + inventory.setItem(slot, stack); + } + + @Override + public int getSlotLimit(int slot) { + return inventory.getItem(slot).getMaxStackSize(); + } + + // Boolean is used to prevent allocation. Null values are not allowed by SnapshotParticipant. + class MarkDirtyParticipant extends SnapshotParticipant { + @Override + protected Boolean createSnapshot() { + return Boolean.TRUE; + } + + @Override + protected void readSnapshot(Boolean snapshot) { + } + + @Override + protected void onFinalCommit() { + inventory.setChanged(); + } + } +} diff --git a/src/main/java/slimeknights/mantle/fabric/transfer/PlayerInventoryStorage.java b/src/main/java/slimeknights/mantle/fabric/transfer/PlayerInventoryStorage.java new file mode 100644 index 00000000..7efd02cd --- /dev/null +++ b/src/main/java/slimeknights/mantle/fabric/transfer/PlayerInventoryStorage.java @@ -0,0 +1,156 @@ +package slimeknights.mantle.fabric.transfer; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import net.fabricmc.fabric.api.transfer.v1.item.ItemVariant; +import net.fabricmc.fabric.api.transfer.v1.storage.StoragePreconditions; +import net.fabricmc.fabric.api.transfer.v1.storage.StorageUtil; +import net.fabricmc.fabric.api.transfer.v1.storage.base.SingleSlotStorage; +import net.fabricmc.fabric.api.transfer.v1.transaction.TransactionContext; +import net.fabricmc.fabric.api.transfer.v1.transaction.base.SnapshotParticipant; +import net.fabricmc.fabric.impl.transfer.DebugMessages; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; + +class PlayerInventoryStorage extends InventoryStorage { + private final DroppedStacks droppedStacks; + private final Inventory playerInventory; + + PlayerInventoryStorage(Inventory playerInventory) { + super(playerInventory); + this.droppedStacks = new DroppedStacks(); + this.playerInventory = playerInventory; + } + + @Override + public long insert(ItemVariant resource, long maxAmount, TransactionContext transaction) { + return offer(resource, maxAmount, transaction); + } + + public long offer(ItemVariant resource, long amount, TransactionContext tx) { + StoragePreconditions.notBlankNotNegative(resource, amount); + long initialAmount = amount; + + List> mainSlots = getSlots().subList(0, Inventory.INVENTORY_SIZE); + + // Stack into the main stack first and the offhand stack second. + for (InteractionHand hand : InteractionHand.values()) { + SingleSlotStorage handSlot = getHandSlot(hand); + + if (handSlot.getResource().equals(resource)) { + amount -= handSlot.insert(resource, amount, tx); + + if (amount == 0) return initialAmount; + } + } + + // Otherwise insert into the main slots, stacking first. + amount -= StorageUtil.insertStacking(mainSlots, resource, amount, tx); + + return initialAmount - amount; + } + + public void drop(ItemVariant variant, long amount, boolean throwRandomly, boolean retainOwnership, TransactionContext transaction) { + StoragePreconditions.notBlankNotNegative(variant, amount); + + // Drop in the world on the server side (will be synced by the game with the client). + // Dropping items is server-side only because it involves randomness. + if (amount > 0 && !playerInventory.player.level().isClientSide()) { + droppedStacks.addDrop(variant, amount, throwRandomly, retainOwnership, transaction); + } + } + + public SingleSlotStorage getHandSlot(InteractionHand hand) { + if (Objects.requireNonNull(hand) == InteractionHand.MAIN_HAND) { + if (Inventory.isHotbarSlot(playerInventory.selected)) { + return getSlot(playerInventory.selected); + } else { + throw new RuntimeException("Unexpected player selected slot: " + playerInventory.selected); + } + } else if (hand == InteractionHand.OFF_HAND) { + return getSlot(Inventory.SLOT_OFFHAND); + } else { + throw new UnsupportedOperationException("Unknown hand: " + hand); + } + } + + @Override + public String toString() { + return "PlayerInventoryStorage[" + DebugMessages.forInventory(playerInventory) + "]"; + } + + private class DroppedStacks extends SnapshotParticipant { + final List entries = new ArrayList<>(); + + void addDrop(ItemVariant key, long amount, boolean throwRandomly, boolean retainOwnership, TransactionContext transaction) { + updateSnapshots(transaction); + entries.add(new Entry(key, amount, throwRandomly, retainOwnership)); + } + + @Override + protected Integer createSnapshot() { + return entries.size(); + } + + @Override + protected void readSnapshot(Integer snapshot) { + // effectively cancel dropping the stacks + int previousSize = snapshot; + + while (entries.size() > previousSize) { + entries.remove(entries.size() - 1); + } + } + + @Override + protected void onFinalCommit() { + // actually drop the stacks + for (Entry entry : entries) { + long remainder = entry.amount; + + while (remainder > 0) { + int dropped = (int) Math.min(entry.key.getItem().getMaxStackSize(), remainder); + playerInventory.player.drop(entry.key.toStack(dropped), entry.throwRandomly, entry.retainOwnership); + remainder -= dropped; + } + } + + entries.clear(); + } + + private record Entry(ItemVariant key, long amount, boolean throwRandomly, boolean retainOwnership) { + } + } + + /** + * Throw items in the world from the player's location. + * + *

Note: This function has full transaction support, and will not actually drop the items until the outermost transaction is committed. + * + * @param variant The variant to drop. + * @param amount How many of the variant to drop. + * @param retainOwnership If true, set the {@code Thrower} NBT data to the player's UUID. + * @param transaction The transaction this operation is part of. + * @see Player#drop(ItemStack, boolean, boolean) + */ + public void drop(ItemVariant variant, long amount, boolean retainOwnership, TransactionContext transaction) { + drop(variant, amount, false, retainOwnership, transaction); + } + + /** + * Throw items in the world from the player's location. + * + *

Note: This function has full transaction support, and will not actually drop the items until the outermost transaction is committed. + * + * @param variant The variant to drop. + * @param amount How many of the variant to drop. + * @param transaction The transaction this operation is part of. + * @see Player#drop(ItemStack, boolean, boolean) + */ + public void drop(ItemVariant variant, long amount, TransactionContext transaction) { + drop(variant, amount, false, transaction); + } +} diff --git a/src/main/java/slimeknights/mantle/fabric/transfer/SidedInventorySlotWrapper.java b/src/main/java/slimeknights/mantle/fabric/transfer/SidedInventorySlotWrapper.java new file mode 100644 index 00000000..5c8f775c --- /dev/null +++ b/src/main/java/slimeknights/mantle/fabric/transfer/SidedInventorySlotWrapper.java @@ -0,0 +1,73 @@ +package slimeknights.mantle.fabric.transfer; + +import net.fabricmc.fabric.api.transfer.v1.item.ItemVariant; +import net.fabricmc.fabric.api.transfer.v1.storage.StorageView; +import net.fabricmc.fabric.api.transfer.v1.storage.base.SingleSlotStorage; +import net.fabricmc.fabric.api.transfer.v1.transaction.TransactionContext; +import net.fabricmc.fabric.impl.transfer.DebugMessages; +import net.fabricmc.fabric.impl.transfer.item.ItemVariantImpl; +import net.minecraft.core.Direction; +import net.minecraft.world.WorldlyContainer; + +/** + * Wrapper around an {@link InventorySlotWrapper}, with additional canInsert and canExtract checks. + */ +class SidedInventorySlotWrapper implements SingleSlotStorage { + private final InventorySlotWrapper slotWrapper; + private final WorldlyContainer sidedInventory; + private final Direction direction; + + SidedInventorySlotWrapper(InventorySlotWrapper slotWrapper, WorldlyContainer sidedInventory, Direction direction) { + this.slotWrapper = slotWrapper; + this.sidedInventory = sidedInventory; + this.direction = direction; + } + + @Override + public long insert(ItemVariant resource, long maxAmount, TransactionContext transaction) { + if (!sidedInventory.canPlaceItemThroughFace(slotWrapper.slot, ((ItemVariantImpl) resource).getCachedStack(), direction)) { + return 0; + } else { + return slotWrapper.insert(resource, maxAmount, transaction); + } + } + + @Override + public long extract(ItemVariant resource, long maxAmount, TransactionContext transaction) { + if (!sidedInventory.canTakeItemThroughFace(slotWrapper.slot, ((ItemVariantImpl) resource).getCachedStack(), direction)) { + return 0; + } else { + return slotWrapper.extract(resource, maxAmount, transaction); + } + } + + @Override + public boolean isResourceBlank() { + return slotWrapper.isResourceBlank(); + } + + @Override + public ItemVariant getResource() { + return slotWrapper.getResource(); + } + + @Override + public long getAmount() { + return slotWrapper.getAmount(); + } + + @Override + public long getCapacity() { + return slotWrapper.getCapacity(); + } + + @Override + public StorageView getUnderlyingView() { + return slotWrapper.getUnderlyingView(); + } + + @Override + public String toString() { + return "SidedInventorySlotWrapper[%s#%d/%s]".formatted(DebugMessages.forInventory(sidedInventory), slotWrapper.slot, direction.getName()); + } +} diff --git a/src/main/java/slimeknights/mantle/fabric/transfer/SidedInventoryStorage.java b/src/main/java/slimeknights/mantle/fabric/transfer/SidedInventoryStorage.java new file mode 100644 index 00000000..227564fb --- /dev/null +++ b/src/main/java/slimeknights/mantle/fabric/transfer/SidedInventoryStorage.java @@ -0,0 +1,61 @@ +package slimeknights.mantle.fabric.transfer; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import net.fabricmc.fabric.api.transfer.v1.item.ItemVariant; +import net.fabricmc.fabric.api.transfer.v1.storage.base.CombinedStorage; +import net.fabricmc.fabric.api.transfer.v1.storage.base.SingleSlotStorage; +import net.minecraft.core.Direction; +import net.minecraft.world.WorldlyContainer; +import net.minecraft.world.item.ItemStack; + +/** + * Sidedness-aware wrapper around a {@link InventoryStorage} for sided inventories. + */ +class SidedInventoryStorage extends CombinedStorage> implements IInventoryStorage { + private final InventoryStorage backingStorage; + + SidedInventoryStorage(InventoryStorage storage, Direction direction) { + super(Collections.unmodifiableList(createWrapperList(storage, direction))); + this.backingStorage = storage; + } + + @Override + public List> getSlots() { + return parts; + } + + private static List> createWrapperList(InventoryStorage storage, Direction direction) { + WorldlyContainer inventory = (WorldlyContainer) storage.inventory; + int[] availableSlots = inventory.getSlotsForFace(direction); + SidedInventorySlotWrapper[] slots = new SidedInventorySlotWrapper[availableSlots.length]; + + for (int i = 0; i < availableSlots.length; ++i) { + slots[i] = new SidedInventorySlotWrapper(storage.backingList.get(availableSlots[i]), inventory, direction); + } + + return Arrays.asList(slots); + } + + @Override + public String toString() { + // These two are the same from the user's perspective. + return backingStorage.toString(); + } + + @Override + public ItemStack getStackInSlot(int slot) { + return backingStorage.getStackInSlot(slot); + } + + @Override + public void setStackInSlot(int slot, ItemStack stack) { + backingStorage.setStackInSlot(slot, stack); + } + + @Override + public int getSlotLimit(int slot) { + return backingStorage.getSlotLimit(slot); + } +}