diff --git a/build.gradle b/build.gradle index daa0b40..49778a6 100644 --- a/build.gradle +++ b/build.gradle @@ -3489,7 +3489,7 @@ tasks.register("verifyMirrorLavenderDrawerRelease") { def drawerRecipe = file("redstoner/src/main/resources/data/redstoner/recipe/drawer.json") if (rootProject.pack_version != "26.2.0-alpha.221" || rootProject.anotherworld_version != "0.0.0-alpha.55" - || rootProject.redstoner_version != "0.0.0-alpha.12" + || rootProject.redstoner_version != "0.0.0-alpha.13" || release.pack_version != rootProject.pack_version || index.versionId != rootProject.pack_version || migration.source?.pack_version != "26.2.0-alpha.191" || migration.source?.modules != [anotherworld: "0.0.0-alpha.48", redstoner: "0.0.0-alpha.9"] @@ -4086,6 +4086,8 @@ project(":redstoner") { "screen.redstoner.personal_computer.shutdown", "message.redstoner.personal_computer.atlas_marker_exists", "screen.redstoner.storage_terminal.title", + "screen.redstoner.storage_terminal.search", + "screen.redstoner.storage_terminal.no_results", "screen.redstoner.storage_terminal.summary", "screen.redstoner.storage_terminal.error.invalid_terminal", "screen.redstoner.storage_terminal.error.disconnected", @@ -4103,7 +4105,7 @@ project(":redstoner") { "src/main/resources/data/redstoner/recipe/controller.json")) def terminalRecipe = new JsonSlurper().parse(file( "src/main/resources/data/redstoner/recipe/storage_terminal.json")) - if (project.version.toString() != "0.0.0-alpha.12" + if (project.version.toString() != "0.0.0-alpha.13" || manifest.custom?.sanctuary26?.data_version != 3 || manifest.custom?.sanctuary26?.network_protocol != 7 || terminalMigration.source?.pack_version != "26.2.0-alpha.217" @@ -4144,7 +4146,7 @@ project(":redstoner") { P: "anotherworld:silver_plate", G: "minecraft:tinted_glass", C: "redstoner:controller", T: "anotherworld:titanium_ingot"] || terminalRecipe.result != [id: "redstoner:storage_terminal", count: 1]) { - throw new GradleException("Red-Stoner alpha.12 Storage Terminal menu contract is incomplete") + throw new GradleException("Red-Stoner alpha.13 Storage Terminal contract is incomplete") } if (file("src/main/resources/data/redstoner/recipe/personal_computer.json").exists() || file("src/main/resources/data/redstoner/recipe/drawer.json").exists() @@ -6443,7 +6445,10 @@ tasks.register("verifyAlpha218Release") { "file = \"mods/${id}-${version}.jar\"" } if (rootProject.pack_version != "26.2.0-alpha.221" - || currentModules.any { id, version -> rootProject.property("${id}_version") != version } + || rootProject.redstoner_version != "0.0.0-alpha.13" + || currentModules.findAll { id, version -> id != "redstoner" }.any { + id, version -> rootProject.property("${id}_version") != version + } || migration.source?.pack_version != "26.2.0-alpha.217" || migration.source?.modules != sourceModules || migration.target?.pack_version != "26.2.0-alpha.218" @@ -6620,7 +6625,7 @@ tasks.register("verifyAlpha220Release") { def networking = file( "redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/network/RedStonerNetworking.java").text if (rootProject.pack_version != "26.2.0-alpha.221" - || rootProject.redstoner_version != "0.0.0-alpha.12" + || rootProject.redstoner_version != "0.0.0-alpha.13" || migration.source?.pack_version != "26.2.0-alpha.219" || migration.source?.modules != [redstoner: "0.0.0-alpha.11"] || migration.target?.pack_version != "26.2.0-alpha.220" @@ -6775,6 +6780,75 @@ tasks.register("verifyAlpha221Release") { } } +tasks.register("verifyStorageTerminalSearchFeature") { + group = "verification" + description = "Checks the unreleased Storage Terminal keyboard-search feature." + inputs.file(file("pack/release.json")) + inputs.file(file("pack/prism/modrinth.index.json")) + inputs.file(file("pack/packwiz/pack.toml")) + inputs.file(file("pack/packwiz/index.toml")) + inputs.file(file("redstoner/src/main/resources/fabric.mod.json")) + inputs.file(file("redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/storage/StorageTerminalMenu.java")) + inputs.file(file("redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/storage/StorageTerminalNetwork.java")) + inputs.file(file("redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/storage/StorageTerminalRules.java")) + inputs.file(file("redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/storage/StorageTerminalLayout.java")) + inputs.file(file("redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/storage/StorageTerminalSearch.java")) + inputs.file(file("redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/client/gui/StorageTerminalScreen.java")) + inputs.file(file("redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/network/RedStonerNetworking.java")) + inputs.file(file("redstoner/src/test/java/fr/koka99cab/sanctuary26/redstoner/storage/StorageTerminalSmoke.java")) + doLast { + def release = new JsonSlurper().parse(file("pack/release.json")) + def prism = new JsonSlurper().parse(file("pack/prism/modrinth.index.json")) + def packwiz = file("pack/packwiz/pack.toml").text + def packwizIndex = file("pack/packwiz/index.toml").text + def manifest = new JsonSlurper().parse(file("redstoner/src/main/resources/fabric.mod.json")) + def menu = file( + "redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/storage/StorageTerminalMenu.java").text + def network = file( + "redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/storage/StorageTerminalNetwork.java").text + def rules = file( + "redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/storage/StorageTerminalRules.java").text + def layout = file( + "redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/storage/StorageTerminalLayout.java").text + def search = file( + "redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/storage/StorageTerminalSearch.java").text + def screen = file( + "redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/client/gui/StorageTerminalScreen.java").text + def networking = file( + "redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/network/RedStonerNetworking.java").text + def smoke = file( + "redstoner/src/test/java/fr/koka99cab/sanctuary26/redstoner/storage/StorageTerminalSmoke.java").text + if (rootProject.pack_version != "26.2.0-alpha.221" + || rootProject.redstoner_version != "0.0.0-alpha.13" + || manifest.custom?.sanctuary26?.data_version != 3 + || manifest.custom?.sanctuary26?.network_protocol != 7 + || !rules.contains("MAX_SEARCH_LENGTH = 32") + || !rules.contains("compactCount(int count)") + || !layout.contains("Keeps terminal slot identities stable") + || !layout.contains("new StorageTerminalInventory.Entry(identity.representative(), 0)") + || !menu.contains("SEARCH_CODEPOINT_BUTTON_BASE") + || !menu.contains("isSearchCodePoint") + || !network.contains("StorageTerminalSearch.filter") + || !search.contains("Normalizer.Form.NFD") + || !search.contains("BuiltInRegistries.ITEM.getKey") + || !screen.contains("new EditBox") + || !screen.contains("setInitialFocus(searchBox)") + || !screen.contains("searchBox.setResponder(this::searchChanged)") + || !smoke.contains("verifySearch()") + || !smoke.contains("verifyStableLayout()") + || !networking.contains("PROTOCOL_VERSION = 7") + || networking.contains("storage_terminal_search") + || release.pack_version != "26.2.0-alpha.221" + || release.modrinth?.primary_file != "sanctuary-26.2.0-alpha.221.mrpack" + || prism.versionId != "26.2.0-alpha.221" + || !packwiz.contains('version = "26.2.0-alpha.221"') + || !packwizIndex.contains('file = "mods/redstoner-0.0.0-alpha.12.jar"') + || packwizIndex.contains('file = "mods/redstoner-0.0.0-alpha.13.jar"')) { + throw new GradleException("The unreleased Storage Terminal keyboard-search feature is incomplete") + } + } +} + tasks.named("verifyWorkspace") { dependsOn tasks.named("verifyAlpha213Release") dependsOn tasks.named("verifyAlpha214Release") @@ -6785,6 +6859,7 @@ tasks.named("verifyWorkspace") { dependsOn tasks.named("verifyAlpha219Release") dependsOn tasks.named("verifyAlpha220Release") dependsOn tasks.named("verifyAlpha221Release") + dependsOn tasks.named("verifyStorageTerminalSearchFeature") } tasks.named("check") { diff --git a/gradle.properties b/gradle.properties index 4347b3c..8ff1633 100644 --- a/gradle.properties +++ b/gradle.properties @@ -14,7 +14,7 @@ mod_version=0.0.0-alpha.0 module_lifecycle=standby ambiance_version=0.0.0-alpha.22 ambiance_lifecycle=active -redstoner_version=0.0.0-alpha.12 +redstoner_version=0.0.0-alpha.13 redstoner_lifecycle=active anotherworld_version=0.0.0-alpha.55 anotherworld_lifecycle=active diff --git a/redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/client/gui/StorageTerminalScreen.java b/redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/client/gui/StorageTerminalScreen.java index f9a94a9..ccf9f8c 100644 --- a/redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/client/gui/StorageTerminalScreen.java +++ b/redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/client/gui/StorageTerminalScreen.java @@ -1,12 +1,15 @@ package fr.koka99cab.sanctuary26.redstoner.client.gui; import fr.koka99cab.sanctuary26.redstoner.storage.StorageTerminalMenu; +import fr.koka99cab.sanctuary26.redstoner.storage.StorageTerminalRules; import fr.koka99cab.sanctuary26.redstoner.storage.StorageTerminalStatus; import java.util.ArrayList; import java.util.List; import net.minecraft.ChatFormatting; import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.EditBox; import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.client.input.KeyEvent; import net.minecraft.client.input.MouseButtonEvent; import net.minecraft.client.renderer.RenderPipelines; import net.minecraft.network.chat.Component; @@ -24,6 +27,9 @@ public final class StorageTerminalScreen extends AbstractContainerScreen 1) { - String label = compactCount(total); + String label = StorageTerminalRules.compactCount(total); graphics.text(font, label, slot.x + 17 - font.width(label), slot.y + 9, 0xFFFFFFFF, true); } @@ -111,6 +125,15 @@ public final class StorageTerminalScreen extends AbstractContainerScreen sendMenuButton( + StorageTerminalMenu.SEARCH_CODEPOINT_BUTTON_BASE + codePoint)); + } + sentSearchValue = value; + } + + private void sendMenuButton(int buttonId) { + if (minecraft != null && minecraft.gameMode != null) { + minecraft.gameMode.handleInventoryButtonClick(menu.containerId, buttonId); + } + } + private void extractScrollBar(GuiGraphicsExtractor graphics) { int x = leftPos + SCROLL_X; int y = topPos + TERMINAL_TOP; @@ -137,9 +185,4 @@ public final class StorageTerminalScreen extends AbstractContainerScreen= 1_000_000) return (count / 1_000_000) + "m"; - if (count >= 10_000) return (count / 1_000) + "k"; - return Integer.toString(count); - } } diff --git a/redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/storage/StorageTerminalInventory.java b/redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/storage/StorageTerminalInventory.java index 7f3f112..06a1550 100644 --- a/redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/storage/StorageTerminalInventory.java +++ b/redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/storage/StorageTerminalInventory.java @@ -12,12 +12,12 @@ public final class StorageTerminalInventory { } public static List aggregate(List containers) { - Map grouped = new LinkedHashMap<>(); + Map grouped = new LinkedHashMap<>(); for (Container container : containers) { for (int slot = 0; slot < container.getContainerSize(); slot++) { ItemStack stack = container.getItem(slot); if (stack.isEmpty()) continue; - StackKey key = new StackKey(stack); + StackIdentity key = identity(stack); MutableEntry entry = grouped.computeIfAbsent(key, ignored -> new MutableEntry(key.stack)); entry.total = saturatedAdd(entry.total, stack.getCount()); } @@ -84,10 +84,14 @@ public final class StorageTerminalInventory { public record Entry(ItemStack stack, int total) { public Entry { stack = stack == null || stack.isEmpty() ? ItemStack.EMPTY : stack.copyWithCount(1); - total = stack.isEmpty() ? 0 : Math.max(1, total); + total = stack.isEmpty() ? 0 : Math.max(0, total); } } + static StackIdentity identity(ItemStack stack) { + return new StackIdentity(stack); + } + private static final class MutableEntry { private final ItemStack stack; private int total; @@ -97,11 +101,11 @@ public final class StorageTerminalInventory { } } - private static final class StackKey { + static final class StackIdentity { private final ItemStack stack; private final int hash; - private StackKey(ItemStack stack) { + private StackIdentity(ItemStack stack) { this.stack = stack.copyWithCount(1); this.hash = ItemStack.hashItemAndComponents(this.stack); } @@ -113,7 +117,12 @@ public final class StorageTerminalInventory { @Override public boolean equals(Object other) { - return other instanceof StackKey key && ItemStack.isSameItemSameComponents(stack, key.stack); + return other instanceof StackIdentity identity + && ItemStack.isSameItemSameComponents(stack, identity.stack); + } + + ItemStack representative() { + return stack.copyWithCount(1); } } } diff --git a/redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/storage/StorageTerminalLayout.java b/redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/storage/StorageTerminalLayout.java new file mode 100644 index 0000000..51e5208 --- /dev/null +++ b/redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/storage/StorageTerminalLayout.java @@ -0,0 +1,52 @@ +package fr.koka99cab.sanctuary26.redstoner.storage; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import net.minecraft.world.item.ItemStack; + +/** Keeps terminal slot identities stable for the lifetime of one search layout. */ +final class StorageTerminalLayout { + private static final StorageTerminalInventory.Entry EMPTY_ENTRY = + new StorageTerminalInventory.Entry(ItemStack.EMPTY, 0); + private final List order = new ArrayList<>(); + private Map liveByIdentity = Map.of(); + + void update(List liveEntries, boolean resetOrder) { + Map live = + new LinkedHashMap<>(); + for (StorageTerminalInventory.Entry entry : liveEntries) { + live.put(StorageTerminalInventory.identity(entry.stack()), entry); + } + if (resetOrder) order.clear(); + Set known = new HashSet<>(order); + for (StorageTerminalInventory.StackIdentity identity : live.keySet()) { + if (known.add(identity)) order.add(identity); + } + liveByIdentity = Map.copyOf(live); + } + + int size() { + return order.size(); + } + + List page(int requestedPage) { + int start = StorageTerminalRules.pageStart(requestedPage, order.size()); + List entries = new ArrayList<>(StorageTerminalRules.PAGE_SIZE); + for (int slot = 0; slot < StorageTerminalRules.PAGE_SIZE; slot++) { + int orderedIndex = start + slot; + if (orderedIndex >= order.size()) { + entries.add(EMPTY_ENTRY); + continue; + } + StorageTerminalInventory.StackIdentity identity = order.get(orderedIndex); + StorageTerminalInventory.Entry live = liveByIdentity.get(identity); + entries.add(live == null + ? new StorageTerminalInventory.Entry(identity.representative(), 0) : live); + } + return List.copyOf(entries); + } +} diff --git a/redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/storage/StorageTerminalMenu.java b/redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/storage/StorageTerminalMenu.java index 887dc95..839cfb9 100644 --- a/redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/storage/StorageTerminalMenu.java +++ b/redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/storage/StorageTerminalMenu.java @@ -21,10 +21,13 @@ import net.minecraft.world.inventory.Slot; import net.minecraft.world.item.ItemStack; import net.minecraft.world.phys.Vec3; -/** A server-authoritative, read-only aggregate view over the connected physical storage. */ +/** A server-authoritative aggregate view over the connected physical storage. */ public final class StorageTerminalMenu extends AbstractContainerMenu { public static final int TERMINAL_SLOT_COUNT = StorageTerminalRules.PAGE_SIZE; public static final int PAGE_BUTTON_BASE = 10; + public static final int SEARCH_CLEAR_BUTTON = 100; + public static final int SEARCH_BACKSPACE_BUTTON = 101; + public static final int SEARCH_CODEPOINT_BUTTON_BASE = 1_000; private static final int PLAYER_INVENTORY_Y = 140; private static final int REFRESH_INTERVAL = 20; private static final double MAX_DISTANCE_SQUARED = 64.0D; @@ -43,7 +46,9 @@ public final class StorageTerminalMenu extends AbstractContainerMenu { private final BlockPos terminalPos; private StorageTerminalNetwork.Discovery discovery = StorageTerminalNetwork.Discovery.error(StorageTerminalStatus.INVALID_TERMINAL); + private final StorageTerminalLayout layout = new StorageTerminalLayout(); private List entries = List.of(); + private String searchQuery = ""; private int refreshCooldown = REFRESH_INTERVAL; public StorageTerminalMenu(int containerId, Inventory inventory) { @@ -52,7 +57,7 @@ public final class StorageTerminalMenu extends AbstractContainerMenu { public StorageTerminalMenu(int containerId, Inventory inventory, ServerPlayer player, BlockPos pos) { this(containerId, inventory, new SimpleContainerData(DATA_COUNT), player, pos.immutable()); - refresh(0); + refresh(0, true); } private StorageTerminalMenu(int containerId, Inventory inventory, ContainerData data, @@ -115,8 +120,25 @@ public final class StorageTerminalMenu extends AbstractContainerMenu { public boolean clickMenuButton(Player player, int buttonId) { if (serverPlayer == null || player != serverPlayer || !stillValid(player)) return false; int requestedPage = buttonId - PAGE_BUTTON_BASE; - if (requestedPage < 0 || requestedPage >= StorageTerminalRules.MAX_PAGE_COUNT) return false; - refresh(requestedPage); + if (requestedPage >= 0 && requestedPage < StorageTerminalRules.MAX_PAGE_COUNT) { + refresh(requestedPage, false); + broadcastChanges(); + return true; + } + if (buttonId == SEARCH_CLEAR_BUTTON) { + searchQuery = ""; + } else if (buttonId == SEARCH_BACKSPACE_BUTTON) { + if (searchQuery.isEmpty()) return true; + searchQuery = searchQuery.substring(0, + searchQuery.offsetByCodePoints(searchQuery.length(), -1)); + } else { + int codePoint = buttonId - SEARCH_CODEPOINT_BUTTON_BASE; + if (!isSearchCodePoint(codePoint)) return false; + String appended = new String(Character.toChars(codePoint)); + if (searchQuery.length() + appended.length() > StorageTerminalRules.MAX_SEARCH_LENGTH) return false; + searchQuery += appended; + } + refresh(0, true); broadcastChanges(); return true; } @@ -156,7 +178,7 @@ public final class StorageTerminalMenu extends AbstractContainerMenu { if (source.isEmpty()) sourceSlot.setByPlayer(ItemStack.EMPTY); else sourceSlot.setChanged(); sourceSlot.onTake(player, source); - refresh(page()); + refresh(page(), false); broadcastChanges(); return original; } @@ -171,7 +193,7 @@ public final class StorageTerminalMenu extends AbstractContainerMenu { @Override public void broadcastChanges() { - if (serverPlayer != null && --refreshCooldown <= 0) refresh(page()); + if (serverPlayer != null && --refreshCooldown <= 0) refresh(page(), false); super.broadcastChanges(); } @@ -185,19 +207,27 @@ public final class StorageTerminalMenu extends AbstractContainerMenu { return !(slot instanceof TerminalDisplaySlot) && super.canTakeItemForPickAll(stack, slot); } - private void refresh(int requestedPage) { + private void refresh(int requestedPage, boolean resetOrder) { if (serverPlayer == null || terminalPos == null) return; discovery = StorageTerminalNetwork.discover(serverPlayer, terminalPos); - StorageTerminalNetwork.Snapshot snapshot = StorageTerminalNetwork.snapshot(discovery, requestedPage); - entries = snapshot.entries(); - data.set(STATUS_DATA, snapshot.status().ordinal()); - data.set(PAGE_DATA, snapshot.page()); - data.set(PAGE_COUNT_DATA, snapshot.pageCount()); - data.set(CONTAINER_COUNT_DATA, snapshot.containerCount()); - data.set(TOTAL_ENTRIES_DATA, snapshot.totalEntries()); + List liveEntries = + StorageTerminalNetwork.filteredEntries(discovery, searchQuery); + layout.update(liveEntries, resetOrder); + + int page = discovery.status() == StorageTerminalStatus.READY + ? StorageTerminalRules.clampPage(requestedPage, layout.size()) : 0; + int pageCount = discovery.status() == StorageTerminalStatus.READY + ? StorageTerminalRules.pageCount(layout.size()) : 1; + entries = discovery.status() == StorageTerminalStatus.READY ? layout.page(page) : List.of(); + data.set(STATUS_DATA, discovery.status().ordinal()); + data.set(PAGE_DATA, page); + data.set(PAGE_COUNT_DATA, pageCount); + data.set(CONTAINER_COUNT_DATA, discovery.containers().size()); + data.set(TOTAL_ENTRIES_DATA, liveEntries.size()); for (int slot = 0; slot < TERMINAL_SLOT_COUNT; slot++) { StorageTerminalInventory.Entry entry = slot < entries.size() ? entries.get(slot) : null; - display.setItem(slot, entry == null ? ItemStack.EMPTY : entry.stack().copyWithCount(1)); + display.setItem(slot, entry == null || entry.total() <= 0 + ? ItemStack.EMPTY : entry.stack().copyWithCount(1)); int total = entry == null ? 0 : entry.total(); int base = TOTALS_DATA + slot * WORDS_PER_TOTAL; data.set(base, StorageTerminalRules.totalLowWord(total)); @@ -206,6 +236,12 @@ public final class StorageTerminalMenu extends AbstractContainerMenu { refreshCooldown = REFRESH_INTERVAL; } + private static boolean isSearchCodePoint(int codePoint) { + return Character.isValidCodePoint(codePoint) + && !(codePoint >= Character.MIN_SURROGATE && codePoint <= Character.MAX_SURROGATE) + && !Character.isISOControl(codePoint); + } + private boolean ready() { if (serverPlayer == null || terminalPos == null) return false; discovery = StorageTerminalNetwork.discover(serverPlayer, terminalPos); @@ -215,12 +251,13 @@ public final class StorageTerminalMenu extends AbstractContainerMenu { private void takeToCarried(int slot, boolean single) { if (!ready() || slot >= entries.size()) return; StorageTerminalInventory.Entry entry = entries.get(slot); + if (entry.stack().isEmpty() || entry.total() <= 0) return; int requested = single ? 1 : entry.stack().getMaxStackSize(); ItemStack extracted = StorageTerminalInventory.take(discovery.inventoryContainers(), entry.stack(), Math.min(entry.total(), requested)); if (extracted.isEmpty()) return; setCarried(extracted); - refresh(page()); + refresh(page(), false); broadcastChanges(); } @@ -233,13 +270,14 @@ public final class StorageTerminalMenu extends AbstractContainerMenu { if (moved <= 0) return; carried.shrink(moved); if (carried.isEmpty()) setCarried(ItemStack.EMPTY); - refresh(page()); + refresh(page(), false); broadcastChanges(); } private ItemStack takeIntoPlayer(int slot) { if (!ready() || slot >= entries.size()) return ItemStack.EMPTY; StorageTerminalInventory.Entry entry = entries.get(slot); + if (entry.stack().isEmpty() || entry.total() <= 0) return ItemStack.EMPTY; int requested = Math.min(entry.total(), entry.stack().getMaxStackSize()); ItemStack extracted = StorageTerminalInventory.take(discovery.inventoryContainers(), entry.stack(), requested); if (extracted.isEmpty()) return ItemStack.EMPTY; @@ -247,7 +285,7 @@ public final class StorageTerminalMenu extends AbstractContainerMenu { int inserted = insertPlayer(serverPlayer, extracted); if (!extracted.isEmpty()) StorageTerminalInventory.insert(discovery.inventoryContainers(), extracted); if (inserted <= 0) return ItemStack.EMPTY; - refresh(page()); + refresh(page(), false); broadcastChanges(); return original.copyWithCount(inserted); } diff --git a/redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/storage/StorageTerminalNetwork.java b/redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/storage/StorageTerminalNetwork.java index 9a9b3cc..51e961d 100644 --- a/redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/storage/StorageTerminalNetwork.java +++ b/redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/storage/StorageTerminalNetwork.java @@ -48,16 +48,11 @@ public final class StorageTerminalNetwork { return new Discovery(StorageTerminalStatus.READY, containers); } - public static Snapshot snapshot(Discovery discovery, int requestedPage) { - if (discovery.status() != StorageTerminalStatus.READY) { - return new Snapshot(discovery.status(), 0, 1, 0, 0, List.of()); - } - List all = StorageTerminalInventory.aggregate(discovery.containers()); - int page = StorageTerminalRules.clampPage(requestedPage, all.size()); - int start = StorageTerminalRules.pageStart(page, all.size()); - int end = Math.min(all.size(), start + StorageTerminalRules.PAGE_SIZE); - return new Snapshot(StorageTerminalStatus.READY, page, StorageTerminalRules.pageCount(all.size()), - discovery.containers().size(), all.size(), all.subList(start, end)); + public static List filteredEntries( + Discovery discovery, String searchQuery) { + if (discovery.status() != StorageTerminalStatus.READY) return List.of(); + return StorageTerminalSearch.filter( + StorageTerminalInventory.aggregate(discovery.containers()), searchQuery); } private static BaseContainerBlockEntity storageEntity(ServerLevel level, BlockPos pos) { @@ -81,12 +76,4 @@ public final class StorageTerminalNetwork { return containers; } } - - public record Snapshot(StorageTerminalStatus status, int page, int pageCount, int containerCount, - int totalEntries, List entries) { - public Snapshot { - status = status == null ? StorageTerminalStatus.INVALID_TERMINAL : status; - entries = entries == null ? List.of() : List.copyOf(entries); - } - } } diff --git a/redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/storage/StorageTerminalRules.java b/redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/storage/StorageTerminalRules.java index 4352764..9229067 100644 --- a/redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/storage/StorageTerminalRules.java +++ b/redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/storage/StorageTerminalRules.java @@ -6,6 +6,7 @@ public final class StorageTerminalRules { public static final int SLOTS_PER_CONTAINER = 27; public static final int MAX_ENTRIES = MAX_CONTAINERS * SLOTS_PER_CONTAINER; public static final int MAX_PAGE_COUNT = MAX_ENTRIES / PAGE_SIZE; + public static final int MAX_SEARCH_LENGTH = 32; private StorageTerminalRules() { } @@ -34,4 +35,20 @@ public final class StorageTerminalRules { public static int totalFromWords(int low, int high) { return (low & 0xFFFF) | (high & 0xFFFF) << 16; } + + public static String compactCount(int count) { + int bounded = Math.max(0, count); + if (bounded < 1_000) return Integer.toString(bounded); + if (bounded < 10_000) return decimalUnit(bounded, 1_000, "K"); + if (bounded < 1_000_000) return (bounded / 1_000) + "K"; + if (bounded < 10_000_000) return decimalUnit(bounded, 1_000_000, "M"); + return (bounded / 1_000_000) + "M"; + } + + private static String decimalUnit(int count, int unit, String suffix) { + int tenths = count / (unit / 10); + int decimal = tenths % 10; + return decimal == 0 ? (tenths / 10) + suffix + : (tenths / 10) + "." + decimal + suffix; + } } diff --git a/redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/storage/StorageTerminalSearch.java b/redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/storage/StorageTerminalSearch.java new file mode 100644 index 0000000..ff0d0bb --- /dev/null +++ b/redstoner/src/main/java/fr/koka99cab/sanctuary26/redstoner/storage/StorageTerminalSearch.java @@ -0,0 +1,51 @@ +package fr.koka99cab.sanctuary26.redstoner.storage; + +import java.text.Normalizer; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.regex.Pattern; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.resources.Identifier; +import net.minecraft.world.item.ItemStack; + +/** Server-side matching for the bounded terminal search field. */ +public final class StorageTerminalSearch { + private static final Pattern COMBINING_MARKS = Pattern.compile("\\p{M}+"); + private static final Pattern NON_ALPHANUMERIC = Pattern.compile("[^\\p{L}\\p{N}]+"); + + private StorageTerminalSearch() { + } + + public static List filter( + List entries, String query) { + String normalizedQuery = normalize(query); + if (normalizedQuery.isEmpty()) return List.copyOf(entries); + String[] terms = normalizedQuery.split(" "); + List matches = new ArrayList<>(); + for (StorageTerminalInventory.Entry entry : entries) { + if (matches(entry.stack(), terms)) matches.add(entry); + } + return List.copyOf(matches); + } + + static String normalize(String value) { + if (value == null || value.isBlank()) return ""; + String withoutMarks = COMBINING_MARKS.matcher( + Normalizer.normalize(value, Normalizer.Form.NFD)).replaceAll(""); + return NON_ALPHANUMERIC.matcher(withoutMarks.toLowerCase(Locale.ROOT)) + .replaceAll(" ").strip(); + } + + private static boolean matches(ItemStack stack, String[] terms) { + StringBuilder searchable = new StringBuilder(stack.getHoverName().getString()); + Identifier id = BuiltInRegistries.ITEM.getKey(stack.getItem()); + if (id != null) searchable.append(' ').append(id); + searchable.append(' ').append(stack.getItem().getDescriptionId()); + String normalized = normalize(searchable.toString()); + for (String term : terms) { + if (!normalized.contains(term)) return false; + } + return true; + } +} diff --git a/redstoner/src/main/resources/assets/redstoner/lang/en_us.json b/redstoner/src/main/resources/assets/redstoner/lang/en_us.json index 60fb008..7de24e6 100644 --- a/redstoner/src/main/resources/assets/redstoner/lang/en_us.json +++ b/redstoner/src/main/resources/assets/redstoner/lang/en_us.json @@ -52,6 +52,8 @@ "tooltip.redstoner.particuleur": "Insert a catalyst, then power the block with redstone.", "block.redstoner.storage_terminal": "Storage Terminal", "screen.redstoner.storage_terminal.title": "Storage Terminal", + "screen.redstoner.storage_terminal.search": "Search…", + "screen.redstoner.storage_terminal.no_results": "No items found.", "screen.redstoner.storage_terminal.summary": "%s/%s · %s blocks · %s types", "screen.redstoner.storage_terminal.error.invalid_terminal": "The terminal is no longer accessible.", "screen.redstoner.storage_terminal.error.disconnected": "No connected chest or barrel.", diff --git a/redstoner/src/main/resources/assets/redstoner/lang/fr_fr.json b/redstoner/src/main/resources/assets/redstoner/lang/fr_fr.json index 77b1801..6d4cac0 100644 --- a/redstoner/src/main/resources/assets/redstoner/lang/fr_fr.json +++ b/redstoner/src/main/resources/assets/redstoner/lang/fr_fr.json @@ -52,6 +52,8 @@ "tooltip.redstoner.particuleur": "Insérez un catalyseur puis alimentez le bloc en redstone.", "block.redstoner.storage_terminal": "Terminal de stockage", "screen.redstoner.storage_terminal.title": "Terminal de stockage", + "screen.redstoner.storage_terminal.search": "Rechercher…", + "screen.redstoner.storage_terminal.no_results": "Aucun objet trouvé.", "screen.redstoner.storage_terminal.summary": "%s/%s · %s blocs · %s types", "screen.redstoner.storage_terminal.error.invalid_terminal": "Le terminal n'est plus accessible.", "screen.redstoner.storage_terminal.error.disconnected": "Aucun coffre ou baril connecté.", diff --git a/redstoner/src/main/resources/assets/redstoner/lang/ru_ru.json b/redstoner/src/main/resources/assets/redstoner/lang/ru_ru.json index d6dbc2f..fec0373 100644 --- a/redstoner/src/main/resources/assets/redstoner/lang/ru_ru.json +++ b/redstoner/src/main/resources/assets/redstoner/lang/ru_ru.json @@ -52,6 +52,8 @@ "tooltip.redstoner.particuleur": "Вставьте катализатор и подайте сигнал редстоуна.", "block.redstoner.storage_terminal": "Терминал хранилища", "screen.redstoner.storage_terminal.title": "Терминал хранилища", + "screen.redstoner.storage_terminal.search": "Поиск…", + "screen.redstoner.storage_terminal.no_results": "Предметы не найдены.", "screen.redstoner.storage_terminal.summary": "%s/%s · блоков: %s · типов: %s", "screen.redstoner.storage_terminal.error.invalid_terminal": "Терминал больше недоступен.", "screen.redstoner.storage_terminal.error.disconnected": "Нет подключённых сундуков или бочек.", diff --git a/redstoner/src/test/java/fr/koka99cab/sanctuary26/redstoner/storage/StorageTerminalSmoke.java b/redstoner/src/test/java/fr/koka99cab/sanctuary26/redstoner/storage/StorageTerminalSmoke.java index e8a9863..0a47e17 100644 --- a/redstoner/src/test/java/fr/koka99cab/sanctuary26/redstoner/storage/StorageTerminalSmoke.java +++ b/redstoner/src/test/java/fr/koka99cab/sanctuary26/redstoner/storage/StorageTerminalSmoke.java @@ -25,6 +25,8 @@ public final class StorageTerminalSmoke { verifyTopology(); verifyPagination(); verifyAggregationAndTransfers(); + verifySearch(); + verifyStableLayout(); } private static void verifyTopology() { @@ -58,6 +60,12 @@ public final class StorageTerminalSmoke { (short) StorageTerminalRules.totalLowWord(networkMaximum), (short) StorageTerminalRules.totalHighWord(networkMaximum)) == networkMaximum, "A large aggregated quantity was truncated by menu data synchronization"); + require(StorageTerminalRules.compactCount(999).equals("999") + && StorageTerminalRules.compactCount(1_000).equals("1K") + && StorageTerminalRules.compactCount(1_250).equals("1.2K") + && StorageTerminalRules.compactCount(12_345).equals("12K") + && StorageTerminalRules.compactCount(1_250_000).equals("1.2M"), + "Terminal quantities were not compacted without overstating their value"); } private static void verifyAggregationAndTransfers() { @@ -98,6 +106,55 @@ public final class StorageTerminalSmoke { .findFirst().orElseThrow().total() == 6, "Terminal extraction did not update physical slots"); } + private static void verifySearch() { + SimpleContainer container = new SimpleContainer(4); + container.setItem(0, new ItemStack(stackable(Items.COBBLESTONE), 12)); + ItemStack accented = new ItemStack(stackable(Items.DIAMOND), 2); + accented.set(DataComponents.CUSTOM_NAME, Component.literal("Élément spécial")); + container.setItem(1, accented); + ItemStack russian = new ItemStack(stackable(Items.REDSTONE), 3); + russian.set(DataComponents.CUSTOM_NAME, Component.literal("Красный камень")); + container.setItem(2, russian); + + List entries = StorageTerminalInventory.aggregate(List.of(container)); + require(StorageTerminalSearch.filter(entries, "minecraft:cobblestone").size() == 1, + "Terminal search did not match a namespaced item id"); + require(StorageTerminalSearch.filter(entries, "cobble stone").size() == 1, + "Terminal search did not combine multiple terms"); + require(StorageTerminalSearch.filter(entries, "element special").size() == 1, + "Terminal search did not ignore accents in a custom name"); + require(StorageTerminalSearch.filter(entries, "красный камень").size() == 1, + "Terminal search did not preserve non-Latin keyboard input"); + require(StorageTerminalSearch.filter(entries, "missing").isEmpty(), + "Terminal search retained an unrelated item"); + } + + private static Holder stackable(Item item) { + return new Holder.Direct<>(item, DataComponentMap.builder() + .set(DataComponents.MAX_STACK_SIZE, 64).build()); + } + + private static void verifyStableLayout() { + StorageTerminalInventory.Entry stone = new StorageTerminalInventory.Entry( + new ItemStack(stackable(Items.STONE)), 64); + StorageTerminalInventory.Entry dirt = new StorageTerminalInventory.Entry( + new ItemStack(stackable(Items.DIRT)), 64); + StorageTerminalInventory.Entry diamond = new StorageTerminalInventory.Entry( + new ItemStack(stackable(Items.DIAMOND)), 8); + StorageTerminalLayout layout = new StorageTerminalLayout(); + layout.update(List.of(stone, dirt), true); + layout.update(List.of(dirt, diamond), false); + List page = layout.page(0); + require(page.get(0).total() == 0 && ItemStack.isSameItemSameComponents(page.get(0).stack(), stone.stack()), + "An exhausted terminal resource moved another item under the cursor"); + require(ItemStack.isSameItemSameComponents(page.get(1).stack(), dirt.stack()) + && ItemStack.isSameItemSameComponents(page.get(2).stack(), diamond.stack()), + "A terminal refresh did not preserve existing slots or append a new resource"); + layout.update(List.of(dirt, diamond), true); + require(ItemStack.isSameItemSameComponents(layout.page(0).get(0).stack(), dirt.stack()), + "A deliberate terminal layout reset did not compact the visible resources"); + } + private static void require(boolean condition, String message) { if (!condition) throw new IllegalStateException(message); }