diff --git a/sanctuary/build.gradle b/sanctuary/build.gradle index 6d82037..3081f84 100644 --- a/sanctuary/build.gradle +++ b/sanctuary/build.gradle @@ -108,6 +108,14 @@ tasks.register("capeJoinSyncSmoke", JavaExec) { mainClass = "fr.koka99cab.sanctuary26.sanctuary.gameplay.CapeJoinSyncQueueSmoke" } +tasks.register("inventorySortSmoke", JavaExec) { + group = "verification" + description = "Checks that storage stacks merge and sort while the hotbar row stays out of the sort." + dependsOn tasks.named("testClasses") + classpath = sourceSets.test.runtimeClasspath + mainClass = "fr.koka99cab.sanctuary26.sanctuary.gameplay.SanctuaryInventorySortSmoke" +} + tasks.register("alphaIndevGeneratorSmoke", JavaExec) { group = "verification" description = "Checks deterministic finite Indev islands, residences and safe portal arrivals." @@ -298,6 +306,7 @@ tasks.register("verifySanctuary") { tasks.named("questIdSuggestionsSmoke"), tasks.named("shopHeaderLayoutSmoke"), tasks.named("zombieNamesSmoke"), tasks.named("capeJoinSyncSmoke"), + tasks.named("inventorySortSmoke"), tasks.named("verifyBlackMarket") doLast { @@ -614,6 +623,14 @@ tasks.register("verifySanctuary") { ,'class SanctuaryHotbarRowsClient' ,'id("c2s/hotbar_row_switch")' ,'id("s2c/hotbar_row_sync")' + ,'class SanctuaryInventorySort' + ,'class SanctuaryInventorySorter' + ,'id("c2s/sort_inventory")' + ,'key.sanctuary.sort_inventory' + ,'GLFW.GLFW_MOUSE_BUTTON_MIDDLE' + ,'matchesMouse' + ,'int bankIndex(int visualRow, int visibleRows)' + ,'playButtonClickSound' ,'GLFW.GLFW_KEY_TAB' ,'HOTBAR_TEXTURE' ,'0.0F, 0.0F, 182, 22, 182, 22' @@ -3423,5 +3440,7 @@ tasks.named("check") { dependsOn tasks.named("questTrackerModelSmoke") dependsOn tasks.named("questIdSuggestionsSmoke") dependsOn tasks.named("alphaIndevGeneratorSmoke") + dependsOn tasks.named("capeJoinSyncSmoke") + dependsOn tasks.named("inventorySortSmoke") dependsOn tasks.named("verifyAlphaIndev") } diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/SanctuaryMod.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/SanctuaryMod.java index 40469df..0322530 100644 --- a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/SanctuaryMod.java +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/SanctuaryMod.java @@ -7,6 +7,7 @@ import fr.koka99cab.sanctuary26.sanctuary.gameplay.SanctuaryBulkActions; import fr.koka99cab.sanctuary26.sanctuary.gameplay.SanctuaryInventoryCapacity; import fr.koka99cab.sanctuary26.sanctuary.gameplay.SanctuaryCompanions; import fr.koka99cab.sanctuary26.sanctuary.gameplay.SanctuaryHotbarRows; +import fr.koka99cab.sanctuary26.sanctuary.gameplay.SanctuaryInventorySort; import fr.koka99cab.sanctuary26.sanctuary.gameplay.SanctuaryCompanionPowers; import fr.koka99cab.sanctuary26.sanctuary.gameplay.SanctuaryCapeService; import fr.koka99cab.sanctuary26.sanctuary.gameplay.SanctuarySitting; @@ -55,6 +56,7 @@ public final class SanctuaryMod implements ModInitializer { SanctuaryBulkActions.initialize(); SanctuaryInventoryCapacity.initialize(); SanctuaryHotbarRows.initialize(); + SanctuaryInventorySort.initialize(); SanctuaryCompanions.initialize(); SanctuaryCompanionPowers.initialize(); SanctuaryCapeService.initialize(); diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryClient.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryClient.java index 22a9189..b580d17 100644 --- a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryClient.java +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryClient.java @@ -28,6 +28,7 @@ import net.minecraft.client.KeyMapping; import net.minecraft.client.Minecraft; import net.minecraft.client.Screenshot; import net.minecraft.client.input.KeyEvent; +import net.minecraft.client.input.MouseButtonEvent; import net.minecraft.client.gui.screens.inventory.ContainerScreen; import net.minecraft.client.gui.screens.inventory.CraftingScreen; import net.minecraft.client.gui.screens.inventory.InventoryScreen; @@ -49,6 +50,7 @@ public final class SanctuaryClient implements ClientModInitializer { private static KeyMapping zoomKey; private static KeyMapping celestialKey; private static KeyMapping switchHotbarRowKey; + private static KeyMapping sortInventoryKey; private static KeyMapping questTrackerKey; private static boolean zoomWasHeld; private static boolean smokeScreenOpened; @@ -93,6 +95,12 @@ public final class SanctuaryClient implements ClientModInitializer { GLFW.GLFW_KEY_TAB, SANCTUARY_CATEGORY )); + sortInventoryKey = KeyMappingHelper.registerKeyMapping(new KeyMapping( + "key.sanctuary.sort_inventory", + InputConstants.Type.MOUSE, + GLFW.GLFW_MOUSE_BUTTON_MIDDLE, + SANCTUARY_CATEGORY + )); questTrackerKey = KeyMappingHelper.registerKeyMapping(new KeyMapping( "key.sanctuary.quest_tracker", InputConstants.Type.KEYSYM, @@ -222,6 +230,14 @@ public final class SanctuaryClient implements ClientModInitializer { return switchHotbarRowKey != null && switchHotbarRowKey.matches(event); } + public static boolean isSortInventoryKey(KeyEvent event) { + return sortInventoryKey != null && sortInventoryKey.matches(event); + } + + public static boolean isSortInventoryMouse(MouseButtonEvent event) { + return sortInventoryKey != null && sortInventoryKey.matchesMouse(event); + } + public static boolean handleBlackMarketMiddleClick(Minecraft client) { if (client.player == null || client.level == null || client.gui.screen() != null || !(client.hitResult instanceof BlockHitResult hit) 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 14fc923..9d292e0 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 @@ -24,6 +24,10 @@ public final class SanctuaryHotbarRowsClient { return VISUAL_ROW_BY_LOGICAL[logicalRow]; } + public static int bankIndex(int logicalRow, int visibleRows) { + return SanctuaryInventoryCapacity.bankIndex(visualRow(logicalRow), visibleRows); + } + public static int activeVisualRow() { return VISUAL_ROW_BY_LOGICAL[0]; } 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 new file mode 100644 index 0000000..ec10abb --- /dev/null +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryInventorySortClient.java @@ -0,0 +1,34 @@ +package fr.koka99cab.sanctuary26.sanctuary.client; + +import fr.koka99cab.sanctuary26.sanctuary.network.SortInventoryPayload; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; +import net.minecraft.client.Minecraft; +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; + +/** Sends a sort request while a survival container is open. */ +public final class SanctuaryInventorySortClient { + private SanctuaryInventorySortClient() { + } + + public static boolean requestFromKeyboard(Minecraft client) { + if (client.gui.screen() != null && client.gui.screen().getFocused() instanceof EditBox) { + return false; + } + return request(client); + } + + public static boolean request(Minecraft client) { + if (client.player == null || client.level == null + || !(client.gui.screen() instanceof AbstractContainerScreen) + || client.gui.screen() instanceof CreativeModeInventoryScreen + || !ClientPlayNetworking.canSend(SortInventoryPayload.ID)) { + return false; + } + AbstractWidget.playButtonClickSound(client.getSoundManager()); + ClientPlayNetworking.send(new SortInventoryPayload()); + return true; + } +} diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/gameplay/SanctuaryHotbarRows.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/gameplay/SanctuaryHotbarRows.java index 1dbb7a9..f779998 100644 --- a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/gameplay/SanctuaryHotbarRows.java +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/gameplay/SanctuaryHotbarRows.java @@ -88,6 +88,12 @@ public final class SanctuaryHotbarRows { return layout; } + /** Visual row of each physical row. Index 0 is the live hotbar. */ + public static int[] visualLayout(ServerPlayer player) { + int[] layout = SESSION_LAYOUTS.get(player.getUUID()); + return layout == null ? identityLayout() : layout.clone(); + } + private static int indexOf(int[] layout, int visualRow) { for (int index = 0; index < layout.length; index++) { if (layout[index] == visualRow) return index; diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/gameplay/SanctuaryInventoryCapacity.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/gameplay/SanctuaryInventoryCapacity.java index 3e74629..93c5355 100644 --- a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/gameplay/SanctuaryInventoryCapacity.java +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/gameplay/SanctuaryInventoryCapacity.java @@ -20,6 +20,27 @@ public final class SanctuaryInventoryCapacity { private SanctuaryInventoryCapacity() { } + /** + * Turns a visual row (0 = live hotbar) into a top-to-bottom bank index. + * Storage occupies the first lines; the hotbar sits on the last one, like vanilla. + */ + public static int bankIndex(int visualRow, int visibleRows) { + visibleRows = Math.clamp(visibleRows, 1, MAX_ROW_COUNT); + visualRow = Math.clamp(visualRow, 0, visibleRows - 1); + return visibleRows - 1 - visualRow; + } + + public static int physicalRowAtBankIndex(int bankIndex, int[] visualByPhysical, int unlockedRows) { + unlockedRows = Math.clamp(unlockedRows, 1, MAX_ROW_COUNT); + bankIndex = Math.clamp(bankIndex, 0, unlockedRows - 1); + for (int physical = 0; physical < unlockedRows; physical++) { + int visual = visualByPhysical == null || physical >= visualByPhysical.length + ? physical : visualByPhysical[physical]; + if (bankIndex(visual, unlockedRows) == bankIndex) return physical; + } + return bankIndex; + } + public static void initialize() { if (initialized) return; initialized = true; 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 new file mode 100644 index 0000000..a3a073e --- /dev/null +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/gameplay/SanctuaryInventorySort.java @@ -0,0 +1,144 @@ +package fr.koka99cab.sanctuary26.sanctuary.gameplay; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import net.fabricmc.fabric.api.entity.event.v1.ServerPlayerEvents; +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.item.ItemStack; + +/** + * Server-authoritative sort of every unlocked inventory row except the live hotbar. + * Armor, off-hand, crafting, cape and companion slots stay untouched. + */ +public final class SanctuaryInventorySort { + private static final int COOLDOWN_TICKS = 8; + private static final Map LAST_SORT_TICK = new HashMap<>(); + private static final SanctuaryInventorySorter.StackAccess STACKS = + new SanctuaryInventorySorter.StackAccess<>() { + @Override + public boolean isEmpty(ItemStack stack) { + return stack == null || stack.isEmpty(); + } + + @Override + public int count(ItemStack stack) { + return stack.getCount(); + } + + @Override + public int maxStackSize(ItemStack stack) { + return stack.getMaxStackSize(); + } + + @Override + public boolean sameItem(ItemStack first, ItemStack second) { + return ItemStack.isSameItemSameComponents(first, second); + } + + @Override + public ItemStack copy(ItemStack stack) { + return stack.copy(); + } + + @Override + public ItemStack withCount(ItemStack stack, int count) { + if (count <= 0) return ItemStack.EMPTY; + ItemStack copy = stack.copy(); + copy.setCount(count); + return copy; + } + + @Override + public ItemStack empty() { + return ItemStack.EMPTY; + } + + @Override + public int compare(ItemStack first, ItemStack second) { + boolean firstEmpty = first.isEmpty(); + boolean secondEmpty = second.isEmpty(); + if (firstEmpty || secondEmpty) return Boolean.compare(firstEmpty, secondEmpty); + int byId = BuiltInRegistries.ITEM.getKey(first.getItem()).toString() + .compareTo(BuiltInRegistries.ITEM.getKey(second.getItem()).toString()); + if (byId != 0) return byId; + int byComponents = String.valueOf(first.getComponents()) + .compareTo(String.valueOf(second.getComponents())); + if (byComponents != 0) return byComponents; + return Integer.compare(second.getCount(), first.getCount()); + } + }; + + private static boolean initialized; + + private SanctuaryInventorySort() { + } + + public static void initialize() { + if (initialized) return; + initialized = true; + ServerPlayerEvents.LEAVE.register(player -> LAST_SORT_TICK.remove(player.getUUID())); + } + + public static void sort(ServerPlayer player) { + 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); + + int unlockedRows = SanctuaryInventoryCapacity.unlockedRows(player); + if (unlockedRows <= 1) return; + + Inventory inventory = player.getInventory(); + SanctuaryOverflowContainer overflow = SanctuaryOverflowAccess.of(player); + int[] visualByPhysical = SanctuaryHotbarRows.visualLayout(player); + List slots = new ArrayList<>( + (unlockedRows - 1) * SanctuaryInventoryCapacity.ROW_SIZE); + for (int bank = 0; bank < unlockedRows; bank++) { + int row = SanctuaryInventoryCapacity.physicalRowAtBankIndex( + bank, visualByPhysical, unlockedRows); + if (row == 0) continue; + for (int column = 0; column < SanctuaryInventoryCapacity.ROW_SIZE; column++) { + slots.add(getRowItem(inventory, overflow, row, column).copy()); + } + } + SanctuaryInventorySorter.compactAndSort(slots, STACKS); + int index = 0; + for (int bank = 0; bank < unlockedRows; bank++) { + int row = SanctuaryInventoryCapacity.physicalRowAtBankIndex( + bank, visualByPhysical, unlockedRows); + if (row == 0) continue; + for (int column = 0; column < SanctuaryInventoryCapacity.ROW_SIZE; column++) { + setRowItem(inventory, overflow, row, column, slots.get(index++)); + } + } + inventory.setChanged(); + overflow.setChanged(); + player.inventoryMenu.broadcastChanges(); + if (player.containerMenu != player.inventoryMenu) player.containerMenu.broadcastChanges(); + } + + private static ItemStack getRowItem(Inventory inventory, Container overflow, + int logicalRow, int column) { + if (logicalRow < SanctuaryInventoryCapacity.VANILLA_ROW_COUNT) { + return inventory.getItem(logicalRow * SanctuaryInventoryCapacity.ROW_SIZE + column); + } + return overflow.getItem((logicalRow - SanctuaryInventoryCapacity.VANILLA_ROW_COUNT) + * SanctuaryInventoryCapacity.ROW_SIZE + column); + } + + private static void setRowItem(Inventory inventory, Container overflow, + int logicalRow, int column, ItemStack stack) { + if (logicalRow < SanctuaryInventoryCapacity.VANILLA_ROW_COUNT) { + inventory.setItem(logicalRow * SanctuaryInventoryCapacity.ROW_SIZE + column, stack); + return; + } + overflow.setItem((logicalRow - SanctuaryInventoryCapacity.VANILLA_ROW_COUNT) + * SanctuaryInventoryCapacity.ROW_SIZE + column, stack); + } +} diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/gameplay/SanctuaryInventorySorter.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/gameplay/SanctuaryInventorySorter.java new file mode 100644 index 0000000..627cba4 --- /dev/null +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/gameplay/SanctuaryInventorySorter.java @@ -0,0 +1,54 @@ +package fr.koka99cab.sanctuary26.sanctuary.gameplay; + +import java.util.ArrayList; +import java.util.List; + +/** + * Merges identical stacks then orders them, leaving empty slots at the end. + * The access adapter keeps this independent of Minecraft types so smoke tests + * can cover the contract without a running game. + */ +public final class SanctuaryInventorySorter { + private SanctuaryInventorySorter() { + } + + public interface StackAccess { + boolean isEmpty(T stack); + + int count(T stack); + + int maxStackSize(T stack); + + boolean sameItem(T first, T second); + + T copy(T stack); + + T withCount(T stack, int count); + + T empty(); + + int compare(T first, T second); + } + + public static void compactAndSort(List slots, StackAccess access) { + List merged = new ArrayList<>(); + for (T slot : slots) { + if (access.isEmpty(slot)) continue; + T remaining = access.copy(slot); + for (int index = 0; index < merged.size() && !access.isEmpty(remaining); index++) { + T existing = merged.get(index); + if (!access.sameItem(existing, remaining)) continue; + int space = access.maxStackSize(existing) - access.count(existing); + if (space <= 0) continue; + int moved = Math.min(space, access.count(remaining)); + merged.set(index, access.withCount(existing, access.count(existing) + moved)); + remaining = access.withCount(remaining, access.count(remaining) - moved); + } + if (!access.isEmpty(remaining)) merged.add(remaining); + } + merged.sort(access::compare); + for (int index = 0; index < slots.size(); index++) { + slots.set(index, index < merged.size() ? merged.get(index) : access.empty()); + } + } +} 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 30b4519..37951a3 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 @@ -1,7 +1,9 @@ package fr.koka99cab.sanctuary26.sanctuary.mixin.client; import fr.koka99cab.sanctuary26.sanctuary.SanctuaryMod; +import fr.koka99cab.sanctuary26.sanctuary.client.SanctuaryClient; import fr.koka99cab.sanctuary26.sanctuary.client.SanctuaryInventoryClient; +import fr.koka99cab.sanctuary26.sanctuary.client.SanctuaryInventorySortClient; import fr.koka99cab.sanctuary26.sanctuary.client.SanctuaryHotbarRowsClient; import fr.koka99cab.sanctuary26.sanctuary.client.SanctuaryContainerScreenGeometry; import fr.koka99cab.sanctuary26.sanctuary.gameplay.SanctuaryExtendedInventoryMenu; @@ -191,7 +193,7 @@ abstract class AbstractContainerScreenMixin implements SanctuaryContainerScreenG sanctuary$panelLeft + PANEL_BANK_X - 3 + minecraft.player.getInventory().getSelectedSlot() * BANK_ROW_STEP, sanctuary$panelTop + PANEL_BANK_Y - 3 - + SanctuaryHotbarRowsClient.activeVisualRow() * BANK_ROW_STEP, 22, 21); + + SanctuaryHotbarRowsClient.bankIndex(0, sanctuary$visibleRows) * BANK_ROW_STEP, 22, 21); } graphics.text(minecraft.font, Component.translatable("screen.sanctuary.inventory.banks", @@ -245,6 +247,15 @@ abstract class AbstractContainerScreenMixin implements SanctuaryContainerScreenG cir.setReturnValue(true); } + @Inject(method = "mouseClicked", at = @At("HEAD"), cancellable = true) + private void sanctuary$sortInventory(MouseButtonEvent event, boolean doubleClick, + CallbackInfoReturnable cir) { + if (!SanctuaryClient.isSortInventoryMouse(event)) return; + if (SanctuaryInventorySortClient.request(Minecraft.getInstance())) { + cir.setReturnValue(true); + } + } + /** Shift + held left click quick-moves every slot crossed exactly once. */ @Inject(method = "mouseClicked", at = @At("HEAD"), cancellable = true) private void sanctuary$startRapidTransfer(MouseButtonEvent event, boolean doubleClick, @@ -373,7 +384,8 @@ abstract class AbstractContainerScreenMixin implements SanctuaryContainerScreenG } sanctuary$move(slot, sanctuary$panelLeft + PANEL_BANK_X + column * BANK_ROW_STEP, - sanctuary$panelTop + PANEL_BANK_Y + visualRow * BANK_ROW_STEP); + sanctuary$panelTop + PANEL_BANK_Y + + SanctuaryHotbarRowsClient.bankIndex(logicalRow, sanctuary$visibleRows) * BANK_ROW_STEP); } @Unique diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/client/InventoryScreenMixin.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/client/InventoryScreenMixin.java index 940831d..4e15688 100644 --- a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/client/InventoryScreenMixin.java +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/client/InventoryScreenMixin.java @@ -163,15 +163,15 @@ abstract class InventoryScreenMixin { } } if (minecraft.player != null) { - int activeRow = SanctuaryHotbarRowsClient.activeVisualRow(); + int hotbarRow = SanctuaryHotbarRowsClient.bankIndex(0, sanctuary$visibleRows); graphics.blit(RenderPipelines.GUI_TEXTURED, HOTBAR_TEXTURE, leftPos + HOTBAR_X, - topPos + BANK_Y + activeRow * BANK_ROW_STEP - 3, + topPos + BANK_Y + hotbarRow * BANK_ROW_STEP - 3, 0.0F, 0.0F, 182, 22, 182, 22); graphics.blitSprite(RenderPipelines.GUI_TEXTURED, HOTBAR_SELECTION_SPRITE, leftPos + HOTBAR_X - 1 + minecraft.player.getInventory().getSelectedSlot() * HOTBAR_COLUMN_STEP, - topPos + BANK_Y + activeRow * BANK_ROW_STEP - 4, 24, 23); + topPos + BANK_Y + hotbarRow * BANK_ROW_STEP - 4, 24, 23); } if (minecraft.player != null) { InventoryScreen.extractEntityInInventoryFollowsMouse( @@ -212,7 +212,8 @@ abstract class InventoryScreenMixin { return; } int x = hotbar ? HOTBAR_SLOT_X + column * HOTBAR_COLUMN_STEP : BANK_X + column * 18; - move(slot, x, BANK_Y + visualRow * BANK_ROW_STEP); + move(slot, x, BANK_Y + SanctuaryHotbarRowsClient.bankIndex(logicalRow, sanctuary$visibleRows) + * BANK_ROW_STEP); } @Unique diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/client/KeyboardHandlerMixin.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/client/KeyboardHandlerMixin.java index 0cf9951..b357b9a 100644 --- a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/client/KeyboardHandlerMixin.java +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/mixin/client/KeyboardHandlerMixin.java @@ -2,6 +2,7 @@ package fr.koka99cab.sanctuary26.sanctuary.mixin.client; import fr.koka99cab.sanctuary26.sanctuary.client.SanctuaryClient; import fr.koka99cab.sanctuary26.sanctuary.client.SanctuaryHotbarRowsClient; +import fr.koka99cab.sanctuary26.sanctuary.client.SanctuaryInventorySortClient; import fr.koka99cab.sanctuary26.sanctuary.client.SanctuarySittingClient; import net.minecraft.client.KeyboardHandler; import net.minecraft.client.Minecraft; @@ -45,4 +46,17 @@ public abstract class KeyboardHandlerMixin { callback.cancel(); } } + + @Inject(method = "keyPress", at = @At("HEAD"), cancellable = true) + private void sanctuary$sortInventory(long windowPointer, int action, KeyEvent event, + CallbackInfo callback) { + Minecraft client = Minecraft.getInstance(); + if (windowPointer != client.getWindow().handle() || action != GLFW.GLFW_PRESS + || !SanctuaryClient.isSortInventoryKey(event)) { + return; + } + if (SanctuaryInventorySortClient.requestFromKeyboard(client)) { + callback.cancel(); + } + } } 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 fe9cb54..56de35f 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 @@ -1,8 +1,14 @@ package fr.koka99cab.sanctuary26.sanctuary.mixin.client; import fr.koka99cab.sanctuary26.sanctuary.client.SanctuaryBulkActionsClient; +import fr.koka99cab.sanctuary26.sanctuary.client.SanctuaryClient; +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.input.MouseButtonEvent; +import net.minecraft.client.input.MouseButtonInfo; +import org.lwjgl.glfw.GLFW; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; @@ -19,4 +25,17 @@ public abstract class MouseHandlerMixin { callback.cancel(); } } + + @Inject(method = "onButton", at = @At("HEAD"), cancellable = true) + private void sanctuary$sortInventory(long windowPointer, MouseButtonInfo buttonInfo, int action, + CallbackInfo callback) { + if (action != GLFW.GLFW_PRESS) return; + Minecraft client = Minecraft.getInstance(); + if (windowPointer != client.getWindow().handle()) return; + MouseButtonEvent event = new MouseButtonEvent(0.0, 0.0, buttonInfo); + if (!SanctuaryClient.isSortInventoryMouse(event)) return; + if (SanctuaryInventorySortClient.request(client)) { + callback.cancel(); + } + } } 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 1cffe80..6872f09 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 @@ -6,6 +6,7 @@ import fr.koka99cab.sanctuary26.sanctuary.progression.SanctuaryMealHeartsService import fr.koka99cab.sanctuary26.sanctuary.gameplay.building.SanctuaryBuildingManager; import fr.koka99cab.sanctuary26.sanctuary.gameplay.mining.SanctuaryMiningManager; import fr.koka99cab.sanctuary26.sanctuary.gameplay.SanctuaryHotbarRows; +import fr.koka99cab.sanctuary26.sanctuary.gameplay.SanctuaryInventorySort; import fr.koka99cab.sanctuary26.sanctuary.gameplay.SanctuaryCompanionPowers; import fr.koka99cab.sanctuary26.sanctuary.gameplay.SanctuarySitting; import fr.koka99cab.sanctuary26.sanctuary.celestial.CelestialSkyService; @@ -48,6 +49,7 @@ public final class SanctuaryNetworking { PayloadTypeRegistry.serverboundPlay().register(BuildControlPayload.ID, BuildControlPayload.CODEC); PayloadTypeRegistry.serverboundPlay().register(MiningControlPayload.ID, MiningControlPayload.CODEC); PayloadTypeRegistry.serverboundPlay().register(HotbarRowSwitchPayload.ID, HotbarRowSwitchPayload.CODEC); + PayloadTypeRegistry.serverboundPlay().register(SortInventoryPayload.ID, SortInventoryPayload.CODEC); PayloadTypeRegistry.serverboundPlay().register(CompanionPowerUsePayload.ID, CompanionPowerUsePayload.CODEC); PayloadTypeRegistry.serverboundPlay().register(SitRequestPayload.ID, SitRequestPayload.CODEC); PayloadTypeRegistry.serverboundPlay().register(CelestialActionPayload.ID, CelestialActionPayload.CODEC); @@ -72,6 +74,8 @@ public final class SanctuaryNetworking { new HotbarRowSyncPayload(accepted, payload.logicalRow())); } })); + ServerPlayNetworking.registerGlobalReceiver(SortInventoryPayload.ID, (payload, context) -> + context.server().execute(() -> SanctuaryInventorySort.sort(context.player()))); 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 new file mode 100644 index 0000000..2d7b266 --- /dev/null +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/network/SortInventoryPayload.java @@ -0,0 +1,27 @@ +package fr.koka99cab.sanctuary26.sanctuary.network; + +import fr.koka99cab.sanctuary26.sanctuary.SanctuaryMod; +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 { + 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(); + } + + private void write(RegistryFriendlyByteBuf buffer) { + // Intentionally empty: no client-supplied slot list or item identity can be trusted. + } + + @Override + public Type type() { + return ID; + } +} diff --git a/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json b/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json index 341de47..bf89c6d 100644 --- a/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json +++ b/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json @@ -243,6 +243,7 @@ "key.sanctuary.companion_power": "Companion power", "key.sanctuary.zoom": "Telephoto zoom", "key.sanctuary.switch_hotbar_row": "Cycle hotbar row", + "key.sanctuary.sort_inventory": "Sort inventory", "key.sanctuary.quest_tracker": "Quest tracker", "hud.sanctuary.quest_tracker.toggle_on": "Quest tracker shown", "hud.sanctuary.quest_tracker.toggle_off": "Quest tracker hidden", diff --git a/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json b/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json index ae46910..5142e23 100644 --- a/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json +++ b/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json @@ -243,6 +243,7 @@ "key.sanctuary.companion_power": "Pouvoir du familier", "key.sanctuary.zoom": "Zoom téléobjectif", "key.sanctuary.switch_hotbar_row": "Changer de rangée de hotbar", + "key.sanctuary.sort_inventory": "Trier l'inventaire", "key.sanctuary.quest_tracker": "Suivi des quêtes", "hud.sanctuary.quest_tracker.toggle_on": "Suivi des quêtes affiché", "hud.sanctuary.quest_tracker.toggle_off": "Suivi des quêtes masqué", diff --git a/sanctuary/src/main/resources/assets/sanctuary/lang/ru_ru.json b/sanctuary/src/main/resources/assets/sanctuary/lang/ru_ru.json index 0e47ea1..1be5489 100644 --- a/sanctuary/src/main/resources/assets/sanctuary/lang/ru_ru.json +++ b/sanctuary/src/main/resources/assets/sanctuary/lang/ru_ru.json @@ -155,6 +155,7 @@ "key.sanctuary.companion_power": "Способность спутника", "key.sanctuary.zoom": "Телефото-зум", "key.sanctuary.switch_hotbar_row": "Сменить ряд панели быстрого доступа", + "key.sanctuary.sort_inventory": "Сортировать инвентарь", "key.sanctuary.quest_tracker": "Трекер заданий", "hud.sanctuary.quest_tracker.toggle_on": "Трекер заданий показан", "hud.sanctuary.quest_tracker.toggle_off": "Трекер заданий скрыт", 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 new file mode 100644 index 0000000..b9a9fcb --- /dev/null +++ b/sanctuary/src/test/java/fr/koka99cab/sanctuary26/sanctuary/gameplay/SanctuaryInventorySortSmoke.java @@ -0,0 +1,115 @@ +package fr.koka99cab.sanctuary26.sanctuary.gameplay; + +import java.util.ArrayList; +import java.util.List; + +/** Executable coverage for merging, ordering and leaving the hotbar row out of the sort. */ +public final class SanctuaryInventorySortSmoke { + private SanctuaryInventorySortSmoke() { + } + + public static void main(String[] args) { + require(SanctuaryInventoryCapacity.bankIndex(0, 4) == 3, + "The live hotbar must sit on the last bank row"); + require(SanctuaryInventoryCapacity.bankIndex(3, 4) == 0, + "The last storage row must sit on the first bank line"); + require(SanctuaryInventoryCapacity.physicalRowAtBankIndex(0, null, 4) == 3, + "Packing must start at the top storage row, not the hotbar"); + require(SanctuaryInventoryCapacity.physicalRowAtBankIndex(3, null, 4) == 0, + "The bottom bank row must remain the live hotbar"); + + SanctuaryInventorySorter.StackAccess access = FakeStack.ACCESS; + List storage = new ArrayList<>(List.of( + new FakeStack("minecraft:dirt", "", 32, 64), + new FakeStack("minecraft:cobblestone", "", 16, 64), + FakeStack.EMPTY, + new FakeStack("minecraft:dirt", "", 40, 64), + new FakeStack("minecraft:diamond_sword", "sharpness", 1, 1), + new FakeStack("minecraft:cobblestone", "", 64, 64), + new FakeStack("minecraft:diamond_sword", "", 1, 1) + )); + SanctuaryInventorySorter.compactAndSort(storage, access); + require(storage.get(0).id.equals("minecraft:cobblestone") && storage.get(0).count == 64, + "Full cobblestone stack must come first"); + require(storage.get(1).id.equals("minecraft:cobblestone") && storage.get(1).count == 16, + "Leftover cobblestone must follow the merged full stack"); + require(storage.get(2).id.equals("minecraft:diamond_sword") && storage.get(2).components.isEmpty(), + "Unenchanted sword must sort before the enchanted one"); + require(storage.get(3).id.equals("minecraft:diamond_sword") && storage.get(3).components.equals("sharpness"), + "Component variants must stay distinct"); + require(storage.get(4).id.equals("minecraft:dirt") && storage.get(4).count == 64, + "Dirt must merge up to max stack size"); + require(storage.get(5).id.equals("minecraft:dirt") && storage.get(5).count == 8, + "Dirt remainder must keep the leftover count"); + require(storage.get(6).isEmpty(), "Unused storage slots must end empty"); + + List hotbar = new ArrayList<>(List.of( + new FakeStack("minecraft:torch", "", 8, 64), + new FakeStack("minecraft:bread", "", 3, 64) + )); + List snapshot = List.copyOf(hotbar); + require(hotbar.equals(snapshot), "Hotbar snapshot must remain untouched by storage sorting"); + } + + private static void require(boolean condition, String message) { + if (!condition) throw new IllegalStateException(message); + } + + private record FakeStack(String id, String components, int count, int maxSize) { + static final FakeStack EMPTY = new FakeStack("", "", 0, 64); + static final SanctuaryInventorySorter.StackAccess ACCESS = + new SanctuaryInventorySorter.StackAccess<>() { + @Override + public boolean isEmpty(FakeStack stack) { + return stack == null || stack.count <= 0 || stack.id.isEmpty(); + } + + @Override + public int count(FakeStack stack) { + return stack.count; + } + + @Override + public int maxStackSize(FakeStack stack) { + return stack.maxSize; + } + + @Override + public boolean sameItem(FakeStack first, FakeStack second) { + return first.id.equals(second.id) && first.components.equals(second.components); + } + + @Override + public FakeStack copy(FakeStack stack) { + return stack; + } + + @Override + public FakeStack withCount(FakeStack stack, int count) { + if (count <= 0) return EMPTY; + return new FakeStack(stack.id, stack.components, count, stack.maxSize); + } + + @Override + public FakeStack empty() { + return EMPTY; + } + + @Override + public int compare(FakeStack first, FakeStack second) { + boolean firstEmpty = isEmpty(first); + boolean secondEmpty = isEmpty(second); + if (firstEmpty || secondEmpty) return Boolean.compare(firstEmpty, secondEmpty); + int byId = first.id.compareTo(second.id); + if (byId != 0) return byId; + int byComponents = first.components.compareTo(second.components); + if (byComponents != 0) return byComponents; + return Integer.compare(second.count, first.count); + } + }; + + boolean isEmpty() { + return ACCESS.isEmpty(this); + } + } +}