feat(redstoner): rechercher et vider sans glissement dans le terminal #97

Merged
koka merged 3 commits from feature/storage-terminal-search into main 2026-08-30 08:06:20 +00:00
13 changed files with 391 additions and 56 deletions
+80 -5
View File
@@ -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") {
+1 -1
View File
@@ -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
@@ -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<Storage
private static final int SCROLL_X = 171;
private static final int SCROLL_WIDTH = 4;
private int requestedPage;
private EditBox searchBox;
private String searchValue = "";
private String sentSearchValue = "";
public StorageTerminalScreen(StorageTerminalMenu menu, Inventory inventory, Component title) {
super(menu, inventory, title, 176, 222);
@@ -33,8 +39,14 @@ public final class StorageTerminalScreen extends AbstractContainerScreen<Storage
@Override
protected void init() {
super.init();
titleLabelX = 8;
titleLabelY = 6;
searchBox = addRenderableWidget(new EditBox(font, leftPos + 8, topPos + 2, 126, 14,
Component.translatable("screen.redstoner.storage_terminal.search")));
searchBox.setMaxLength(StorageTerminalRules.MAX_SEARCH_LENGTH);
searchBox.setHint(Component.translatable("screen.redstoner.storage_terminal.search"));
searchBox.setValue(searchValue);
searchBox.setResponder(this::searchChanged);
setInitialFocus(searchBox);
}
@Override
@@ -47,11 +59,13 @@ public final class StorageTerminalScreen extends AbstractContainerScreen<Storage
@Override
protected void extractLabels(GuiGraphicsExtractor graphics, int mouseX, int mouseY) {
graphics.text(font, title, titleLabelX, titleLabelY, 0xFF404040, false);
Component pageLabel = Component.literal((menu.page() + 1) + "/" + menu.pageCount());
graphics.text(font, pageLabel, 168 - font.width(pageLabel), titleLabelY, 0xFF404040, false);
if (menu.status() != StorageTerminalStatus.READY) {
graphics.centeredText(font, menu.statusMessage(), imageWidth / 2, 68, 0xFFAA3333);
} else if (!searchValue.isBlank() && menu.totalEntries() == 0) {
graphics.centeredText(font, Component.translatable(
"screen.redstoner.storage_terminal.no_results"), imageWidth / 2, 68, 0xFF777777);
}
}
@@ -61,7 +75,7 @@ public final class StorageTerminalScreen extends AbstractContainerScreen<Storage
if (slot.index < StorageTerminalMenu.TERMINAL_SLOT_COUNT && slot.hasItem()) {
int total = menu.totalAt(slot.index);
if (total > 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<Storage
return super.mouseClicked(event, doubleClick);
}
@Override
public boolean keyPressed(KeyEvent event) {
if (searchBox != null && searchBox.isFocused() && event.key() != 256) {
searchBox.keyPressed(event);
return true;
}
return super.keyPressed(event);
}
@Override
protected void containerTick() {
requestedPage = menu.page();
@@ -125,6 +148,31 @@ public final class StorageTerminalScreen extends AbstractContainerScreen<Storage
}
}
private void searchChanged(String value) {
String previous = sentSearchValue;
searchValue = value;
requestedPage = 0;
if (value.startsWith(previous)
&& value.codePointCount(previous.length(), value.length()) == 1) {
sendMenuButton(StorageTerminalMenu.SEARCH_CODEPOINT_BUTTON_BASE
+ value.codePointAt(previous.length()));
} else if (previous.startsWith(value)
&& previous.codePointCount(value.length(), previous.length()) == 1) {
sendMenuButton(StorageTerminalMenu.SEARCH_BACKSPACE_BUTTON);
} else {
sendMenuButton(StorageTerminalMenu.SEARCH_CLEAR_BUTTON);
value.codePoints().forEach(codePoint -> 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<Storage
graphics.outline(x, y, SCROLL_WIDTH, TERMINAL_HEIGHT, 0xFF373737);
}
private static String compactCount(int count) {
if (count >= 1_000_000) return (count / 1_000_000) + "m";
if (count >= 10_000) return (count / 1_000) + "k";
return Integer.toString(count);
}
}
@@ -12,12 +12,12 @@ public final class StorageTerminalInventory {
}
public static List<Entry> aggregate(List<? extends Container> containers) {
Map<StackKey, MutableEntry> grouped = new LinkedHashMap<>();
Map<StackIdentity, MutableEntry> 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);
}
}
}
@@ -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<StorageTerminalInventory.StackIdentity> order = new ArrayList<>();
private Map<StorageTerminalInventory.StackIdentity, StorageTerminalInventory.Entry> liveByIdentity = Map.of();
void update(List<StorageTerminalInventory.Entry> liveEntries, boolean resetOrder) {
Map<StorageTerminalInventory.StackIdentity, StorageTerminalInventory.Entry> live =
new LinkedHashMap<>();
for (StorageTerminalInventory.Entry entry : liveEntries) {
live.put(StorageTerminalInventory.identity(entry.stack()), entry);
}
if (resetOrder) order.clear();
Set<StorageTerminalInventory.StackIdentity> 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<StorageTerminalInventory.Entry> page(int requestedPage) {
int start = StorageTerminalRules.pageStart(requestedPage, order.size());
List<StorageTerminalInventory.Entry> 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);
}
}
@@ -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<StorageTerminalInventory.Entry> 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<StorageTerminalInventory.Entry> 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);
}
@@ -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<StorageTerminalInventory.Entry> 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<StorageTerminalInventory.Entry> 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<StorageTerminalInventory.Entry> entries) {
public Snapshot {
status = status == null ? StorageTerminalStatus.INVALID_TERMINAL : status;
entries = entries == null ? List.of() : List.copyOf(entries);
}
}
}
@@ -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;
}
}
@@ -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<StorageTerminalInventory.Entry> filter(
List<StorageTerminalInventory.Entry> entries, String query) {
String normalizedQuery = normalize(query);
if (normalizedQuery.isEmpty()) return List.copyOf(entries);
String[] terms = normalizedQuery.split(" ");
List<StorageTerminalInventory.Entry> 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;
}
}
@@ -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.",
@@ -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é.",
@@ -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": "Нет подключённых сундуков или бочек.",
@@ -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<StorageTerminalInventory.Entry> 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<Item> 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<StorageTerminalInventory.Entry> 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);
}