diff --git a/CHANGELOG.md b/CHANGELOG.md index 956bd51..bc5436b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,12 @@ Ce journal suit les **versions du modpack**. Lorsqu'un seul module change, sa ve ## Non publié -Aucun changement non publié. +Issue [#169](https://git.botsu.net/koka/sanctuary/issues/169) — catalogue du Shop. + +- ajoute une barre de recherche dans l’onglet catalogue, avec repli des accents et filtrage local ; +- permet de choisir la grille du catalogue : **3×3**, **4×4** ou **5×5** maximum, mémorisée entre les sessions ; +- affiche le numéro de page du catalogue (`2/5`) à côté de la navigation ; +- conserve la grille 3×3 et les neuf emplacements des ventes flash. ## `26.2.0-alpha.242` diff --git a/sanctuary/build.gradle b/sanctuary/build.gradle index f5056b2..eb5c246 100644 --- a/sanctuary/build.gradle +++ b/sanctuary/build.gradle @@ -100,6 +100,14 @@ tasks.register("shopHeaderLayoutSmoke", JavaExec) { mainClass = "fr.koka99cab.sanctuary26.sanctuary.client.ShopDailyHeaderLayoutSmoke" } +tasks.register("shopCatalogBrowseSmoke", JavaExec) { + group = "verification" + description = "Checks catalogue search folding, 3x3/4x4/5x5 grids and filtered pagination." + dependsOn tasks.named("testClasses") + classpath = sourceSets.test.runtimeClasspath + mainClass = "fr.koka99cab.sanctuary26.sanctuary.shop.ShopCatalogBrowseSmoke" +} + tasks.register("zombieNamesSmoke", JavaExec) { group = "verification" description = "Checks deterministic Xbox-360-style names for every vanilla Zombie subtype." @@ -360,7 +368,8 @@ tasks.register("verifySanctuary") { tasks.named("shopDeliveryTimingSmoke"), tasks.named("blackMarketModelSmoke"), tasks.named("questBoardModelSmoke"), tasks.named("questTrackerModelSmoke"), tasks.named("questIdSuggestionsSmoke"), - tasks.named("shopHeaderLayoutSmoke"), tasks.named("zombieNamesSmoke"), + tasks.named("shopHeaderLayoutSmoke"), tasks.named("shopCatalogBrowseSmoke"), + tasks.named("zombieNamesSmoke"), tasks.named("capeJoinSyncSmoke"), tasks.named("inventorySortSmoke"), tasks.named("verifyBlackMarket") @@ -3706,6 +3715,7 @@ tasks.named("check") { dependsOn tasks.named("questTrackerModelSmoke") dependsOn tasks.named("questTrackerDisplaySmoke") dependsOn tasks.named("questIdSuggestionsSmoke") + dependsOn tasks.named("shopCatalogBrowseSmoke") dependsOn tasks.named("alphaIndevGeneratorSmoke") dependsOn tasks.named("capeJoinSyncSmoke") dependsOn tasks.named("capeSyncPayloadSmoke") 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 da6d409..e319622 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 @@ -116,6 +116,7 @@ public final class SanctuaryClient implements ClientModInitializer { )); SanctuaryAtlasOptions.load(); SanctuaryClientQuests.load(); + SanctuaryShopOptions.load(); SanctuaryBulkActionsClient.initialize(); SanctuaryHealthPreview.register(); CelestialSpyglassSelection.register(); diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryShopOptions.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryShopOptions.java new file mode 100644 index 0000000..0cdd9d7 --- /dev/null +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryShopOptions.java @@ -0,0 +1,71 @@ +package fr.koka99cab.sanctuary26.sanctuary.client; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import fr.koka99cab.sanctuary26.sanctuary.SanctuaryMod; +import fr.koka99cab.sanctuary26.sanctuary.shop.ShopCatalogBrowse; +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import net.fabricmc.loader.api.FabricLoader; + +/** Persisted catalogue grid preference for the Shop screen. */ +final class SanctuaryShopOptions { + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + private static final Path FILE = FabricLoader.getInstance().getConfigDir().resolve("sanctuary-shop.json"); + private static Settings settings = new Settings(); + private static boolean loaded; + + private SanctuaryShopOptions() { + } + + static void load() { + if (loaded) return; + loaded = true; + if (!Files.isRegularFile(FILE)) return; + try (Reader reader = Files.newBufferedReader(FILE, StandardCharsets.UTF_8)) { + Settings decoded = GSON.fromJson(reader, Settings.class); + if (decoded != null) settings = decoded.normalized(); + } catch (IOException | RuntimeException exception) { + SanctuaryMod.LOGGER.warn("[{}] Could not read Shop catalogue options.", + SanctuaryMod.DISPLAY_NAME, exception); + } + } + + static int catalogGrid() { + load(); + return settings.catalogGrid; + } + + static void setCatalogGrid(int grid) { + load(); + int next = ShopCatalogBrowse.clampGrid(grid); + if (next == settings.catalogGrid) return; + settings.catalogGrid = next; + save(); + } + + private static void save() { + try { + Files.createDirectories(FILE.getParent()); + try (Writer writer = Files.newBufferedWriter(FILE, StandardCharsets.UTF_8)) { + GSON.toJson(settings, writer); + } + } catch (IOException exception) { + SanctuaryMod.LOGGER.warn("[{}] Could not save Shop catalogue options.", + SanctuaryMod.DISPLAY_NAME, exception); + } + } + + private static final class Settings { + int catalogGrid = ShopCatalogBrowse.DEFAULT_GRID; + + private Settings normalized() { + catalogGrid = ShopCatalogBrowse.clampGrid(catalogGrid); + return this; + } + } +} diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryShopScreen.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryShopScreen.java index cdca491..b95f9fd 100644 --- a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryShopScreen.java +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/client/SanctuaryShopScreen.java @@ -3,6 +3,7 @@ package fr.koka99cab.sanctuary26.sanctuary.client; import fr.koka99cab.sanctuary26.sanctuary.network.ShopActionPayload; import fr.koka99cab.sanctuary26.sanctuary.network.ShopRequestPayload; import fr.koka99cab.sanctuary26.sanctuary.network.ShopSnapshotPayload; +import fr.koka99cab.sanctuary26.sanctuary.shop.ShopCatalogBrowse; import fr.koka99cab.sanctuary26.sanctuary.shop.ShopOffer; import fr.koka99cab.sanctuary26.sanctuary.shop.ShopProgression; import java.util.ArrayList; @@ -10,7 +11,11 @@ import java.util.List; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; import net.minecraft.client.gui.GuiGraphicsExtractor; import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.components.Tooltip; import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.input.KeyEvent; +import net.minecraft.client.input.MouseButtonEvent; import net.minecraft.core.registries.BuiltInRegistries; import net.minecraft.network.chat.Component; import net.minecraft.resources.Identifier; @@ -21,6 +26,8 @@ public final class SanctuaryShopScreen extends Screen { private static final int PAGE_SIZE = 9; private static final int GRID_COLUMNS = 3; private static final int GRID_ROWS = 3; + private static final int TOOLBAR_Y = 48; + private static final int PAGE_LABEL_WIDTH = 44; private final Screen parent; private final List actionHints = new ArrayList<>(); private ShopSnapshotPayload rendered; @@ -31,6 +38,9 @@ public final class SanctuaryShopScreen extends Screen { private long requestedRefreshForExpiry = Long.MIN_VALUE; private int page; private int renderedExperienceLevel = -1; + private String catalogSearch = ""; + private boolean catalogSearchFocused; + private EditBox catalogSearchBox; public SanctuaryShopScreen(Screen parent) { super(Component.translatable("screen.sanctuary.shop.title")); @@ -41,6 +51,7 @@ public final class SanctuaryShopScreen extends Screen { rendered = SanctuaryClientShop.snapshot(); renderedExperienceLevel = minecraft == null || minecraft.player == null ? -1 : minecraft.player.experienceLevel; actionHints.clear(); + catalogSearchBox = null; if (rendered == null || !rendered.operator()) { operatorPage = false; confirmCatalogReset = false; @@ -84,25 +95,16 @@ public final class SanctuaryShopScreen extends Screen { }).bounds(center - 100, 109, 200, 20).build()); } else { List offers = visibleOffers(); - int maxPage = offers.isEmpty() ? 0 : (offers.size() - 1) / PAGE_SIZE; - page = Math.min(page, maxPage); - int start = page * PAGE_SIZE; - int end = Math.min(offers.size(), start + PAGE_SIZE); + int pageSize = pageSize(); + page = ShopCatalogBrowse.clampPage(page, offers.size(), pageSize); + int start = page * pageSize; + int end = Math.min(offers.size(), start + pageSize); GridLayout layout = gridLayout(); for (int index = start; index < end; index++) addOfferButtons(offers.get(index), index - start, layout); if (!catalogPage && rendered != null) addLockedOfferButtons(layout); - if (catalogPage) { - Button previous = addRenderableWidget(Button.builder(Component.literal("<"), button -> { - page = Math.max(0, page - 1); rebuildWidgets(); - }).bounds(layout.left(), 48, 32, 18).build()); - Button next = addRenderableWidget(Button.builder(Component.literal(">"), button -> { - page++; rebuildWidgets(); - }).bounds(layout.left() + 36, 48, 32, 18).build()); - previous.active = page > 0; - next.active = (page + 1) * PAGE_SIZE < offers.size(); - } + if (catalogPage) addCatalogToolbar(layout, offers.size()); } if (!requested && ClientPlayNetworking.canSend(ShopRequestPayload.ID)) { requested = true; @@ -110,6 +112,40 @@ public final class SanctuaryShopScreen extends Screen { } } + private void addCatalogToolbar(GridLayout layout, int offerCount) { + int pageSize = pageSize(); + Button previous = addRenderableWidget(Button.builder(Component.literal("<"), button -> { + page = Math.max(0, page - 1); rebuildWidgets(); + }).bounds(layout.left(), TOOLBAR_Y, 32, 18).build()); + Button next = addRenderableWidget(Button.builder(Component.literal(">"), button -> { + page++; rebuildWidgets(); + }).bounds(layout.left() + 36, TOOLBAR_Y, 32, 18).build()); + previous.active = page > 0; + next.active = (page + 1) * pageSize < offerCount; + + int gridControlsWidth = 34 * 3 + 4 * 2; + int searchX = layout.left() + 72 + PAGE_LABEL_WIDTH + 4; + int searchWidth = Math.max(72, layout.width() - 72 - PAGE_LABEL_WIDTH - 4 - gridControlsWidth - 6); + catalogSearchBox = addRenderableWidget(new EditBox(font, searchX, TOOLBAR_Y, searchWidth, 18, + Component.translatable("screen.sanctuary.shop.search"))); + catalogSearchBox.setMaxLength(ShopCatalogBrowse.MAX_SEARCH_LENGTH); + catalogSearchBox.setHint(Component.translatable("screen.sanctuary.shop.search_hint")); + catalogSearchBox.setValue(catalogSearch); + catalogSearchBox.setResponder(this::catalogSearchChanged); + if (catalogSearchFocused) setInitialFocus(catalogSearchBox); + + int gridX = searchX + searchWidth + 6; + for (int grid = ShopCatalogBrowse.MIN_GRID; grid <= ShopCatalogBrowse.MAX_GRID; grid++) { + int selected = grid; + Button mode = addRenderableWidget(Button.builder(Component.literal(selected + "×" + selected), + button -> setCatalogGrid(selected)) + .bounds(gridX + (selected - ShopCatalogBrowse.MIN_GRID) * 38, TOOLBAR_Y, 34, 18) + .tooltip(Tooltip.create(Component.translatable("screen.sanctuary.shop.grid", selected, selected))) + .build()); + mode.active = catalogGrid() != selected; + } + } + private void addOfferButtons(ShopOffer offer, int visibleIndex, GridLayout layout) { Card card = layout.card(visibleIndex); int actionY = card.y() + card.height() - 21; @@ -178,9 +214,11 @@ public final class SanctuaryShopScreen extends Screen { Component.translatable("screen.sanctuary.shop.slots_maximum", rendered.visibleOfferSlots(), PAGE_SIZE), layout.left() + Math.min(240, layout.width() / 2) / 2, ShopDailyHeaderLayout.PROGRESS_Y, 0xFF80E09A); + if (catalogPage) drawCatalogPage(graphics, layout); } if (!operatorPage && !rendered.hasDeliveryBox()) graphics.centeredText(font, - Component.translatable("screen.sanctuary.shop.no_box"), width / 2, 58, 0xFFFF7070); + Component.translatable("screen.sanctuary.shop.no_box"), width / 2, + catalogPage ? Math.max(68, height - 54) : 58, 0xFFFF7070); if (!rendered.status().isEmpty()) graphics.centeredText(font, Component.translatable("screen.sanctuary.shop.status." + rendered.status()), width / 2, Math.max(68, height - 41), 0xFF80E09A); if (operatorPage) { @@ -194,23 +232,66 @@ public final class SanctuaryShopScreen extends Screen { drawActionHints(graphics, mouseX, mouseY); } + @Override + public boolean keyPressed(KeyEvent event) { + if (ShopCatalogBrowse.capturesInventoryKey(catalogSearchBox != null && catalogSearchBox.isFocused(), + minecraft != null && minecraft.options.keyInventory.matches(event))) return true; + return super.keyPressed(event); + } + + @Override + public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) { + if (catalogSearchBox != null && catalogSearchBox.isFocused() + && !catalogSearchBox.isMouseOver(event.x(), event.y())) { + catalogSearchFocused = false; + setFocused(null); + } + return super.mouseClicked(event, doubleClick); + } + + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) { + if (catalogPage && !operatorPage && scrollY != 0.0D) { + int next = page + (scrollY < 0.0D ? 1 : -1); + int clamped = ShopCatalogBrowse.clampPage(next, visibleOffers().size(), pageSize()); + if (clamped != page) { + page = clamped; + rebuildWidgets(); + return true; + } + } + return super.mouseScrolled(mouseX, mouseY, scrollX, scrollY); + } + private void drawOffers(GuiGraphicsExtractor graphics, int mouseX, int mouseY, GridLayout layout) { List offers = visibleOffers(); - int start = Math.min(page * PAGE_SIZE, offers.size()); - int end = Math.min(offers.size(), start + PAGE_SIZE); + int pageSize = pageSize(); + int start = Math.min(page * pageSize, offers.size()); + int end = Math.min(offers.size(), start + pageSize); for (int index = start; index < end; index++) { ShopOffer offer = offers.get(index); int visible = index - start; Card card = layout.card(visible); graphics.fill(card.x(), card.y(), card.x() + card.width(), card.y() + card.height(), 0x38000000); graphics.outline(card.x(), card.y(), card.width(), card.height(), 0x697F7F7F); ItemStack stack = offer.stack(); - drawLargeItem(graphics, stack, card.x() + 7, card.y() + 6); - String displayName = font.plainSubstrByWidth(stack.getHoverName().getString(), Math.max(20, card.width() - 49)); - graphics.text(font, Component.literal(displayName), card.x() + 45, card.y() + 8, 0xFFFFFFFF); - if (offer.count() > 1) graphics.text(font, Component.literal("×" + offer.count()), - card.x() + 45, card.y() + 21, 0xFFB9B9B9); - if (mouseX >= card.x() + 6 && mouseX < card.x() + 40 - && mouseY >= card.y() + 5 && mouseY < card.y() + 39) + boolean compact = card.width() < 90 || card.height() < 70; + float itemScale = compact ? 1.0F : 2.0F; + int iconSize = Math.round(16 * itemScale); + int itemX = card.x() + (compact ? 4 : 7); + int itemY = card.y() + (compact ? 4 : 6); + drawScaledItem(graphics, stack, itemX, itemY, itemScale); + int textX = itemX + iconSize + 4; + int textWidth = Math.max(12, card.x() + card.width() - 4 - textX); + graphics.text(font, Component.literal(font.plainSubstrByWidth( + stack.getHoverName().getString(), textWidth)), textX, itemY + 2, 0xFFFFFFFF); + if (offer.count() > 1) { + int countY = compact ? itemY + 2 + font.lineHeight : itemY + 15; + if (countY + font.lineHeight <= card.y() + card.height() - 22) { + graphics.text(font, Component.literal("×" + offer.count()), textX, countY, 0xFFB9B9B9); + } + } + if (mouseX >= card.x() && mouseX < card.x() + card.width() + && mouseY >= card.y() && mouseY < card.y() + card.height() - 21) graphics.setTooltipForNextFrame(font, stack, mouseX, mouseY); } if (!catalogPage && rendered != null) { @@ -223,7 +304,19 @@ public final class SanctuaryShopScreen extends Screen { card.x() + card.width() / 2, card.y() + card.height() / 2 - 23, 0xFF8A8A8A); } } - if (offers.isEmpty()) graphics.centeredText(font, Component.translatable("screen.sanctuary.shop.catalog_empty"), width / 2, 112, 0xFF555555); + if (offers.isEmpty()) graphics.centeredText(font, Component.translatable(catalogPage + && !ShopCatalogBrowse.normalize(catalogSearch).isEmpty() + ? "screen.sanctuary.shop.catalog_no_results" + : "screen.sanctuary.shop.catalog_empty"), width / 2, 112, 0xFF555555); + } + + private void drawCatalogPage(GuiGraphicsExtractor graphics, GridLayout layout) { + int itemCount = visibleOffers().size(); + int pageSize = pageSize(); + graphics.centeredText(font, Component.translatable("screen.sanctuary.shop.page", + ShopCatalogBrowse.clampPage(page, itemCount, pageSize) + 1, + ShopCatalogBrowse.pageCount(itemCount, pageSize)), + layout.left() + 72 + PAGE_LABEL_WIDTH / 2, TOOLBAR_Y + 5, 0xFFD0D0D0); } private void drawActionHints(GuiGraphicsExtractor graphics, int mouseX, int mouseY) { @@ -243,9 +336,13 @@ public final class SanctuaryShopScreen extends Screen { } private static void drawLargeItem(GuiGraphicsExtractor graphics, ItemStack stack, int x, int y) { + drawScaledItem(graphics, stack, x, y, 2.0F); + } + + private static void drawScaledItem(GuiGraphicsExtractor graphics, ItemStack stack, int x, int y, float scale) { graphics.pose().pushMatrix(); graphics.pose().translate(x, y); - graphics.pose().scale(2.0F, 2.0F); + graphics.pose().scale(scale, scale); graphics.item(stack, 0, 0); graphics.pose().popMatrix(); } @@ -290,14 +387,44 @@ public final class SanctuaryShopScreen extends Screen { private List visibleOffers() { if (rendered == null || operatorPage) return List.of(); - return catalogPage ? rendered.catalogOffers() : rendered.dailyOffers(); + if (!catalogPage) return rendered.dailyOffers(); + return ShopCatalogBrowse.filter(rendered.catalogOffers(), catalogSearch, ShopOffer::itemId, + offer -> offer.stack().getHoverName().getString()); } + + private int catalogGrid() { + return SanctuaryShopOptions.catalogGrid(); + } + + private int pageSize() { + return catalogPage ? ShopCatalogBrowse.pageSize(catalogGrid()) : PAGE_SIZE; + } + + private void catalogSearchChanged(String value) { + if (value.equals(catalogSearch)) return; + catalogSearch = value; + catalogSearchFocused = true; + page = 0; + rebuildWidgets(); + } + + private void setCatalogGrid(int requested) { + int next = ShopCatalogBrowse.clampGrid(requested); + if (next == catalogGrid()) return; + SanctuaryShopOptions.setCatalogGrid(next); + page = 0; + rebuildWidgets(); + } + private GridLayout gridLayout() { int panelWidth = Math.max(240, Math.min(720, width - 12)); int left = width / 2 - panelWidth / 2; int top = ShopDailyHeaderLayout.GRID_TOP; int availableHeight = Math.max(174, height - top - 36); - return new GridLayout(left, top, panelWidth, availableHeight); + int grid = catalogGrid(); + int columns = catalogPage ? grid : GRID_COLUMNS; + int rows = catalogPage ? grid : GRID_ROWS; + return new GridLayout(left, top, panelWidth, availableHeight, columns, rows); } private void action(String action, String itemId) { if (ClientPlayNetworking.canSend(ShopActionPayload.ID)) ClientPlayNetworking.send(new ShopActionPayload(action, itemId)); @@ -305,12 +432,14 @@ public final class SanctuaryShopScreen extends Screen { @Override public void onClose() { if (minecraft != null) minecraft.gui.setScreen(parent); } @Override public boolean isPauseScreen() { return false; } - private record GridLayout(int left, int top, int width, int height) { + private record GridLayout(int left, int top, int width, int height, int columns, int rows) { private static final int GAP = 6; Card card(int index) { - int cardWidth = (width - GAP * (GRID_COLUMNS - 1)) / GRID_COLUMNS; - int cardHeight = (height - GAP * (GRID_ROWS - 1)) / GRID_ROWS; - int column = index % GRID_COLUMNS, row = index / GRID_COLUMNS; + int cols = Math.max(1, columns); + int gridRows = Math.max(1, rows); + int cardWidth = (width - GAP * (cols - 1)) / cols; + int cardHeight = (height - GAP * (gridRows - 1)) / gridRows; + int column = index % cols, row = index / cols; return new Card(left + column * (cardWidth + GAP), top + row * (cardHeight + GAP), cardWidth, cardHeight); } } diff --git a/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopCatalogBrowse.java b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopCatalogBrowse.java new file mode 100644 index 0000000..c30f122 --- /dev/null +++ b/sanctuary/src/main/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopCatalogBrowse.java @@ -0,0 +1,93 @@ +package fr.koka99cab.sanctuary26.sanctuary.shop; + +import java.text.Normalizer; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.function.Function; +import java.util.regex.Pattern; + +/** Client-side catalogue search, square-grid modes and pagination. */ +public final class ShopCatalogBrowse { + public static final int MIN_GRID = 3; + public static final int MAX_GRID = 5; + public static final int DEFAULT_GRID = 3; + public static final int MAX_SEARCH_LENGTH = 48; + private static final Pattern COMBINING_MARKS = Pattern.compile("\\p{M}+"); + private static final Pattern NON_ALPHANUMERIC = Pattern.compile("[^\\p{L}\\p{N}]+"); + + private ShopCatalogBrowse() { + } + + public static int clampGrid(int grid) { + return Math.max(MIN_GRID, Math.min(MAX_GRID, grid)); + } + + public static int pageSize(int grid) { + int size = clampGrid(grid); + return size * size; + } + + public static int previousGrid(int grid) { + return clampGrid(clampGrid(grid) - 1); + } + + public static int nextGrid(int grid) { + return clampGrid(clampGrid(grid) + 1); + } + + public static int pageCount(int itemCount, int pageSize) { + int size = Math.max(0, itemCount); + int safePageSize = Math.max(1, pageSize); + if (size == 0) return 1; + return (size + safePageSize - 1) / safePageSize; + } + + public static int clampPage(int page, int itemCount, int pageSize) { + return Math.max(0, Math.min(page, pageCount(itemCount, pageSize) - 1)); + } + + public static String pageLabel(int page, int itemCount, int pageSize) { + return (clampPage(page, itemCount, pageSize) + 1) + "/" + pageCount(itemCount, pageSize); + } + + public 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(); + } + + public static boolean matches(String itemId, String displayName, String query) { + String normalizedQuery = normalize(query); + if (normalizedQuery.isEmpty()) return true; + return matches(itemId, displayName, normalizedQuery.split(" ")); + } + + public static List filter(List items, String query, + Function itemId, Function displayName) { + if (items == null || items.isEmpty()) return List.of(); + String normalizedQuery = normalize(query); + if (normalizedQuery.isEmpty()) return List.copyOf(items); + String[] terms = normalizedQuery.split(" "); + List matches = new ArrayList<>(); + for (T item : items) { + if (matches(itemId.apply(item), displayName.apply(item), terms)) matches.add(item); + } + return List.copyOf(matches); + } + + public static boolean capturesInventoryKey(boolean searchFocused, boolean inventoryKey) { + return searchFocused && inventoryKey; + } + + private static boolean matches(String itemId, String displayName, String[] terms) { + String searchable = normalize((itemId == null ? "" : itemId) + " " + + (displayName == null ? "" : displayName)); + for (String term : terms) { + if (!searchable.contains(term)) return false; + } + return true; + } +} 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 d6341bc..e4cbfc1 100644 --- a/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json +++ b/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json @@ -418,6 +418,11 @@ "screen.sanctuary.shop.balance": "%s rubies · %s sapphires", "screen.sanctuary.shop.no_box": "Place your Mailbox nearby to receive parcels.", "screen.sanctuary.shop.catalog_empty": "Your permanent catalogue is empty.", + "screen.sanctuary.shop.catalog_no_results": "No catalogue item matches this search.", + "screen.sanctuary.shop.search": "Search the catalogue", + "screen.sanctuary.shop.search_hint": "Search…", + "screen.sanctuary.shop.grid": "%s×%s grid", + "screen.sanctuary.shop.page": "%s/%s", "screen.sanctuary.shop.operator": "Operator", "screen.sanctuary.shop.operator_title": "Operator tools", "screen.sanctuary.shop.operator_description": "Server-authoritative shop maintenance.", 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 5f2246b..6aac1e1 100644 --- a/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json +++ b/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json @@ -418,6 +418,11 @@ "screen.sanctuary.shop.balance": "%s rubis · %s saphirs", "screen.sanctuary.shop.no_box": "Pose ta boîte aux lettres à proximité pour recevoir les colis.", "screen.sanctuary.shop.catalog_empty": "Ton catalogue permanent est vide.", + "screen.sanctuary.shop.catalog_no_results": "Aucun objet du catalogue ne correspond à cette recherche.", + "screen.sanctuary.shop.search": "Rechercher dans le catalogue", + "screen.sanctuary.shop.search_hint": "Rechercher…", + "screen.sanctuary.shop.grid": "Grille %s×%s", + "screen.sanctuary.shop.page": "%s/%s", "screen.sanctuary.shop.operator": "Opérateur", "screen.sanctuary.shop.operator_title": "Outils opérateur", "screen.sanctuary.shop.operator_description": "Maintenance du Shop validée par le serveur.", 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 52aa85f..73c4526 100644 --- a/sanctuary/src/main/resources/assets/sanctuary/lang/ru_ru.json +++ b/sanctuary/src/main/resources/assets/sanctuary/lang/ru_ru.json @@ -329,6 +329,11 @@ "screen.sanctuary.shop.balance": "%s рубинов · %s сапфиров", "screen.sanctuary.shop.no_box": "Поставьте свой ящик доставки рядом, чтобы получать покупки.", "screen.sanctuary.shop.catalog_empty": "Ваш постоянный каталог пуст.", + "screen.sanctuary.shop.catalog_no_results": "Ни один предмет каталога не соответствует этому поиску.", + "screen.sanctuary.shop.search": "Поиск по каталогу", + "screen.sanctuary.shop.search_hint": "Поиск…", + "screen.sanctuary.shop.grid": "Сетка %s×%s", + "screen.sanctuary.shop.page": "%s/%s", "screen.sanctuary.shop.operator": "Оператор", "screen.sanctuary.shop.operator_title": "Инструменты оператора", "screen.sanctuary.shop.operator_description": "Обслуживание магазина с проверкой на сервере.", diff --git a/sanctuary/src/test/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopCatalogBrowseSmoke.java b/sanctuary/src/test/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopCatalogBrowseSmoke.java new file mode 100644 index 0000000..5b3b976 --- /dev/null +++ b/sanctuary/src/test/java/fr/koka99cab/sanctuary26/sanctuary/shop/ShopCatalogBrowseSmoke.java @@ -0,0 +1,109 @@ +package fr.koka99cab.sanctuary26.sanctuary.shop; + +import java.util.List; + +/** Deterministic coverage for catalogue search, page-size controls and pagination. */ +public final class ShopCatalogBrowseSmoke { + private ShopCatalogBrowseSmoke() { + } + + public record Item(String itemId, String displayName) {} + + public static void main(String[] args) { + verifySearch(); + verifyPagination(); + verifyPageSize(); + verifyKeyboardRouting(); + } + + private static void verifySearch() { + List catalog = List.of( + new Item("minecraft:diamond", "Diamant"), + new Item("minecraft:diamond_sword", "Épée en diamant"), + new Item("minecraft:wheat", "Blé"), + new Item("anotherworld:ruby", "Rubis"), + new Item("minecraft:oak_log", "Bûche de chêne")); + require(ShopCatalogBrowse.filter(catalog, "", Item::itemId, Item::displayName).size() == 5, + "An empty query must keep the whole catalogue"); + require(ShopCatalogBrowse.filter(catalog, " ", Item::itemId, Item::displayName).size() == 5, + "Whitespace-only search must not hide items"); + require(ids(catalog, "diam").equals(List.of("minecraft:diamond", "minecraft:diamond_sword")), + "Partial names must match both the gem and the sword"); + require(ids(catalog, "epee").equals(List.of("minecraft:diamond_sword")), + "Accent-folded display names must match"); + require(ids(catalog, "ble").equals(List.of("minecraft:wheat")), + "Blé must resolve from ble"); + require(ids(catalog, "minecraft:wheat").equals(List.of("minecraft:wheat")), + "Full item IDs must match"); + require(ids(catalog, "epee diam").equals(List.of("minecraft:diamond_sword")), + "Multi-term search must require every term"); + require(ids(catalog, "missing").isEmpty(), + "Unknown names must produce no results"); + require(ShopCatalogBrowse.matches("minecraft:diamond", "Diamant", null), + "A null query must keep the item visible"); + require(ShopCatalogBrowse.normalize("Élément spécial").equals("element special"), + "Search folding must strip accents and punctuation"); + } + + private static void verifyPagination() { + require(ShopCatalogBrowse.pageCount(0, 9) == 1, "An empty catalogue must keep one page"); + require(ShopCatalogBrowse.pageCount(9, 9) == 1, "A full default page overflowed"); + require(ShopCatalogBrowse.pageCount(10, 9) == 2, "The second catalogue page did not open"); + require(ShopCatalogBrowse.pageCount(20, 16) == 2, "A 4x4 grid must reduce page count"); + require(ShopCatalogBrowse.pageCount(25, 25) == 1, "A full 5x5 page overflowed"); + require(ShopCatalogBrowse.clampPage(99, 20, 9) == 2, "A hostile page index was not clamped"); + require(ShopCatalogBrowse.clampPage(4, 2, 9) == 0, + "Search that shrinks the list must snap back to the first page"); + require(ShopCatalogBrowse.pageLabel(0, 20, 9).equals("1/3"), + "The first catalogue page must be labelled 1/3"); + require(ShopCatalogBrowse.pageLabel(2, 20, 9).equals("3/3"), + "The last catalogue page must be labelled 3/3"); + require(ShopCatalogBrowse.pageLabel(99, 20, 9).equals("3/3"), + "An out-of-range page must still show a valid label"); + require(ShopCatalogBrowse.pageLabel(0, 0, 9).equals("1/1"), + "An empty catalogue must keep page 1/1"); + List catalog = numbered(20); + List matches = ShopCatalogBrowse.filter(catalog, "item 1", Item::itemId, Item::displayName); + require(matches.size() == 11, "Prefix search over numbered labels is incorrect"); + int page = ShopCatalogBrowse.clampPage(3, matches.size(), 9); + require(page == 1, "Filtered pagination must clamp after search"); + } + + private static void verifyPageSize() { + require(ShopCatalogBrowse.clampGrid(0) == 3, "Grid mode must not drop below 3x3"); + require(ShopCatalogBrowse.clampGrid(9) == 5, "Grid mode must stay at 5x5 maximum"); + require(ShopCatalogBrowse.pageSize(3) == 9, "3x3 must show nine items"); + require(ShopCatalogBrowse.pageSize(4) == 16, "4x4 must show sixteen items"); + require(ShopCatalogBrowse.pageSize(5) == 25, "5x5 must show twenty-five items"); + require(ShopCatalogBrowse.nextGrid(3) == 4, "The next mode after 3x3 must be 4x4"); + require(ShopCatalogBrowse.nextGrid(5) == 5, "5x5 must be the last catalogue grid"); + require(ShopCatalogBrowse.previousGrid(4) == 3, "The previous mode after 4x4 must be 3x3"); + require(ShopCatalogBrowse.previousGrid(3) == 3, "3x3 must not shrink further"); + } + + private static void verifyKeyboardRouting() { + require(ShopCatalogBrowse.capturesInventoryKey(true, true), + "A focused search field must keep the inventory key"); + require(!ShopCatalogBrowse.capturesInventoryKey(false, true), + "An unfocused catalogue must still close on the inventory key"); + require(!ShopCatalogBrowse.capturesInventoryKey(true, false), + "Unrelated keys must not be swallowed by catalogue search"); + } + + private static List ids(List catalog, String query) { + return ShopCatalogBrowse.filter(catalog, query, Item::itemId, Item::displayName) + .stream().map(Item::itemId).toList(); + } + + private static List numbered(int count) { + List items = new java.util.ArrayList<>(); + for (int index = 1; index <= count; index++) { + items.add(new Item("minecraft:item_" + index, "Item " + index)); + } + return List.copyOf(items); + } + + private static void require(boolean value, String message) { + if (!value) throw new AssertionError(message); + } +}