Follow client predictions whenever possible

This commit is contained in:
GoldenStack 2024-05-01 17:23:46 -05:00 committed by mworzala
parent 67b22fc06b
commit 5c236944e2
No known key found for this signature in database
GPG Key ID: B148F922E64797C7
8 changed files with 192 additions and 69 deletions

View File

@ -11,6 +11,7 @@ import net.minestom.server.inventory.click.ClickProcessors;
import net.minestom.server.item.ItemStack;
import net.minestom.server.network.packet.server.play.OpenWindowPacket;
import net.minestom.server.network.packet.server.play.WindowPropertyPacket;
import net.minestom.server.utils.inventory.ClickUtils;
import net.minestom.server.utils.inventory.PlayerInventoryUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@ -35,42 +36,27 @@ public non-sealed class ContainerInventory extends InventoryImpl {
* @return the click result, or null if the click did not occur
*/
public static @Nullable List<Click.Change> handleClick(@NotNull Inventory inventory, @NotNull Player player, @NotNull Click.Info info,
@NotNull ClickProcessors.InventoryProcessor processor) {
@NotNull ClickProcessors.InventoryProcessor processor) {
PlayerInventory playerInventory = player.getInventory();
InventoryPreClickEvent preClickEvent = new InventoryPreClickEvent(playerInventory, inventory, player, info);
EventDispatcher.call(preClickEvent);
if (!preClickEvent.isCancelled()) {
final Click.Info newInfo = preClickEvent.getClickInfo();
Click.Getter getter = new Click.Getter(inventory::getItemStack, playerInventory::getItemStack, playerInventory.getCursorItem(), inventory.getSize());
final List<Click.Change> changes = processor.apply(newInfo, getter);
if (preClickEvent.isCancelled()) return null;
InventoryClickEvent clickEvent = new InventoryClickEvent(playerInventory, inventory, player, newInfo, changes);
EventDispatcher.call(clickEvent);
final Click.Info newInfo = preClickEvent.getClickInfo();
final List<Click.Change> changes = processor.apply(newInfo, ClickUtils.makeGetter(inventory, playerInventory));
if (!clickEvent.isCancelled()) {
final List<Click.Change> newChanges = clickEvent.getChanges();
InventoryClickEvent clickEvent = new InventoryClickEvent(playerInventory, inventory, player, newInfo, changes);
EventDispatcher.call(clickEvent);
if (clickEvent.isCancelled()) return null;
apply(newChanges, player, inventory);
final List<Click.Change> newChanges = clickEvent.getChanges();
EventDispatcher.call(new InventoryPostClickEvent(player, inventory, newInfo, newChanges));
apply(newChanges, player, inventory);
if (!info.equals(newInfo) || !changes.equals(newChanges)) {
inventory.update(player);
if (inventory != playerInventory) {
playerInventory.update(player);
}
}
EventDispatcher.call(new InventoryPostClickEvent(player, inventory, newInfo, newChanges));
return newChanges;
}
}
inventory.update(player);
if (inventory != playerInventory) {
playerInventory.update(player);
}
return null;
return newChanges;
}
public static void apply(@NotNull List<Click.Change> changes, @NotNull Player player, @NotNull Inventory inventory) {
@ -146,9 +132,18 @@ public non-sealed class ContainerInventory extends InventoryImpl {
}
@Override
public @Nullable List<Click.Change> handleClick(@NotNull Player player, Click.@NotNull Info info) {
return ContainerInventory.handleClick(this, player, info,
ClickProcessors.PROCESSORS_MAP.getOrDefault(inventoryType, ClickProcessors.GENERIC_PROCESSOR));
public @Nullable List<Click.Change> handleClick(@NotNull Player player, Click.@NotNull Info info, @Nullable List<Click.Change> clientPrediction) {
// We can use the client prediction if it's conservative (i.e. doesn't create or delete items) or the client is in creative.
// Otherwise, we make our own.
if (clientPrediction != null && (ClickUtils.conservative(clientPrediction, this, player.getInventory()) || player.isCreative())) {
return ContainerInventory.handleClick(this, player, info, (i, g) -> clientPrediction);
} else {
var results = ContainerInventory.handleClick(this, player, info,
ClickProcessors.PROCESSORS_MAP.getOrDefault(inventoryType, ClickProcessors.GENERIC_PROCESSOR));
update(player);
player.getInventory().update(player);
return results;
}
}
@Override

View File

@ -51,9 +51,10 @@ public sealed interface Inventory extends Taggable, Viewable permits InventoryIm
*
* @param player the player that clicked
* @param info the information about the player's click
* @param clientPrediction the client prediction (null if none)
* @return the results of the click, or null if the click was cancelled or otherwise was not handled
*/
@Nullable List<Click.Change> handleClick(@NotNull Player player, @NotNull Click.Info info);
@Nullable List<Click.Change> handleClick(@NotNull Player player, @NotNull Click.Info info, @Nullable List<Click.Change> clientPrediction);
/**
* Gets all the {@link ItemStack} in the inventory.

View File

@ -9,6 +9,7 @@ import net.minestom.server.inventory.click.ClickProcessors;
import net.minestom.server.item.ItemStack;
import net.minestom.server.network.packet.server.play.SetSlotPacket;
import net.minestom.server.network.packet.server.play.WindowItemsPacket;
import net.minestom.server.utils.inventory.ClickUtils;
import net.minestom.server.utils.inventory.PlayerInventoryUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@ -158,8 +159,16 @@ public non-sealed class PlayerInventory extends InventoryImpl {
}
@Override
public @Nullable List<Click.Change> handleClick(@NotNull Player player, @NotNull Click.Info info) {
return ContainerInventory.handleClick(this, player, info, ClickProcessors.PLAYER_PROCESSOR);
public @Nullable List<Click.Change> handleClick(@NotNull Player player, Click.@NotNull Info info, @Nullable List<Click.Change> clientPrediction) {
// We can use the client prediction if it's conservative (i.e. doesn't create or delete items) or the client is in creative.
// Otherwise, we make our own.
if (clientPrediction != null && (ClickUtils.conservative(clientPrediction, this, this) || player.isCreative())) {
return ContainerInventory.handleClick(this, player, info, (i, g) -> clientPrediction);
} else {
var results = ContainerInventory.handleClick(this, player, info, ClickProcessors.PLAYER_PROCESSOR);
update(player);
return results;
}
}
public @NotNull ItemStack getEquipment(@NotNull EquipmentSlot slot, int heldSlot) {

View File

@ -6,6 +6,8 @@ import net.minestom.server.item.ItemStack;
import net.minestom.server.network.packet.client.play.ClientCreativeInventoryActionPacket;
import net.minestom.server.utils.inventory.PlayerInventoryUtils;
import java.util.List;
public final class CreativeInventoryActionListener {
public static void listener(ClientCreativeInventoryActionPacket packet, Player player) {
if (!player.isCreative()) return;
@ -13,12 +15,12 @@ public final class CreativeInventoryActionListener {
ItemStack item = packet.item();
if (packet.slot() == -1) { // -1 here indicates a drop
player.getInventory().handleClick(player, new Click.Info.CreativeDropItem(item));
player.getInventory().handleClick(player, new Click.Info.CreativeDropItem(item), List.of(new Click.Change.DropFromPlayer(item)));
}
int slot = PlayerInventoryUtils.protocolToMinestom(packet.slot());
if (slot == -1) return; // -1 after conversion indicates an invalid slot
player.getInventory().handleClick(player, new Click.Info.CreativeSetItem(slot, item));
player.getInventory().handleClick(player, new Click.Info.CreativeSetItem(slot, item), List.of(new Click.Change.Container(slot, item)));
}
}

View File

@ -19,9 +19,12 @@ import net.minestom.server.network.packet.client.play.ClientPlayerDiggingPacket;
import net.minestom.server.network.packet.server.play.AcknowledgeBlockChangePacket;
import net.minestom.server.network.packet.server.play.BlockEntityDataPacket;
import net.minestom.server.utils.block.BlockUtils;
import net.minestom.server.utils.inventory.PlayerInventoryUtils;
import org.jetbrains.annotations.NotNull;
import org.jglrxavpok.hephaistos.nbt.NBTCompound;
import java.util.List;
public final class PlayerDiggingListener {
public static void playerDiggingListener(ClientPlayerDiggingPacket packet, Player player) {
@ -41,13 +44,19 @@ public final class PlayerDiggingListener {
if (!instance.isChunkLoaded(blockPosition)) return;
diggingResult = finishDigging(player, instance, blockPosition, packet.blockFace());
} else if (status == ClientPlayerDiggingPacket.Status.DROP_ITEM_STACK) {
player.getInventory().handleClick(player, new Click.Info.DropSlot(player.getHeldSlot(), true));
player.getInventory().handleClick(player, new Click.Info.DropSlot(player.getHeldSlot(), true),
List.of(new Click.Change.DropFromPlayer(player.getItemInMainHand())));
} else if (status == ClientPlayerDiggingPacket.Status.DROP_ITEM) {
player.getInventory().handleClick(player, new Click.Info.DropSlot(player.getHeldSlot(), false));
player.getInventory().handleClick(player, new Click.Info.DropSlot(player.getHeldSlot(), false),
List.of(new Click.Change.DropFromPlayer(player.getItemInMainHand().withAmount(1))));
} else if (status == ClientPlayerDiggingPacket.Status.UPDATE_ITEM_STATE) {
updateItemState(player);
} else if (status == ClientPlayerDiggingPacket.Status.SWAP_ITEM_HAND) {
player.getInventory().handleClick(player, new Click.Info.OffhandSwap(player.getHeldSlot()));
player.getInventory().handleClick(player, new Click.Info.OffhandSwap(player.getHeldSlot()),
List.of(
new Click.Change.Player(PlayerInventoryUtils.OFF_HAND_SLOT, player.getItemInMainHand()),
new Click.Change.Player(player.getHeldSlot(), player.getItemInOffHand())
));
}
// Acknowledge start/cancel/finish digging status
if (diggingResult != null) {

View File

@ -11,6 +11,9 @@ import net.minestom.server.network.packet.client.play.ClientClickWindowButtonPac
import net.minestom.server.network.packet.client.play.ClientClickWindowPacket;
import net.minestom.server.network.packet.client.play.ClientCloseWindowPacket;
import net.minestom.server.network.packet.server.common.PingPacket;
import net.minestom.server.utils.inventory.ClickUtils;
import java.util.List;
public class WindowListener {
@ -24,7 +27,12 @@ public class WindowListener {
Click.Preprocessor preprocessor = player.clickPreprocessor();
final Click.Info info = preprocessor.processClick(packet, player.isCreative(), playerInventory ? null : inventory.getSize());
if (info != null) inventory.handleClick(player, info);
if (info != null) {
Click.Getter getter = ClickUtils.makeGetter(inventory, player.getInventory());
List<Click.Change> clientPrediction = ClickUtils.packetToChanges(packet, info, getter, playerInventory);
inventory.handleClick(player, info, clientPrediction);
}
// (Why is the ping packet necessary?)
player.sendPacket(new PingPacket((1 << 30) | (windowId << 16)));

View File

@ -0,0 +1,125 @@
package net.minestom.server.utils.inventory;
import it.unimi.dsi.fastutil.objects.Object2IntMap;
import it.unimi.dsi.fastutil.objects.Object2IntOpenHashMap;
import net.minestom.server.inventory.Inventory;
import net.minestom.server.inventory.PlayerInventory;
import net.minestom.server.inventory.click.Click;
import net.minestom.server.item.ItemStack;
import net.minestom.server.network.packet.client.play.ClientClickWindowPacket;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.BiConsumer;
public class ClickUtils {
public static @NotNull Click.Getter makeGetter(@NotNull Inventory inventory, @NotNull PlayerInventory playerInventory) {
return new Click.Getter(inventory::getItemStack, playerInventory::getItemStack, playerInventory.getCursorItem(), inventory.getSize());
}
/**
* Determines whether or not the given changes are conservative (i.e., whether or not they create or delete items).
*/
public static boolean conservative(@NotNull List<Click.Change> clientDefault, @NotNull Inventory inventory, @NotNull PlayerInventory playerInventory) {
Click.Getter getter = makeGetter(inventory, playerInventory);
return consolidate(clientDefault, getter.mainSize()).conservative(getter);
}
/**
* Converts a click window packet and a click info into a list of changes.
*/
public static @NotNull List<Click.Change> packetToChanges(@NotNull ClientClickWindowPacket packet, @NotNull Click.Info info, @NotNull Click.Getter getter, boolean playerInventory) {
List<Click.Change> changes = new ArrayList<>();
for (var change : packet.changedSlots()) {
int slot = change.slot();
if (playerInventory) slot = PlayerInventoryUtils.protocolToMinestom(slot);
changes.add(new Click.Change.Container(slot, change.item()));
}
changes.add(new Click.Change.Cursor(packet.clickedItem()));
if (info instanceof Click.Info.OffhandSwap swap && !playerInventory) {
changes.add(new Click.Change.Player(PlayerInventoryUtils.OFF_HAND_SLOT, getter.get(swap.slot())));
}
switch (info) {
case Click.Info.LeftDropCursor() -> changes.add(new Click.Change.DropFromPlayer(getter.cursor()));
case Click.Info.RightDropCursor() -> changes.add(new Click.Change.DropFromPlayer(getter.cursor().withAmount(1)));
case Click.Info.DropSlot(int slot, boolean all) -> changes.add(new Click.Change.DropFromPlayer(all ? getter.get(slot) : getter.get(slot).withAmount(1)));
case Click.Info.CreativeDropItem(ItemStack item) -> changes.add(new Click.Change.DropFromPlayer(item));
default -> {}
}
return changes;
}
/**
* Consolidates a list of changes into a single object.
*/
public static @NotNull FlatChanges consolidate(@NotNull List<Click.Change> changes, int size) {
Map<Integer, ItemStack> container = new HashMap<>();
Map<Integer, ItemStack> player = new HashMap<>();
@Nullable ItemStack cursor = null;
List<ItemStack> dropped = new ArrayList<>();
for (var change : changes) {
switch (change) {
case Click.Change.Container(int slot, ItemStack item) -> {
if (slot < size) {
container.put(slot, item);
} else {
player.put(PlayerInventoryUtils.protocolToMinestom(slot, size), item);
}
}
case Click.Change.Player(int slot, ItemStack item) -> player.put(slot, item);
case Click.Change.Cursor(ItemStack item) -> cursor = item;
case Click.Change.DropFromPlayer(ItemStack item) -> dropped.add(item);
}
}
return new FlatChanges(container, player, cursor, dropped);
}
public record FlatChanges(@NotNull Map<Integer, ItemStack> container, @NotNull Map<Integer, ItemStack> player,
@Nullable ItemStack cursor, @NotNull List<ItemStack> dropped) {
/**
* Determines whether or not these changes are conservative. If they create or destroy any items, the changes
* are not conservative.
*/
public boolean conservative(@NotNull Click.Getter getter) {
// A count of each item; this will ideally end up with everything at zero.
Object2IntMap<ItemStack> items = new Object2IntOpenHashMap<>();
items.defaultReturnValue(0);
BiConsumer<ItemStack, Integer> updater = (item, count) -> {
if (item.isAir()) return;
ItemStack one = item.withAmount(1);
items.put(one, items.getInt(one) + count);
};
container.values().forEach(item -> updater.accept(item, item.amount()));
player.values().forEach(item -> updater.accept(item, item.amount()));
dropped.forEach(item -> updater.accept(item, item.amount()));
container.keySet().stream().map(getter.main()::apply).forEach(item -> updater.accept(item, -item.amount()));
player.keySet().stream().map(getter.player()::apply).forEach(item -> updater.accept(item, -item.amount()));
if (cursor != null) {
updater.accept(cursor, cursor.amount());
updater.accept(getter.cursor(), -getter.cursor().amount());
}
return items.values().intStream().allMatch(i -> i == 0);
}
}
}

View File

@ -9,13 +9,14 @@ import net.minestom.server.item.Material;
import net.minestom.server.network.packet.client.play.ClientClickWindowPacket;
import net.minestom.server.network.packet.server.SendablePacket;
import net.minestom.server.network.player.PlayerConnection;
import net.minestom.server.utils.inventory.PlayerInventoryUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.net.SocketAddress;
import java.util.*;
import static net.minestom.server.utils.inventory.ClickUtils.consolidate;
import static org.junit.jupiter.api.Assertions.assertEquals;
public final class ClickUtils {
@ -61,7 +62,7 @@ public final class ClickUtils {
var inventory = createInventory();
ContainerInventory.apply(initial, player, inventory);
var actual = inventory.handleClick(player, info);
var actual = inventory.handleClick(player, info, null);
assertChanges(expected, actual, inventory.getSize());
}
@ -71,40 +72,13 @@ public final class ClickUtils {
var inventory = player.getInventory();
ContainerInventory.apply(initial, player, inventory);
var actual = inventory.handleClick(player, info);
var actual = inventory.handleClick(player, info, null);
assertChanges(expected, actual, inventory.getSize());
}
public static void assertChanges(List<Click.Change> expected, List<Click.Change> actual, int size) {
assertEquals(ChangeResult.make(expected, size), ChangeResult.make(actual, size));
}
private record ChangeResult(Map<Integer, ItemStack> main, Map<Integer, ItemStack> player,
@Nullable ItemStack cursor, List<ItemStack> drops) {
private static ChangeResult make(@NotNull List<Click.Change> changes, int size) {
Map<Integer, ItemStack> main = new HashMap<>();
Map<Integer, ItemStack> player = new HashMap<>();
@Nullable ItemStack cursor = null;
List<ItemStack> drops = new ArrayList<>();
for (var change : changes) {
switch (change) {
case Click.Change.Container(int slot, ItemStack item) -> {
if (slot < size) {
main.put(slot, item);
} else {
player.put(PlayerInventoryUtils.protocolToMinestom(slot, size), item);
}
}
case Click.Change.Player(int slot, ItemStack item) -> player.put(slot, item);
case Click.Change.Cursor(ItemStack item) -> cursor = item;
case Click.Change.DropFromPlayer(ItemStack item) -> drops.add(item);
}
}
return new ChangeResult(main, player, cursor, drops);
}
assertEquals(consolidate(expected, size), consolidate(actual, size));
}
public static void assertProcessed(@NotNull Click.Preprocessor preprocessor, @NotNull Player player, @Nullable Click.Info info, @NotNull ClientClickWindowPacket packet) {