diff --git a/src/main/java/org/cyclops/integratedterminals/GeneralConfig.java b/src/main/java/org/cyclops/integratedterminals/GeneralConfig.java index 70dd1cd2bf..d03387f337 100644 --- a/src/main/java/org/cyclops/integratedterminals/GeneralConfig.java +++ b/src/main/java/org/cyclops/integratedterminals/GeneralConfig.java @@ -68,6 +68,9 @@ public class GeneralConfig extends DummyConfig { @ConfigurableProperty(category = "general", comment = "If the search box and button states should be synchronized between the item storage and crafting tabs.", isCommandable = true, configLocation = ModConfig.Type.CLIENT) public static boolean syncItemStorageAndCraftingTabStates = true; + @ConfigurableProperty(category = "general", comment = "If storage terminal interactions should be shown immediately, before the server confirms them.", isCommandable = true, configLocation = ModConfig.Type.CLIENT) + public static boolean guiStoragePredictInteractions = true; + @ConfigurableProperty(category = "general", comment = "If shift-clicking on the crafting terminal's crafting result slot should only produce a single result.", isCommandable = true, configLocation = ModConfig.Type.CLIENT) public static boolean shiftClickCraftingResultLimit = false; diff --git a/src/main/java/org/cyclops/integratedterminals/api/ingredient/IIngredientComponentTerminalStorageHandler.java b/src/main/java/org/cyclops/integratedterminals/api/ingredient/IIngredientComponentTerminalStorageHandler.java index b984b69138..556dfdc74e 100644 --- a/src/main/java/org/cyclops/integratedterminals/api/ingredient/IIngredientComponentTerminalStorageHandler.java +++ b/src/main/java/org/cyclops/integratedterminals/api/ingredient/IIngredientComponentTerminalStorageHandler.java @@ -1,5 +1,6 @@ package org.cyclops.integratedterminals.api.ingredient; +import com.google.common.collect.Iterables; import net.minecraft.client.gui.GuiGraphics; import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; import net.minecraft.world.entity.player.Player; @@ -14,12 +15,16 @@ import org.cyclops.commoncapabilities.api.ingredient.IIngredientMatcher; import org.cyclops.commoncapabilities.api.ingredient.IngredientComponent; import org.cyclops.commoncapabilities.api.ingredient.storage.IIngredientComponentStorage; +import org.cyclops.cyclopscore.ingredient.collection.IIngredientCollapsedCollectionMutable; +import org.cyclops.cyclopscore.ingredient.collection.IngredientCollectionHelpers; +import org.cyclops.cyclopscore.ingredient.storage.IngredientComponentStorageCollectionWrapper; import org.cyclops.integratedterminals.client.gui.container.ContainerScreenTerminalStorage; import org.cyclops.integratedterminals.core.terminalstorage.query.SearchMode; import javax.annotation.Nullable; import java.util.Collection; import java.util.List; +import java.util.function.BiConsumer; import java.util.function.Predicate; /** @@ -180,6 +185,93 @@ public default void insertMaxIntoContainer(IIngredientComponentStorage sto */ public T insertIntoContainer(IIngredientComponentStorage storage, AbstractContainerMenu container, int containerSlot, T maxInstance, @Nullable Player player, boolean transferFullSelection); + /** + * Simulate {@link #insertMaxIntoContainer(IIngredientComponentStorage, AbstractContainerMenu, int, int, Object)} + * against the client-side container, so that its effect can be shown before the server confirms it. + * + * The container is modified, the storage is not, as the client has no storage to modify. + * + * This is part of this capability, and not a helper next to its only caller, + * so that a handler whose movements can not run client-side can opt out of being predicted + * by returning an empty instance here. + * + * @param container The client-side container to insert to. + * @param containerSlotStart The container slot to start from. + * @param containerSlotEnd The container slot to end at (exclusive). + * @param instance The instance to move. + * @param availableQuantity The quantity that is expected to be available in the storage. + * @return The instance quantity that would be moved. + */ + public default T predictInsertMaxIntoContainer(AbstractContainerMenu container, int containerSlotStart, + int containerSlotEnd, T instance, long availableQuantity) { + return predictMovement(availableQuantity, instance, (storage, movedInstance) -> + insertMaxIntoContainer(storage, container, containerSlotStart, containerSlotEnd, movedInstance)); + } + + /** + * Simulate {@link #insertIntoContainer(IIngredientComponentStorage, AbstractContainerMenu, int, Object, Player, boolean)} + * against the client-side container, so that its effect can be shown before the server confirms it. + * + * The container is modified, the storage is not, as the client has no storage to modify. + * No player is passed, so that the container slot contents are never picked up by the prediction. + * The server may still do so, in which case the prediction simply moves nothing. + * + * @param container The client-side container to insert to. + * @param containerSlot The container slot to insert to. + * @param maxInstance The instance to move. + * @param transferFullSelection If the selected stack should be moved fully. + * @param availableQuantity The quantity that is expected to be available in the storage. + * @return The instance quantity that would be moved. + */ + public default T predictInsertIntoContainer(AbstractContainerMenu container, int containerSlot, T maxInstance, + boolean transferFullSelection, long availableQuantity) { + return predictMovement(availableQuantity, maxInstance, (storage, movedInstance) -> + insertIntoContainer(storage, container, containerSlot, movedInstance, null, transferFullSelection)); + } + + /** + * Simulate {@link #extractMaxFromContainerSlot(IIngredientComponentStorage, AbstractContainerMenu, int, Inventory, int)} + * against the client-side container, so that its effect can be shown before the server confirms it. + * + * The container is modified, the storage is not, as the client has no storage to modify. + * + * @param container The client-side container to extract from. + * @param containerSlot The container slot to extract from. + * @param playerInventory The active player inventory. + * @param limit The max limit. -1 is no limit. + * @return The instance quantity that would be moved. + */ + public default T predictExtractMaxFromContainerSlot(AbstractContainerMenu container, int containerSlot, + Inventory playerInventory, int limit) { + IIngredientCollapsedCollectionMutable collection = IngredientCollectionHelpers + .createCollapsedCollection(getComponent()); + extractMaxFromContainerSlot(new IngredientComponentStorageCollectionWrapper<>(collection), + container, containerSlot, playerInventory, limit); + return Iterables.getFirst(collection, getComponent().getMatcher().getEmptyInstance()); + } + + /** + * Run the given movement against a storage that holds the given available quantity of the given instance, + * and determine how much was taken out of it. + * + * The storage that is simulated has no rate limit, while the network may have one, + * in which case the prediction moves more than the server will. + * The client can not know that limit, so such a prediction is left to expire. + */ + private T predictMovement(long availableQuantity, T instance, + BiConsumer, T> movement) { + IIngredientMatcher matcher = getComponent().getMatcher(); + if (availableQuantity <= 0) { + return matcher.getEmptyInstance(); + } + IIngredientCollapsedCollectionMutable collection = IngredientCollectionHelpers + .createCollapsedCollection(getComponent()); + collection.add(matcher.withQuantity(instance, availableQuantity)); + // The movement may modify the instance it is given, so it never gets the caller's instance + movement.accept(new IngredientComponentStorageCollectionWrapper<>(collection), matcher.copy(instance)); + return matcher.withQuantity(instance, availableQuantity - collection.getQuantity(instance)); + } + /** * Move the ingredient in the active player stack to the storage. * @param storage The storage to insert to. diff --git a/src/main/java/org/cyclops/integratedterminals/api/terminalstorage/ITerminalStorageTabClient.java b/src/main/java/org/cyclops/integratedterminals/api/terminalstorage/ITerminalStorageTabClient.java index 6db5dae819..3e1e4ad0ef 100644 --- a/src/main/java/org/cyclops/integratedterminals/api/terminalstorage/ITerminalStorageTabClient.java +++ b/src/main/java/org/cyclops/integratedterminals/api/terminalstorage/ITerminalStorageTabClient.java @@ -136,6 +136,22 @@ public boolean handleClick(AbstractContainerMenu container, int channel, int hov boolean hasClickedOutside, boolean hasClickedInStorage, int hoveredContainerSlot, boolean isQuickMove); + /** + * If {@link #handleClick} should already be called when the mouse button goes down, + * instead of when it is released. + * + * Clicks are handled on release by default, as the press may also be the start of a drag. + * Tabs can return true for the clicks that can never start a drag, so that they are applied instantly. + * This is only called when the player's cursor is empty. + * + * @param channel The active channel. + * @param hoveringStorageSlot The storage slot id that is being hovered. -1 if none. + * @return If the click should be handled on press. + */ + public default boolean isClickHandledOnPress(int channel, int hoveringStorageSlot) { + return false; + } + /** * Called when a mouse scroll happens in a gui. * @param container The active container. diff --git a/src/main/java/org/cyclops/integratedterminals/capability/ingredient/IngredientComponentTerminalStorageHandlerFluidStack.java b/src/main/java/org/cyclops/integratedterminals/capability/ingredient/IngredientComponentTerminalStorageHandlerFluidStack.java index 8844232f84..0737001f34 100644 --- a/src/main/java/org/cyclops/integratedterminals/capability/ingredient/IngredientComponentTerminalStorageHandlerFluidStack.java +++ b/src/main/java/org/cyclops/integratedterminals/capability/ingredient/IngredientComponentTerminalStorageHandlerFluidStack.java @@ -37,6 +37,7 @@ import org.cyclops.integratedterminals.capability.ingredient.sorter.FluidStackQuantitySorter; import org.cyclops.integratedterminals.client.gui.container.ContainerScreenTerminalStorage; import org.cyclops.integratedterminals.client.gui.tooltip.TooltipRenderHelpers; +import org.cyclops.integratedterminals.core.terminalstorage.query.IngredientQueryMatchers; import org.cyclops.integratedterminals.core.terminalstorage.query.SearchMode; import javax.annotation.Nullable; @@ -233,14 +234,15 @@ public void drainActivePlayerStackQuantity(Inventory playerInventory, AbstractCo @Override @OnlyIn(Dist.CLIENT) public Predicate getInstanceFilterPredicate(SearchMode searchMode, String query) { + Predicate matcher = IngredientQueryMatchers.containsQuery(query); return switch (searchMode) { - case MOD -> i -> BuiltInRegistries.FLUID.getKey(i.getFluid()).getNamespace() - .toLowerCase(Locale.ENGLISH).matches(".*" + query + ".*"); + case MOD -> i -> matcher.test(BuiltInRegistries.FLUID.getKey(i.getFluid()).getNamespace() + .toLowerCase(Locale.ENGLISH)); case TOOLTIP -> i -> false; // Fluids have no tooltip case TAG -> i -> i.getFluid().builtInRegistryHolder().tags() - .filter(tag -> tag.location().toString().toLowerCase(Locale.ENGLISH).matches(".*" + query + ".*")) + .filter(tag -> matcher.test(tag.location().toString().toLowerCase(Locale.ENGLISH))) .anyMatch(tag -> !BuiltInRegistries.FLUID.getTag(tag).isEmpty()); - case DEFAULT -> i -> i != null && i.getHoverName().getString().toLowerCase(Locale.ENGLISH).matches(".*" + query + ".*"); + case DEFAULT -> i -> i != null && matcher.test(i.getHoverName().getString().toLowerCase(Locale.ENGLISH)); }; } diff --git a/src/main/java/org/cyclops/integratedterminals/capability/ingredient/IngredientComponentTerminalStorageHandlerItemStack.java b/src/main/java/org/cyclops/integratedterminals/capability/ingredient/IngredientComponentTerminalStorageHandlerItemStack.java index 2d3c4d2287..b63be24a61 100644 --- a/src/main/java/org/cyclops/integratedterminals/capability/ingredient/IngredientComponentTerminalStorageHandlerItemStack.java +++ b/src/main/java/org/cyclops/integratedterminals/capability/ingredient/IngredientComponentTerminalStorageHandlerItemStack.java @@ -33,6 +33,7 @@ import org.cyclops.integratedterminals.capability.ingredient.sorter.ItemStackQuantitySorter; import org.cyclops.integratedterminals.client.gui.container.ContainerScreenTerminalStorage; import org.cyclops.integratedterminals.client.gui.tooltip.TooltipRenderHelpers; +import org.cyclops.integratedterminals.core.terminalstorage.query.IngredientQueryMatchers; import org.cyclops.integratedterminals.core.terminalstorage.query.SearchMode; import org.lwjgl.opengl.GL11; @@ -243,16 +244,16 @@ public void drainActivePlayerStackQuantity(Inventory playerInventory, AbstractCo @Override @OnlyIn(Dist.CLIENT) public Predicate getInstanceFilterPredicate(SearchMode searchMode, String query) { + Predicate matcher = IngredientQueryMatchers.containsQuery(query); return switch (searchMode) { - case MOD -> i -> Optional.ofNullable(i.getItem().getCreatorModId(i)) - .orElse("minecraft").toLowerCase(Locale.ENGLISH) - .matches(".*" + query + ".*"); + case MOD -> i -> matcher.test(Optional.ofNullable(i.getItem().getCreatorModId(i)) + .orElse("minecraft").toLowerCase(Locale.ENGLISH)); case TOOLTIP -> i -> i.getTooltipLines(Item.TooltipContext.of(Minecraft.getInstance().player.registryAccess()), Minecraft.getInstance().player, TooltipFlag.Default.NORMAL).stream() - .anyMatch(s -> s.getString().toLowerCase(Locale.ENGLISH).matches(".*" + query + ".*")); + .anyMatch(s -> matcher.test(s.getString().toLowerCase(Locale.ENGLISH))); case TAG -> i -> i.getItem().builtInRegistryHolder().tags() - .filter(tag -> tag.location().toString().toLowerCase(Locale.ENGLISH).matches(".*" + query + ".*")) + .filter(tag -> matcher.test(tag.location().toString().toLowerCase(Locale.ENGLISH))) .anyMatch(tag -> !BuiltInRegistries.ITEM.getTag(tag).isEmpty()); - case DEFAULT -> i -> i.getHoverName().getString().toLowerCase(Locale.ENGLISH).matches(".*" + query + ".*"); + case DEFAULT -> i -> matcher.test(i.getHoverName().getString().toLowerCase(Locale.ENGLISH)); }; } diff --git a/src/main/java/org/cyclops/integratedterminals/client/gui/container/ContainerScreenTerminalStorage.java b/src/main/java/org/cyclops/integratedterminals/client/gui/container/ContainerScreenTerminalStorage.java index 9c28003a82..880b51db5d 100644 --- a/src/main/java/org/cyclops/integratedterminals/client/gui/container/ContainerScreenTerminalStorage.java +++ b/src/main/java/org/cyclops/integratedterminals/client/gui/container/ContainerScreenTerminalStorage.java @@ -606,6 +606,22 @@ && mouseX > getGuiLeft() + TAB_OFFSET_X } }); + // Handle clicks that don't have to wait for the mouse button to be released + if (this.clicked && tabOptional.isPresent() && getMenu().getCarried().isEmpty()) { + ITerminalStorageTabClient tab = tabOptional.get(); + int slot = getStorageSlotIndexAtPosition(mouseX, mouseY); + if (tab.isClickHandledOnPress(getMenu().getSelectedChannel(), slot)) { + this.clicked = false; // To avoid handling this click again on mouse release + Slot playerSlot = getSlotUnderMouse(); + if (tab.handleClick(getMenu(), getMenu().getSelectedChannel(), slot, mouseButton, + this.hasClickedOutside(mouseX, mouseY, this.leftPos, this.topPos, mouseButton), + this.hasClickedInStorage(mouseX, mouseY), + playerSlot != null ? playerSlot.index : -1, false)) { + return true; + } + } + } + return super.mouseClicked(mouseX, mouseY, mouseButton); } diff --git a/src/main/java/org/cyclops/integratedterminals/core/terminalstorage/ContainerHelpers.java b/src/main/java/org/cyclops/integratedterminals/core/terminalstorage/ContainerHelpers.java new file mode 100644 index 0000000000..ce1f94e50b --- /dev/null +++ b/src/main/java/org/cyclops/integratedterminals/core/terminalstorage/ContainerHelpers.java @@ -0,0 +1,55 @@ +package org.cyclops.integratedterminals.core.terminalstorage; + +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.ItemStack; + +import java.util.List; +import java.util.Map; + +/** + * Helpers for telling the server which container slots a client-side prediction changed. + * + * This is the same reconciliation that vanilla container clicks use: + * the client sends the slots it changed, and the server only sends back the ones it disagrees with. + * + * @author rubensworks + */ +public final class ContainerHelpers { + + private ContainerHelpers() { + } + + /** + * @param container A container. + * @return A copy of the contents of all slots in the given container. + */ + public static List copyContents(AbstractContainerMenu container) { + List contents = Lists.newArrayListWithExpectedSize(container.slots.size()); + for (Slot slot : container.slots) { + contents.add(slot.getItem().copy()); + } + return contents; + } + + /** + * Determine which slots of the given container changed since the given contents were copied. + * @param contentsBefore The contents from {@link #copyContents(AbstractContainerMenu)}. + * @param container The container, after it was changed. + * @return The new contents of the changed slots, by slot id. + */ + public static Map getChangedContents(List contentsBefore, + AbstractContainerMenu container) { + Map changed = Maps.newHashMap(); + for (int i = 0; i < contentsBefore.size(); i++) { + ItemStack after = container.getSlot(i).getItem(); + if (!ItemStack.matches(contentsBefore.get(i), after)) { + changed.put(i, after.copy()); + } + } + return changed; + } + +} diff --git a/src/main/java/org/cyclops/integratedterminals/core/terminalstorage/TerminalStorageIngredientPredictions.java b/src/main/java/org/cyclops/integratedterminals/core/terminalstorage/TerminalStorageIngredientPredictions.java new file mode 100644 index 0000000000..a361f51191 --- /dev/null +++ b/src/main/java/org/cyclops/integratedterminals/core/terminalstorage/TerminalStorageIngredientPredictions.java @@ -0,0 +1,209 @@ +package org.cyclops.integratedterminals.core.terminalstorage; + +import com.google.common.collect.Lists; +import org.cyclops.commoncapabilities.api.ingredient.IIngredientMatcher; +import org.cyclops.commoncapabilities.api.ingredient.IngredientComponent; +import org.cyclops.integrateddynamics.api.network.IPositionedAddonsNetwork; +import org.cyclops.integratedterminals.core.terminalstorage.TerminalStorageTabIngredientComponentClient.InstanceWithMetadata; + +import java.util.Iterator; +import java.util.List; + +/** + * Client-side predictions of instances that the player took out of the storage, + * but that the server has not confirmed yet. + * + * These are applied on top of the server-provided ingredients view, and are never merged into it, + * as the server sends diffs that would otherwise be applied twice. + * + * A prediction is dropped as soon as the server sends a change for its instance, + * or when it expires, so a wrong prediction always corrects itself. + * + * @param The instance type. + * @param The matching condition parameter. + * @author rubensworks + */ +public class TerminalStorageIngredientPredictions { + + /** + * The time after which unconfirmed predictions are dropped. + * This is only reached when the server did not send any change for the predicted instance, + * which means that the prediction was wrong. + */ + private static final long EXPIRY_TIME_MS = 2000; + + private final IngredientComponent ingredientComponent; + private final List> predictions; + + public TerminalStorageIngredientPredictions(IngredientComponent ingredientComponent) { + this.ingredientComponent = ingredientComponent; + this.predictions = Lists.newArrayList(); + } + + public boolean isEmpty() { + return this.predictions.isEmpty(); + } + + /** + * Predict that the given instance was removed from the given channel. + * @param channel The channel the instance was removed from. + * @param instance The removed instance, with the removed quantity. + */ + public void add(int channel, T instance) { + if (!this.ingredientComponent.getMatcher().isEmpty(instance)) { + this.predictions.add(new Prediction<>(channel, instance, + System.currentTimeMillis() + EXPIRY_TIME_MS)); + } + } + + /** + * Drop all predictions that were not confirmed in time. + * @return If at least one prediction was dropped. + */ + public boolean removeExpired() { + long time = System.currentTimeMillis(); + return this.predictions.removeIf(prediction -> prediction.getExpiryTime() <= time); + } + + /** + * Confirm the given change that the server has sent. + * + * The changed quantities are subtracted from the predictions that expected them, + * so that predictions that the change does not cover yet remain shown. + * Without this, a second click would briefly be shown as undone + * when the server confirms the first one. + * + * Predictions are matched on their instance only, deliberately not on their channel: + * a click in the wildcard channel is stored under that channel, + * while the server confirms it in the channel it actually happened in. + * The cost is that unrelated network activity for the same instance confirms a prediction early, + * after which the shown quantity falls back to the server's until the real change arrives. + * + * @param instances The removed instances. + * @return If at least one prediction was confirmed. + */ + public boolean consume(Iterable instances) { + if (this.predictions.isEmpty()) { + return false; + } + IIngredientMatcher matcher = this.ingredientComponent.getMatcher(); + M matchCondition = matcher.getExactMatchNoQuantityCondition(); + boolean consumed = false; + for (T instance : instances) { + long remaining = matcher.getQuantity(instance); + Iterator> it = this.predictions.iterator(); + while (it.hasNext() && remaining > 0) { + Prediction prediction = it.next(); + if (matcher.matches(prediction.getInstance(), instance, matchCondition)) { + long quantity = matcher.getQuantity(prediction.getInstance()); + if (quantity <= remaining) { + remaining -= quantity; + it.remove(); + } else { + prediction.setInstance(matcher.withQuantity(prediction.getInstance(), quantity - remaining)); + remaining = 0; + } + consumed = true; + } + } + } + return consumed; + } + + /** + * @param channel The channel that is being viewed. + * @param instance An instance. + * @return The predicted quantity change for the given instance, which is negative or zero. + */ + public long getDelta(int channel, T instance) { + IIngredientMatcher matcher = this.ingredientComponent.getMatcher(); + M matchCondition = matcher.getExactMatchNoQuantityCondition(); + long delta = 0; + for (Prediction prediction : this.predictions) { + if (appliesTo(prediction, channel) + && matcher.matches(prediction.getInstance(), instance, matchCondition)) { + delta -= matcher.getQuantity(prediction.getInstance()); + } + } + return delta; + } + + /** + * Apply all predictions of the given channel to the given ingredients view. + * @param channel The channel that is being viewed. + * @param view A mutable ingredients view, without any predictions applied yet. + */ + public void apply(int channel, List> view) { + if (this.predictions.isEmpty()) { + return; + } + IIngredientMatcher matcher = this.ingredientComponent.getMatcher(); + M matchCondition = matcher.getExactMatchNoQuantityCondition(); + for (Prediction prediction : this.predictions) { + if (!appliesTo(prediction, channel)) { + continue; + } + long removed = matcher.getQuantity(prediction.getInstance()); + for (int i = 0; i < view.size(); i++) { + InstanceWithMetadata entry = view.get(i); + // Crafting option entries show a recipe output, not a stored quantity, so they are never predicted + if (entry.getCraftingOption() == null + && matcher.matches(entry.getInstance(), prediction.getInstance(), matchCondition)) { + long quantity = matcher.getQuantity(entry.getInstance()) - removed; + if (quantity <= 0) { + view.remove(i); + } else { + view.set(i, new InstanceWithMetadata<>( + matcher.withQuantity(entry.getInstance(), quantity), null)); + } + break; + } + } + } + } + + /** + * Predictions are stored for the channel they were caused in, + * and are also shown in the wildcard channel, just like the server-sent changes. + */ + protected boolean appliesTo(Prediction prediction, int channel) { + return prediction.getChannel() == channel + || (channel == IPositionedAddonsNetwork.WILDCARD_CHANNEL + && prediction.getChannel() != IPositionedAddonsNetwork.WILDCARD_CHANNEL); + } + + public static class Prediction { + + private final int channel; + private T instance; + private final long expiryTime; + + public Prediction(int channel, T instance, long expiryTime) { + this.channel = channel; + this.instance = instance; + this.expiryTime = expiryTime; + } + + public int getChannel() { + return channel; + } + + public T getInstance() { + return instance; + } + + /** + * Reduce this prediction to the part that the server has not confirmed yet. + * @param instance The remaining instance. + */ + public void setInstance(T instance) { + this.instance = instance; + } + + public long getExpiryTime() { + return expiryTime; + } + + } + +} diff --git a/src/main/java/org/cyclops/integratedterminals/core/terminalstorage/TerminalStorageTabIngredientComponentClient.java b/src/main/java/org/cyclops/integratedterminals/core/terminalstorage/TerminalStorageTabIngredientComponentClient.java index c8f80b9d08..2a8642449f 100644 --- a/src/main/java/org/cyclops/integratedterminals/core/terminalstorage/TerminalStorageTabIngredientComponentClient.java +++ b/src/main/java/org/cyclops/integratedterminals/core/terminalstorage/TerminalStorageTabIngredientComponentClient.java @@ -23,6 +23,7 @@ import net.neoforged.bus.api.SubscribeEvent; import net.neoforged.neoforge.common.NeoForge; import net.neoforged.neoforge.event.entity.player.ItemTooltipEvent; +import org.apache.logging.log4j.Level; import org.cyclops.commoncapabilities.api.ingredient.IIngredientMatcher; import org.cyclops.commoncapabilities.api.ingredient.IngredientComponent; import org.cyclops.cyclopscore.client.gui.image.Images; @@ -106,6 +107,7 @@ public class TerminalStorageTabIngredientComponentClient private final List> buttons; private final Int2ObjectMap> ingredientsUnsortedViews; + private final TerminalStorageIngredientPredictions predictions; private final Int2ObjectMap>> filteredIngredientsViews; private final Int2ObjectMap>> lastFilteredIngredientsViews; private final Int2ObjectMap>> craftingOptions; @@ -153,6 +155,7 @@ public TerminalStorageTabIngredientComponentClient(ContainerTerminalStorageBase this.buttons = event.getButtons(); this.ingredientsUnsortedViews = new Int2ObjectOpenHashMap<>(); + this.predictions = new TerminalStorageIngredientPredictions<>(this.ingredientComponent); this.filteredIngredientsViews = new Int2ObjectOpenHashMap<>(); this.lastFilteredIngredientsViews = new Int2ObjectOpenHashMap<>(); this.craftingOptions = new Int2ObjectOpenHashMap<>(); @@ -380,6 +383,9 @@ public List> createUnfilteredIngredientsView(int channel enrichedIngredients.add(new InstanceWithMetadata<>(persistedIngredient, null)); } + // Show the effect of clicks that the server has not confirmed yet + this.predictions.apply(channel, enrichedIngredients); + // Add all crafting option outputs Collection> craftingOptions = getCraftingOptions(channel); if (craftingOptions != null) { @@ -404,14 +410,20 @@ public Collection getUniqueCraftingOptionOutputs(ITerminalCraftingOption c protected List> getFilteredIngredientsView(int channel) { updateSortingPausedState(channel); + if (this.predictions.removeExpired()) { + this.lastChangeId++; + // Predictions are applied to all channel views, so all of them have to be rebuilt + this.filteredIngredientsViews.clear(); + } List> ingredientsView = filteredIngredientsViews.get(channel); if (ingredientsView == null) { ingredientsView = createUnfilteredIngredientsView(channel); // Filter + IIngredientQuery query = IIngredientQuery.parse(ingredientComponent, getInstanceFilter(channel)); ingredientsView = Lists.newArrayList( this.transformIngredientsView(ingredientsView.stream()) - .filter(im -> IIngredientQuery.parse(ingredientComponent, getInstanceFilter(channel)).test(im.getInstance())) + .filter(im -> query.test(im.getInstance())) .filter(getInstanceFilterMetadata()) .collect(Collectors.toList())); @@ -586,6 +598,15 @@ public synchronized void onChange(int channel, IIngredientComponentStorageObserv long newQuantity = totalQuantities.get(channel) + quantity; totalQuantities.put(channel, newQuantity); + // Confirm the predictions that this change covers. + // This is deliberately skipped for the wildcard channel, as that one is a copy of this same change. + if (channel != IPositionedAddonsNetwork.WILDCARD_CHANNEL + && changeType == IIngredientComponentStorageObservable.Change.DELETION + && this.predictions.consume(ingredients)) { + // Predictions are applied to all channel views, so all of them have to be rebuilt + this.filteredIngredientsViews.clear(); + } + // Apply diff IIngredientCollapsedCollectionMutable rawPersistedIngredients = getRawUnfilteredIngredientsView(channel); IngredientCollectionDiff diff = new IngredientCollectionDiff<>( @@ -852,11 +873,24 @@ public boolean handleClick(AbstractContainerMenu container, int channel, int hov activeInstance = matcher.withQuantity(slot.getInstance(), moveQuantity); } } + // Predict before sending, so that the server is told which container slots we changed. + // It can only correct a wrong prediction for slots that it knows we changed, + // as it sends us the slots that changed for it, which are not always the same ones. + List containerBefore = ContainerHelpers.copyContents(container); + try { + predictClick(container, clickType, channel, hoveringStorageInstance.orElse(matcher.getEmptyInstance()), + hoveredContainerSlot, activeInstance, transferFullSelection); + } catch (Exception e) { + // Predicting runs the ingredient component's own movement logic, which may not expect this. + // The click itself must still reach the server, so it is only shown later instead of not at all. + IntegratedTerminals.clog(Level.WARN, "Could not predict a storage terminal click: " + e); + } IntegratedTerminals._instance.getPacketHandler().sendToServer(new TerminalStorageIngredientSlotClickPacket<>( player.level().registryAccess(), this.getName().toString(), ingredientComponent, clickType, channel, hoveringStorageInstance.orElse(matcher.getEmptyInstance()), - hoveredContainerSlot, movePlayerQuantity, activeInstance, transferFullSelection)); + hoveredContainerSlot, movePlayerQuantity, activeInstance, transferFullSelection, + ContainerHelpers.getChangedContents(containerBefore, container))); if (reset) { resetActiveSlot(); } @@ -867,6 +901,99 @@ public boolean handleClick(AbstractContainerMenu container, int channel, int hov return false; } + /** + * Show the effect of the given click before the server has confirmed it. + * + * The server remains the only source of truth: predictions are shown on top of the server-sent state, + * and are dropped again as soon as the server has sent a change for the predicted instance. + * A prediction that the server does not confirm expires by itself. + * + * @param container The active container. + * @param clickType The click that was sent to the server. + * @param channel The active channel. + * @param hoveringStorageInstance The storage instance that is being hovered. + * @param hoveredContainerSlot The container slot id that is being hovered. -1 if none. + * @param activeInstance The selected storage instance, with the quantity that is being moved. + * @param transferFullSelection If the selected stack should be moved fully. + */ + protected void predictClick(AbstractContainerMenu container, TerminalClickType clickType, int channel, + T hoveringStorageInstance, int hoveredContainerSlot, T activeInstance, + boolean transferFullSelection) { + if (!GeneralConfig.guiStoragePredictInteractions) { + return; + } + + IIngredientMatcher matcher = this.ingredientComponent.getMatcher(); + IIngredientComponentTerminalStorageHandler viewHandler = getViewHandler(); + switch (clickType) { + case STORAGE_QUICK_MOVE: + case STORAGE_QUICK_MOVE_INCREMENTAL: { + T requested = clickType == TerminalClickType.STORAGE_QUICK_MOVE + ? hoveringStorageInstance + : matcher.withQuantity(hoveringStorageInstance, Math.min( + viewHandler.getIncrementalInstanceMovementQuantity(), + matcher.getQuantity(hoveringStorageInstance))); + addPrediction(channel, viewHandler.predictInsertMaxIntoContainer(container, 0, 4 * 9, requested, + getPredictedQuantity(channel, requested))); + break; + } + case STORAGE_PLACE_PLAYER: + addPrediction(channel, viewHandler.predictInsertIntoContainer(container, hoveredContainerSlot, + activeInstance, transferFullSelection, + getPredictedQuantity(channel, activeInstance))); + break; + case PLAYER_QUICK_MOVE: + case PLAYER_QUICK_MOVE_INCREMENTAL: + // Only the slot that the instance leaves is predicted, not its arrival in the storage: + // whether the storage accepts it depends on position filters, on the free space per position, + // and on the network's transfer rate, none of which the client knows. + viewHandler.predictExtractMaxFromContainerSlot(container, hoveredContainerSlot, + Minecraft.getInstance().player.getInventory(), + clickType == TerminalClickType.PLAYER_QUICK_MOVE + ? -1 : viewHandler.getIncrementalInstanceMovementQuantity()); + break; + case STORAGE_PLACE_WORLD: + case PLAYER_PLACE_STORAGE: + // Nothing to predict: the thrown instance leaves the storage in one piece or not at all, + // and the instance placed from the cursor is drained client-side already. + break; + } + } + + protected synchronized void addPrediction(int channel, T instance) { + if (!this.ingredientComponent.getMatcher().isEmpty(instance)) { + // Remember the selected instance, as this change might change its position or quantity. + Optional lastInstance = getSlotInstance(channel, this.activeSlotId); + + this.predictions.add(channel, instance); + this.lastChangeId++; + // Predictions are applied to all channel views, so all of them have to be rebuilt. + // The sorting order that is kept while sorting is paused is deliberately not reset, + // just like for the changes that are sent by the server. + this.filteredIngredientsViews.clear(); + + // Update the active instance by searching for its new position in the slots + updateActiveInstance(lastInstance, channel); + } + } + + /** + * @param channel A channel id. + * @param instance An instance. + * @return The quantity of the given instance that is currently being shown, predictions included. + */ + protected long getPredictedQuantity(int channel, T instance) { + return Math.max(0, getRawUnfilteredIngredientsView(channel).getQuantity(instance) + + this.predictions.getDelta(channel, instance)); + } + + @Override + public boolean isClickHandledOnPress(int channel, int hoveringStorageSlot) { + // Clicks that can not start a drag over the player inventory are applied as soon as the button goes down, + // just like vanilla containers do when the cursor is empty. + return hoveringStorageSlot >= 0 && getActiveSlotId() < 0; + } + @Override public boolean handleScroll(AbstractContainerMenu container, int channel, int hoveringStorageSlot, double delta, boolean hasClickedOutside, boolean hasClickedInStorage, int hoveredContainerSlot) { diff --git a/src/main/java/org/cyclops/integratedterminals/core/terminalstorage/TerminalStorageTabIngredientComponentServer.java b/src/main/java/org/cyclops/integratedterminals/core/terminalstorage/TerminalStorageTabIngredientComponentServer.java index b47df51076..1e824f7eeb 100644 --- a/src/main/java/org/cyclops/integratedterminals/core/terminalstorage/TerminalStorageTabIngredientComponentServer.java +++ b/src/main/java/org/cyclops/integratedterminals/core/terminalstorage/TerminalStorageTabIngredientComponentServer.java @@ -9,6 +9,7 @@ import net.minecraft.resources.ResourceLocation; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.item.ItemStack; import net.neoforged.neoforge.server.ServerLifecycleHooks; import org.apache.commons.lang3.tuple.Pair; import org.cyclops.commoncapabilities.api.ingredient.IIngredientMatcher; @@ -468,7 +469,8 @@ public IPositionedAddonsNetworkIngredients getIngredientNetwork() { @Nullable public void handleStorageSlotClick(AbstractContainerMenu container, ServerPlayer player, TerminalClickType clickType, int channel, T hoveringStorageInstance, int hoveredContainerSlot, - long moveQuantityPlayerSlot, T activeStorageInstance, boolean transferFullSelection) { + long moveQuantityPlayerSlot, T activeStorageInstance, boolean transferFullSelection, + Map predictedContainerSlots) { IIngredientComponentTerminalStorageHandler viewHandler = ingredientComponent.getCapability(org.cyclops.integratedterminals.Capabilities.IngredientComponentTerminalStorageHandler.INGREDIENT) .orElseThrow(() -> new IllegalStateException("Could not find an ingredient terminal storage handler")); IIngredientComponentStorage storage = ingredientNetwork.getChannel(channel); @@ -512,5 +514,18 @@ public void handleStorageSlotClick(AbstractContainerMenu container, ServerPlayer if (updateActivePlayerStack) { player.connection.send(new ClientboundContainerSetSlotPacket(-1, 0, 0, container.getCarried())); } + + // Tell the container what the client made of this click, + // so that the slots we disagree about are the only ones that are sent back to it. + // Without this, a slot that only the client changed would stay wrong, + // as we only send the slots that changed for us. + // This is the same reconciliation that vanilla container clicks use. + for (Map.Entry predictedSlot : predictedContainerSlots.entrySet()) { + int slot = predictedSlot.getKey(); + if (slot >= 0 && slot < container.slots.size()) { + container.setRemoteSlotNoCopy(slot, predictedSlot.getValue()); + } + } + container.broadcastChanges(); } } diff --git a/src/main/java/org/cyclops/integratedterminals/core/terminalstorage/query/IngredientQueryLeaf.java b/src/main/java/org/cyclops/integratedterminals/core/terminalstorage/query/IngredientQueryLeaf.java index 4f6607e04c..047c4db3d4 100644 --- a/src/main/java/org/cyclops/integratedterminals/core/terminalstorage/query/IngredientQueryLeaf.java +++ b/src/main/java/org/cyclops/integratedterminals/core/terminalstorage/query/IngredientQueryLeaf.java @@ -15,7 +15,13 @@ public class IngredientQueryLeaf implements IIngredientQuery { public IngredientQueryLeaf(String query, IIngredientComponentTerminalStorageHandler handler) { Pair parsed = parseQuery(query); - this.tester = handler.getInstanceFilterPredicate(parsed.getLeft(), parsed.getRight()); + if (parsed.getLeft() == SearchMode.DEFAULT && parsed.getRight().isEmpty()) { + // An empty search matches everything, so don't resolve every instance's name to find that out. + // This is the state the terminal is in whenever the player is not searching. + this.tester = t -> true; + } else { + this.tester = handler.getInstanceFilterPredicate(parsed.getLeft(), parsed.getRight()); + } } public static Pair parseQuery(String query) { diff --git a/src/main/java/org/cyclops/integratedterminals/core/terminalstorage/query/IngredientQueryMatchers.java b/src/main/java/org/cyclops/integratedterminals/core/terminalstorage/query/IngredientQueryMatchers.java new file mode 100644 index 0000000000..296dd932fb --- /dev/null +++ b/src/main/java/org/cyclops/integratedterminals/core/terminalstorage/query/IngredientQueryMatchers.java @@ -0,0 +1,36 @@ +package org.cyclops.integratedterminals.core.terminalstorage.query; + +import java.util.function.Predicate; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; + +/** + * Helpers for matching strings against a search query. + * @author rubensworks + */ +public final class IngredientQueryMatchers { + + private IngredientQueryMatchers() { + } + + /** + * Create a matcher for the given query. + * + * The query is compiled only once, as the returned matcher is called + * for every shown ingredient, every time the view is rebuilt. + * + * @param query A query string, which may be a regex. + * @return A matcher that tests if a string contains the given query. + * Invalid queries match nothing. + */ + public static Predicate containsQuery(String query) { + Pattern pattern; + try { + pattern = Pattern.compile(".*" + query + ".*"); + } catch (PatternSyntaxException e) { + return value -> false; + } + return value -> pattern.matcher(value).matches(); + } + +} diff --git a/src/main/java/org/cyclops/integratedterminals/gametest/GameTestIngredientQueryMatchers.java b/src/main/java/org/cyclops/integratedterminals/gametest/GameTestIngredientQueryMatchers.java new file mode 100644 index 0000000000..dd4a7ce2c5 --- /dev/null +++ b/src/main/java/org/cyclops/integratedterminals/gametest/GameTestIngredientQueryMatchers.java @@ -0,0 +1,63 @@ +package org.cyclops.integratedterminals.gametest; + +import net.minecraft.gametest.framework.GameTest; +import net.minecraft.gametest.framework.GameTestHelper; +import net.neoforged.neoforge.gametest.GameTestHolder; +import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; +import org.cyclops.integratedterminals.Reference; +import org.cyclops.integratedterminals.core.terminalstorage.query.IngredientQueryMatchers; + +import java.util.function.Predicate; + +/** + * Game tests for the storage terminal search query matchers. + * @author rubensworks + */ +@GameTestHolder(Reference.MOD_ID) +@PrefixGameTestTemplate(false) +public class GameTestIngredientQueryMatchers { + + @GameTest(template = "empty", templateNamespace = "cyclopscore") + public void testSubstringsMatch(GameTestHelper helper) { + Predicate matcher = IngredientQueryMatchers.containsQuery("ton"); + + helper.assertTrue(matcher.test("stone"), "A contained query should match"); + helper.assertTrue(matcher.test("ton"), "An equal query should match"); + helper.assertTrue(!matcher.test("dirt"), "An absent query should not match"); + + helper.succeed(); + } + + @GameTest(template = "empty", templateNamespace = "cyclopscore") + public void testEmptyQueryMatchesEverything(GameTestHelper helper) { + Predicate matcher = IngredientQueryMatchers.containsQuery(""); + + helper.assertTrue(matcher.test("stone"), "An empty query should match"); + helper.assertTrue(matcher.test(""), "An empty query should match an empty value"); + + helper.succeed(); + } + + @GameTest(template = "empty", templateNamespace = "cyclopscore") + public void testRegexQueriesMatch(GameTestHelper helper) { + Predicate matcher = IngredientQueryMatchers.containsQuery("st[a-z]ne"); + + helper.assertTrue(matcher.test("stone"), "A matching value should match"); + helper.assertTrue(matcher.test("cobbled stone brick"), "A matching value should match anywhere"); + helper.assertTrue(!matcher.test("stne"), "A non-matching value should not match"); + helper.assertTrue(!matcher.test("dirt"), "Other values should not match"); + + helper.succeed(); + } + + @GameTest(template = "empty", templateNamespace = "cyclopscore") + public void testInvalidQueriesMatchNothing(GameTestHelper helper) { + Predicate matcher = IngredientQueryMatchers.containsQuery("["); + + helper.assertTrue(!matcher.test("stone"), "An invalid query should not match"); + helper.assertTrue(!matcher.test("["), "An invalid query should not even match itself"); + + helper.succeed(); + } + +} diff --git a/src/main/java/org/cyclops/integratedterminals/gametest/GameTestTerminalStorageClickPredictions.java b/src/main/java/org/cyclops/integratedterminals/gametest/GameTestTerminalStorageClickPredictions.java new file mode 100644 index 0000000000..cccf2491b2 --- /dev/null +++ b/src/main/java/org/cyclops/integratedterminals/gametest/GameTestTerminalStorageClickPredictions.java @@ -0,0 +1,190 @@ +package org.cyclops.integratedterminals.gametest; + +import net.minecraft.gametest.framework.GameTest; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.GameType; +import net.neoforged.neoforge.gametest.GameTestHolder; +import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; +import org.cyclops.commoncapabilities.IngredientComponents; +import org.cyclops.integratedterminals.Capabilities; +import org.cyclops.integratedterminals.Reference; +import org.cyclops.integratedterminals.api.ingredient.IIngredientComponentTerminalStorageHandler; +import org.cyclops.integratedterminals.core.terminalstorage.ContainerHelpers; + +import java.util.List; +import java.util.Map; + +/** + * Game tests for the client-side simulation of storage terminal clicks. + * + * These run the same movement logic that the server runs when it handles a click, + * so that what is predicted is what the server will do. + * + * @author rubensworks + */ +@GameTestHolder(Reference.MOD_ID) +@PrefixGameTestTemplate(false) +public class GameTestTerminalStorageClickPredictions { + + /** + * The player inventory slots in the player's own container. + */ + private static final int SLOT_START = 9; + private static final int SLOT_END = 45; + + private static IIngredientComponentTerminalStorageHandler getHandler() { + return IngredientComponents.ITEMSTACK + .getCapability(Capabilities.IngredientComponentTerminalStorageHandler.INGREDIENT) + .orElseThrow(() -> new IllegalStateException("Could not find an ingredient terminal storage handler")); + } + + private static AbstractContainerMenu createMenu(GameTestHelper helper) { + Player player = helper.makeMockPlayer(GameType.SURVIVAL); + player.getInventory().clearContent(); + return player.inventoryMenu; + } + + @GameTest(template = "empty", templateNamespace = "cyclopscore") + public void testQuickMoveMovesOneStack(GameTestHelper helper) { + AbstractContainerMenu menu = createMenu(helper); + + ItemStack moved = getHandler().predictInsertMaxIntoContainer(menu, SLOT_START, SLOT_END, + new ItemStack(Items.STONE, 500), 500); + + helper.assertTrue(moved.getCount() == 64, "One stack should be moved, but was " + moved.getCount()); + helper.assertTrue(menu.getSlot(SLOT_START).getItem().getCount() == 64, + "The first slot should hold one stack"); + + helper.succeed(); + } + + @GameTest(template = "empty", templateNamespace = "cyclopscore") + public void testQuickMoveIsLimitedByTheStorage(GameTestHelper helper) { + AbstractContainerMenu menu = createMenu(helper); + + ItemStack moved = getHandler().predictInsertMaxIntoContainer(menu, SLOT_START, SLOT_END, + new ItemStack(Items.STONE, 10), 10); + + helper.assertTrue(moved.getCount() == 10, "Only the available quantity should be moved"); + helper.assertTrue(menu.getSlot(SLOT_START).getItem().getCount() == 10, + "The first slot should hold the available quantity"); + + helper.succeed(); + } + + @GameTest(template = "empty", templateNamespace = "cyclopscore") + public void testQuickMoveWithoutStorageContentsMovesNothing(GameTestHelper helper) { + AbstractContainerMenu menu = createMenu(helper); + + ItemStack moved = getHandler().predictInsertMaxIntoContainer(menu, SLOT_START, SLOT_END, + new ItemStack(Items.STONE, 64), 0); + + helper.assertTrue(moved.isEmpty(), "Nothing should be moved"); + helper.assertTrue(menu.getSlot(SLOT_START).getItem().isEmpty(), "The first slot should stay empty"); + + helper.succeed(); + } + + @GameTest(template = "empty", templateNamespace = "cyclopscore") + public void testQuickMoveFillsPartialStacks(GameTestHelper helper) { + AbstractContainerMenu menu = createMenu(helper); + menu.getSlot(SLOT_START).set(new ItemStack(Items.STONE, 60)); + + ItemStack moved = getHandler().predictInsertMaxIntoContainer(menu, SLOT_START, SLOT_END, + new ItemStack(Items.STONE, 500), 500); + + helper.assertTrue(moved.getCount() == 64, "One stack should be moved, but was " + moved.getCount()); + helper.assertTrue(menu.getSlot(SLOT_START).getItem().getCount() == 64, + "The partial stack should be filled up"); + helper.assertTrue(menu.getSlot(SLOT_START + 1).getItem().getCount() == 60, + "The remainder should end up in the next slot"); + + helper.succeed(); + } + + @GameTest(template = "empty", templateNamespace = "cyclopscore") + public void testPlaceInSlotMovesTheSelection(GameTestHelper helper) { + AbstractContainerMenu menu = createMenu(helper); + + ItemStack moved = getHandler().predictInsertIntoContainer(menu, SLOT_START, + new ItemStack(Items.STONE, 16), true, 100); + + helper.assertTrue(moved.getCount() == 16, "The selection should be moved"); + helper.assertTrue(menu.getSlot(SLOT_START).getItem().getCount() == 16, "The slot should hold the selection"); + + helper.succeed(); + } + + @GameTest(template = "empty", templateNamespace = "cyclopscore") + public void testPlaceInOccupiedSlotMovesNothing(GameTestHelper helper) { + AbstractContainerMenu menu = createMenu(helper); + menu.getSlot(SLOT_START).set(new ItemStack(Items.DIRT, 1)); + + ItemStack moved = getHandler().predictInsertIntoContainer(menu, SLOT_START, + new ItemStack(Items.STONE, 16), true, 100); + + // The server picks the other item up into the player's cursor, which is deliberately not predicted + helper.assertTrue(moved.isEmpty(), "Nothing should be moved into a slot holding another item"); + helper.assertTrue(menu.getSlot(SLOT_START).getItem().getItem() == Items.DIRT, "The slot should be untouched"); + + helper.succeed(); + } + + @GameTest(template = "empty", templateNamespace = "cyclopscore") + public void testExtractFromSlotEmptiesIt(GameTestHelper helper) { + AbstractContainerMenu menu = createMenu(helper); + menu.getSlot(SLOT_START).set(new ItemStack(Items.STONE, 32)); + + ItemStack moved = getHandler().predictExtractMaxFromContainerSlot(menu, SLOT_START, + helper.makeMockPlayer(GameType.SURVIVAL).getInventory(), -1); + + helper.assertTrue(moved.getCount() == 32, "The whole slot should be moved"); + helper.assertTrue(menu.getSlot(SLOT_START).getItem().isEmpty(), "The slot should be emptied"); + + helper.succeed(); + } + + /** + * The slots that a prediction changed are sent to the server, + * so that it can correct the ones it disagrees with, including the ones it did not change itself. + */ + @GameTest(template = "empty", templateNamespace = "cyclopscore") + public void testChangedSlotsAreCollected(GameTestHelper helper) { + AbstractContainerMenu menu = createMenu(helper); + menu.getSlot(SLOT_START).set(new ItemStack(Items.STONE, 60)); + List before = ContainerHelpers.copyContents(menu); + + helper.assertTrue(ContainerHelpers.getChangedContents(before, menu).isEmpty(), + "An untouched container should report no changed slots"); + + // Fills the partial stack up and spills the rest into the next slot + getHandler().predictInsertMaxIntoContainer(menu, SLOT_START, SLOT_END, + new ItemStack(Items.STONE, 500), 500); + + Map changed = ContainerHelpers.getChangedContents(before, menu); + helper.assertTrue(changed.size() == 2, "Both filled slots should be reported, but got " + changed.size()); + helper.assertTrue(changed.get(SLOT_START).getCount() == 64, "The filled up slot should be reported"); + helper.assertTrue(changed.get(SLOT_START + 1).getCount() == 60, "The spilled slot should be reported"); + + helper.succeed(); + } + + @GameTest(template = "empty", templateNamespace = "cyclopscore") + public void testExtractFromSlotIsLimited(GameTestHelper helper) { + AbstractContainerMenu menu = createMenu(helper); + menu.getSlot(SLOT_START).set(new ItemStack(Items.STONE, 32)); + + ItemStack moved = getHandler().predictExtractMaxFromContainerSlot(menu, SLOT_START, + helper.makeMockPlayer(GameType.SURVIVAL).getInventory(), 2); + + helper.assertTrue(moved.getCount() == 2, "Only the limit should be moved"); + helper.assertTrue(menu.getSlot(SLOT_START).getItem().getCount() == 30, "The rest should stay in the slot"); + + helper.succeed(); + } + +} diff --git a/src/main/java/org/cyclops/integratedterminals/gametest/GameTestTerminalStorageIngredientPredictions.java b/src/main/java/org/cyclops/integratedterminals/gametest/GameTestTerminalStorageIngredientPredictions.java new file mode 100644 index 0000000000..fd64fee830 --- /dev/null +++ b/src/main/java/org/cyclops/integratedterminals/gametest/GameTestTerminalStorageIngredientPredictions.java @@ -0,0 +1,186 @@ +package org.cyclops.integratedterminals.gametest; + +import com.google.common.collect.Lists; +import net.minecraft.gametest.framework.GameTest; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.neoforged.neoforge.gametest.GameTestHolder; +import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; +import org.cyclops.commoncapabilities.IngredientComponents; +import org.cyclops.cyclopscore.ingredient.collection.IngredientArrayList; +import org.cyclops.integrateddynamics.api.network.IPositionedAddonsNetwork; +import org.cyclops.integratedterminals.Reference; +import org.cyclops.integratedterminals.core.terminalstorage.TerminalStorageIngredientPredictions; +import org.cyclops.integratedterminals.core.terminalstorage.TerminalStorageTabIngredientComponentClient.InstanceWithMetadata; + +import java.util.List; + +/** + * Game tests for the client-side predictions of storage terminal interactions. + * @author rubensworks + */ +@GameTestHolder(Reference.MOD_ID) +@PrefixGameTestTemplate(false) +public class GameTestTerminalStorageIngredientPredictions { + + private static final int CHANNEL = 3; + + private static TerminalStorageIngredientPredictions createPredictions() { + return new TerminalStorageIngredientPredictions<>(IngredientComponents.ITEMSTACK); + } + + private static List> createView(ItemStack... instances) { + List> view = Lists.newArrayList(); + for (ItemStack instance : instances) { + view.add(new InstanceWithMetadata<>(instance, null)); + } + return view; + } + + private static ItemStack getInstance(List> view, int index) { + return view.get(index).getInstance(); + } + + private static IngredientArrayList createChange(ItemStack... instances) { + return new IngredientArrayList<>(IngredientComponents.ITEMSTACK, instances); + } + + @GameTest(template = "empty", templateNamespace = "cyclopscore") + public void testEmptyPredictionsDontChangeTheView(GameTestHelper helper) { + TerminalStorageIngredientPredictions predictions = createPredictions(); + List> view = createView(new ItemStack(Items.STONE, 10)); + + helper.assertTrue(predictions.isEmpty(), "No predictions should be pending"); + predictions.apply(CHANNEL, view); + + helper.assertTrue(view.size() == 1, "The view should be unchanged"); + helper.assertTrue(getInstance(view, 0).getCount() == 10, "The quantity should be unchanged"); + + helper.succeed(); + } + + @GameTest(template = "empty", templateNamespace = "cyclopscore") + public void testRemovalIsSubtracted(GameTestHelper helper) { + TerminalStorageIngredientPredictions predictions = createPredictions(); + predictions.add(CHANNEL, new ItemStack(Items.STONE, 4)); + List> view = createView(new ItemStack(Items.STONE, 10)); + predictions.apply(CHANNEL, view); + + helper.assertTrue(view.size() == 1, "The instance should still be shown"); + helper.assertTrue(getInstance(view, 0).getCount() == 6, "4 stone should have been subtracted"); + helper.assertTrue(predictions.getDelta(CHANNEL, new ItemStack(Items.STONE)) == -4, + "The predicted delta should be negative"); + + helper.succeed(); + } + + @GameTest(template = "empty", templateNamespace = "cyclopscore") + public void testFullRemovalHidesTheInstance(GameTestHelper helper) { + TerminalStorageIngredientPredictions predictions = createPredictions(); + predictions.add(CHANNEL, new ItemStack(Items.STONE, 10)); + List> view = createView(new ItemStack(Items.STONE, 10), + new ItemStack(Items.DIRT, 2)); + predictions.apply(CHANNEL, view); + + helper.assertTrue(view.size() == 1, "The emptied instance should not be shown anymore"); + helper.assertTrue(getInstance(view, 0).getItem() == Items.DIRT, "Other instances should be kept"); + + helper.succeed(); + } + + @GameTest(template = "empty", templateNamespace = "cyclopscore") + public void testOtherInstancesAreUntouched(GameTestHelper helper) { + TerminalStorageIngredientPredictions predictions = createPredictions(); + predictions.add(CHANNEL, new ItemStack(Items.STONE, 4)); + List> view = createView(new ItemStack(Items.DIRT, 10)); + predictions.apply(CHANNEL, view); + + helper.assertTrue(getInstance(view, 0).getCount() == 10, "Other instances should be unchanged"); + + helper.succeed(); + } + + @GameTest(template = "empty", templateNamespace = "cyclopscore") + public void testPredictionsAreShownInTheWildcardChannel(GameTestHelper helper) { + TerminalStorageIngredientPredictions predictions = createPredictions(); + predictions.add(CHANNEL, new ItemStack(Items.STONE, 4)); + + List> wildcardView = createView(new ItemStack(Items.STONE, 10)); + predictions.apply(IPositionedAddonsNetwork.WILDCARD_CHANNEL, wildcardView); + helper.assertTrue(getInstance(wildcardView, 0).getCount() == 6, + "The prediction should also be shown in the wildcard channel"); + + List> otherView = createView(new ItemStack(Items.STONE, 10)); + predictions.apply(CHANNEL + 1, otherView); + helper.assertTrue(getInstance(otherView, 0).getCount() == 10, + "The prediction should not be shown in other channels"); + + helper.succeed(); + } + + @GameTest(template = "empty", templateNamespace = "cyclopscore") + public void testServerChangeConfirmsPrediction(GameTestHelper helper) { + TerminalStorageIngredientPredictions predictions = createPredictions(); + predictions.add(CHANNEL, new ItemStack(Items.STONE, 4)); + + helper.assertTrue(!predictions.consume(createChange(new ItemStack(Items.DIRT, 4))), + "A change for another instance should not confirm the prediction"); + helper.assertTrue(!predictions.isEmpty(), "The prediction should still be pending"); + + helper.assertTrue(predictions.consume(createChange(new ItemStack(Items.STONE, 4))), + "A change for the predicted instance should confirm the prediction"); + helper.assertTrue(predictions.isEmpty(), "No predictions should be pending anymore"); + + List> view = createView(new ItemStack(Items.STONE, 10)); + predictions.apply(CHANNEL, view); + helper.assertTrue(getInstance(view, 0).getCount() == 10, "The server state should be shown as-is"); + + helper.succeed(); + } + + @GameTest(template = "empty", templateNamespace = "cyclopscore") + public void testPartialServerChangeKeepsRemainingPrediction(GameTestHelper helper) { + TerminalStorageIngredientPredictions predictions = createPredictions(); + predictions.add(CHANNEL, new ItemStack(Items.STONE, 4)); + predictions.add(CHANNEL, new ItemStack(Items.STONE, 6)); + + // The server confirms the first click only + helper.assertTrue(predictions.consume(createChange(new ItemStack(Items.STONE, 4))), + "The change should confirm the first prediction"); + helper.assertTrue(predictions.getDelta(CHANNEL, new ItemStack(Items.STONE)) == -6, + "The unconfirmed prediction should be kept"); + + List> view = createView(new ItemStack(Items.STONE, 16)); + predictions.apply(CHANNEL, view); + helper.assertTrue(getInstance(view, 0).getCount() == 10, + "The second click should still be shown as applied"); + + helper.succeed(); + } + + @GameTest(template = "empty", templateNamespace = "cyclopscore") + public void testLargerServerChangeConfirmsAllPredictions(GameTestHelper helper) { + TerminalStorageIngredientPredictions predictions = createPredictions(); + predictions.add(CHANNEL, new ItemStack(Items.STONE, 4)); + + // Another player took some stone as well, so the change is larger than what we predicted + helper.assertTrue(predictions.consume(createChange(new ItemStack(Items.STONE, 100))), + "The change should confirm the prediction"); + helper.assertTrue(predictions.isEmpty(), "No predictions should be pending anymore"); + + helper.succeed(); + } + + @GameTest(template = "empty", templateNamespace = "cyclopscore") + public void testPredictionsDontExpireImmediately(GameTestHelper helper) { + TerminalStorageIngredientPredictions predictions = createPredictions(); + predictions.add(CHANNEL, new ItemStack(Items.STONE, 4)); + + helper.assertTrue(!predictions.removeExpired(), "The prediction should not have expired yet"); + helper.assertTrue(!predictions.isEmpty(), "The prediction should still be pending"); + + helper.succeed(); + } + +} diff --git a/src/main/java/org/cyclops/integratedterminals/network/packet/TerminalStorageIngredientSlotClickPacket.java b/src/main/java/org/cyclops/integratedterminals/network/packet/TerminalStorageIngredientSlotClickPacket.java index 4fd0f2fb65..657b2aa086 100644 --- a/src/main/java/org/cyclops/integratedterminals/network/packet/TerminalStorageIngredientSlotClickPacket.java +++ b/src/main/java/org/cyclops/integratedterminals/network/packet/TerminalStorageIngredientSlotClickPacket.java @@ -1,5 +1,6 @@ package org.cyclops.integratedterminals.network.packet; +import com.google.common.collect.Maps; import net.minecraft.core.HolderLookup; import net.minecraft.nbt.CompoundTag; import net.minecraft.network.RegistryFriendlyByteBuf; @@ -7,6 +8,7 @@ import net.minecraft.resources.ResourceLocation; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; import net.minecraft.world.level.Level; import net.neoforged.api.distmarker.Dist; import net.neoforged.api.distmarker.OnlyIn; @@ -19,6 +21,8 @@ import org.cyclops.integratedterminals.core.terminalstorage.TerminalStorageTabIngredientComponentServer; import org.cyclops.integratedterminals.inventory.container.ContainerTerminalStorageBase; +import java.util.Map; + /** * Packet for sending a storage slot click event from client to server. * @author rubensworks @@ -47,6 +51,8 @@ public class TerminalStorageIngredientSlotClickPacket extends PacketCodec predictedContainerSlots) { super((Type) ID); this.tabId = tabId; this.clickType = clickType.ordinal(); @@ -70,6 +77,11 @@ public TerminalStorageIngredientSlotClickPacket(HolderLookup.Provider lookupProv this.activeStorageInstanceData = new CompoundTag(); this.activeStorageInstanceData.put("i", serializer.serializeInstance(lookupProvider, activeStorageInstance)); this.transferFullSelection = transferFullSelection; + this.predictedContainerSlots = new CompoundTag(); + for (Map.Entry entry : predictedContainerSlots.entrySet()) { + this.predictedContainerSlots.put(String.valueOf(entry.getKey()), + entry.getValue().saveOptional(lookupProvider)); + } } @Override @@ -93,8 +105,22 @@ public void actionServer(Level world, ServerPlayer player) { T hoveringStorageInstance = serializer.deserializeInstance(world.registryAccess(), this.hoveringStorageInstanceData.get("i")); T activeInstance = serializer.deserializeInstance(world.registryAccess(), this.activeStorageInstanceData.get("i")); tab.handleStorageSlotClick(container, player, getClickType(), getChannel(), hoveringStorageInstance, - hoveredContainerSlot, moveQuantityPlayerSlot, activeInstance, transferFullSelection); + hoveredContainerSlot, moveQuantityPlayerSlot, activeInstance, transferFullSelection, + getPredictedContainerSlots(world.registryAccess())); + } + } + + /** + * @param lookupProvider A lookup provider. + * @return The container slot contents that the client has predicted for this click, by slot id. + */ + public Map getPredictedContainerSlots(HolderLookup.Provider lookupProvider) { + Map slots = Maps.newHashMap(); + for (String key : this.predictedContainerSlots.getAllKeys()) { + slots.put(Integer.valueOf(key), ItemStack.parseOptional(lookupProvider, + this.predictedContainerSlots.getCompound(key))); } + return slots; } public TerminalClickType getClickType() {