Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/gui-screens.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ stacks to one item. Sparse machine indexes remain reserved and inactive.
}
```

`screenItems()` must also work on the client. The client's menu keeps its own copy of the slot
contents, and asks the client block entity's `screenItems()` what each slot accepts and how much it
holds, so a click is predicted the way the server will answer it. It's only asked about rules, so its
contents don't need to be synced.

`screenDestinations` controls shift-click routing into the machine; its default tries all storage
indexes, subject to their filters. `screenRecipeSlots` explicitly names recipe inputs in recipe
order; its default disables JEI transfer. Both methods must work on the client as well as the
Expand Down
36 changes: 36 additions & 0 deletions src/main/java/zone/moddev/mc/lib/gui/runtime/ClientMenuItems.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package zone.moddev.mc.lib.gui.runtime;

import net.minecraft.world.item.ItemStack;

import net.neoforged.neoforge.items.IItemHandler;
import net.neoforged.neoforge.items.ItemStackHandler;

/**
* The client's copy of the slots behind a {@link MachineMenu}. Vanilla's menu sync fills it; what
* each slot accepts and how much it holds come from the client block entity's own {@link
* ScreenHost#screenItems()}, so a click is predicted the way the server will answer it. The server
* still decides: a filter that depends on server-only state can mispredict, and the server's answer
* replaces the prediction.
*/
public final class ClientMenuItems extends ItemStackHandler {
private final IItemHandler rules;

/**
* @param size the menu's slot count, which ghost slots can take past the end of {@code rules}
* @param rules asked what each slot accepts; its contents aren't read or changed
*/
public ClientMenuItems(int size, IItemHandler rules) {
super(size);
this.rules = rules;
}

@Override
public boolean isItemValid(int slot, ItemStack stack) {
return slot < rules.getSlots() && rules.isItemValid(slot, stack);
}

@Override
public int getSlotLimit(int slot) {
return slot < rules.getSlots() ? rules.getSlotLimit(slot) : 0;
}
}
9 changes: 5 additions & 4 deletions src/main/java/zone/moddev/mc/lib/gui/runtime/MachineMenu.java
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,11 @@ public MachineMenu(int id, Inventory inventory, BlockEntity entity) {
.mapToInt(e -> ((Element.Slot) e).index() + 1)
.max()
.orElse(0);
IItemHandler items =
owner.level().isClientSide
? new ItemStackHandler(count)
: host == null ? new ItemStackHandler(0) : host.screenItems();
IItemHandler items;
if (owner.level().isClientSide)
items =
host == null ? new ItemStackHandler(count) : new ClientMenuItems(count, host.screenItems());
else items = host == null ? new ItemStackHandler(0) : host.screenItems();
machineSlots = count;
var layout =
ScreenLayout.of(
Expand Down
3 changes: 2 additions & 1 deletion src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenHost.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
public interface ScreenHost {
/**
* The inventory behind the screen's {@code <slot index>} elements, numbered as in the XML;
* returning an {@code ItemStorage} keeps its output restrictions and filters for players.
* returning an {@code ItemStorage} keeps its output restrictions and filters for players. It must
* work on the client too, where the menu asks it what each slot accepts to predict clicks.
*/
IItemHandlerModifiable screenItems();

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package zone.moddev.mc.lib.smoke;

import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;

import zone.moddev.mc.lib.api.capabilities.ItemStorage;
import zone.moddev.mc.lib.api.capabilities.Role;
import zone.moddev.mc.lib.gui.runtime.ClientMenuItems;

/**
* Checks the client's slot handler against a real storage's rules. The GUI smoke test checks it
* behind a real client menu.
*/
final class ClientMenuItemsSmoke {
private ClientMenuItemsSmoke() {}

static void check() {
var changes = new int[1];
var rules =
new ItemStorage(2)
.onChange(() -> changes[0]++)
.role(Role.OUTPUT, 1)
.filter(stack -> !stack.is(Items.DIRT), 0)
.limit(16, 0);
var client = new ClientMenuItems(3, rules);
var iron = new ItemStack(Items.IRON_INGOT);

require(client.isItemValid(0, iron), "the filter accepts iron");
require(!client.isItemValid(0, new ItemStack(Items.DIRT)), "the filter refuses dirt");
require(!client.isItemValid(1, iron), "an output slot refuses players");
require(client.getSlotLimit(0) == 16, "the slot limit, got " + client.getSlotLimit(0));
require(client.getSlotLimit(1) == rules.getSlotLimit(1), "an unlimited slot");

// Past the storage's end, as ghost slots can be: refused, not thrown.
require(!client.isItemValid(2, iron), "no rules for a slot past the storage");
require(client.getSlotLimit(2) == 0, "no room in a slot past the storage");

// Contents are the menu's: the server may put anything anywhere, and rules stay untouched.
client.setStackInSlot(1, new ItemStack(Items.GOLD_INGOT, 3));
require(client.getStackInSlot(1).is(Items.GOLD_INGOT), "sync fills an output slot");
require(rules.getStackInSlot(1).isEmpty(), "the rules' storage is left alone");
require(changes[0] == 0, "the rules' storage saw no change");
}

private static void require(boolean condition, String what) {
if (!condition) throw new AssertionError("Client menu items: " + what);
}
}
18 changes: 18 additions & 0 deletions src/runtimeTest/java/zone/moddev/mc/lib/smoke/GuiClientSmoke.java
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,15 @@ public static void frame(RenderFrameEvent.Post event) {
"MMDLIB_GUI_JEI_SERVER_PASS: recipe ingredient moved to input and ghost filter synced"
+ " from server");
}
var input = screen.getMenu().slots.get(0);
var ingot = new net.minecraft.world.item.ItemStack(net.minecraft.world.item.Items.IRON_INGOT);
if (input.getMaxStackSize(ingot) != 16
|| !input.mayPlace(ingot)
|| input.mayPlace(
new net.minecraft.world.item.ItemStack(net.minecraft.world.item.Items.DIRT))) {
fail(new AssertionError("Client menu ignored the input slot's limit or filter"));
return;
}
GuiGaugeSmoke.check(screen);
Screenshot.grab(
mc.gameDirectory,
Expand All @@ -193,6 +202,15 @@ public static void frame(RenderFrameEvent.Post event) {
fail(new AssertionError("Rendered context visibility incorrect"));
return;
}
if (screen
.getMenu()
.slots
.get(1)
.mayPlace(
new net.minecraft.world.item.ItemStack(net.minecraft.world.item.Items.IRON_INGOT))) {
fail(new AssertionError("Client menu predicted placing an item in an output slot"));
return;
}
var config = screen.getMenu().bound.screen().find("config").orElseThrow();
var r = screen.bounds(config);
screen.mouseClicked(r.x() + 32, r.y() + 52, 0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ public static void standaloneMachine(GameTestHelper helper) {
helper.succeed();
}

@GameTest(template = EMPTY)
public static void clientMenuItems(GameTestHelper helper) {
ClientMenuItemsSmoke.check();
helper.succeed();
}

@GameTest(template = EMPTY)
public static void machinePartsLoad(GameTestHelper helper) {
MachinePartsLoaderSmoke.check();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
* Registers a standalone {@code mmdlib:machine} with a ticking block entity, and a standalone item,
* through a second runtime registrar, then checks them on a running server.
*/
@EventBusSubscriber(modid = "mmdlib", bus = EventBusSubscriber.Bus.MOD)

Check warning on line 38 in src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineSmoke.java

View workflow job for this annotation

GitHub Actions / Unit tests, checks and game tests

[removal] Bus in EventBusSubscriber has been deprecated and marked for removal

Check warning on line 38 in src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineSmoke.java

View workflow job for this annotation

GitHub Actions / Unit tests, checks and game tests

[removal] bus() in EventBusSubscriber has been deprecated and marked for removal

Check warning on line 38 in src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineSmoke.java

View workflow job for this annotation

GitHub Actions / Client GUI smoke test

[removal] Bus in EventBusSubscriber has been deprecated and marked for removal

Check warning on line 38 in src/runtimeTest/java/zone/moddev/mc/lib/smoke/MachineSmoke.java

View workflow job for this annotation

GitHub Actions / Client GUI smoke test

[removal] bus() in EventBusSubscriber has been deprecated and marked for removal
public final class MachineSmoke {
private static final String NAMESPACE = "mmdlib_smoke";
private static final ResourceLocation MACHINE = id("counter");
Expand Down Expand Up @@ -134,7 +134,10 @@
implements zone.moddev.mc.lib.gui.runtime.ScreenHost {
public int clicks, guiReads;
public final zone.moddev.mc.lib.api.capabilities.ItemStorage items =
new zone.moddev.mc.lib.api.capabilities.ItemStorage(4);
new zone.moddev.mc.lib.api.capabilities.ItemStorage(4)
.role(zone.moddev.mc.lib.api.capabilities.Role.OUTPUT, 1)
.filter(stack -> !stack.is(net.minecraft.world.item.Items.DIRT), 0)
.limit(16, 0);
public final java.util.Map<String, zone.moddev.mc.lib.api.capabilities.SideConfig> sides =
java.util.Map.of(
"input",
Expand Down
Loading