diff --git a/build.gradle b/build.gradle index a6f628f..cab6ba1 100644 --- a/build.gradle +++ b/build.gradle @@ -7062,8 +7062,10 @@ tasks.register("verifyAlpha223Release") { || !sortClient.contains("instanceof CreativeModeInventoryScreen") || !sortClient.contains("playButtonClickSound") || !sortPayload.contains('SanctuaryMod.id("c2s/sort_inventory")') - || !sortPayload.contains("Intentionally empty") - || !networking.contains("SanctuaryInventorySort.sort(context.player())") + || !sortPayload.contains("buffer.writeBoolean(container)") + || !inventorySort.contains("sortOpenContainer") + || !inventorySort.contains("instanceof ChestMenu") + || !networking.contains("SanctuaryInventorySort.sort(context.player(), payload.container())") || !networking.contains("PROTOCOL_VERSION = 22") || !displayMode.contains('TEXT("text")') || !displayMode.contains('PANEL("panel")') diff --git a/sanctuary/build.gradle b/sanctuary/build.gradle index 6d761c3..3f3a50d 100644 --- a/sanctuary/build.gradle +++ b/sanctuary/build.gradle @@ -639,6 +639,14 @@ tasks.register("verifySanctuary") { ,'matchesMouse' ,'int bankIndex(int visualRow, int visibleRows)' ,'playButtonClickSound' + ,'requestVisualRow(client, 0, currentRows)' + ,'isSortableStorageMenu' + ,'instanceof ChestMenu' + ,'instanceof ShulkerBoxMenu' + ,'instanceof HopperMenu' + ,'instanceof DispenserMenu' + ,'buffer.writeBoolean(container)' + ,'sortOpenContainer' ,'GLFW.GLFW_KEY_TAB' ,'HOTBAR_TEXTURE' ,'0.0F, 0.0F, 182, 22, 182, 22' diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryClientProgress.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryClientProgress.java index c246070..e6bfa18 100644 --- a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryClientProgress.java +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryClientProgress.java @@ -33,7 +33,9 @@ public final class SanctuaryClientProgress { Minecraft client = Minecraft.getInstance(); if (currentRows > 1) { - SanctuaryHotbarRowsClient.requestVisualRow(client, currentRows - 1, currentRows); + // Visual 0 is the bottom bank line after bankIndex inversion. Keep the + // live hotbar there on login and after an inventory upgrade. + SanctuaryHotbarRowsClient.requestVisualRow(client, 0, currentRows); } if (client.gui.screen() instanceof AbstractContainerScreen screen) { screen.resize(client.getWindow().getGuiScaledWidth(), diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryHotbarRowsClient.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryHotbarRowsClient.java index 9d292e0..f22e794 100644 --- a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryHotbarRowsClient.java +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryHotbarRowsClient.java @@ -44,7 +44,7 @@ public final class SanctuaryHotbarRowsClient { return requestVisualRow(client, targetVisualRow, unlockedRows); } - /** Makes the requested visible row the real hotbar, notably after an inventory upgrade. */ + /** Makes the requested visible row the real hotbar. Visual 0 is the bottom line. */ public static boolean requestVisualRow(Minecraft client, int targetVisualRow, int unlockedRows) { unlockedRows = Math.clamp(unlockedRows, 1, SanctuaryInventoryCapacity.MAX_ROW_COUNT); if (pending || unlockedRows <= 1 || client.player == null || client.getConnection() == null diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryInventorySortClient.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryInventorySortClient.java index ec10abb..4ff03a1 100644 --- a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryInventorySortClient.java +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryInventorySortClient.java @@ -1,5 +1,7 @@ package fr.koka99cab.sanctuary26.sanctuary.client; +import fr.koka99cab.sanctuary26.sanctuary.gameplay.SanctuaryInventorySort; +import fr.koka99cab.sanctuary26.sanctuary.mixin.client.AbstractContainerScreenAccessor; import fr.koka99cab.sanctuary26.sanctuary.network.SortInventoryPayload; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; import net.minecraft.client.Minecraft; @@ -7,6 +9,8 @@ import net.minecraft.client.gui.components.AbstractWidget; import net.minecraft.client.gui.components.EditBox; import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; import net.minecraft.client.gui.screens.inventory.CreativeModeInventoryScreen; +import net.minecraft.client.gui.screens.inventory.InventoryScreen; +import net.minecraft.world.inventory.Slot; /** Sends a sort request while a survival container is open. */ public final class SanctuaryInventorySortClient { @@ -17,18 +21,35 @@ public final class SanctuaryInventorySortClient { if (client.gui.screen() != null && client.gui.screen().getFocused() instanceof EditBox) { return false; } - return request(client); + return request(client, null); } public static boolean request(Minecraft client) { + return request(client, null); + } + + public static boolean request(Minecraft client, Slot underCursor) { if (client.player == null || client.level == null - || !(client.gui.screen() instanceof AbstractContainerScreen) - || client.gui.screen() instanceof CreativeModeInventoryScreen + || !(client.gui.screen() instanceof AbstractContainerScreen screen) + || screen instanceof CreativeModeInventoryScreen || !ClientPlayNetworking.canSend(SortInventoryPayload.ID)) { return false; } + boolean container = resolveContainerTarget(screen, underCursor); AbstractWidget.playButtonClickSound(client.getSoundManager()); - ClientPlayNetworking.send(new SortInventoryPayload()); + ClientPlayNetworking.send(new SortInventoryPayload(container)); return true; } + + private static boolean resolveContainerTarget(AbstractContainerScreen screen, + Slot underCursor) { + if (screen instanceof InventoryScreen) return false; + Slot hovered = underCursor != null ? underCursor + : ((AbstractContainerScreenAccessor) screen).sanctuary$getHoveredSlot(); + if (hovered != null) { + return SanctuaryInventorySort.isSortableStorageMenu(screen.getMenu()) + && !SanctuaryInventorySort.isPlayerInventorySlot(hovered); + } + return SanctuaryInventorySort.isSortableStorageMenu(screen.getMenu()); + } } diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/gameplay/SanctuaryInventorySort.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/gameplay/SanctuaryInventorySort.java index a3a073e..530c105 100644 --- a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/gameplay/SanctuaryInventorySort.java +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/gameplay/SanctuaryInventorySort.java @@ -10,11 +10,18 @@ import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.server.level.ServerPlayer; import net.minecraft.world.Container; import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.ChestMenu; +import net.minecraft.world.inventory.DispenserMenu; +import net.minecraft.world.inventory.HopperMenu; +import net.minecraft.world.inventory.ShulkerBoxMenu; +import net.minecraft.world.inventory.Slot; import net.minecraft.world.item.ItemStack; /** - * Server-authoritative sort of every unlocked inventory row except the live hotbar. - * Armor, off-hand, crafting, cape and companion slots stay untouched. + * Server-authoritative sort of player storage or an open chest-like container. + * Armor, off-hand, crafting, cape, companion slots and the live hotbar stay + * untouched. Furnaces, shops and the storage terminal are never rewritten. */ public final class SanctuaryInventorySort { private static final int COOLDOWN_TICKS = 8; @@ -85,11 +92,31 @@ public final class SanctuaryInventorySort { ServerPlayerEvents.LEAVE.register(player -> LAST_SORT_TICK.remove(player.getUUID())); } - public static void sort(ServerPlayer player) { + public static boolean isSortableStorageMenu(AbstractContainerMenu menu) { + return menu instanceof ChestMenu + || menu instanceof ShulkerBoxMenu + || menu instanceof HopperMenu + || menu instanceof DispenserMenu; + } + + public static boolean isPlayerInventorySlot(Slot slot) { + if (slot == null) return false; + Container container = slot.container; + return container instanceof Inventory + || container instanceof SanctuaryOverflowContainer + || container instanceof SanctuaryCapeContainer + || container instanceof SanctuaryCompanionEggContainer; + } + + public static void sort(ServerPlayer player, boolean container) { long now = player.level().getGameTime(); Long previous = LAST_SORT_TICK.get(player.getUUID()); if (previous != null && now - previous < COOLDOWN_TICKS) return; LAST_SORT_TICK.put(player.getUUID(), now); + if (container) { + sortOpenContainer(player); + return; + } int unlockedRows = SanctuaryInventoryCapacity.unlockedRows(player); if (unlockedRows <= 1) return; @@ -123,6 +150,28 @@ public final class SanctuaryInventorySort { if (player.containerMenu != player.inventoryMenu) player.containerMenu.broadcastChanges(); } + private static void sortOpenContainer(ServerPlayer player) { + AbstractContainerMenu menu = player.containerMenu; + if (menu == null || menu == player.inventoryMenu || !isSortableStorageMenu(menu) + || !menu.stillValid(player)) { + return; + } + List sortable = new ArrayList<>(); + for (Slot slot : menu.slots) { + if (!slot.isActive() || isPlayerInventorySlot(slot)) continue; + sortable.add(slot); + } + if (sortable.isEmpty()) return; + List items = new ArrayList<>(sortable.size()); + for (Slot slot : sortable) items.add(slot.getItem().copy()); + SanctuaryInventorySorter.compactAndSort(items, STACKS); + for (int index = 0; index < sortable.size(); index++) { + Slot slot = sortable.get(index); + slot.container.setItem(slot.getContainerSlot(), items.get(index)); + } + menu.broadcastChanges(); + } + private static ItemStack getRowItem(Inventory inventory, Container overflow, int logicalRow, int column) { if (logicalRow < SanctuaryInventoryCapacity.VANILLA_ROW_COUNT) { diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/client/AbstractContainerScreenAccessor.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/client/AbstractContainerScreenAccessor.java index d39fc0d..79d3514 100644 --- a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/client/AbstractContainerScreenAccessor.java +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/client/AbstractContainerScreenAccessor.java @@ -1,6 +1,7 @@ package fr.koka99cab.sanctuary26.sanctuary.mixin.client; import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.world.inventory.Slot; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Mutable; import org.spongepowered.asm.mixin.gen.Accessor; @@ -21,4 +22,7 @@ public interface AbstractContainerScreenAccessor { @Mutable @Accessor("imageHeight") void sanctuary$setImageHeight(int imageHeight); + + @Accessor("hoveredSlot") + Slot sanctuary$getHoveredSlot(); } diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/client/AbstractContainerScreenMixin.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/client/AbstractContainerScreenMixin.java index 37951a3..ea606b2 100644 --- a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/client/AbstractContainerScreenMixin.java +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/client/AbstractContainerScreenMixin.java @@ -251,7 +251,8 @@ abstract class AbstractContainerScreenMixin implements SanctuaryContainerScreenG private void sanctuary$sortInventory(MouseButtonEvent event, boolean doubleClick, CallbackInfoReturnable cir) { if (!SanctuaryClient.isSortInventoryMouse(event)) return; - if (SanctuaryInventorySortClient.request(Minecraft.getInstance())) { + if (SanctuaryInventorySortClient.request(Minecraft.getInstance(), + sanctuary$slotAt(event.x(), event.y()))) { cir.setReturnValue(true); } } diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/client/MouseHandlerMixin.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/client/MouseHandlerMixin.java index 56de35f..829b026 100644 --- a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/client/MouseHandlerMixin.java +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/client/MouseHandlerMixin.java @@ -6,6 +6,7 @@ import fr.koka99cab.sanctuary26.sanctuary.client.SanctuaryInventorySortClient; import fr.koka99cab.sanctuary26.sanctuary.client.SanctuaryZoom; import net.minecraft.client.Minecraft; import net.minecraft.client.MouseHandler; +import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; import net.minecraft.client.input.MouseButtonEvent; import net.minecraft.client.input.MouseButtonInfo; import org.lwjgl.glfw.GLFW; @@ -32,6 +33,9 @@ public abstract class MouseHandlerMixin { if (action != GLFW.GLFW_PRESS) return; Minecraft client = Minecraft.getInstance(); if (windowPointer != client.getWindow().handle()) return; + if (client.gui.screen() instanceof AbstractContainerScreen) { + return; + } MouseButtonEvent event = new MouseButtonEvent(0.0, 0.0, buttonInfo); if (!SanctuaryClient.isSortInventoryMouse(event)) return; if (SanctuaryInventorySortClient.request(client)) { diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/network/SanctuaryNetworking.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/network/SanctuaryNetworking.java index 6872f09..79869ae 100644 --- a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/network/SanctuaryNetworking.java +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/network/SanctuaryNetworking.java @@ -75,7 +75,8 @@ public final class SanctuaryNetworking { } })); ServerPlayNetworking.registerGlobalReceiver(SortInventoryPayload.ID, (payload, context) -> - context.server().execute(() -> SanctuaryInventorySort.sort(context.player()))); + context.server().execute(() -> + SanctuaryInventorySort.sort(context.player(), payload.container()))); ServerPlayNetworking.registerGlobalReceiver(CompanionPowerUsePayload.ID, (payload, context) -> context.server().execute(() -> SanctuaryCompanionPowers.use(context.player()))); ServerPlayNetworking.registerGlobalReceiver(SitRequestPayload.ID, (payload, context) -> diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/network/SortInventoryPayload.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/network/SortInventoryPayload.java index 2d7b266..fef4cec 100644 --- a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/network/SortInventoryPayload.java +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/network/SortInventoryPayload.java @@ -5,19 +5,22 @@ import net.minecraft.network.RegistryFriendlyByteBuf; import net.minecraft.network.codec.StreamCodec; import net.minecraft.network.protocol.common.custom.CustomPacketPayload; -/** A parameterless client intent. The server owns which unlocked rows are sorted. */ -public record SortInventoryPayload() implements CustomPacketPayload { +/** + * Bounded client intent: player storage or the open storage container. + * The server still decides which slots exist and ignores non-storage menus. + */ +public record SortInventoryPayload(boolean container) implements CustomPacketPayload { public static final Type ID = new Type<>(SanctuaryMod.id("c2s/sort_inventory")); public static final StreamCodec CODEC = CustomPacketPayload.codec(SortInventoryPayload::write, SortInventoryPayload::new); private SortInventoryPayload(RegistryFriendlyByteBuf buffer) { - this(); + this(buffer.readBoolean()); } private void write(RegistryFriendlyByteBuf buffer) { - // Intentionally empty: no client-supplied slot list or item identity can be trusted. + buffer.writeBoolean(container); } @Override diff --git a/sanctuary/src/test/java/fr/koka99cab/sanctuary26/sanctuary/gameplay/SanctuaryInventorySortSmoke.java b/sanctuary/src/test/java/fr/koka99cab/sanctuary26/sanctuary/gameplay/SanctuaryInventorySortSmoke.java index b9a9fcb..d35642e 100644 --- a/sanctuary/src/test/java/fr/koka99cab/sanctuary26/sanctuary/gameplay/SanctuaryInventorySortSmoke.java +++ b/sanctuary/src/test/java/fr/koka99cab/sanctuary26/sanctuary/gameplay/SanctuaryInventorySortSmoke.java @@ -43,6 +43,21 @@ public final class SanctuaryInventorySortSmoke { "Dirt remainder must keep the leftover count"); require(storage.get(6).isEmpty(), "Unused storage slots must end empty"); + List chest = new ArrayList<>(List.of( + FakeStack.EMPTY, + new FakeStack("minecraft:oak_log", "", 16, 64), + new FakeStack("minecraft:oak_log", "", 50, 64), + new FakeStack("minecraft:arrow", "", 32, 64) + )); + SanctuaryInventorySorter.compactAndSort(chest, access); + require(chest.get(0).id.equals("minecraft:arrow") && chest.get(0).count == 32, + "A chest-like list must compact from the first slot"); + require(chest.get(1).id.equals("minecraft:oak_log") && chest.get(1).count == 64, + "Identical chest stacks must merge up to max size"); + require(chest.get(2).id.equals("minecraft:oak_log") && chest.get(2).count == 2, + "Chest remainder must follow the merged stack"); + require(chest.get(3).isEmpty(), "Unused chest slots must end empty"); + List hotbar = new ArrayList<>(List.of( new FakeStack("minecraft:torch", "", 8, 64), new FakeStack("minecraft:bread", "", 3, 64)