Merge pull request 'feat(sanctuary): recherche et grilles 3x3/4x4/5x5 au catalogue du shop' (#173) from feature/shop-catalog-search into main
Verification 26.2 / check (push) Canceled after 0s

Reviewed-on: #173
This commit was merged in pull request #173.
This commit is contained in:
2026-09-09 13:25:16 +00:00
10 changed files with 467 additions and 34 deletions
+6 -1
View File
@@ -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 longlet 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`
+11 -1
View File
@@ -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")
@@ -116,6 +116,7 @@ public final class SanctuaryClient implements ClientModInitializer {
));
SanctuaryAtlasOptions.load();
SanctuaryClientQuests.load();
SanctuaryShopOptions.load();
SanctuaryBulkActionsClient.initialize();
SanctuaryHealthPreview.register();
CelestialSpyglassSelection.register();
@@ -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;
}
}
}
@@ -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<ActionHint> 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<ShopOffer> 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<ShopOffer> 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<ShopOffer> 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);
}
}
@@ -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 <T> List<T> filter(List<T> items, String query,
Function<T, String> itemId, Function<T, String> 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<T> 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;
}
}
@@ -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.",
@@ -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.",
@@ -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": "Обслуживание магазина с проверкой на сервере.",
@@ -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<Item> 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<Item> catalog = numbered(20);
List<Item> 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<String> ids(List<Item> catalog, String query) {
return ShopCatalogBrowse.filter(catalog, query, Item::itemId, Item::displayName)
.stream().map(Item::itemId).toList();
}
private static List<Item> numbered(int count) {
List<Item> 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);
}
}