From dfddf400bc12d1c4ac91a0aeab7484da0bc84519 Mon Sep 17 00:00:00 2001 From: rubensworks Date: Fri, 4 Sep 2026 21:04:16 +0000 Subject: [PATCH 1/3] Show storage terminal interactions before the server confirms them Every terminal interaction was a custom packet with no client-side prediction, so nothing happened until the server answered: the grabbed item appeared after a round trip, and the shown quantity only after the ingredient network observer had picked up the change, a few ticks later. The client now simulates a click as soon as it sends it: * The container change is simulated by running the same movement logic that the server runs, against a storage that holds what the client believes is available. So what is predicted is what the server does. * The shown quantities get the predicted change on top of the server-sent state, never merged into it, as the server sends diffs that would otherwise be applied twice. The server stays the only source of truth. A prediction is dropped as soon as the server sends the change it expected, and expires by itself when the server never does. Slots that the client changed but the server did not are corrected by sending the full container state after a click, which the regular per-slot sync can not do. Clicks that can not start a drag are also handled when the mouse button goes down instead of when it is released, like vanilla containers do when the cursor is empty. This drops the button hold time, which was part of every interaction. Also parse the search query once per view rebuild instead of once per shown ingredient, as predictions rebuild the view more often. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01N51XZVzjfA7j3EUKbBaCZW --- .../integratedterminals/GeneralConfig.java | 3 + ...edientComponentTerminalStorageHandler.java | 84 +++++++ .../ITerminalStorageTabClient.java | 16 ++ ...onentTerminalStorageHandlerFluidStack.java | 10 +- ...ponentTerminalStorageHandlerItemStack.java | 13 +- .../ContainerScreenTerminalStorage.java | 16 ++ .../TerminalStorageIngredientPredictions.java | 217 ++++++++++++++++++ ...alStorageTabIngredientComponentClient.java | 130 ++++++++++- ...alStorageTabIngredientComponentServer.java | 6 + .../query/IngredientQueryMatchers.java | 36 +++ .../GameTestIngredientQueryMatchers.java | 63 +++++ ...meTestTerminalStorageClickPredictions.java | 161 +++++++++++++ ...tTerminalStorageIngredientPredictions.java | 214 +++++++++++++++++ 13 files changed, 958 insertions(+), 11 deletions(-) create mode 100644 src/main/java/org/cyclops/integratedterminals/core/terminalstorage/TerminalStorageIngredientPredictions.java create mode 100644 src/main/java/org/cyclops/integratedterminals/core/terminalstorage/query/IngredientQueryMatchers.java create mode 100644 src/main/java/org/cyclops/integratedterminals/gametest/GameTestIngredientQueryMatchers.java create mode 100644 src/main/java/org/cyclops/integratedterminals/gametest/GameTestTerminalStorageClickPredictions.java create mode 100644 src/main/java/org/cyclops/integratedterminals/gametest/GameTestTerminalStorageIngredientPredictions.java 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..d93d9b125a 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,85 @@ 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. + * + * @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. + */ + 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/TerminalStorageIngredientPredictions.java b/src/main/java/org/cyclops/integratedterminals/core/terminalstorage/TerminalStorageIngredientPredictions.java new file mode 100644 index 0000000000..945820f30f --- /dev/null +++ b/src/main/java/org/cyclops/integratedterminals/core/terminalstorage/TerminalStorageIngredientPredictions.java @@ -0,0 +1,217 @@ +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 storage changes that were caused by the player, + * but that have not been confirmed by the server 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 added to or removed from the given channel. + * @param channel The channel the change was caused in. + * @param instance The changed instance, with the changed quantity. + * @param addition If the instance was added, otherwise it was removed. + */ + public void add(int channel, T instance, boolean addition) { + if (!this.ingredientComponent.getMatcher().isEmpty(instance)) { + this.predictions.add(new Prediction<>(channel, instance, addition, + 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. + * + * @param instances The changed instances. + * @param addition If the instances were added, otherwise they were removed. + * @return If at least one prediction was confirmed. + */ + public boolean consume(Iterable instances, boolean addition) { + 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 (prediction.isAddition() == addition + && 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 can be negative. + */ + 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()) * (prediction.isAddition() ? 1 : -1); + } + } + 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 delta = matcher.getQuantity(prediction.getInstance()) * (prediction.isAddition() ? 1 : -1); + boolean applied = false; + 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()) + delta; + if (quantity <= 0) { + view.remove(i); + } else { + view.set(i, new InstanceWithMetadata<>( + matcher.withQuantity(entry.getInstance(), quantity), null)); + } + applied = true; + break; + } + } + if (!applied && delta > 0) { + view.add(new InstanceWithMetadata<>(prediction.getInstance(), null)); + } + } + } + + /** + * 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 boolean addition; + private final long expiryTime; + + public Prediction(int channel, T instance, boolean addition, long expiryTime) { + this.channel = channel; + this.instance = instance; + this.addition = addition; + 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 boolean isAddition() { + return addition; + } + + 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..2bc66428bf 100644 --- a/src/main/java/org/cyclops/integratedterminals/core/terminalstorage/TerminalStorageTabIngredientComponentClient.java +++ b/src/main/java/org/cyclops/integratedterminals/core/terminalstorage/TerminalStorageTabIngredientComponentClient.java @@ -106,6 +106,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 +154,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 +382,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 +409,19 @@ public Collection getUniqueCraftingOptionOutputs(ITerminalCraftingOption c protected List> getFilteredIngredientsView(int channel) { updateSortingPausedState(channel); + if (this.predictions.removeExpired()) { + // 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 +596,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 + && this.predictions.consume(ingredients, + changeType == IIngredientComponentStorageObservable.Change.ADDITION)) { + // 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<>( @@ -857,6 +876,8 @@ public boolean handleClick(AbstractContainerMenu container, int channel, int hov this.getName().toString(), ingredientComponent, clickType, channel, hoveringStorageInstance.orElse(matcher.getEmptyInstance()), hoveredContainerSlot, movePlayerQuantity, activeInstance, transferFullSelection)); + predictClick(container, clickType, channel, hoveringStorageInstance.orElse(matcher.getEmptyInstance()), + hoveredContainerSlot, activeInstance, transferFullSelection); if (reset) { resetActiveSlot(); } @@ -867,6 +888,113 @@ 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)), false); + break; + } + case STORAGE_PLACE_PLAYER: + addPrediction(channel, viewHandler.predictInsertIntoContainer(container, hoveredContainerSlot, + activeInstance, transferFullSelection, + getPredictedQuantity(channel, activeInstance)), false); + break; + case STORAGE_PLACE_WORLD: + // The server throws the full selection, or nothing at all + if (getPredictedQuantity(channel, activeInstance) >= matcher.getQuantity(activeInstance)) { + addPrediction(channel, activeInstance, false); + } + break; + case PLAYER_QUICK_MOVE: + case PLAYER_QUICK_MOVE_INCREMENTAL: + if (hasStorageSpaceFor(channel, container.getSlot(hoveredContainerSlot).getItem())) { + addPrediction(channel, viewHandler.predictExtractMaxFromContainerSlot(container, + hoveredContainerSlot, Minecraft.getInstance().player.getInventory(), + clickType == TerminalClickType.PLAYER_QUICK_MOVE + ? -1 : viewHandler.getIncrementalInstanceMovementQuantity()), true); + } + break; + case PLAYER_PLACE_STORAGE: + // The moved instance is drained from the player's cursor client-side already, + // its arrival in the storage is left to the server. + break; + } + } + + protected synchronized void addPrediction(int channel, T instance, boolean addition) { + 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, addition); + 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)); + } + + protected boolean hasStorageSpaceFor(int channel, ItemStack stack) { + long maxQuantity = getMaxQuantity(channel); + if (maxQuantity <= 0) { + // The capacity is unknown, so just assume that it fits + return true; + } + T instance = getViewHandler().getInstance(stack); + return getTotalQuantity(channel) + this.ingredientComponent.getMatcher().getQuantity(instance) <= maxQuantity; + } + + @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..0be4751340 100644 --- a/src/main/java/org/cyclops/integratedterminals/core/terminalstorage/TerminalStorageTabIngredientComponentServer.java +++ b/src/main/java/org/cyclops/integratedterminals/core/terminalstorage/TerminalStorageTabIngredientComponentServer.java @@ -512,5 +512,11 @@ public void handleStorageSlotClick(AbstractContainerMenu container, ServerPlayer if (updateActivePlayerStack) { player.connection.send(new ClientboundContainerSetSlotPacket(-1, 0, 0, container.getCarried())); } + + // Send the container state as we know it, so that a client that predicted this click + // is corrected when its prediction was wrong. + // Without this, a slot that the client changed but the server did not would stay wrong, + // as only slots that changed server-side are sent otherwise. + container.broadcastFullState(); } } 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..21fb69833c --- /dev/null +++ b/src/main/java/org/cyclops/integratedterminals/gametest/GameTestTerminalStorageClickPredictions.java @@ -0,0 +1,161 @@ +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; + +/** + * 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(); + } + + @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..ede93f3f6b --- /dev/null +++ b/src/main/java/org/cyclops/integratedterminals/gametest/GameTestTerminalStorageIngredientPredictions.java @@ -0,0 +1,214 @@ +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), false); + 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), false); + 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 testAdditionIsAdded(GameTestHelper helper) { + TerminalStorageIngredientPredictions predictions = createPredictions(); + predictions.add(CHANNEL, new ItemStack(Items.STONE, 4), true); + List> view = createView(new ItemStack(Items.STONE, 10)); + predictions.apply(CHANNEL, view); + + helper.assertTrue(getInstance(view, 0).getCount() == 14, "4 stone should have been added"); + + helper.succeed(); + } + + @GameTest(template = "empty", templateNamespace = "cyclopscore") + public void testAdditionOfUnshownInstanceIsAdded(GameTestHelper helper) { + TerminalStorageIngredientPredictions predictions = createPredictions(); + predictions.add(CHANNEL, new ItemStack(Items.DIRT, 4), true); + List> view = createView(new ItemStack(Items.STONE, 10)); + predictions.apply(CHANNEL, view); + + helper.assertTrue(view.size() == 2, "The new instance should be shown"); + helper.assertTrue(getInstance(view, 1).getItem() == Items.DIRT, "The new instance should be dirt"); + helper.assertTrue(getInstance(view, 1).getCount() == 4, "4 dirt should be shown"); + + helper.succeed(); + } + + @GameTest(template = "empty", templateNamespace = "cyclopscore") + public void testOtherInstancesAreUntouched(GameTestHelper helper) { + TerminalStorageIngredientPredictions predictions = createPredictions(); + predictions.add(CHANNEL, new ItemStack(Items.STONE, 4), false); + 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), false); + + 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), false); + + helper.assertTrue(!predictions.consume(createChange(new ItemStack(Items.DIRT, 4)), false), + "A change for another instance should not confirm the prediction"); + helper.assertTrue(!predictions.consume(createChange(new ItemStack(Items.STONE, 4)), true), + "A change in the other direction 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)), false), + "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), false); + predictions.add(CHANNEL, new ItemStack(Items.STONE, 6), false); + + // The server confirms the first click only + helper.assertTrue(predictions.consume(createChange(new ItemStack(Items.STONE, 4)), false), + "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), false); + + // 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)), false), + "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), false); + + helper.assertTrue(!predictions.removeExpired(), "The prediction should not have expired yet"); + helper.assertTrue(!predictions.isEmpty(), "The prediction should still be pending"); + + helper.succeed(); + } + +} From ab11394dda0114be409f0dec4be79561d745fa35 Mon Sep 17 00:00:00 2001 From: rubensworks Date: Sat, 5 Sep 2026 07:00:51 +0000 Subject: [PATCH 2/3] Only send the full container state when the server moved nothing Sending it after every click undid the predictions of any click the player made in the meantime, until the server had caught up with those as well, so clicking faster than the round trip made items flicker. The server's own changes are already sent as usual, which confirms or corrects the prediction. The only case that is not covered is a slot that only the client changed, and that happens exactly when the server moved nothing at all, so send the full state only then. Also skip resolving every ingredient's name when the search is empty, which is the state the terminal is in whenever the player is not searching. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01N51XZVzjfA7j3EUKbBaCZW --- ...edientComponentTerminalStorageHandler.java | 8 +++++ .../TerminalStorageIngredientPredictions.java | 6 ++++ ...alStorageTabIngredientComponentClient.java | 1 + ...alStorageTabIngredientComponentServer.java | 34 ++++++++++++++++--- .../query/IngredientQueryLeaf.java | 8 ++++- ...meTestTerminalStorageClickPredictions.java | 31 +++++++++++++++++ 6 files changed, 82 insertions(+), 6 deletions(-) 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 d93d9b125a..556dfdc74e 100644 --- a/src/main/java/org/cyclops/integratedterminals/api/ingredient/IIngredientComponentTerminalStorageHandler.java +++ b/src/main/java/org/cyclops/integratedterminals/api/ingredient/IIngredientComponentTerminalStorageHandler.java @@ -191,6 +191,10 @@ public default void insertMaxIntoContainer(IIngredientComponentStorage sto * * 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). @@ -249,6 +253,10 @@ public default T predictExtractMaxFromContainerSlot(AbstractContainerMenu contai /** * 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) { diff --git a/src/main/java/org/cyclops/integratedterminals/core/terminalstorage/TerminalStorageIngredientPredictions.java b/src/main/java/org/cyclops/integratedterminals/core/terminalstorage/TerminalStorageIngredientPredictions.java index 945820f30f..fe4c27e99e 100644 --- a/src/main/java/org/cyclops/integratedterminals/core/terminalstorage/TerminalStorageIngredientPredictions.java +++ b/src/main/java/org/cyclops/integratedterminals/core/terminalstorage/TerminalStorageIngredientPredictions.java @@ -74,6 +74,12 @@ public boolean removeExpired() { * 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 changed instances. * @param addition If the instances were added, otherwise they were removed. * @return If at least one prediction was confirmed. 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 2bc66428bf..e08ea60bc9 100644 --- a/src/main/java/org/cyclops/integratedterminals/core/terminalstorage/TerminalStorageTabIngredientComponentClient.java +++ b/src/main/java/org/cyclops/integratedterminals/core/terminalstorage/TerminalStorageTabIngredientComponentClient.java @@ -410,6 +410,7 @@ 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(); } 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 0be4751340..703753373c 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,8 @@ import net.minecraft.resources.ResourceLocation; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.Slot; +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; @@ -474,6 +476,7 @@ public void handleStorageSlotClick(AbstractContainerMenu container, ServerPlayer IIngredientComponentStorage storage = ingredientNetwork.getChannel(channel); boolean updateActivePlayerStack = false; + List containerBefore = copyContainerContents(container); switch (clickType) { case STORAGE_QUICK_MOVE: @@ -513,10 +516,31 @@ public void handleStorageSlotClick(AbstractContainerMenu container, ServerPlayer player.connection.send(new ClientboundContainerSetSlotPacket(-1, 0, 0, container.getCarried())); } - // Send the container state as we know it, so that a client that predicted this click - // is corrected when its prediction was wrong. - // Without this, a slot that the client changed but the server did not would stay wrong, - // as only slots that changed server-side are sent otherwise. - container.broadcastFullState(); + // A client that predicted this click has already applied it to its own container. + // The slots that we changed are sent to it as usual, which confirms or corrects them, + // but a slot that only the client changed is not sent, as nothing changed for us. + // That only happens when we moved nothing at all, so send our full state in that case. + // Doing that after every click instead would undo the predictions of any click + // that the player made in the meantime, until the server has caught up with those as well. + if (isUnchanged(containerBefore, container)) { + container.broadcastFullState(); + } + } + + public static List copyContainerContents(AbstractContainerMenu container) { + List contents = Lists.newArrayListWithExpectedSize(container.slots.size()); + for (Slot slot : container.slots) { + contents.add(slot.getItem().copy()); + } + return contents; + } + + public static boolean isUnchanged(List contentsBefore, AbstractContainerMenu container) { + for (int i = 0; i < contentsBefore.size(); i++) { + if (!ItemStack.matches(contentsBefore.get(i), container.getSlot(i).getItem())) { + return false; + } + } + return true; } } 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/gametest/GameTestTerminalStorageClickPredictions.java b/src/main/java/org/cyclops/integratedterminals/gametest/GameTestTerminalStorageClickPredictions.java index 21fb69833c..c1dc39754f 100644 --- a/src/main/java/org/cyclops/integratedterminals/gametest/GameTestTerminalStorageClickPredictions.java +++ b/src/main/java/org/cyclops/integratedterminals/gametest/GameTestTerminalStorageClickPredictions.java @@ -13,6 +13,9 @@ import org.cyclops.integratedterminals.Capabilities; import org.cyclops.integratedterminals.Reference; import org.cyclops.integratedterminals.api.ingredient.IIngredientComponentTerminalStorageHandler; +import org.cyclops.integratedterminals.core.terminalstorage.TerminalStorageTabIngredientComponentServer; + +import java.util.List; /** * Game tests for the client-side simulation of storage terminal clicks. @@ -144,6 +147,34 @@ public void testExtractFromSlotEmptiesIt(GameTestHelper helper) { helper.succeed(); } + /** + * The server only sends its full container state when it changed nothing itself, + * as that is the only case in which a client-side prediction is not corrected by the regular sync. + */ + @GameTest(template = "empty", templateNamespace = "cyclopscore") + public void testUnchangedContainerIsDetected(GameTestHelper helper) { + AbstractContainerMenu menu = createMenu(helper); + menu.getSlot(SLOT_START).set(new ItemStack(Items.STONE, 32)); + List before = TerminalStorageTabIngredientComponentServer.copyContainerContents(menu); + + helper.assertTrue(TerminalStorageTabIngredientComponentServer.isUnchanged(before, menu), + "An untouched container should be unchanged"); + + menu.getSlot(SLOT_START).getItem().shrink(1); + helper.assertTrue(!TerminalStorageTabIngredientComponentServer.isUnchanged(before, menu), + "A changed quantity should be detected"); + + menu.getSlot(SLOT_START).set(new ItemStack(Items.STONE, 32)); + helper.assertTrue(TerminalStorageTabIngredientComponentServer.isUnchanged(before, menu), + "A restored container should be unchanged again"); + + menu.getSlot(SLOT_START).set(new ItemStack(Items.DIRT, 32)); + helper.assertTrue(!TerminalStorageTabIngredientComponentServer.isUnchanged(before, menu), + "A changed item should be detected"); + + helper.succeed(); + } + @GameTest(template = "empty", templateNamespace = "cyclopscore") public void testExtractFromSlotIsLimited(GameTestHelper helper) { AbstractContainerMenu menu = createMenu(helper); From 7ddbd23f4f84091ec549814df18da90f15855cc9 Mon Sep 17 00:00:00 2001 From: rubensworks Date: Sat, 5 Sep 2026 07:28:22 +0000 Subject: [PATCH 3/3] Reconcile predictions the way vanilla container clicks do The server only sends the slots that changed for it, so a slot that only the client predicted was never corrected when the server moved less than predicted into more than one slot: the server sent the slots it did fill and stayed silent about the rest, leaving items in the client's inventory that are not there. Sending the full state when the server moved nothing did not cover that, as the server did move something. The click now carries the slots that the prediction changed, and the server marks those as what the client believes before it sends its changes, so every slot they disagree about is corrected and no other slot is touched. This is what vanilla does for its own container clicks, and what the terminal already relied on for quick-moves into storage, which go through the vanilla click packet. Predicting the arrival of an instance in the storage is dropped: 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, so it could show ingredients that the network does not have until the prediction expired. The slot it leaves is still predicted, and is corrected by the server as above. What the player takes out of the storage is unaffected, which is what this is all about. A failed prediction no longer swallows the click either, as predicting now runs before the click is sent, and it runs the ingredient component's own movement logic. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01N51XZVzjfA7j3EUKbBaCZW --- .../terminalstorage/ContainerHelpers.java | 55 ++++++++++++++++ .../TerminalStorageIngredientPredictions.java | 44 +++++-------- ...alStorageTabIngredientComponentClient.java | 64 +++++++++---------- ...alStorageTabIngredientComponentServer.java | 39 ++++------- ...meTestTerminalStorageClickPredictions.java | 34 +++++----- ...tTerminalStorageIngredientPredictions.java | 54 ++++------------ ...minalStorageIngredientSlotClickPacket.java | 30 ++++++++- 7 files changed, 170 insertions(+), 150 deletions(-) create mode 100644 src/main/java/org/cyclops/integratedterminals/core/terminalstorage/ContainerHelpers.java 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 index fe4c27e99e..a361f51191 100644 --- a/src/main/java/org/cyclops/integratedterminals/core/terminalstorage/TerminalStorageIngredientPredictions.java +++ b/src/main/java/org/cyclops/integratedterminals/core/terminalstorage/TerminalStorageIngredientPredictions.java @@ -10,8 +10,8 @@ import java.util.List; /** - * Client-side predictions of storage changes that were caused by the player, - * but that have not been confirmed by the server yet. + * 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. @@ -45,14 +45,13 @@ public boolean isEmpty() { } /** - * Predict that the given instance was added to or removed from the given channel. - * @param channel The channel the change was caused in. - * @param instance The changed instance, with the changed quantity. - * @param addition If the instance was added, otherwise it was removed. + * 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, boolean addition) { + public void add(int channel, T instance) { if (!this.ingredientComponent.getMatcher().isEmpty(instance)) { - this.predictions.add(new Prediction<>(channel, instance, addition, + this.predictions.add(new Prediction<>(channel, instance, System.currentTimeMillis() + EXPIRY_TIME_MS)); } } @@ -80,11 +79,10 @@ public boolean removeExpired() { * 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 changed instances. - * @param addition If the instances were added, otherwise they were removed. + * @param instances The removed instances. * @return If at least one prediction was confirmed. */ - public boolean consume(Iterable instances, boolean addition) { + public boolean consume(Iterable instances) { if (this.predictions.isEmpty()) { return false; } @@ -96,8 +94,7 @@ public boolean consume(Iterable instances, boolean addition) { Iterator> it = this.predictions.iterator(); while (it.hasNext() && remaining > 0) { Prediction prediction = it.next(); - if (prediction.isAddition() == addition - && matcher.matches(prediction.getInstance(), instance, matchCondition)) { + if (matcher.matches(prediction.getInstance(), instance, matchCondition)) { long quantity = matcher.getQuantity(prediction.getInstance()); if (quantity <= remaining) { remaining -= quantity; @@ -116,7 +113,7 @@ public boolean consume(Iterable instances, boolean addition) { /** * @param channel The channel that is being viewed. * @param instance An instance. - * @return The predicted quantity change for the given instance, which can be negative. + * @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(); @@ -125,7 +122,7 @@ public long getDelta(int channel, T instance) { for (Prediction prediction : this.predictions) { if (appliesTo(prediction, channel) && matcher.matches(prediction.getInstance(), instance, matchCondition)) { - delta += matcher.getQuantity(prediction.getInstance()) * (prediction.isAddition() ? 1 : -1); + delta -= matcher.getQuantity(prediction.getInstance()); } } return delta; @@ -146,27 +143,22 @@ public void apply(int channel, List> view) { if (!appliesTo(prediction, channel)) { continue; } - long delta = matcher.getQuantity(prediction.getInstance()) * (prediction.isAddition() ? 1 : -1); - boolean applied = false; + 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()) + delta; + 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)); } - applied = true; break; } } - if (!applied && delta > 0) { - view.add(new InstanceWithMetadata<>(prediction.getInstance(), null)); - } } } @@ -184,13 +176,11 @@ public static class Prediction { private final int channel; private T instance; - private final boolean addition; private final long expiryTime; - public Prediction(int channel, T instance, boolean addition, long expiryTime) { + public Prediction(int channel, T instance, long expiryTime) { this.channel = channel; this.instance = instance; - this.addition = addition; this.expiryTime = expiryTime; } @@ -210,10 +200,6 @@ public void setInstance(T instance) { this.instance = instance; } - public boolean isAddition() { - return addition; - } - 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 e08ea60bc9..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; @@ -600,8 +601,8 @@ public synchronized void onChange(int channel, IIngredientComponentStorageObserv // 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 - && this.predictions.consume(ingredients, - changeType == IIngredientComponentStorageObservable.Change.ADDITION)) { + && 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(); } @@ -872,13 +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)); - predictClick(container, clickType, channel, hoveringStorageInstance.orElse(matcher.getEmptyInstance()), - hoveredContainerSlot, activeInstance, transferFullSelection); + hoveredContainerSlot, movePlayerQuantity, activeInstance, transferFullSelection, + ContainerHelpers.getChangedContents(containerBefore, container))); if (reset) { resetActiveSlot(); } @@ -922,42 +934,38 @@ protected void predictClick(AbstractContainerMenu container, TerminalClickType c viewHandler.getIncrementalInstanceMovementQuantity(), matcher.getQuantity(hoveringStorageInstance))); addPrediction(channel, viewHandler.predictInsertMaxIntoContainer(container, 0, 4 * 9, requested, - getPredictedQuantity(channel, requested)), false); + getPredictedQuantity(channel, requested))); break; } case STORAGE_PLACE_PLAYER: addPrediction(channel, viewHandler.predictInsertIntoContainer(container, hoveredContainerSlot, activeInstance, transferFullSelection, - getPredictedQuantity(channel, activeInstance)), false); - break; - case STORAGE_PLACE_WORLD: - // The server throws the full selection, or nothing at all - if (getPredictedQuantity(channel, activeInstance) >= matcher.getQuantity(activeInstance)) { - addPrediction(channel, activeInstance, false); - } + getPredictedQuantity(channel, activeInstance))); break; case PLAYER_QUICK_MOVE: case PLAYER_QUICK_MOVE_INCREMENTAL: - if (hasStorageSpaceFor(channel, container.getSlot(hoveredContainerSlot).getItem())) { - addPrediction(channel, viewHandler.predictExtractMaxFromContainerSlot(container, - hoveredContainerSlot, Minecraft.getInstance().player.getInventory(), - clickType == TerminalClickType.PLAYER_QUICK_MOVE - ? -1 : viewHandler.getIncrementalInstanceMovementQuantity()), true); - } + // 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: - // The moved instance is drained from the player's cursor client-side already, - // its arrival in the storage is left to the server. + // 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, boolean addition) { + 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, addition); + 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, @@ -979,16 +987,6 @@ protected long getPredictedQuantity(int channel, T instance) { + this.predictions.getDelta(channel, instance)); } - protected boolean hasStorageSpaceFor(int channel, ItemStack stack) { - long maxQuantity = getMaxQuantity(channel); - if (maxQuantity <= 0) { - // The capacity is unknown, so just assume that it fits - return true; - } - T instance = getViewHandler().getInstance(stack); - return getTotalQuantity(channel) + this.ingredientComponent.getMatcher().getQuantity(instance) <= maxQuantity; - } - @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, 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 703753373c..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,7 +9,6 @@ import net.minecraft.resources.ResourceLocation; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.inventory.AbstractContainerMenu; -import net.minecraft.world.inventory.Slot; import net.minecraft.world.item.ItemStack; import net.neoforged.neoforge.server.ServerLifecycleHooks; import org.apache.commons.lang3.tuple.Pair; @@ -470,13 +469,13 @@ 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); boolean updateActivePlayerStack = false; - List containerBefore = copyContainerContents(container); switch (clickType) { case STORAGE_QUICK_MOVE: @@ -516,31 +515,17 @@ public void handleStorageSlotClick(AbstractContainerMenu container, ServerPlayer player.connection.send(new ClientboundContainerSetSlotPacket(-1, 0, 0, container.getCarried())); } - // A client that predicted this click has already applied it to its own container. - // The slots that we changed are sent to it as usual, which confirms or corrects them, - // but a slot that only the client changed is not sent, as nothing changed for us. - // That only happens when we moved nothing at all, so send our full state in that case. - // Doing that after every click instead would undo the predictions of any click - // that the player made in the meantime, until the server has caught up with those as well. - if (isUnchanged(containerBefore, container)) { - container.broadcastFullState(); - } - } - - public static List copyContainerContents(AbstractContainerMenu container) { - List contents = Lists.newArrayListWithExpectedSize(container.slots.size()); - for (Slot slot : container.slots) { - contents.add(slot.getItem().copy()); - } - return contents; - } - - public static boolean isUnchanged(List contentsBefore, AbstractContainerMenu container) { - for (int i = 0; i < contentsBefore.size(); i++) { - if (!ItemStack.matches(contentsBefore.get(i), container.getSlot(i).getItem())) { - return false; + // 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()); } } - return true; + container.broadcastChanges(); } } diff --git a/src/main/java/org/cyclops/integratedterminals/gametest/GameTestTerminalStorageClickPredictions.java b/src/main/java/org/cyclops/integratedterminals/gametest/GameTestTerminalStorageClickPredictions.java index c1dc39754f..cccf2491b2 100644 --- a/src/main/java/org/cyclops/integratedterminals/gametest/GameTestTerminalStorageClickPredictions.java +++ b/src/main/java/org/cyclops/integratedterminals/gametest/GameTestTerminalStorageClickPredictions.java @@ -13,9 +13,10 @@ import org.cyclops.integratedterminals.Capabilities; import org.cyclops.integratedterminals.Reference; import org.cyclops.integratedterminals.api.ingredient.IIngredientComponentTerminalStorageHandler; -import org.cyclops.integratedterminals.core.terminalstorage.TerminalStorageTabIngredientComponentServer; +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. @@ -148,29 +149,26 @@ public void testExtractFromSlotEmptiesIt(GameTestHelper helper) { } /** - * The server only sends its full container state when it changed nothing itself, - * as that is the only case in which a client-side prediction is not corrected by the regular sync. + * 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 testUnchangedContainerIsDetected(GameTestHelper helper) { + public void testChangedSlotsAreCollected(GameTestHelper helper) { AbstractContainerMenu menu = createMenu(helper); - menu.getSlot(SLOT_START).set(new ItemStack(Items.STONE, 32)); - List before = TerminalStorageTabIngredientComponentServer.copyContainerContents(menu); - - helper.assertTrue(TerminalStorageTabIngredientComponentServer.isUnchanged(before, menu), - "An untouched container should be unchanged"); + menu.getSlot(SLOT_START).set(new ItemStack(Items.STONE, 60)); + List before = ContainerHelpers.copyContents(menu); - menu.getSlot(SLOT_START).getItem().shrink(1); - helper.assertTrue(!TerminalStorageTabIngredientComponentServer.isUnchanged(before, menu), - "A changed quantity should be detected"); + helper.assertTrue(ContainerHelpers.getChangedContents(before, menu).isEmpty(), + "An untouched container should report no changed slots"); - menu.getSlot(SLOT_START).set(new ItemStack(Items.STONE, 32)); - helper.assertTrue(TerminalStorageTabIngredientComponentServer.isUnchanged(before, menu), - "A restored container should be unchanged again"); + // 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); - menu.getSlot(SLOT_START).set(new ItemStack(Items.DIRT, 32)); - helper.assertTrue(!TerminalStorageTabIngredientComponentServer.isUnchanged(before, menu), - "A changed item should be detected"); + 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(); } diff --git a/src/main/java/org/cyclops/integratedterminals/gametest/GameTestTerminalStorageIngredientPredictions.java b/src/main/java/org/cyclops/integratedterminals/gametest/GameTestTerminalStorageIngredientPredictions.java index ede93f3f6b..fd64fee830 100644 --- a/src/main/java/org/cyclops/integratedterminals/gametest/GameTestTerminalStorageIngredientPredictions.java +++ b/src/main/java/org/cyclops/integratedterminals/gametest/GameTestTerminalStorageIngredientPredictions.java @@ -63,7 +63,7 @@ public void testEmptyPredictionsDontChangeTheView(GameTestHelper helper) { @GameTest(template = "empty", templateNamespace = "cyclopscore") public void testRemovalIsSubtracted(GameTestHelper helper) { TerminalStorageIngredientPredictions predictions = createPredictions(); - predictions.add(CHANNEL, new ItemStack(Items.STONE, 4), false); + predictions.add(CHANNEL, new ItemStack(Items.STONE, 4)); List> view = createView(new ItemStack(Items.STONE, 10)); predictions.apply(CHANNEL, view); @@ -78,7 +78,7 @@ public void testRemovalIsSubtracted(GameTestHelper helper) { @GameTest(template = "empty", templateNamespace = "cyclopscore") public void testFullRemovalHidesTheInstance(GameTestHelper helper) { TerminalStorageIngredientPredictions predictions = createPredictions(); - predictions.add(CHANNEL, new ItemStack(Items.STONE, 10), false); + 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); @@ -89,36 +89,10 @@ public void testFullRemovalHidesTheInstance(GameTestHelper helper) { helper.succeed(); } - @GameTest(template = "empty", templateNamespace = "cyclopscore") - public void testAdditionIsAdded(GameTestHelper helper) { - TerminalStorageIngredientPredictions predictions = createPredictions(); - predictions.add(CHANNEL, new ItemStack(Items.STONE, 4), true); - List> view = createView(new ItemStack(Items.STONE, 10)); - predictions.apply(CHANNEL, view); - - helper.assertTrue(getInstance(view, 0).getCount() == 14, "4 stone should have been added"); - - helper.succeed(); - } - - @GameTest(template = "empty", templateNamespace = "cyclopscore") - public void testAdditionOfUnshownInstanceIsAdded(GameTestHelper helper) { - TerminalStorageIngredientPredictions predictions = createPredictions(); - predictions.add(CHANNEL, new ItemStack(Items.DIRT, 4), true); - List> view = createView(new ItemStack(Items.STONE, 10)); - predictions.apply(CHANNEL, view); - - helper.assertTrue(view.size() == 2, "The new instance should be shown"); - helper.assertTrue(getInstance(view, 1).getItem() == Items.DIRT, "The new instance should be dirt"); - helper.assertTrue(getInstance(view, 1).getCount() == 4, "4 dirt should be shown"); - - helper.succeed(); - } - @GameTest(template = "empty", templateNamespace = "cyclopscore") public void testOtherInstancesAreUntouched(GameTestHelper helper) { TerminalStorageIngredientPredictions predictions = createPredictions(); - predictions.add(CHANNEL, new ItemStack(Items.STONE, 4), false); + predictions.add(CHANNEL, new ItemStack(Items.STONE, 4)); List> view = createView(new ItemStack(Items.DIRT, 10)); predictions.apply(CHANNEL, view); @@ -130,7 +104,7 @@ public void testOtherInstancesAreUntouched(GameTestHelper helper) { @GameTest(template = "empty", templateNamespace = "cyclopscore") public void testPredictionsAreShownInTheWildcardChannel(GameTestHelper helper) { TerminalStorageIngredientPredictions predictions = createPredictions(); - predictions.add(CHANNEL, new ItemStack(Items.STONE, 4), false); + predictions.add(CHANNEL, new ItemStack(Items.STONE, 4)); List> wildcardView = createView(new ItemStack(Items.STONE, 10)); predictions.apply(IPositionedAddonsNetwork.WILDCARD_CHANNEL, wildcardView); @@ -148,15 +122,13 @@ public void testPredictionsAreShownInTheWildcardChannel(GameTestHelper helper) { @GameTest(template = "empty", templateNamespace = "cyclopscore") public void testServerChangeConfirmsPrediction(GameTestHelper helper) { TerminalStorageIngredientPredictions predictions = createPredictions(); - predictions.add(CHANNEL, new ItemStack(Items.STONE, 4), false); + predictions.add(CHANNEL, new ItemStack(Items.STONE, 4)); - helper.assertTrue(!predictions.consume(createChange(new ItemStack(Items.DIRT, 4)), false), + helper.assertTrue(!predictions.consume(createChange(new ItemStack(Items.DIRT, 4))), "A change for another instance should not confirm the prediction"); - helper.assertTrue(!predictions.consume(createChange(new ItemStack(Items.STONE, 4)), true), - "A change in the other direction 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)), false), + 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"); @@ -170,11 +142,11 @@ public void testServerChangeConfirmsPrediction(GameTestHelper helper) { @GameTest(template = "empty", templateNamespace = "cyclopscore") public void testPartialServerChangeKeepsRemainingPrediction(GameTestHelper helper) { TerminalStorageIngredientPredictions predictions = createPredictions(); - predictions.add(CHANNEL, new ItemStack(Items.STONE, 4), false); - predictions.add(CHANNEL, new ItemStack(Items.STONE, 6), false); + 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)), false), + 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"); @@ -190,10 +162,10 @@ public void testPartialServerChangeKeepsRemainingPrediction(GameTestHelper helpe @GameTest(template = "empty", templateNamespace = "cyclopscore") public void testLargerServerChangeConfirmsAllPredictions(GameTestHelper helper) { TerminalStorageIngredientPredictions predictions = createPredictions(); - predictions.add(CHANNEL, new ItemStack(Items.STONE, 4), false); + 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)), false), + 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"); @@ -203,7 +175,7 @@ public void testLargerServerChangeConfirmsAllPredictions(GameTestHelper helper) @GameTest(template = "empty", templateNamespace = "cyclopscore") public void testPredictionsDontExpireImmediately(GameTestHelper helper) { TerminalStorageIngredientPredictions predictions = createPredictions(); - predictions.add(CHANNEL, new ItemStack(Items.STONE, 4), false); + 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"); 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() {