diff --git a/docs/gui-screens.md b/docs/gui-screens.md
index 2e3fca9e..7ba7e6de 100644
--- a/docs/gui-screens.md
+++ b/docs/gui-screens.md
@@ -4,6 +4,10 @@
> open through `block-right-click`, and use one generic menu with custom value packets.
> Rendering and optional JEI integration live exclusively on the client.
+Machines can opt into [inventory colours, side drawers and item-provided filter editors](inventory-automation.md)
+through `ScreenHost.screenAutomation()`. This adds the selectors, fixed addon/filter slots and
+embedded editor without adding per-filter XML to each screen.
+
## Implementation map
| Piece | Where | State |
@@ -431,6 +435,13 @@ The standard player inventory (three rows of nine, a 4 px gap, then the hotbar)
bottom automatically. Write `` only to place it somewhere else, or set
`inventory="none"` on `` to leave it out.
+Optional `` goes after the main body and before tabs/popouts. Its button
+children form a right-aligned row above the player inventory (for example redstone and pause
+toggles). Automation's reusable `InventoryColorButtonBar` shares that row on the left, with
+scrollable overflow. The row is allocated once, and both sides align with the inventory edges.
+Use ordinary button declarations inside it; no spacer or coordinates are needed. This element
+requires a player inventory and is available to every screen, not just companion machines.
+
## Layout
- **Boxes.** `vbox` stacks children top to bottom and `hbox` left to right, so text of any
@@ -700,7 +711,7 @@ When JEI is installed:
Pop-out tabs are 22×24 rectangles, coloured by kind: yellow information, amber warning,
red error. Clicking one opens a matching drawer outward from that edge, with the icon
retained on a darker rail as the close control. Its body is up to 150 pixels wide and
- extends to the main panel's bottom. Text wraps to the available width; scroll inside
+ fits its text, capped at the main panel's bottom and the screen edge. Text wraps to the available width; scroll inside
to read longer content. Opening another drawer on the same edge replaces the open drawer;
tabs remain visible and clickable on the rail.
diff --git a/docs/gui-sprites.md b/docs/gui-sprites.md
index 70569c55..4c86103d 100644
--- a/docs/gui-sprites.md
+++ b/docs/gui-sprites.md
@@ -25,9 +25,12 @@ icons and progress arrows are full colour.
| `radio`, `radio_selected` | 10×10 | none | Bevelled circle: raised when off, pressed in when selected |
| `progress_arrow`, `progress_arrow_filled` | 24×16 | none | Bevelled blue-grey arrow / brighter blue progress fill, with matching outlines and glossy highlights |
| `upgrade_speed`, `upgrade_energy`, `upgrade_capacity` | 16×16 | none | Outline shown in an empty upgrade slot |
-| `icon_sides` | 16×16 | none | White cube icon for the side-configuration button |
+| `icon_sides` | 16×16 | none | Small white cube for generic controls |
+| `icon_side_config` | 16×16 | none | Central machine with connected cyan/orange ports for the side-configuration drawer |
+| `side_mode_input`, `side_mode_output`, `side_mode_both`, `side_mode_disabled` | 14×14 | none | Outlined cyan insertion arrow, orange extraction arrow, opposing arrows, or grey cross. The drawer draws 7×7 corner badges: input at top-right, output at bottom-right, both badges for bidirectional sides, or a bottom-right cross when disabled. |
| `icon_pause`, `icon_resume` | 16×16 | none | Gold pause bars and green play triangle; `_a` and `_b` variants identify independent lanes |
| `button_flat`, `button_flat_highlighted` | 14×14 | nine-slice, border 1 | Compact button with a uniform dark outline and flat grey fill; hover lightens only the interior |
+| `icon_filter_whitelist`, `icon_filter_blacklist` | 10×10 | none | White/black list cards on the filter's flat grey mode toggle |
| `icon_redstone_ignore`, `icon_redstone_high`, `icon_redstone_low` | 16×16 | none | Redstone torch with × / + / − mode badge |
| `icon_info` | 16×16 | none | Pop-out `info`: white serif "i" with a dark outline and transparent background |
| `icon_warning` | 16×16 | none | Pop-out `warning`: yellow triangle, black "!" |
diff --git a/docs/inventory-automation.md b/docs/inventory-automation.md
new file mode 100644
index 00000000..737b6fe3
--- /dev/null
+++ b/docs/inventory-automation.md
@@ -0,0 +1,173 @@
+# Inventory colours, sides and addons
+
+Implement `ScreenHost.screenAutomation()` to opt a machine into the inventory drawer. Existing
+screens need no new XML element. The host inserts a colour selector row above the player inventory,
+reserves the first right drawer button, and places a second selector vertically inside the drawer.
+Each selection configures one resource group. The reusable `InventoryColorButtonBar` builds the
+buttons automatically from the groups and their screen anchors: screen reading order (rows from
+left to right, stacks from top to bottom), once per group. Groups without a matching anchor follow
+in declaration order; item groups without explicit anchors can use their slot indices. The same
+order drives the drawer selector and keyboard navigation, without renumbering saved groups or
+menu slots. F6 focuses the selector; arrow keys change inventory. Overflow scrolls within the
+colour bar instead of covering adjacent controls.
+
+Place optional machine buttons in a top-level `` after the main screen body.
+They align right on the same row as the left-aligned colour bar, against the player inventory's
+edges. Layout reserves this row once and computes the selector's remaining width automatically.
+Screens without machine controls give the colour bar the full inventory width.
+The main inventory panel remains centred when drawers or filters open. Information drawers stay
+on the left; inventory side configuration stays on the right. Filter controls occupy a framed,
+scrollable region inside the configuration drawer below the addon slots. The arrow bar beneath
+the installed filter shows/hides this region; it has no duplicate selected-filter header.
+The drawer fits its
+visible controls and grows downward to fit an open filter, stopping four GUI pixels above the
+screen edge (with extra clearance for JEI's bottom toolbar when visible); overflow scrolls within
+the filter region. The colour selector keeps its own compact
+height. Info drawers fit their text up to the main panel's bottom or the screen edge, whichever
+comes first.
+
+## Define independent resource groups
+
+A `ResourceGroup` declares a stable save ID, translated name, RGB colour, `ResourceType`,
+supported `IoMode`, member indices, optional screen element IDs to highlight, and `SideConfig`.
+Bind its facing supplier and capability view, then create one persistent `AutomationInventory`
+containing the groups. Use the same definitions on client and server.
+
+```java
+var input = new ResourceGroup<>("input", Component.translatable("example.input"),
+ 0x568bd8, ResourceType.ITEMS, IoMode.INPUT, List.of(0, 1), List.of("input_slots"),
+ new SideConfig(IoMode.INPUT));
+input.bind(this::facing, side -> items.view(slot ->
+ input.contains(slot) ? input.mode(side) : IoMode.DISABLED));
+automation = new AutomationInventory(this, List.of(input));
+```
+
+Register passive item/fluid capabilities using `storage.view(index -> automation.mode(type,index,side))`.
+Use `energy.view(() -> automation.mode(ResourceType.ENERGY,0,side))` for energy. Every view retains
+all original slot/tank indices; permissions refuse operations instead of renumbering storage.
+The group's **active transfer port must restrict access to that group alone**, as above.
+Both views preserve storage reservations. Null-side views still honour the declared group role.
+
+Groups are saved by ID, independently of UI ordering. Call `automation.save(registries)` and
+`automation.load(tag, registries)` in the block entity's persistence hooks. Missing groups preserve
+their raw saved content. Call `automation.dropAll(recoverResources)` from the block's contents-drop
+hook. Addons, filters and pending item transfers drop as items. The required callback must preserve
+pending fluid/energy transfers, unpaid energy refunds and custom resource payloads in a dropped
+container. It receives a compound with a `resources` list (`type` resource ID plus transport-owned
+`data`) and an optional long `refund`. Ownership is handed to the callback before pending data is
+cleared. Do not also drop the same addon slots separately. This hook handles transfer escrow;
+normal tank/buffer contents remain the host's separate break policy.
+
+The companion drops a **Recovery Capsule**, a companion item carrying this data. Use it on a
+companion machine to restore fluids into accepting tanks and energy into available buffer space.
+Partial restoration leaves the remainder in the same capsule; a fully restored capsule is consumed,
+including in creative mode. Unknown resource payloads remain preserved. No recovery item is
+registered by MMDLib itself.
+
+## Addon item capabilities
+
+Register `AddonCapabilities.ADDON` for any item through `RegisterCapabilitiesEvent`. Its
+`InventoryAddon` returns an `AddonSpec`: stable type ID, resource type, required operations,
+batch size, interval, RF cost per unit and filter support. Acceptance requires **both** the
+resource type and supported inventory operations. Disabling every face does not invalidate an addon.
+
+Each group has four addon slots. A filter-capable addon exposes a smaller child slot. Register
+`AddonCapabilities.FILTER` for filter items; `acceptsFilter` can further restrict compatible filters.
+Each transfer role may be installed only once per group, regardless of item ID or tier: one input
+addon and one output addon can coexist when the inventory supports both. A bidirectional addon
+occupies both roles. Separate inventories may each have their own addons. Storage insertion,
+direct assignment and client menu prediction enforce the same rule. Loading an older save keeps
+the first addon for each role and queues duplicates with their attached filters for recovery.
+The parent and child are real fixed menu slots. Ghost entries in editors are not real item slots.
+All vanilla interaction paths use the same slot validation, including shift-click and hotbar swap.
+Removing/replacing a parent returns its configured filter to the acting player's inventory, or
+drops it beside the machine when full. Programmatic removals queue recovery; call `recover(player)`
+after the operation, or `recover(null)` to drop queued items. Recovery is persisted across saves.
+
+Unknown or incompatible installed items remain removable. An installed filter whose capability is
+missing blocks its module; it does not become an unfiltered transfer. Empty optional filter slots
+are intentionally unfiltered.
+Validation is dirty-driven: normal slot edits and loading invalidate the affected groups. Call
+`automation.invalidateAddons()` after an external addon-capability/definition change; this also
+invalidates editor generations. Use the slot setters for attachment changes rather than mutating
+installed stacks without notification.
+
+## Item-provided filter panels
+
+`InventoryFilter` supplies a resource type, editor ID, control tree, matching predicate and
+configuration validation. The default configuration adapter stores only the `mmdlib_filter`
+compound in the stack's custom data and preserves unrelated components.
+
+```java
+public FilterControl controls(ItemStack stack) {
+ return new FilterControl.Box("panel", false, List.of(
+ new FilterControl.Toggle("deny", MY_ICON, Component.translatable("example.deny")),
+ new FilterControl.Samples("entries", 18, ResourceType.ITEMS)));
+}
+```
+
+Use nested `Box` controls for rows/columns, `Toggle` for flat icon buttons, `Text` for bounded text/tag
+input, `Number` for bounded numeric values, and `Samples` for a wrapped ghost grid. Width is supplied
+by the drawer; tall content scrolls within the available screen space. Counts, nesting, IDs and
+text lengths are bounded. A sample comes from the **server's cursor stack**, without consuming it.
+Standard item samples match item IDs; fluid samples use a fluid container's contents. A filter
+may override sample extraction and validation for stricter matching. Ghost item previews render at
+60% opacity. A toggle may provide separate icons and tooltips for its false/true states; the
+three-argument constructor retains one shared icon and tooltip. The companion's basic item filter
+provides eight ghost cells and white/black list cards for its mode toggle.
+
+For specialised controls, register a common `FilterWidgetType` with a bounded action codec and
+a handler that updates only a candidate configuration. Include `FilterControl.Custom` in the tree.
+On the client, `FilterWidgets.register(type, factory)` supplies a fresh instance per open panel.
+Its access object exposes measured bounds, configuration copies, pending state and a typed
+submitter. The host clips rendering and routes focus, keyboard, pointer dragging, release and
+scroll events. `cancel`/`removed` let controls discard drafts and capture when their attachment,
+layout or selection changes. Client registrations must stay in client-only classes.
+
+The server resolves the current installed item and declared control before any edit. Menu session,
+attachment generation and configuration revision reject stale/replayed edits and concurrent
+overwrites. The candidate is committed only after full filter validation. Authoritative stack sync
+updates all viewers. Return `false` from validation to keep the original configuration unchanged.
+Custom action JSON is limited to 256 characters; choose codecs with bounded collections and values.
+
+See `CompanionAddons` and `CompanionWidgetsClient`: the basic/expanded item filters use different
+grid sizes, the fluid filter accepts fluid samples, and the tag filter supplies a text field plus
+a separately registered custom allow/deny button. None requires machine-specific screen XML.
+
+## Active transfers
+
+Call `AutomationTransfers.tick(automation, energy)` once per server tick, after recipe processing.
+The scheduler rotates module and face priorities, observes enabled sides and installed predicates,
+and charges RF only for actual delivery. It never force-loads a neighbour's chunk. Built-in item,
+fluid and energy transports simulate before extracting and retain unexpected insertion remainders
+in persistent escrow for later delivery. Inbound escrow belongs to the machine already and finishes
+locally without querying the old source, even if that neighbour is removed or external sides are
+disabled. Outbound escrow first tries its previous destination, then other enabled output faces;
+with no accepting destination it remains saved. It retains its original direction and cost even
+if the module is removed, and cannot be reused as a new transfer with different settings.
+Refunds that do not fit the power buffer remain persisted and are retried when room is available.
+Capability implementations must obey the normal
+simulation/count contracts; no API can recover reliably from an external handler throwing after
+an undocumented partial mutation.
+
+The companion uses paid mechanical pullers/pushers and fluid pumps. Its energy relays are lossless
+and have no auxiliary RF fee, so importing can bootstrap an empty buffer. Pending refund ownership
+is independent of buffer capacity, including when handlers refill the buffer during a transfer.
+
+Register future content with `ResourceType.register(id, capabilityInterface)` and
+`AutomationTransfers.register(type, transport)`. Custom transports implement `move` for a fresh
+transfer and `deliver` for an already-extracted payload; `deliver` must not consult or extract from
+the source. They own their bounded persistent escrow schema and must preserve unaccepted resources.
+Override `recovery` to provide a resource-only payload for the host's break container; the default
+preserves the whole pending state. Resource types are not a closed enum; gas
+support can add a capability and transport without changing the drawer.
+
+The optional companion's Logistics Workbench demonstrates seven groups: two item inventories,
+four independent fluid tanks and energy. Its output tanks can be filled by the owning machine/test
+setup; passive external insertion correctly refuses them.
+
+Neighbour capability lookups use per-machine NeoForge caches that follow invalidation, replacement
+and chunk loading. Resource membership uses indexed lookups, and menus construct sync snapshots
+only when their revision or viewer state changes. `AutomationLayout` is the shared source for drawer
+frames, real slot coordinates and filter hit bounds. Unsided companion energy queries retain the
+buffer's normal insert/extract limits; sided queries additionally obey inventory side settings.
diff --git a/scripts/generate_gui_sprites.py b/scripts/generate_gui_sprites.py
index ca507300..d049b7f5 100644
--- a/scripts/generate_gui_sprites.py
+++ b/scripts/generate_gui_sprites.py
@@ -256,6 +256,14 @@ def write_flat_buttons():
write_png(name, img, {"type": "nine_slice", "width": 14, "height": 14, "border": 1})
+def write_filter_icons():
+ """White/black list cards for the flat grey mode toggle."""
+ for mode, fill in (("whitelist", 0xF1), ("blacklist", 0x24)):
+ img = canvas(10, 10)
+ rect(img, 2, 1, 6, 8, grey(fill))
+ write_png("icon_filter_" + mode, img)
+
+
def write_control_icons():
"""16 px control glyphs with a one-pixel shadow and optional lane/mode badge."""
def glyph(img, rows, x, y, ink):
@@ -290,6 +298,58 @@ def glyph(img, rows, x, y, ink):
write_png("icon_redstone_" + mode, img)
+def write_side_config_sprites():
+ """Outlined directional glyphs remain legible over faded neighbour previews."""
+ cyan, orange = (133, 210, 255, 255), (255, 185, 101, 255)
+ ink = (30, 40, 52, 255)
+
+ def outlined(pixels):
+ result = canvas(len(pixels[0]), len(pixels))
+ for y, row in enumerate(pixels):
+ for x, colour in enumerate(row):
+ if colour == CLEAR: continue
+ for dx, dy in ((-1, 0), (1, 0), (0, -1), (0, 1)):
+ nx, ny = x + dx, y + dy
+ if 0 <= ny < len(result) and 0 <= nx < len(row): result[ny][nx] = ink
+ for y, row in enumerate(pixels):
+ for x, colour in enumerate(row):
+ if colour != CLEAR: result[y][x] = colour
+ return result
+
+ def arrow(img, x, y, down, colour):
+ rows = ["..##..", "..##..", "..##..", "..##..", "######", ".####.", "..##.."]
+ if not down: rows = rows[::-1]
+ for dy, row in enumerate(rows):
+ for dx, bit in enumerate(row):
+ if bit == "#": img[y + dy][x + dx] = colour
+
+ for mode in ("input", "output", "both", "disabled"):
+ img = canvas(14, 14)
+ if mode == "both":
+ arrow(img, 1, 5, True, cyan)
+ arrow(img, 7, 2, False, orange)
+ elif mode == "disabled":
+ for i in range(8):
+ rect(img, 3 + i, 3 + i, 1, 1, grey(205))
+ rect(img, 10 - i, 3 + i, 1, 1, grey(205))
+ else:
+ arrow(img, 4, 2, mode == "input", cyan if mode == "input" else orange)
+ rect(img, 3, 10, 8, 2, grey(225))
+ rect(img, 3, 9, 1, 1, grey(225))
+ rect(img, 10, 9, 1, 1, grey(225))
+ write_png("side_mode_" + mode, outlined(img))
+
+ # A central machine with four connected, coloured ports identifies side configuration.
+ img = canvas(16, 16)
+ rect(img, 7, 2, 2, 12, grey(220))
+ rect(img, 2, 7, 12, 2, grey(220))
+ rect(img, 5, 5, 6, 6, grey(240))
+ rect(img, 6, 6, 4, 4, (93, 119, 151, 255))
+ for x, y, colour in ((6, 1, cyan), (1, 6, cyan), (11, 6, orange), (6, 11, orange)):
+ rect(img, x, y, 4, 4, colour)
+ write_png("icon_side_config", outlined(img))
+
+
def main():
OUT.mkdir(parents=True, exist_ok=True)
print(f"writing to {OUT}:")
@@ -301,7 +361,9 @@ def main():
write_png("compact_frame", compact_frame(), {"type": "nine_slice", "width": 8, "height": 8, "border": 1})
write_drawer_sprites()
write_control_icons()
+ write_filter_icons()
write_flat_buttons()
+ write_side_config_sprites()
write_png("checkbox", checkbox(False))
write_png("checkbox_selected", checkbox(True))
write_png("radio", radio(False))
diff --git a/src/companion/java/zone/moddev/mc/companion/CompanionAddons.java b/src/companion/java/zone/moddev/mc/companion/CompanionAddons.java
new file mode 100644
index 00000000..9f11af46
--- /dev/null
+++ b/src/companion/java/zone/moddev/mc/companion/CompanionAddons.java
@@ -0,0 +1,167 @@
+package zone.moddev.mc.companion;
+
+import java.util.*;
+
+import net.minecraft.core.HolderLookup;
+import net.minecraft.core.registries.*;
+import net.minecraft.network.chat.Component;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.tags.TagKey;
+import net.minecraft.world.item.*;
+
+import net.neoforged.bus.api.IEventBus;
+import net.neoforged.neoforge.capabilities.RegisterCapabilitiesEvent;
+import net.neoforged.neoforge.fluids.FluidStack;
+import net.neoforged.neoforge.registries.*;
+
+import zone.moddev.mc.lib.api.automation.*;
+import zone.moddev.mc.lib.api.capabilities.IoMode;
+
+/** Companion content demonstrates capabilities: the library does not register concrete addons. */
+public final class CompanionAddons {
+ private static final DeferredRegister- ITEMS =
+ DeferredRegister.create(Registries.ITEM, CompanionMod.ID);
+ public static final DeferredHolder
- RECOVERY_CAPSULE =
+ ITEMS.register("recovery_capsule", RecoveryCapsuleItem::new);
+ private static final Map, InventoryAddon> ADDONS =
+ new LinkedHashMap<>();
+ private static final Map, InventoryFilter> FILTERS =
+ new LinkedHashMap<>();
+ public static final FilterWidgetType TAG_MODE =
+ FilterWidgetType.register(
+ CompanionMod.id("tag_mode"),
+ com.mojang.serialization.Codec.BOOL,
+ (config, deny) -> {
+ config.putBoolean("deny", deny);
+ return true;
+ });
+ public static final DeferredHolder
- PULLER =
+ addon("mechanical_puller", ResourceType.ITEMS, IoMode.INPUT, 8, 20, 4, true);
+ public static final DeferredHolder
- PUSHER =
+ addon("mechanical_pusher", ResourceType.ITEMS, IoMode.OUTPUT, 8, 20, 4, true);
+ public static final DeferredHolder
- INPUT_PUMP =
+ addon("input_pump", ResourceType.FLUIDS, IoMode.INPUT, 250, 10, 1, true);
+ public static final DeferredHolder
- OUTPUT_PUMP =
+ addon("output_pump", ResourceType.FLUIDS, IoMode.OUTPUT, 250, 10, 1, true);
+ public static final DeferredHolder
- IMPORT_RELAY =
+ addon("import_relay", ResourceType.ENERGY, IoMode.INPUT, 1000, 5, 0, false);
+ public static final DeferredHolder
- EXPORT_RELAY =
+ addon("export_relay", ResourceType.ENERGY, IoMode.OUTPUT, 1000, 5, 0, false);
+ public static final DeferredHolder
- ITEM_FILTER =
+ filter("item_filter", ResourceType.ITEMS, 8, false);
+ public static final DeferredHolder
- LARGE_FILTER =
+ filter("large_item_filter", ResourceType.ITEMS, 36, false);
+ public static final DeferredHolder
- TAG_FILTER =
+ filter("tag_filter", ResourceType.ITEMS, 0, true);
+ public static final DeferredHolder
- FLUID_FILTER =
+ filter("fluid_filter", ResourceType.FLUIDS, 6, false);
+
+ private static DeferredHolder
- addon(
+ String name,
+ ResourceType> type,
+ IoMode direction,
+ int batch,
+ int interval,
+ long cost,
+ boolean filters) {
+ var item = ITEMS.register(name, () -> new Item(new Item.Properties().stacksTo(1)));
+ var spec =
+ new AddonSpec(CompanionMod.id(name), type, direction, batch, interval, cost, filters);
+ ADDONS.put(item, stack -> spec);
+ return item;
+ }
+
+ private static DeferredHolder
- filter(
+ String name, ResourceType> type, int size, boolean tag) {
+ var item = ITEMS.register(name, () -> new Item(new Item.Properties().stacksTo(1)));
+ FILTERS.put(
+ item,
+ new InventoryFilter() {
+ public ResourceType> resource(ItemStack stack) {
+ return type;
+ }
+
+ public ResourceLocation editorType(ItemStack stack) {
+ return CompanionMod.id(name);
+ }
+
+ public FilterControl controls(ItemStack stack) {
+ return new FilterControl.Box(
+ "root",
+ false,
+ List.of(
+ tag
+ ? new FilterControl.Custom("deny", TAG_MODE, 14)
+ : new FilterControl.Toggle(
+ "deny",
+ ResourceLocation.parse("mmdlib:icon_filter_whitelist"),
+ ResourceLocation.parse("mmdlib:icon_filter_blacklist"),
+ Component.translatable("screen.mmdlib_companion.filter.whitelist"),
+ Component.translatable("screen.mmdlib_companion.filter.blacklist")),
+ tag
+ ? new FilterControl.Text(
+ "tag",
+ 128,
+ Component.translatable("screen.mmdlib_companion.filter.tag"))
+ : new FilterControl.Samples("samples", size, type)));
+ }
+
+ public boolean validate(
+ ItemStack stack,
+ net.minecraft.nbt.CompoundTag config,
+ HolderLookup.Provider registries) {
+ if (!tag || config.getString("tag").isEmpty()) return true;
+ var id = ResourceLocation.tryParse(config.getString("tag"));
+ return id != null
+ && registries
+ .lookupOrThrow(Registries.ITEM)
+ .get(TagKey.create(Registries.ITEM, id))
+ .isPresent();
+ }
+
+ public boolean matches(
+ ItemStack stack, Object candidate, HolderLookup.Provider registries) {
+ var config = configuration(stack);
+ boolean match = false;
+ if (tag && candidate instanceof ItemStack item) {
+ var id = ResourceLocation.tryParse(config.getString("tag"));
+ match = id != null && item.is(TagKey.create(Registries.ITEM, id));
+ } else {
+ String id =
+ candidate instanceof ItemStack item
+ ? BuiltInRegistries.ITEM.getKey(item.getItem()).toString()
+ : candidate instanceof FluidStack fluid
+ ? BuiltInRegistries.FLUID.getKey(fluid.getFluid()).toString()
+ : "";
+ var samples = config.getList("samples", net.minecraft.nbt.Tag.TAG_STRING);
+ for (int i = 0; i < Math.min(size, samples.size()); i++)
+ if (samples.getString(i).equals(id)) {
+ match = true;
+ break;
+ }
+ }
+ return config.getBoolean("deny") ? !match : match;
+ }
+ });
+ return item;
+ }
+
+ public static List> items() {
+ var result = new ArrayList<>(ADDONS.keySet());
+ result.addAll(FILTERS.keySet());
+ return List.copyOf(result);
+ }
+
+ public static void register(IEventBus bus) {
+ ITEMS.register(bus);
+ }
+
+ public static void capabilities(RegisterCapabilitiesEvent event) {
+ ADDONS.forEach(
+ (item, provider) ->
+ event.registerItem(AddonCapabilities.ADDON, (stack, context) -> provider, item.get()));
+ FILTERS.forEach(
+ (item, provider) ->
+ event.registerItem(AddonCapabilities.FILTER, (stack, context) -> provider, item.get()));
+ }
+}
diff --git a/src/companion/java/zone/moddev/mc/companion/CompanionMod.java b/src/companion/java/zone/moddev/mc/companion/CompanionMod.java
index 9c798c46..f1e5e16a 100644
--- a/src/companion/java/zone/moddev/mc/companion/CompanionMod.java
+++ b/src/companion/java/zone/moddev/mc/companion/CompanionMod.java
@@ -37,7 +37,8 @@
@Mod(CompanionMod.ID)
public final class CompanionMod {
public static final String ID = "mmdlib_companion";
- private static final DeferredRegister BLOCKS = DeferredRegister.create(Registries.BLOCK, ID);
+ private static final DeferredRegister BLOCKS =
+ DeferredRegister.create(Registries.BLOCK, ID);
private static final DeferredRegister
- ITEMS = DeferredRegister.create(Registries.ITEM, ID);
private static final DeferredRegister> ENTITIES =
DeferredRegister.create(Registries.BLOCK_ENTITY_TYPE, ID);
@@ -53,23 +54,32 @@ public final class CompanionMod {
public static final MachineRecipeType TRANSMUTING =
MachineRecipeTypes.register(RECIPE_TYPES, SERIALIZERS, "transmuting");
public static final Machine CRUSHER = new Machine("crusher", ProcessorEntity.Kind.CRUSHER);
- public static final Machine TRANSMUTER = new Machine("transmuter", ProcessorEntity.Kind.TRANSMUTER);
+ public static final Machine TRANSMUTER =
+ new Machine("transmuter", ProcessorEntity.Kind.TRANSMUTER);
public static final Machine DUAL_CRUSHER = new Machine("dual_crusher", ProcessorEntity.Kind.DUAL);
public static final Machine CREATIVE_POWER = new Machine("creative_power", null);
+ public static final Machine LOGISTICS = new Machine("logistics", ProcessorEntity.Kind.LOGISTICS);
public static final List MACHINES =
- List.of(CRUSHER, TRANSMUTER, DUAL_CRUSHER, CREATIVE_POWER);
+ List.of(CRUSHER, TRANSMUTER, DUAL_CRUSHER, CREATIVE_POWER, LOGISTICS);
static {
- TABS.register("machines", () -> CreativeModeTab.builder()
- .title(Component.translatable("itemGroup.mmdlib_companion"))
- .icon(() -> new ItemStack(CRUSHER.block.get()))
- .displayItems((parameters, output) ->
- MACHINES.forEach(machine -> output.accept(machine.block.get())))
- .build());
+ TABS.register(
+ "machines",
+ () ->
+ CreativeModeTab.builder()
+ .title(Component.translatable("itemGroup.mmdlib_companion"))
+ .icon(() -> new ItemStack(CRUSHER.block.get()))
+ .displayItems(
+ (parameters, output) -> {
+ MACHINES.forEach(machine -> output.accept(machine.block.get()));
+ CompanionAddons.items().forEach(item -> output.accept(item.get()));
+ })
+ .build());
}
public CompanionMod(IEventBus bus) {
CompanionWidgets.register();
+ CompanionAddons.register(bus);
BLOCKS.register(bus);
ITEMS.register(bus);
ENTITIES.register(bus);
@@ -77,50 +87,103 @@ public CompanionMod(IEventBus bus) {
SERIALIZERS.register(bus);
TABS.register(bus);
bus.addListener(CompanionMod::capabilities);
- MaterialExtensions.GLOBAL.events().registerCallback(
- id("tank_container"), MaterialEventTypes.GUI_CLICK, CompanionMod::transferTankContainer);
- MaterialExtensions.GLOBAL.events().registerCallback(
- id("redstone"), MaterialEventTypes.GUI_CLICK,
- click -> {
- if (click.menu().blockEntity instanceof ProcessorEntity machine) machine.cycleRedstoneMode();
- });
+ MaterialExtensions.GLOBAL
+ .events()
+ .registerCallback(
+ id("tank_container"),
+ MaterialEventTypes.GUI_CLICK,
+ CompanionMod::transferTankContainer);
+ MaterialExtensions.GLOBAL
+ .events()
+ .registerCallback(
+ id("redstone"),
+ MaterialEventTypes.GUI_CLICK,
+ click -> {
+ if (click.menu().blockEntity instanceof ProcessorEntity machine)
+ machine.cycleRedstoneMode();
+ });
for (int lane = 0; lane < 2; lane++) {
int index = lane;
- MaterialExtensions.GLOBAL.events().registerCallback(
- id("toggle_" + lane), MaterialEventTypes.GUI_CLICK,
- click -> {
- if (click.menu().blockEntity instanceof ProcessorEntity machine) machine.toggle(index);
- });
+ MaterialExtensions.GLOBAL
+ .events()
+ .registerCallback(
+ id("toggle_" + lane),
+ MaterialEventTypes.GUI_CLICK,
+ click -> {
+ if (click.menu().blockEntity instanceof ProcessorEntity machine)
+ machine.toggle(index);
+ });
}
}
private static void transferTankContainer(ScreenClick click) {
- if (!(click.menu().blockEntity instanceof ProcessorEntity machine) || !machine.hasTank()) return;
+ if (!(click.menu().blockEntity instanceof ProcessorEntity machine) || !machine.hasTank())
+ return;
var carried = click.menu().getCarried();
if (carried.isEmpty() || FluidUtil.getFluidHandler(carried).isEmpty()) return;
var inventory = click.player().getCapability(Capabilities.ItemHandler.ENTITY);
if (inventory == null) return;
// Work on the server's cursor stack. NeoForge handles partial containers and stacked results.
- var result = FluidUtil.tryEmptyContainerAndStow(
- carried, machine.fluids, inventory, Integer.MAX_VALUE, click.player(), true);
+ var result =
+ FluidUtil.tryEmptyContainerAndStow(
+ carried, machine.fluids, inventory, Integer.MAX_VALUE, click.player(), true);
if (!result.isSuccess())
- result = FluidUtil.tryFillContainerAndStow(
- carried, machine.fluids, inventory, Integer.MAX_VALUE, click.player(), true);
+ result =
+ FluidUtil.tryFillContainerAndStow(
+ carried, machine.fluids, inventory, Integer.MAX_VALUE, click.player(), true);
if (result.isSuccess()) {
click.menu().setCarried(result.getResult());
click.menu().broadcastChanges();
} else {
- click.player().displayClientMessage(Component.translatable("screen.mmdlib_companion.tank.refused"), true);
+ click
+ .player()
+ .displayClientMessage(
+ Component.translatable("screen.mmdlib_companion.tank.refused"), true);
}
}
private static void capabilities(RegisterCapabilitiesEvent event) {
- MachineCapabilities.fluids(event, CRUSHER.type.get(), entity -> ((ProcessorEntity) entity).fluids);
+ CompanionAddons.capabilities(event);
for (var machine : MACHINES) {
- MachineCapabilities.energy(event, machine.type.get(), entity -> entity.energy,
- (entity, side) -> entity instanceof CreativePowerEntity ? IoMode.OUTPUT : IoMode.INPUT);
- if (machine != CREATIVE_POWER)
- MachineCapabilities.items(event, machine.type.get(), entity -> ((ProcessorEntity) entity).items);
+ if (machine == CREATIVE_POWER) {
+ MachineCapabilities.energy(
+ event, machine.type.get(), entity -> entity.energy, (entity, side) -> IoMode.OUTPUT);
+ continue;
+ }
+ event.registerBlockEntity(
+ Capabilities.ItemHandler.BLOCK,
+ machine.type.get(),
+ (entity, side) -> {
+ var p = (ProcessorEntity) entity;
+ return p.items.view(
+ slot ->
+ p.automation.mode(
+ zone.moddev.mc.lib.api.automation.ResourceType.ITEMS, slot, side));
+ });
+ event.registerBlockEntity(
+ Capabilities.FluidHandler.BLOCK,
+ machine.type.get(),
+ (entity, side) -> {
+ var p = (ProcessorEntity) entity;
+ return p.hasTank()
+ ? p.fluids.view(
+ tank ->
+ p.automation.mode(
+ zone.moddev.mc.lib.api.automation.ResourceType.FLUIDS, tank, side))
+ : null;
+ });
+ event.registerBlockEntity(
+ Capabilities.EnergyStorage.BLOCK,
+ machine.type.get(),
+ (entity, side) -> {
+ var p = (ProcessorEntity) entity;
+ return side == null
+ ? p.energy.view(zone.moddev.mc.lib.api.capabilities.IoMode.BOTH)
+ : p.energy.view(
+ () ->
+ p.automation.mode(
+ zone.moddev.mc.lib.api.automation.ResourceType.ENERGY, 0, side));
+ });
}
}
@@ -136,11 +199,16 @@ public static final class Machine {
private Machine(String name, ProcessorEntity.Kind kind) {
this.kind = kind;
- block = BLOCKS.register(name, () -> new DemoBlock(
- BlockBehaviour.Properties.of().strength(3.5F).requiresCorrectToolForDrops(),
- this::entityType));
- type = ENTITIES.register(name, () ->
- BlockEntityType.Builder.of(this::create, block.get()).build(null));
+ block =
+ BLOCKS.register(
+ name,
+ () ->
+ new DemoBlock(
+ BlockBehaviour.Properties.of().strength(3.5F).requiresCorrectToolForDrops(),
+ this::entityType));
+ type =
+ ENTITIES.register(
+ name, () -> BlockEntityType.Builder.of(this::create, block.get()).build(null));
ITEMS.register(name, () -> new BlockItem(block.get(), new Item.Properties()));
}
@@ -149,7 +217,8 @@ private BlockEntityType entityType() {
}
private DemoEntity create(BlockPos pos, BlockState state) {
- return kind == null ? new CreativePowerEntity(type.get(), pos, state)
+ return kind == null
+ ? new CreativePowerEntity(type.get(), pos, state)
: new ProcessorEntity(type.get(), pos, state, kind);
}
}
diff --git a/src/companion/java/zone/moddev/mc/companion/DemoBlock.java b/src/companion/java/zone/moddev/mc/companion/DemoBlock.java
index e4e34712..ad12296a 100644
--- a/src/companion/java/zone/moddev/mc/companion/DemoBlock.java
+++ b/src/companion/java/zone/moddev/mc/companion/DemoBlock.java
@@ -45,14 +45,29 @@ public BlockEntityTicker getTicker(
}
@Override
- protected ItemInteractionResult useItemOn(ItemStack stack, BlockState state, Level level,
- BlockPos pos, Player player, InteractionHand hand, BlockHitResult hit) {
- if (level.getBlockEntity(pos) instanceof ProcessorEntity machine && machine.hasTank()
+ protected ItemInteractionResult useItemOn(
+ ItemStack stack,
+ BlockState state,
+ Level level,
+ BlockPos pos,
+ Player player,
+ InteractionHand hand,
+ BlockHitResult hit) {
+ if (stack.is(CompanionAddons.RECOVERY_CAPSULE.get())
+ && level.getBlockEntity(pos) instanceof ProcessorEntity machine) {
+ if (level.isClientSide()) return ItemInteractionResult.SUCCESS;
+ return RecoveryCapsuleItem.restore(stack, machine)
+ ? ItemInteractionResult.CONSUME
+ : ItemInteractionResult.FAIL;
+ }
+ if (level.getBlockEntity(pos) instanceof ProcessorEntity machine
+ && machine.hasTank()
&& FluidUtil.getFluidHandler(stack).isPresent()) {
if (level.isClientSide()) return ItemInteractionResult.SUCCESS;
if (FluidUtil.interactWithFluidHandler(player, hand, machine.fluids))
return ItemInteractionResult.CONSUME;
- player.displayClientMessage(Component.translatable("screen.mmdlib_companion.tank.refused"), true);
+ player.displayClientMessage(
+ Component.translatable("screen.mmdlib_companion.tank.refused"), true);
// Do not let a rejected bucket spill its contents beside the machine.
return ItemInteractionResult.FAIL;
}
@@ -60,10 +75,11 @@ protected ItemInteractionResult useItemOn(ItemStack stack, BlockState state, Lev
}
@Override
- protected InteractionResult useWithoutItem(BlockState state, Level level, BlockPos pos,
- Player player, BlockHitResult hit) {
+ protected InteractionResult useWithoutItem(
+ BlockState state, Level level, BlockPos pos, Player player, BlockHitResult hit) {
return ScreenRuntime.open(player, level.getBlockEntity(pos))
- ? InteractionResult.sidedSuccess(level.isClientSide()) : InteractionResult.PASS;
+ ? InteractionResult.sidedSuccess(level.isClientSide())
+ : InteractionResult.PASS;
}
@Override
diff --git a/src/companion/java/zone/moddev/mc/companion/ProcessorEntity.java b/src/companion/java/zone/moddev/mc/companion/ProcessorEntity.java
index 4d18816d..eb41a738 100644
--- a/src/companion/java/zone/moddev/mc/companion/ProcessorEntity.java
+++ b/src/companion/java/zone/moddev/mc/companion/ProcessorEntity.java
@@ -4,6 +4,7 @@
import java.util.List;
import net.minecraft.core.BlockPos;
+import net.minecraft.core.Direction;
import net.minecraft.core.HolderLookup;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.nbt.NbtOps;
@@ -21,6 +22,8 @@
import net.neoforged.neoforge.fluids.FluidStack;
+import zone.moddev.mc.lib.api.automation.*;
+import zone.moddev.mc.lib.api.capabilities.*;
import zone.moddev.mc.lib.api.capabilities.FluidStorage;
import zone.moddev.mc.lib.api.capabilities.ItemStorage;
import zone.moddev.mc.lib.api.capabilities.Role;
@@ -34,17 +37,21 @@
import zone.moddev.mc.lib.machine.neoforge.StorageGroups;
/** The dual crusher deliberately runs two lanes over the same input and output groups. */
-public final class ProcessorEntity extends DemoEntity implements ScreenHost, DropsContents, Clearable {
+public final class ProcessorEntity extends DemoEntity
+ implements ScreenHost, DropsContents, Clearable {
public enum Kind {
CRUSHER,
TRANSMUTER,
- DUAL
+ DUAL,
+ LOGISTICS
}
public final ItemStorage items;
+ public final AutomationInventory automation;
public final FluidStorage fluids = new FluidStorage().onChange(this::setChanged);
public final List lanes;
- public final FeatureHost features = new FeatureHost(this::setChanged, () -> FeatureSync.send(this));
+ public final FeatureHost features =
+ new FeatureHost(this::setChanged, () -> FeatureSync.send(this));
private final Kind kind;
// 0 ignores redstone, 1 requires a signal, 2 requires no signal. Manual pause is separate.
private int redstoneMode;
@@ -78,16 +85,22 @@ public ProcessorEntity(BlockEntityType> type, BlockPos pos, BlockState state,
super(type, pos, state);
this.kind = kind;
if (kind == Kind.CRUSHER)
- fluids.addTank(4_000, Role.STORAGE,
+ fluids.addTank(
+ 4_000,
+ Role.STORAGE,
fluid -> fluid.getFluid() == Fluids.WATER || fluid.getFluid() == Fluids.LAVA);
+ if (kind == Kind.LOGISTICS)
+ for (int i = 0; i < 4; i++) fluids.addTank(4000, i < 2 ? Role.INPUT : Role.OUTPUT);
var inventory = MachineInventory.builder().group("energy", StorageGroups.energy(energy));
if (kind == Kind.TRANSMUTER) {
// One key, and one item: the recipe consumes the whole slot before inserting gold.
items = new ItemStorage(1).limit(1, 0).onChange(this::setChanged);
inventory.group("slot", StorageGroups.items(items, 0));
} else {
- items = new ItemStorage(2).role(Role.INPUT, 0).role(Role.OUTPUT, 1).onChange(this::setChanged);
- inventory.group("input", StorageGroups.items(items, 0))
+ items =
+ new ItemStorage(2).role(Role.INPUT, 0).role(Role.OUTPUT, 1).onChange(this::setChanged);
+ inventory
+ .group("input", StorageGroups.items(items, 0))
.group("output", StorageGroups.items(items, 1));
}
var shared = inventory.build();
@@ -99,12 +112,85 @@ public ProcessorEntity(BlockEntityType> type, BlockPos pos, BlockState state,
features.add(processor);
}
lanes = List.copyOf(processors);
+ var groups = new ArrayList>();
+ groups.add(
+ itemGroup("input", 0, 0x568bd8, kind == Kind.TRANSMUTER ? IoMode.BOTH : IoMode.INPUT));
+ if (kind != Kind.TRANSMUTER) groups.add(itemGroup("output", 1, 0xe48f45, IoMode.OUTPUT));
+ int[] colours = {0x64bfc1, 0xa678cb, 0xe0c55d, 0xce668e};
+ for (int i = 0; i < fluids.getTanks(); i++) {
+ final int tank = i;
+ var group =
+ new ResourceGroup<>(
+ "tank_" + i,
+ Component.translatable("screen.mmdlib_companion.group.tank", i + 1),
+ colours[i],
+ ResourceType.FLUIDS,
+ kind == Kind.CRUSHER ? IoMode.BOTH : (i < 2 ? IoMode.INPUT : IoMode.OUTPUT),
+ List.of(i),
+ List.of(i == 0 ? "tank" : "tank_" + i),
+ new SideConfig(
+ kind == Kind.CRUSHER ? IoMode.BOTH : (i < 2 ? IoMode.INPUT : IoMode.OUTPUT)));
+ group.bind(
+ () -> Direction.NORTH,
+ side -> fluids.view(index -> index == tank ? group.mode(side) : IoMode.DISABLED));
+ groups.add(group);
+ }
+ var energyGroup =
+ new ResourceGroup<>(
+ "energy",
+ Component.translatable("screen.mmdlib_companion.group.energy"),
+ 0xd85b5b,
+ ResourceType.ENERGY,
+ kind == Kind.LOGISTICS ? IoMode.BOTH : IoMode.INPUT,
+ List.of(0),
+ List.of("energy"),
+ new SideConfig(kind == Kind.LOGISTICS ? IoMode.BOTH : IoMode.INPUT));
+ if (kind == Kind.LOGISTICS) energy.setRates(1000, 1000);
+ energyGroup.bind(() -> Direction.NORTH, side -> energy.view(() -> energyGroup.mode(side)));
+ groups.add(energyGroup);
+ automation = new AutomationInventory(this, groups);
+ }
+
+ private ResourceGroup itemGroup(
+ String id, int slot, int colour, IoMode mode) {
+ var group =
+ new ResourceGroup<>(
+ id,
+ Component.translatable("screen.mmdlib_companion.group." + id),
+ colour,
+ ResourceType.ITEMS,
+ mode,
+ List.of(slot),
+ List.of(id),
+ new SideConfig(mode));
+ return group.bind(
+ () -> Direction.NORTH,
+ side -> items.view(index -> index == slot ? group.mode(side) : IoMode.DISABLED));
+ }
+
+ @Override
+ public AutomationInventory screenAutomation() {
+ return automation;
}
@Override
public void serverTick() {
- if (redstoneAllowsRun() && energy.stored() >= energy.capacity() * reservePercent / 100)
- features.tick(level);
+ if (kind != Kind.LOGISTICS
+ && redstoneAllowsRun()
+ && energy.stored() >= energy.capacity() * reservePercent / 100) features.tick(level);
+ AutomationTransfers.tick(automation, energy);
+ }
+
+ public FluidStack getTank1() {
+ return fluids.getFluidInTank(1);
+ }
+
+ public FluidStack getTank2() {
+ return fluids.getFluidInTank(2);
+ }
+
+ public FluidStack getTank3() {
+ return fluids.getFluidInTank(3);
}
public int getRedstoneMode() {
@@ -117,8 +203,7 @@ public void cycleRedstoneMode() {
}
private boolean redstoneAllowsRun() {
- return redstoneMode == 0
- || (level.hasNeighborSignal(worldPosition) == (redstoneMode == 1));
+ return redstoneMode == 0 || (level.hasNeighborSignal(worldPosition) == (redstoneMode == 1));
}
public void toggle(int index) {
@@ -177,9 +262,13 @@ public List getWarnings() {
for (int i = 0; i < lanes.size(); i++) {
var status = lanes.get(i).status();
if (status == RecipeProcessor.Status.STALLED || status == RecipeProcessor.Status.BLOCKED)
- warnings.add(Component.translatable("screen.mmdlib_companion.lane_" + (i == 0 ? "a" : "b"))
- .append(": ").append(Component.translatable(
- "status.mmdlib_companion." + status.name().toLowerCase(java.util.Locale.ROOT))));
+ warnings.add(
+ Component.translatable("screen.mmdlib_companion.lane_" + (i == 0 ? "a" : "b"))
+ .append(": ")
+ .append(
+ Component.translatable(
+ "status.mmdlib_companion."
+ + status.name().toLowerCase(java.util.Locale.ROOT))));
}
return warnings;
}
@@ -198,17 +287,26 @@ public int[] screenRecipeSlots(ResourceLocation category) {
@Override
public void dropContents(Level level, BlockPos pos) {
items.dropAll(level, pos);
+ automation.dropAll(
+ payload ->
+ net.minecraft.world.Containers.dropItemStack(
+ level,
+ pos.getX() + .5,
+ pos.getY() + .5,
+ pos.getZ() + .5,
+ RecoveryCapsuleItem.create(payload)));
}
@Override
public void clearContent() {
+ automation.clear();
lanes.forEach(RecipeProcessor::reset);
for (int slot = 0; slot < items.getSlots(); slot++) {
items.setPlan(slot, SlotPlan.NONE);
items.setStackInSlot(slot, ItemStack.EMPTY);
}
energy.setStored(0);
- if (hasTank()) fluids.tank(0).setFluid(FluidStack.EMPTY);
+ for (int i = 0; i < fluids.getTanks(); i++) fluids.tank(i).setFluid(FluidStack.EMPTY);
setChanged();
FeatureSync.send(this);
}
@@ -217,6 +315,7 @@ public void clearContent() {
protected void saveAdditional(CompoundTag tag, HolderLookup.Provider registries) {
super.saveAdditional(tag, registries);
tag.put("items", items.serializeNBT(registries));
+ tag.put("automation", automation.save(registries));
tag.putInt("redstone_mode", redstoneMode);
tag.putString("machine_name", machineName);
tag.putInt("reserve_percent", reservePercent);
@@ -239,7 +338,9 @@ protected void loadAdditional(CompoundTag tag, HolderLookup.Provider registries)
: "Crusher";
reservePercent = Math.clamp(tag.getInt("reserve_percent"), 0, 100);
items.deserializeNBT(registries, tag.getCompound("items"));
- if (hasTank() && tag.contains("fluids")) fluids.deserializeNBT(registries, tag.getCompound("fluids"));
+ automation.load(tag.getCompound("automation"), registries);
+ if (hasTank() && tag.contains("fluids"))
+ fluids.deserializeNBT(registries, tag.getCompound("fluids"));
features.load(registries.createSerializationContext(NbtOps.INSTANCE), tag.get("features"));
}
diff --git a/src/companion/java/zone/moddev/mc/companion/RecoveryCapsuleItem.java b/src/companion/java/zone/moddev/mc/companion/RecoveryCapsuleItem.java
new file mode 100644
index 00000000..333e043e
--- /dev/null
+++ b/src/companion/java/zone/moddev/mc/companion/RecoveryCapsuleItem.java
@@ -0,0 +1,97 @@
+package zone.moddev.mc.companion;
+
+import java.util.List;
+
+import net.minecraft.core.component.DataComponents;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.nbt.Tag;
+import net.minecraft.network.chat.Component;
+import net.minecraft.world.item.*;
+import net.minecraft.world.item.component.CustomData;
+
+import net.neoforged.neoforge.fluids.FluidStack;
+import net.neoforged.neoforge.fluids.capability.IFluidHandler;
+
+import zone.moddev.mc.lib.api.automation.ResourceType;
+
+/** Concrete companion content; the library hands its host only the pending resource data. */
+public final class RecoveryCapsuleItem extends Item {
+ private static final String KEY = "mmdlib_recovery";
+
+ public RecoveryCapsuleItem() {
+ super(new Item.Properties().stacksTo(1));
+ }
+
+ public static ItemStack create(CompoundTag payload) {
+ var stack = new ItemStack(CompanionAddons.RECOVERY_CAPSULE.get());
+ var data = new CompoundTag();
+ data.put(KEY, payload.copy());
+ stack.set(DataComponents.CUSTOM_DATA, CustomData.of(data));
+ return stack;
+ }
+
+ public static CompoundTag contents(ItemStack stack) {
+ return stack
+ .getOrDefault(DataComponents.CUSTOM_DATA, CustomData.EMPTY)
+ .copyTag()
+ .getCompound(KEY);
+ }
+
+ /**
+ * Partial restoration leaves every unaccepted resource on the same item, including in creative.
+ */
+ public static boolean restore(ItemStack stack, ProcessorEntity target) {
+ if (!stack.is(CompanionAddons.RECOVERY_CAPSULE.get())
+ || target.getLevel() == null
+ || target.getLevel().isClientSide) return false;
+ var outer = stack.getOrDefault(DataComponents.CUSTOM_DATA, CustomData.EMPTY).copyTag();
+ var payload = outer.getCompound(KEY);
+ var resources = payload.getList("resources", Tag.TAG_COMPOUND);
+ boolean changed = false;
+ for (int i = resources.size() - 1; i >= 0; i--) {
+ var entry = resources.getCompound(i);
+ var data = entry.getCompound("data");
+ String type = entry.getString("type");
+ if (type.equals(ResourceType.FLUIDS.id().toString())) {
+ var fluid =
+ FluidStack.parseOptional(target.getLevel().registryAccess(), data.getCompound("fluid"));
+ int moved = target.fluids.fill(fluid, IFluidHandler.FluidAction.EXECUTE);
+ if (moved > 0) {
+ changed = true;
+ fluid.shrink(moved);
+ if (fluid.isEmpty()) data.remove("fluid");
+ else data.put("fluid", fluid.save(target.getLevel().registryAccess()));
+ }
+ } else if (type.equals(ResourceType.ENERGY.id().toString())) {
+ int amount = data.getInt("energy");
+ int moved = (int) target.energy.insert(amount, false);
+ if (moved > 0) {
+ changed = true;
+ if (moved == amount) data.remove("energy");
+ else data.putInt("energy", amount - moved);
+ }
+ }
+ if (data.isEmpty()) resources.remove(i);
+ }
+ long refund = Math.max(0, payload.getLong("refund"));
+ long moved = target.energy.insert(refund, false);
+ if (moved > 0) changed = true;
+ if (!changed) return false;
+ if (refund == moved) payload.remove("refund");
+ else payload.putLong("refund", refund - moved);
+ if (resources.isEmpty()) payload.remove("resources");
+ if (payload.isEmpty()) stack.shrink(1);
+ else {
+ outer.put(KEY, payload);
+ stack.set(DataComponents.CUSTOM_DATA, CustomData.of(outer));
+ }
+ target.setChanged();
+ return true;
+ }
+
+ @Override
+ public void appendHoverText(
+ ItemStack stack, TooltipContext context, List lines, TooltipFlag flag) {
+ lines.add(Component.translatable("item.mmdlib_companion.recovery_capsule.help"));
+ }
+}
diff --git a/src/companion/java/zone/moddev/mc/companion/client/CompanionWidgetsClient.java b/src/companion/java/zone/moddev/mc/companion/client/CompanionWidgetsClient.java
index 0212392c..d347bfab 100644
--- a/src/companion/java/zone/moddev/mc/companion/client/CompanionWidgetsClient.java
+++ b/src/companion/java/zone/moddev/mc/companion/client/CompanionWidgetsClient.java
@@ -17,6 +17,55 @@ private CompanionWidgetsClient() {}
@SubscribeEvent
public static void setup(FMLClientSetupEvent event) {
+ zone.moddev.mc.lib.gui.client.FilterWidgets.register(
+ zone.moddev.mc.companion.CompanionAddons.TAG_MODE,
+ access ->
+ new zone.moddev.mc.lib.gui.client.FilterWidgets.Instance() {
+ public void render(
+ net.minecraft.client.gui.GuiGraphics g,
+ zone.moddev.mc.lib.gui.layout.Rect r,
+ net.minecraft.nbt.CompoundTag config,
+ int mx,
+ int my) {
+ var button = new zone.moddev.mc.lib.gui.layout.Rect(r.x(), r.y(), 14, 14);
+ g.blitSprite(
+ net.minecraft.resources.ResourceLocation.parse(
+ button.contains(mx, my)
+ ? "mmdlib:button_flat_highlighted"
+ : "mmdlib:button_flat"),
+ button.x(),
+ button.y(),
+ button.width(),
+ button.height());
+ g.blitSprite(
+ net.minecraft.resources.ResourceLocation.parse(
+ config.getBoolean("deny")
+ ? "mmdlib:icon_filter_blacklist"
+ : "mmdlib:icon_filter_whitelist"),
+ r.x() + 2,
+ r.y() + 2,
+ 10,
+ 10);
+ }
+
+ public net.minecraft.network.chat.Component tooltip() {
+ return net.minecraft.network.chat.Component.translatable(
+ access.configuration().get().getBoolean("deny")
+ ? "screen.mmdlib_companion.filter.blacklist"
+ : "screen.mmdlib_companion.filter.whitelist");
+ }
+
+ public boolean click(double x, double y, int button) {
+ if (!access.pending().getAsBoolean())
+ access.submit().accept(!access.configuration().get().getBoolean("deny"));
+ return true;
+ }
+
+ public boolean key(int key, int scan, int modifiers) {
+ if (key == 257 || key == 32) return click(0, 0, 0);
+ return false;
+ }
+ });
WidgetRenderers.GLOBAL.registerInteractive(CompanionWidgets.NAME_EDITOR, NameEditor::new);
WidgetRenderers.GLOBAL.registerInteractive(CompanionWidgets.RESERVE_SLIDER, ReserveSlider::new);
WidgetRenderers.GLOBAL.registerInteractive(CompanionWidgets.LANE_SELECTOR, LaneSelector::new);
diff --git a/src/companion/resources/assets/mmdlib_companion/blockstates/logistics.json b/src/companion/resources/assets/mmdlib_companion/blockstates/logistics.json
new file mode 100644
index 00000000..5d17bc0f
--- /dev/null
+++ b/src/companion/resources/assets/mmdlib_companion/blockstates/logistics.json
@@ -0,0 +1,7 @@
+{
+ "variants": {
+ "": {
+ "model": "mmdlib_companion:block/logistics"
+ }
+ }
+}
diff --git a/src/companion/resources/assets/mmdlib_companion/lang/en_us.json b/src/companion/resources/assets/mmdlib_companion/lang/en_us.json
index 49849e3d..13f3285a 100644
--- a/src/companion/resources/assets/mmdlib_companion/lang/en_us.json
+++ b/src/companion/resources/assets/mmdlib_companion/lang/en_us.json
@@ -48,5 +48,26 @@
"screen.mmdlib_companion.tank.refused": "Cannot transfer fluid. The tank holds water or lava, one type at a time, up to 4 buckets.",
"screen.mmdlib_companion.info.crusher": "*Crusher*\n\nCrush ore into raw materials, or stone into gravel and sand.\n\nPlace a power source beside the machine. Pause to release reserved items.\n\nThe left tank holds 4 buckets of water or lava for display. Click the gauge with a fluid container on the cursor to fill or drain it, or right-click the block with a bucket. Empty the tank before changing fluids. Crushing does not use the fluid.",
"screen.mmdlib_companion.info.transmuter": "*Iron to gold*\n\nInsert one iron ingot. The same slot holds the gold when the cycle finishes.\n\nSupply power from an adjacent block. Pause to release the input.",
- "screen.mmdlib_companion.info.dual_crusher": "*Dual crusher*\n\nTwo lanes share the input and output slots. Each lane reserves its own items and space.\n\nPause either lane independently to release its share."
+ "screen.mmdlib_companion.info.dual_crusher": "*Dual crusher*\n\nTwo lanes share the input and output slots. Each lane reserves its own items and space.\n\nPause either lane independently to release its share.",
+ "item.mmdlib_companion.mechanical_puller": "Mechanical Puller",
+ "item.mmdlib_companion.mechanical_pusher": "Mechanical Pusher",
+ "item.mmdlib_companion.input_pump": "Input Pump",
+ "item.mmdlib_companion.output_pump": "Output Pump",
+ "item.mmdlib_companion.import_relay": "Import Relay",
+ "item.mmdlib_companion.export_relay": "Export Relay",
+ "item.mmdlib_companion.item_filter": "Item Filter",
+ "item.mmdlib_companion.large_item_filter": "Expanded Item Filter",
+ "item.mmdlib_companion.tag_filter": "Tag Filter",
+ "item.mmdlib_companion.fluid_filter": "Fluid Filter",
+ "block.mmdlib_companion.logistics": "Logistics Workbench",
+ "screen.mmdlib_companion.group.input": "Item input",
+ "screen.mmdlib_companion.group.output": "Item output",
+ "screen.mmdlib_companion.group.energy": "Energy",
+ "screen.mmdlib_companion.group.tank": "Fluid tank %s",
+ "screen.mmdlib_companion.filter.deny": "Toggle allow-list / deny-list",
+ "screen.mmdlib_companion.filter.whitelist": "Whitelist: allow matching entries (click for blacklist)",
+ "screen.mmdlib_companion.filter.blacklist": "Blacklist: block matching entries (click for whitelist)",
+ "screen.mmdlib_companion.filter.tag": "Item tag (namespace:path); Enter to apply",
+ "item.mmdlib_companion.recovery_capsule": "Recovery Capsule",
+ "item.mmdlib_companion.recovery_capsule.help": "Use on a companion machine to restore pending fluids and energy. Any remainder stays in the capsule."
}
diff --git a/src/companion/resources/assets/mmdlib_companion/models/block/logistics.json b/src/companion/resources/assets/mmdlib_companion/models/block/logistics.json
new file mode 100644
index 00000000..f699c048
--- /dev/null
+++ b/src/companion/resources/assets/mmdlib_companion/models/block/logistics.json
@@ -0,0 +1,6 @@
+{
+ "parent": "minecraft:block/cube_all",
+ "textures": {
+ "all": "minecraft:block/copper_block"
+ }
+}
diff --git a/src/companion/resources/assets/mmdlib_companion/models/item/export_relay.json b/src/companion/resources/assets/mmdlib_companion/models/item/export_relay.json
new file mode 100644
index 00000000..a6402781
--- /dev/null
+++ b/src/companion/resources/assets/mmdlib_companion/models/item/export_relay.json
@@ -0,0 +1,6 @@
+{
+ "parent": "minecraft:item/generated",
+ "textures": {
+ "layer0": "minecraft:item/repeater"
+ }
+}
diff --git a/src/companion/resources/assets/mmdlib_companion/models/item/fluid_filter.json b/src/companion/resources/assets/mmdlib_companion/models/item/fluid_filter.json
new file mode 100644
index 00000000..9b422900
--- /dev/null
+++ b/src/companion/resources/assets/mmdlib_companion/models/item/fluid_filter.json
@@ -0,0 +1,6 @@
+{
+ "parent": "minecraft:item/generated",
+ "textures": {
+ "layer0": "minecraft:item/glass_bottle"
+ }
+}
diff --git a/src/companion/resources/assets/mmdlib_companion/models/item/import_relay.json b/src/companion/resources/assets/mmdlib_companion/models/item/import_relay.json
new file mode 100644
index 00000000..724d0a33
--- /dev/null
+++ b/src/companion/resources/assets/mmdlib_companion/models/item/import_relay.json
@@ -0,0 +1,6 @@
+{
+ "parent": "minecraft:item/generated",
+ "textures": {
+ "layer0": "minecraft:item/redstone"
+ }
+}
diff --git a/src/companion/resources/assets/mmdlib_companion/models/item/input_pump.json b/src/companion/resources/assets/mmdlib_companion/models/item/input_pump.json
new file mode 100644
index 00000000..eb3c09c9
--- /dev/null
+++ b/src/companion/resources/assets/mmdlib_companion/models/item/input_pump.json
@@ -0,0 +1,6 @@
+{
+ "parent": "minecraft:item/generated",
+ "textures": {
+ "layer0": "minecraft:item/water_bucket"
+ }
+}
diff --git a/src/companion/resources/assets/mmdlib_companion/models/item/item_filter.json b/src/companion/resources/assets/mmdlib_companion/models/item/item_filter.json
new file mode 100644
index 00000000..1fa971f1
--- /dev/null
+++ b/src/companion/resources/assets/mmdlib_companion/models/item/item_filter.json
@@ -0,0 +1,6 @@
+{
+ "parent": "minecraft:item/generated",
+ "textures": {
+ "layer0": "minecraft:item/paper"
+ }
+}
diff --git a/src/companion/resources/assets/mmdlib_companion/models/item/large_item_filter.json b/src/companion/resources/assets/mmdlib_companion/models/item/large_item_filter.json
new file mode 100644
index 00000000..952787b8
--- /dev/null
+++ b/src/companion/resources/assets/mmdlib_companion/models/item/large_item_filter.json
@@ -0,0 +1,6 @@
+{
+ "parent": "minecraft:item/generated",
+ "textures": {
+ "layer0": "minecraft:item/map"
+ }
+}
diff --git a/src/companion/resources/assets/mmdlib_companion/models/item/logistics.json b/src/companion/resources/assets/mmdlib_companion/models/item/logistics.json
new file mode 100644
index 00000000..8bb53b5b
--- /dev/null
+++ b/src/companion/resources/assets/mmdlib_companion/models/item/logistics.json
@@ -0,0 +1,3 @@
+{
+ "parent": "mmdlib_companion:block/logistics"
+}
diff --git a/src/companion/resources/assets/mmdlib_companion/models/item/mechanical_puller.json b/src/companion/resources/assets/mmdlib_companion/models/item/mechanical_puller.json
new file mode 100644
index 00000000..499aca18
--- /dev/null
+++ b/src/companion/resources/assets/mmdlib_companion/models/item/mechanical_puller.json
@@ -0,0 +1,6 @@
+{
+ "parent": "minecraft:item/generated",
+ "textures": {
+ "layer0": "minecraft:item/hopper"
+ }
+}
diff --git a/src/companion/resources/assets/mmdlib_companion/models/item/mechanical_pusher.json b/src/companion/resources/assets/mmdlib_companion/models/item/mechanical_pusher.json
new file mode 100644
index 00000000..9697ac8f
--- /dev/null
+++ b/src/companion/resources/assets/mmdlib_companion/models/item/mechanical_pusher.json
@@ -0,0 +1,6 @@
+{
+ "parent": "minecraft:item/generated",
+ "textures": {
+ "layer0": "minecraft:block/piston_side"
+ }
+}
diff --git a/src/companion/resources/assets/mmdlib_companion/models/item/output_pump.json b/src/companion/resources/assets/mmdlib_companion/models/item/output_pump.json
new file mode 100644
index 00000000..12186178
--- /dev/null
+++ b/src/companion/resources/assets/mmdlib_companion/models/item/output_pump.json
@@ -0,0 +1,6 @@
+{
+ "parent": "minecraft:item/generated",
+ "textures": {
+ "layer0": "minecraft:item/bucket"
+ }
+}
diff --git a/src/companion/resources/assets/mmdlib_companion/models/item/recovery_capsule.json b/src/companion/resources/assets/mmdlib_companion/models/item/recovery_capsule.json
new file mode 100644
index 00000000..f6aee214
--- /dev/null
+++ b/src/companion/resources/assets/mmdlib_companion/models/item/recovery_capsule.json
@@ -0,0 +1,4 @@
+{
+ "parent": "minecraft:item/generated",
+ "textures": { "layer0": "minecraft:item/ender_pearl" }
+}
diff --git a/src/companion/resources/assets/mmdlib_companion/models/item/tag_filter.json b/src/companion/resources/assets/mmdlib_companion/models/item/tag_filter.json
new file mode 100644
index 00000000..61969bf8
--- /dev/null
+++ b/src/companion/resources/assets/mmdlib_companion/models/item/tag_filter.json
@@ -0,0 +1,6 @@
+{
+ "parent": "minecraft:item/generated",
+ "textures": {
+ "layer0": "minecraft:item/name_tag"
+ }
+}
diff --git a/src/companion/resources/data/mmdlib_companion/loot_table/blocks/logistics.json b/src/companion/resources/data/mmdlib_companion/loot_table/blocks/logistics.json
new file mode 100644
index 00000000..87822e48
--- /dev/null
+++ b/src/companion/resources/data/mmdlib_companion/loot_table/blocks/logistics.json
@@ -0,0 +1,19 @@
+{
+ "type": "minecraft:block",
+ "pools": [
+ {
+ "rolls": 1,
+ "entries": [
+ {
+ "type": "minecraft:item",
+ "name": "mmdlib_companion:logistics"
+ }
+ ],
+ "conditions": [
+ {
+ "condition": "minecraft:survives_explosion"
+ }
+ ]
+ }
+ ]
+}
diff --git a/src/companion/resources/mmdlib/screen/crusher.xml b/src/companion/resources/mmdlib/screen/crusher.xml
index d535146e..624cf681 100644
--- a/src/companion/resources/mmdlib/screen/crusher.xml
+++ b/src/companion/resources/mmdlib/screen/crusher.xml
@@ -8,7 +8,7 @@
tooltip="screen.mmdlib_companion.tank.tip" tooltip-fallback="Water or lava: {value} / {max} mB. Click with a fluid container to transfer."
on-click="mmdlib_companion:tank_container" on-right-click="mmdlib_companion:tank_container"/>
-
+
-
+
@@ -28,14 +28,6 @@
tooltip="screen.mmdlib_companion.settings.tip" tooltip-fallback="Edit the machine name, energy reserve and lane state."
shows="panel:settings"/>
-
-
@@ -51,5 +43,15 @@
+
+
+
+
diff --git a/src/companion/resources/mmdlib/screen/dual_crusher.xml b/src/companion/resources/mmdlib/screen/dual_crusher.xml
index de6e01d5..5bb5390c 100644
--- a/src/companion/resources/mmdlib/screen/dual_crusher.xml
+++ b/src/companion/resources/mmdlib/screen/dual_crusher.xml
@@ -3,8 +3,9 @@
-
+
+
@@ -13,26 +14,26 @@
+
-
+
-
-
-
-
-
-
+
+
+
+
+
diff --git a/src/companion/resources/mmdlib/screen/logistics.xml b/src/companion/resources/mmdlib/screen/logistics.xml
new file mode 100644
index 00000000..832a3c7b
--- /dev/null
+++ b/src/companion/resources/mmdlib/screen/logistics.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/companion/resources/mmdlib/screen/transmuter.xml b/src/companion/resources/mmdlib/screen/transmuter.xml
index 6d46d8ef..e2781353 100644
--- a/src/companion/resources/mmdlib/screen/transmuter.xml
+++ b/src/companion/resources/mmdlib/screen/transmuter.xml
@@ -4,22 +4,21 @@
-
+
-
-
-
-
-
+
+
+
+
diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/AutomationClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/AutomationClientSmoke.java
new file mode 100644
index 00000000..e9c5e064
--- /dev/null
+++ b/src/companionTest/java/zone/moddev/mc/companion/test/AutomationClientSmoke.java
@@ -0,0 +1,281 @@
+package zone.moddev.mc.companion.test;
+
+import net.minecraft.client.Minecraft;
+import net.minecraft.client.Screenshot;
+import net.minecraft.client.gui.GuiGraphics;
+import net.minecraft.world.item.ItemStack;
+
+import zone.moddev.mc.companion.*;
+import zone.moddev.mc.lib.api.capabilities.IoMode;
+import zone.moddev.mc.lib.api.capabilities.RelativeFace;
+import zone.moddev.mc.lib.gui.client.MachineScreen;
+import zone.moddev.mc.lib.gui.layout.AutomationLayout;
+import zone.moddev.mc.lib.gui.layout.ScreenLayout;
+
+/** Real client/server round trips, screenshots and item-owned editor transitions. */
+final class AutomationClientSmoke {
+ private static int stage;
+ private static int compactHeight;
+
+ private static void toggleFilter(MachineScreen screen) {
+ var drawer = screen.exclusions().getLast();
+ var toggle = AutomationLayout.filterToggle(0).offset(drawer.x(), drawer.y());
+ if (!screen.mouseClicked(toggle.x() + toggle.width() / 2, toggle.y() + toggle.height() / 2, 0))
+ throw new AssertionError("Filter toggle bar accepts clicks");
+ }
+
+ static boolean renderTooltipCapture(MachineScreen screen, GuiGraphics graphics, float partial) {
+ if (stage == 2 && !screen.getMenu().automation.filter(0, 0).isEmpty()) {
+ var slot =
+ screen
+ .getMenu()
+ .slots
+ .get(
+ screen.getMenu().automation.slotStart()
+ + zone.moddev.mc.lib.api.automation.AutomationInventory.filterSlot(0, 0));
+ screen.render(graphics, screen.guiLeft() + slot.x + 6, screen.guiTop() + slot.y + 6, partial);
+ return true;
+ }
+ if (stage != 8 || !screen.getMenu().automation.open()) return false;
+ var drawer = screen.exclusions().getLast();
+ screen.renderWithTooltip(graphics, drawer.x() + 81, drawer.y() + 52, partial);
+ return true;
+ }
+
+ static boolean run(MachineScreen screen) {
+ var mc = Minecraft.getInstance();
+ var menu = screen.getMenu();
+ var a = menu.automation;
+ if (screen.guiLeft() != (screen.width - screen.layout.panel().width()) / 2
+ || screen.guiTop() != (screen.height - screen.layout.panel().height()) / 2)
+ throw new AssertionError("Drawers must never move the centred main panel");
+ if (a.pending()) return false;
+ if (stage == 0) {
+ if (a.inventory.groups().size() != 7) throw new AssertionError("Seven colour selectors");
+ var info =
+ (zone.moddev.mc.lib.gui.model.Element.Popout)
+ menu.bound.screen().find("info").orElseThrow();
+ if (info.edge() != zone.moddev.mc.lib.gui.model.Element.Edge.LEFT)
+ throw new AssertionError("Information stays on the left");
+ var colours = screen.layout.inventoryColors().orElseThrow();
+ if (!screen.mouseClicked(
+ screen.guiLeft() + colours.x() + 3, screen.guiTop() + colours.y() + 3, 0))
+ throw new AssertionError("Inventory colour bar opens the first displayed inventory");
+ stage++;
+ return false;
+ }
+ if (stage == 1) {
+ if (!a.open()) throw new AssertionError("Colour opens drawer");
+ if (a.selected() != 2)
+ throw new AssertionError("Leftmost tank appears before item inventories");
+ var drawer = screen.exclusions().getLast();
+ var info = menu.bound.screen().find("info").orElseThrow();
+ if (drawer.y() != screen.bounds(info).y())
+ throw new AssertionError("Information and configuration drawers must align at the top");
+ compactHeight = drawer.height();
+ if (compactHeight >= screen.panelBounds().height())
+ throw new AssertionError(
+ "The configuration drawer should fit its controls, not the main panel height");
+ Screenshot.grab(
+ mc.gameDirectory, "automation-seven-colours.png", mc.getMainRenderTarget(), m -> {});
+ a.send("select", 0, -1, "", 0, "");
+ mc.getSingleplayerServer()
+ .execute(
+ () -> {
+ var player = mc.getSingleplayerServer().getPlayerList().getPlayers().getFirst();
+ var machine =
+ (ProcessorEntity)
+ player.serverLevel().getBlockEntity(menu.blockEntity.getBlockPos());
+ machine
+ .automation
+ .slots()
+ .setStackInSlot(0, new ItemStack(CompanionAddons.PULLER.get()));
+ machine
+ .automation
+ .slots()
+ .setStackInSlot(4, new ItemStack(CompanionAddons.LARGE_FILTER.get()));
+ player.containerMenu.broadcastChanges();
+ });
+ stage++;
+ return false;
+ }
+ if (stage == 2) {
+ if (a.filter(0, 0).isEmpty()) return false;
+ Screenshot.grab(
+ mc.gameDirectory, "automation-filter-collapsed.png", mc.getMainRenderTarget(), m -> {});
+ toggleFilter(screen);
+ stage++;
+ return false;
+ }
+ if (stage == 3) {
+ if (a.editor() != 0) throw new AssertionError("Filter editor opens");
+ var exclusions = screen.exclusions();
+ if (exclusions.size() != 3)
+ throw new AssertionError("Filter must remain inside the existing drawer exclusion");
+ var drawer = exclusions.getLast();
+ if (drawer.y() != screen.guiTop() + ScreenLayout.TAB_PADDING
+ || drawer.height() <= compactHeight
+ || drawer.bottom() <= screen.panelBounds().bottom()
+ || drawer.bottom() > screen.drawerBottom())
+ throw new AssertionError(
+ "A large filter should grow the drawer downward within the screen");
+ Screenshot.grab(
+ mc.gameDirectory, "automation-filter-grid.png", mc.getMainRenderTarget(), m -> {});
+ screen.resize(mc, screen.width, 360);
+ var limited = screen.exclusions().getLast();
+ if (limited.bottom() != screen.drawerBottom())
+ throw new AssertionError("The growing drawer must stop at the screen margin");
+ if (!screen.mouseScrolled(limited.x() + 40, limited.bottom() - 20, 0, -2))
+ throw new AssertionError("The embedded filter grid must scroll inside the drawer");
+ a.send("edit", 0, 0, "deny", 0, "");
+ stage++;
+ return false;
+ }
+ if (stage == 4) {
+ Screenshot.grab(
+ mc.gameDirectory, "automation-filter-constrained.png", mc.getMainRenderTarget(), m -> {});
+ screen.resize(mc, mc.getWindow().getGuiScaledWidth(), mc.getWindow().getGuiScaledHeight());
+ var provider =
+ a.filter(0, 0).getCapability(zone.moddev.mc.lib.api.automation.AddonCapabilities.FILTER);
+ if (!provider.configuration(a.filter(0, 0)).getBoolean("deny"))
+ throw new AssertionError("Filter toggle synced");
+ mc.getSingleplayerServer()
+ .execute(
+ () -> {
+ var player = mc.getSingleplayerServer().getPlayerList().getPlayers().getFirst();
+ var machine =
+ (ProcessorEntity)
+ player.serverLevel().getBlockEntity(menu.blockEntity.getBlockPos());
+ machine
+ .automation
+ .slots()
+ .setStackInSlot(4, new ItemStack(CompanionAddons.TAG_FILTER.get()));
+ player.containerMenu.broadcastChanges();
+ });
+ stage++;
+ return false;
+ }
+ if (stage == 5) {
+ if (!a.filter(0, 0).is(CompanionAddons.TAG_FILTER.get()) || a.editor() != -1) return false;
+ toggleFilter(screen);
+ stage++;
+ return false;
+ }
+ if (stage == 6) {
+ if (screen.exclusions().getLast().height() >= screen.panelBounds().height())
+ throw new AssertionError("A smaller filter should shrink the drawer to its content");
+ Screenshot.grab(
+ mc.gameDirectory, "automation-filter-tag.png", mc.getMainRenderTarget(), m -> {});
+ a.send("select", 6, -1, "", 0, "");
+ mc.getSingleplayerServer()
+ .execute(
+ () -> {
+ var player = mc.getSingleplayerServer().getPlayerList().getPlayers().getFirst();
+ var machine =
+ (ProcessorEntity)
+ player.serverLevel().getBlockEntity(menu.blockEntity.getBlockPos());
+ var sides = machine.automation.group(6).sides();
+ sides.set(RelativeFace.FRONT, IoMode.INPUT);
+ sides.set(RelativeFace.LEFT, IoMode.OUTPUT);
+ sides.set(RelativeFace.RIGHT, IoMode.BOTH);
+ sides.set(RelativeFace.BOTTOM, IoMode.DISABLED);
+ sides.set(RelativeFace.TOP, IoMode.DISABLED);
+ sides.set(RelativeFace.BACK, IoMode.OUTPUT);
+ player.containerMenu.broadcastChanges();
+ });
+ stage++;
+ return false;
+ }
+ if (stage == 7) {
+ if (a.mode(6, RelativeFace.BOTTOM.ordinal()) != IoMode.DISABLED
+ || a.mode(6, RelativeFace.RIGHT.ordinal()) != IoMode.BOTH) return false;
+ if (a.selected() != 6 || a.editor() != -1)
+ throw new AssertionError("Selection closes old editor");
+ if (screen.exclusions().getLast().height() != compactHeight)
+ throw new AssertionError("Changing inventory should restore the compact drawer height");
+ Screenshot.grab(mc.gameDirectory, "automation-energy.png", mc.getMainRenderTarget(), m -> {});
+ Screenshot.grab(
+ mc.gameDirectory, "automation-side-colours.png", mc.getMainRenderTarget(), m -> {});
+ stage++;
+ return false;
+ }
+ if (stage == 8) {
+ Screenshot.grab(
+ mc.gameDirectory, "automation-side-tooltip.png", mc.getMainRenderTarget(), m -> {});
+ a.send("close", 6, -1, "", 0, "");
+ stage++;
+ return false;
+ }
+ if (stage == 9) {
+ mc.getSingleplayerServer()
+ .execute(
+ () -> {
+ var player = mc.getSingleplayerServer().getPlayerList().getPlayers().getFirst();
+ var machine =
+ (ProcessorEntity)
+ player.serverLevel().getBlockEntity(menu.blockEntity.getBlockPos());
+ var filter = new ItemStack(CompanionAddons.ITEM_FILTER.get());
+ var provider =
+ filter.getCapability(
+ zone.moddev.mc.lib.api.automation.AddonCapabilities.FILTER);
+ var config = provider.configuration(filter);
+ var entries = new net.minecraft.nbt.ListTag();
+ for (String item :
+ new String[] {
+ "minecraft:diamond",
+ "minecraft:dirt",
+ "minecraft:water_bucket",
+ "minecraft:glass"
+ }) entries.add(net.minecraft.nbt.StringTag.valueOf(item));
+ config.put("samples", entries);
+ provider.configure(filter, config);
+ machine.automation.slots().setStackInSlot(4, filter);
+ player.containerMenu.broadcastChanges();
+ });
+ a.send("select", 0, -1, "", 0, "");
+ stage++;
+ return false;
+ }
+ if (stage == 10) {
+ if (!a.filter(0, 0).is(CompanionAddons.ITEM_FILTER.get())) return false;
+ toggleFilter(screen);
+ stage++;
+ return false;
+ }
+ if (stage == 11) {
+ var provider =
+ a.filter(0, 0).getCapability(zone.moddev.mc.lib.api.automation.AddonCapabilities.FILTER);
+ var samples =
+ zone.moddev.mc.lib.api.automation.FilterControl.flatten(provider.controls(a.filter(0, 0)))
+ .stream()
+ .filter(c -> c instanceof zone.moddev.mc.lib.api.automation.FilterControl.Samples)
+ .map(c -> (zone.moddev.mc.lib.api.automation.FilterControl.Samples) c)
+ .findFirst()
+ .orElseThrow();
+ if (samples.count() != 8)
+ throw new AssertionError("Basic filter has two rows of four ghost cells");
+ Screenshot.grab(
+ mc.gameDirectory, "automation-filter-whitelist.png", mc.getMainRenderTarget(), m -> {});
+ var drawer = screen.exclusions().getLast();
+ if (!screen.mouseClicked(
+ drawer.x() + AutomationLayout.EDITOR_X + 10,
+ drawer.y() + AutomationLayout.EDITOR_Y + AutomationLayout.EDITOR_PADDING + 5,
+ 0)) throw new AssertionError("Header-free mode toggle accepts clicks");
+ stage++;
+ return false;
+ }
+ if (stage == 12) {
+ var provider =
+ a.filter(0, 0).getCapability(zone.moddev.mc.lib.api.automation.AddonCapabilities.FILTER);
+ if (!provider.configuration(a.filter(0, 0)).getBoolean("deny"))
+ throw new AssertionError(
+ "Mode toggle changes whitelist to blacklist without collapsing editor");
+ Screenshot.grab(
+ mc.gameDirectory, "automation-filter-blacklist.png", mc.getMainRenderTarget(), m -> {});
+ a.send("close", 0, -1, "", 0, "");
+ stage++;
+ return false;
+ }
+ return true;
+ }
+}
diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/AutomationGameTests.java b/src/companionTest/java/zone/moddev/mc/companion/test/AutomationGameTests.java
new file mode 100644
index 00000000..48465c32
--- /dev/null
+++ b/src/companionTest/java/zone/moddev/mc/companion/test/AutomationGameTests.java
@@ -0,0 +1,492 @@
+package zone.moddev.mc.companion.test;
+
+import net.minecraft.core.*;
+import net.minecraft.gametest.framework.*;
+import net.minecraft.world.item.*;
+import net.minecraft.world.level.block.Blocks;
+import net.minecraft.world.level.block.entity.ChestBlockEntity;
+
+import net.neoforged.neoforge.gametest.*;
+
+import zone.moddev.mc.companion.*;
+import zone.moddev.mc.lib.api.automation.*;
+import zone.moddev.mc.lib.api.capabilities.*;
+import zone.moddev.mc.lib.gui.runtime.*;
+
+@GameTestHolder(CompanionMod.ID)
+@PrefixGameTestTemplate(false)
+public final class AutomationGameTests {
+ private static ProcessorEntity place(GameTestHelper h) {
+ var pos = h.absolutePos(new BlockPos(2, 2, 2));
+ h.getLevel().setBlock(pos, CompanionMod.LOGISTICS.block.get().defaultBlockState(), 3);
+ return (ProcessorEntity) h.getLevel().getBlockEntity(pos);
+ }
+
+ private static void require(boolean b, String s) {
+ if (!b) throw new AssertionError(s);
+ }
+
+ @GameTest(template = "empty")
+ public static void fullInventoryDropsConfiguredFilterOnce(GameTestHelper h) {
+ var machine = place(h);
+ var a = machine.automation;
+ var player = h.makeMockServerPlayerInLevel();
+ for (int i = 0; i < player.getInventory().items.size(); i++)
+ player.getInventory().items.set(i, new ItemStack(Items.STONE, 64));
+ a.slots().setStackInSlot(0, new ItemStack(CompanionAddons.PULLER.get()));
+ a.slots().setStackInSlot(4, new ItemStack(CompanionAddons.ITEM_FILTER.get()));
+ long generation = a.generation(0, 0), revision = a.revision(0, 0);
+ a.edit(
+ 0,
+ 0,
+ generation,
+ revision,
+ "samples",
+ 0,
+ "sample",
+ new ItemStack(Items.DIAMOND),
+ h.getLevel().registryAccess());
+ // A same-type replacement still invalidates the editor and detaches its old child.
+ a.slots().setStackInSlot(0, new ItemStack(CompanionAddons.PULLER.get()));
+ require(
+ !a.edit(
+ 0,
+ 0,
+ generation,
+ revision,
+ "deny",
+ 0,
+ "",
+ ItemStack.EMPTY,
+ h.getLevel().registryAccess()),
+ "Old attachment cannot edit replacement");
+ a.recover(player);
+ a.recover(player);
+ var drops =
+ h.getLevel()
+ .getEntitiesOfClass(
+ net.minecraft.world.entity.item.ItemEntity.class,
+ new net.minecraft.world.phys.AABB(machine.getBlockPos()).inflate(1),
+ e -> e.getItem().is(CompanionAddons.ITEM_FILTER.get()));
+ require(
+ drops.size() == 1 && drops.getFirst().getItem().getCount() == 1,
+ "Exactly one dropped filter");
+ var filter = drops.getFirst().getItem();
+ require(
+ filter
+ .getCapability(AddonCapabilities.FILTER)
+ .matches(filter, new ItemStack(Items.DIAMOND), h.getLevel().registryAccess()),
+ "Dropped filter keeps configuration");
+ h.succeed();
+ }
+
+ @GameTest(template = "empty")
+ public static void typedCustomFilterActionValidatesPayload(GameTestHelper h) {
+ var machine = place(h);
+ var a = machine.automation;
+ a.slots().setStackInSlot(0, new ItemStack(CompanionAddons.PULLER.get()));
+ a.slots().setStackInSlot(4, new ItemStack(CompanionAddons.TAG_FILTER.get()));
+ long generation = a.generation(0, 0), revision = a.revision(0, 0);
+ require(
+ !a.edit(
+ 0,
+ 0,
+ generation,
+ revision,
+ "deny",
+ 0,
+ "42",
+ ItemStack.EMPTY,
+ h.getLevel().registryAccess()),
+ "Typed boolean refuses numeric action");
+ require(
+ !a.edit(
+ 0,
+ 0,
+ generation,
+ revision,
+ "missing",
+ 0,
+ "true",
+ ItemStack.EMPTY,
+ h.getLevel().registryAccess()),
+ "Undeclared control refused");
+ require(
+ a.edit(
+ 0,
+ 0,
+ generation,
+ revision,
+ "deny",
+ 0,
+ "true",
+ ItemStack.EMPTY,
+ h.getLevel().registryAccess()),
+ "Declared typed action accepted");
+ require(
+ !a.edit(
+ 0,
+ 0,
+ generation,
+ a.revision(0, 0),
+ "tag",
+ 0,
+ "bad tag!",
+ ItemStack.EMPTY,
+ h.getLevel().registryAccess()),
+ "Invalid tag refused atomically");
+ h.succeed();
+ }
+
+ @GameTest(template = "empty")
+ public static void refusedExecutionRetainsEscrowAcrossSave(GameTestHelper h) {
+ var from = new net.neoforged.neoforge.items.ItemStackHandler(1);
+ from.setStackInSlot(0, new ItemStack(Items.DIAMOND, 8));
+ var to =
+ new net.neoforged.neoforge.items.ItemStackHandler(1) {
+ @Override
+ public ItemStack insertItem(int slot, ItemStack stack, boolean simulate) {
+ return simulate ? ItemStack.EMPTY : stack;
+ }
+ };
+ var machine = place(h);
+ var escrow = machine.automation.transferState("input", 0);
+ var transport = AutomationTransfers.transport(ResourceType.ITEMS).orElseThrow();
+ require(
+ transport.move(from, to, 8, value -> true, escrow, h.getLevel().registryAccess()) == 0,
+ "No actual delivery");
+ require(from.getStackInSlot(0).isEmpty(), "Extraction staged");
+ var saved = machine.automation.save(h.getLevel().registryAccess());
+ machine.automation.load(saved, h.getLevel().registryAccess());
+ var accepting = new net.neoforged.neoforge.items.ItemStackHandler(1);
+ escrow = machine.automation.transferState("input", 0);
+ require(
+ transport.move(from, accepting, 3, value -> true, escrow, h.getLevel().registryAccess())
+ == 3,
+ "Bounded retry");
+ require(
+ transport.move(from, accepting, 8, value -> true, escrow, h.getLevel().registryAccess())
+ == 5,
+ "Remaining escrow delivered");
+ require(
+ accepting.getStackInSlot(0).getCount() == 8 && !escrow.contains("item"),
+ "No loss or duplication");
+ h.succeed();
+ }
+
+ @GameTest(template = "empty", timeoutTicks = 80)
+ public static void pumpsAndRelaysRespectIndependentTanks(GameTestHelper h) {
+ var machine = place(h);
+ var neighbour = machine.getBlockPos().relative(Direction.NORTH);
+ h.getLevel().setBlock(neighbour, CompanionMod.LOGISTICS.block.get().defaultBlockState(), 3);
+ var source = (ProcessorEntity) h.getLevel().getBlockEntity(neighbour);
+ source.energy.setStored(10_000);
+ machine.energy.setStored(1000);
+ source
+ .fluids
+ .tank(2)
+ .setFluid(
+ new net.neoforged.neoforge.fluids.FluidStack(
+ net.minecraft.world.level.material.Fluids.WATER, 500));
+ var a = machine.automation;
+ a.slots()
+ .setStackInSlot(
+ AutomationInventory.addonSlot(2, 0), new ItemStack(CompanionAddons.INPUT_PUMP.get()));
+ a.slots()
+ .setStackInSlot(
+ AutomationInventory.addonSlot(6, 0), new ItemStack(CompanionAddons.IMPORT_RELAY.get()));
+ for (var f : RelativeFace.values()) a.group(3).sides().set(f, IoMode.DISABLED);
+ var port = machine.fluids.view(i -> a.mode(ResourceType.FLUIDS, i, Direction.NORTH));
+ require(port.getTanks() == 4, "All four tank numbers stay visible");
+ h.runAfterDelay(
+ 35,
+ () -> {
+ require(
+ machine.fluids.getFluidInTank(0).getAmount() == 500, "Input pump uses its own group");
+ require(machine.fluids.getFluidInTank(1).isEmpty(), "Same-role tank is independent");
+ require(source.fluids.getFluidInTank(2).isEmpty(), "Output tank drained");
+ require(
+ machine.energy.stored() + source.energy.stored() == 10_500,
+ "Energy relay conserves energy; pump costs 500 RF");
+ h.succeed();
+ });
+ }
+
+ @GameTest(template = "empty")
+ public static void compatibilityAndFilterLifetime(GameTestHelper h) {
+ var machine = place(h);
+ var a = machine.automation;
+ require(a.groups().size() == 7, "Seven independent inventories");
+ require(a.accepts(0, new ItemStack(CompanionAddons.PULLER.get())), "Input accepts puller");
+ require(!a.accepts(0, new ItemStack(CompanionAddons.PUSHER.get())), "Input refuses pusher");
+ require(!a.accepts(0, new ItemStack(CompanionAddons.INPUT_PUMP.get())), "Items refuse pump");
+ require(!a.accepts(8, new ItemStack(CompanionAddons.PULLER.get())), "Output refuses puller");
+ a.slots().setStackInSlot(0, new ItemStack(CompanionAddons.PULLER.get()));
+ require(
+ a.accepts(4, new ItemStack(CompanionAddons.ITEM_FILTER.get())),
+ "Filter capability accepted");
+ require(
+ !a.accepts(4, new ItemStack(CompanionAddons.FLUID_FILTER.get())),
+ "Wrong filter resource refused");
+ a.slots().setStackInSlot(4, new ItemStack(CompanionAddons.ITEM_FILTER.get()));
+ long gen = a.generation(0, 0), rev = a.revision(0, 0);
+ require(
+ a.edit(
+ 0,
+ 0,
+ gen,
+ rev,
+ "samples",
+ 0,
+ "sample",
+ new ItemStack(Items.DIAMOND),
+ h.getLevel().registryAccess()),
+ "Sample edit");
+ require(
+ !a.edit(0, 0, gen, rev, "deny", 0, "", ItemStack.EMPTY, h.getLevel().registryAccess()),
+ "Stale revision refused");
+ var filter = a.filter(0, 0);
+ var provider = filter.getCapability(AddonCapabilities.FILTER);
+ require(
+ provider.matches(filter, new ItemStack(Items.DIAMOND), h.getLevel().registryAccess()),
+ "Configured predicate");
+ require(
+ !provider.matches(filter, new ItemStack(Items.DIRT), h.getLevel().registryAccess()),
+ "Other item blocked");
+ var saved = a.save(h.getLevel().registryAccess());
+ a.load(saved, h.getLevel().registryAccess());
+ a.load(saved, h.getLevel().registryAccess());
+ require(a.filter(0, 0).getCount() == 1, "Reload does not duplicate filter");
+ var player = h.makeMockServerPlayerInLevel();
+ a.slots().extractItem(0, 1, false);
+ a.recover(player);
+ require(a.filter(0, 0).isEmpty(), "Parent extraction detaches child");
+ require(
+ player.getInventory().countItem(CompanionAddons.ITEM_FILTER.get()) == 1,
+ "Filter returned exactly once");
+ a.recover(player);
+ require(
+ player.getInventory().countItem(CompanionAddons.ITEM_FILTER.get()) == 1,
+ "Recovery is idempotent");
+ h.succeed();
+ }
+
+ @GameTest(template = "empty")
+ public static void menuActionsAreSessionAndGenerationBound(GameTestHelper h) {
+ var machine = place(h);
+ var player = h.makeMockServerPlayerInLevel();
+ player.setPos(
+ machine.getBlockPos().getX(), machine.getBlockPos().getY(), machine.getBlockPos().getZ());
+ var menu = new MachineMenu(71, player.getInventory(), machine);
+ player.containerMenu = menu;
+ var a = menu.automation;
+ a.action(
+ player,
+ new AutomationNetwork.Action(
+ 71, menu.widgetSession() + 1, 1, "select", 0, -1, 0, 0, "", 0, ""));
+ require(!a.open(), "Wrong session rejected");
+ a.action(
+ player,
+ new AutomationNetwork.Action(
+ 71, menu.widgetSession(), 1, "select", 0, -1, 0, 0, "", 0, ""));
+ require(
+ a.open() && menu.slots.get(2).isActive() && !menu.slots.get(10).isActive(),
+ "Only selected inventory active");
+ a.action(
+ player,
+ new AutomationNetwork.Action(
+ 71, menu.widgetSession(), 2, "face", 0, -1, 0, 0, "OUTPUT", 0, ""));
+ require(
+ machine.automation.group(0).sides().get(RelativeFace.FRONT) == IoMode.INPUT,
+ "Stale face refused");
+ a.action(
+ player,
+ new AutomationNetwork.Action(
+ 71, menu.widgetSession(), 3, "face", 0, -1, 0, 0, "INPUT", 0, ""));
+ require(
+ machine.automation.group(0).sides().get(RelativeFace.FRONT) == IoMode.DISABLED,
+ "Input cycles straight to disabled");
+ a.action(
+ player,
+ new AutomationNetwork.Action(
+ 71, menu.widgetSession(), 3, "select", 1, -1, 0, 0, "", 0, ""));
+ require(a.selected() == 0, "Replay refused");
+ player.containerMenu = player.inventoryMenu;
+ h.succeed();
+ }
+
+ @GameTest(template = "empty", timeoutTicks = 100)
+ public static void outputAddonsTransferItemsFluidsAndEnergyThroughFilters(GameTestHelper h) {
+ var machine = place(h);
+ var pos = machine.getBlockPos().relative(Direction.NORTH);
+ h.getLevel().setBlock(pos, CompanionMod.LOGISTICS.block.get().defaultBlockState(), 3);
+ var target = (ProcessorEntity) h.getLevel().getBlockEntity(pos);
+ machine.energy.setStored(10_000);
+ // Leave room for one export batch without draining the source's powered item/fluid addons.
+ target.energy.setStored(target.energy.capacity() - 1000);
+ long totalEnergy = machine.energy.stored() + target.energy.stored();
+ machine.items.setStackInSlot(1, new ItemStack(Items.DIAMOND, 16));
+ machine
+ .fluids
+ .tank(2)
+ .setFluid(
+ new net.neoforged.neoforge.fluids.FluidStack(
+ net.minecraft.world.level.material.Fluids.WATER, 500));
+ var a = machine.automation;
+ a.slots()
+ .setStackInSlot(
+ AutomationInventory.addonSlot(1, 0), new ItemStack(CompanionAddons.PUSHER.get()));
+ a.slots()
+ .setStackInSlot(
+ AutomationInventory.filterSlot(1, 0),
+ new ItemStack(CompanionAddons.LARGE_FILTER.get()));
+ a.slots()
+ .setStackInSlot(
+ AutomationInventory.addonSlot(4, 0), new ItemStack(CompanionAddons.OUTPUT_PUMP.get()));
+ a.slots()
+ .setStackInSlot(
+ AutomationInventory.filterSlot(4, 0),
+ new ItemStack(CompanionAddons.FLUID_FILTER.get()));
+ a.slots()
+ .setStackInSlot(
+ AutomationInventory.addonSlot(6, 0), new ItemStack(CompanionAddons.EXPORT_RELAY.get()));
+ sample(h, a, 1, 35, new ItemStack(Items.DIRT));
+ sample(h, a, 4, 0, new ItemStack(Items.LAVA_BUCKET));
+ h.runAfterDelay(
+ 25,
+ () -> {
+ require(
+ target.items.getStackInSlot(0).isEmpty() && target.fluids.getFluidInTank(0).isEmpty(),
+ "Item and fluid filters block disallowed exports");
+ require(
+ machine.energy.stored() + target.energy.stored() == totalEnergy
+ && target.energy.stored() == target.energy.capacity(),
+ "Export relay moves energy; refused item/fluid transfers spend no RF");
+ sample(h, a, 1, 35, new ItemStack(Items.DIAMOND));
+ sample(h, a, 4, 0, new ItemStack(Items.WATER_BUCKET));
+ });
+ h.runAfterDelay(
+ 70,
+ () -> {
+ require(
+ machine.items.getStackInSlot(1).isEmpty()
+ && target.items.getStackInSlot(0).getCount() == 16,
+ "Pusher exports every allowed item");
+ require(
+ machine.fluids.getFluidInTank(2).isEmpty()
+ && target.fluids.getFluidInTank(0).getAmount() == 500,
+ "Output pump exports the allowed fluid");
+ require(
+ machine.energy.stored() + target.energy.stored() == totalEnergy - 564,
+ "Only actual exports cost RF: 16 items at 4 RF and 500 mB at 1 RF");
+ h.succeed();
+ });
+ }
+
+ private static void sample(
+ GameTestHelper h, AutomationInventory a, int group, int entry, ItemStack sample) {
+ require(
+ a.edit(
+ group,
+ 0,
+ a.generation(group, 0),
+ a.revision(group, 0),
+ "samples",
+ entry,
+ "sample",
+ sample,
+ h.getLevel().registryAccess()),
+ "Filter sample accepted");
+ }
+
+ @GameTest(template = "empty", timeoutTicks = 80)
+ public static void tagFilterControlsRealPulling(GameTestHelper h) {
+ var machine = place(h);
+ var pos = machine.getBlockPos().relative(Direction.NORTH);
+ h.getLevel().setBlock(pos, Blocks.CHEST.defaultBlockState(), 3);
+ var chest = (ChestBlockEntity) h.getLevel().getBlockEntity(pos);
+ chest.setItem(0, new ItemStack(Items.DIRT, 8));
+ chest.setItem(1, new ItemStack(Items.OAK_PLANKS, 8));
+ var a = machine.automation;
+ a.slots().setStackInSlot(0, new ItemStack(CompanionAddons.PULLER.get()));
+ a.slots().setStackInSlot(4, new ItemStack(CompanionAddons.TAG_FILTER.get()));
+ require(
+ a.edit(
+ 0,
+ 0,
+ a.generation(0, 0),
+ a.revision(0, 0),
+ "tag",
+ 0,
+ "minecraft:planks",
+ ItemStack.EMPTY,
+ h.getLevel().registryAccess()),
+ "Tag filter configured");
+ h.runAfterDelay(
+ 22,
+ () -> {
+ require(machine.items.getStackInSlot(0).isEmpty(), "Unpowered puller cannot transfer");
+ machine.energy.setStored(1000);
+ });
+ h.runAfterDelay(
+ 65,
+ () -> {
+ require(
+ machine.items.getStackInSlot(0).is(Items.OAK_PLANKS)
+ && machine.items.getStackInSlot(0).getCount() == 8,
+ "Tag filter allows matching planks");
+ require(
+ chest.getItem(0).getCount() == 8 && chest.getItem(1).isEmpty(),
+ "Tag filter leaves dirt behind");
+ require(
+ machine.energy.stored() == 968, "Powered tag-filtered transfer pays actual RF cost");
+ h.succeed();
+ });
+ }
+
+ @GameTest(template = "empty", timeoutTicks = 100)
+ public static void poweredPullerHonoursFilterAndDisabledSides(GameTestHelper h) {
+ var machine = place(h);
+ var pos = machine.getBlockPos().relative(Direction.NORTH);
+ h.getLevel().setBlock(pos, Blocks.CHEST.defaultBlockState(), 3);
+ var chest = (ChestBlockEntity) h.getLevel().getBlockEntity(pos);
+ chest.setItem(0, new ItemStack(Items.DIRT, 16));
+ chest.setItem(1, new ItemStack(Items.DIAMOND, 16));
+ machine.energy.setStored(1000);
+ var a = machine.automation;
+ a.slots().setStackInSlot(0, new ItemStack(CompanionAddons.PULLER.get()));
+ a.slots().setStackInSlot(4, new ItemStack(CompanionAddons.ITEM_FILTER.get()));
+ a.edit(
+ 0,
+ 0,
+ a.generation(0, 0),
+ a.revision(0, 0),
+ "samples",
+ 0,
+ "sample",
+ new ItemStack(Items.DIAMOND),
+ h.getLevel().registryAccess());
+ for (var face : RelativeFace.values()) a.group(0).sides().set(face, IoMode.DISABLED);
+ h.runAfterDelay(
+ 22,
+ () -> {
+ require(
+ machine.items.getStackInSlot(0).isEmpty() && machine.energy.stored() == 1000,
+ "Disabled sides idle without spending RF");
+ a.group(0).sides().set(RelativeFace.FRONT, IoMode.INPUT);
+ });
+ h.runAfterDelay(
+ 65,
+ () -> {
+ require(
+ machine.items.getStackInSlot(0).is(Items.DIAMOND)
+ && machine.items.getStackInSlot(0).getCount() == 16,
+ "Allowed items pulled");
+ require(
+ chest.getItem(0).getCount() == 16 && chest.getItem(1).isEmpty(),
+ "Rejected items remain");
+ require(machine.energy.stored() == 936, "RF charged only for delivered items");
+ h.succeed();
+ });
+ }
+}
diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/AutomationRecoveryTests.java b/src/companionTest/java/zone/moddev/mc/companion/test/AutomationRecoveryTests.java
new file mode 100644
index 00000000..247e8f23
--- /dev/null
+++ b/src/companionTest/java/zone/moddev/mc/companion/test/AutomationRecoveryTests.java
@@ -0,0 +1,418 @@
+package zone.moddev.mc.companion.test;
+
+import java.util.List;
+
+import net.minecraft.core.*;
+import net.minecraft.gametest.framework.*;
+import net.minecraft.nbt.*;
+import net.minecraft.network.chat.Component;
+import net.minecraft.world.entity.item.ItemEntity;
+import net.minecraft.world.item.*;
+import net.minecraft.world.level.block.Blocks;
+import net.minecraft.world.level.material.Fluids;
+import net.minecraft.world.phys.AABB;
+
+import net.neoforged.neoforge.capabilities.Capabilities;
+import net.neoforged.neoforge.fluids.FluidStack;
+import net.neoforged.neoforge.fluids.capability.IFluidHandler.FluidAction;
+import net.neoforged.neoforge.fluids.capability.templates.FluidTank;
+import net.neoforged.neoforge.gametest.*;
+import net.neoforged.neoforge.items.ItemStackHandler;
+
+import zone.moddev.mc.companion.*;
+import zone.moddev.mc.lib.api.automation.*;
+import zone.moddev.mc.lib.api.capabilities.*;
+import zone.moddev.mc.lib.gui.runtime.MachineMenu;
+
+@GameTestHolder(CompanionMod.ID)
+@PrefixGameTestTemplate(false)
+public final class AutomationRecoveryTests {
+ private static void require(boolean ok, String message) {
+ if (!ok) throw new AssertionError(message);
+ }
+
+ private static ProcessorEntity place(GameTestHelper h, BlockPos pos) {
+ h.getLevel().setBlock(pos, CompanionMod.LOGISTICS.block.get().defaultBlockState(), 3);
+ return (ProcessorEntity) h.getLevel().getBlockEntity(pos);
+ }
+
+ private static ProcessorEntity place(GameTestHelper h) {
+ return place(h, h.absolutePos(new BlockPos(2, 2, 2)));
+ }
+
+ private static CompoundTag pending(AutomationInventory a, int group, boolean input) {
+ var state = a.transferState(a.group(group).id(), 0);
+ state.putBoolean("input", input);
+ state.putInt("face", RelativeFace.FRONT.ordinal());
+ state.putInt("batch", 4096);
+ state.putLong("cost", 0);
+ return state;
+ }
+
+ /** Stage real extractions into handlers that accept simulation but refuse execution. */
+ private static void stage(GameTestHelper h, AutomationInventory a, boolean input) {
+ var items = new ItemStackHandler(1);
+ items.setStackInSlot(0, new ItemStack(Items.DIAMOND, 8));
+ var refusedItems =
+ new ItemStackHandler(1) {
+ @Override
+ public ItemStack insertItem(int slot, ItemStack stack, boolean simulate) {
+ return simulate ? ItemStack.EMPTY : stack;
+ }
+ };
+ require(
+ AutomationTransfers.transport(ResourceType.ITEMS)
+ .orElseThrow()
+ .move(
+ items,
+ refusedItems,
+ 8,
+ value -> true,
+ pending(a, input ? 0 : 1, input),
+ h.getLevel().registryAccess())
+ == 0,
+ "Item delivery refused after extraction");
+ require(items.getStackInSlot(0).isEmpty(), "Pending items are owned only by escrow");
+ var fluids = new FluidTank(1000);
+ fluids.setFluid(new FluidStack(Fluids.WATER, 750));
+ var refusedFluids =
+ new FluidTank(1000) {
+ @Override
+ public int fill(FluidStack stack, FluidAction action) {
+ return action.simulate() ? stack.getAmount() : 0;
+ }
+ };
+ require(
+ AutomationTransfers.transport(ResourceType.FLUIDS)
+ .orElseThrow()
+ .move(
+ fluids,
+ refusedFluids,
+ 750,
+ value -> true,
+ pending(a, input ? 2 : 4, input),
+ h.getLevel().registryAccess())
+ == 0,
+ "Fluid delivery refused after extraction");
+ require(fluids.isEmpty(), "Pending fluid is owned only by escrow");
+ var energy = new EnergyBuffer(1000, 1000);
+ energy.setStored(750);
+ var refusedEnergy =
+ new EnergyBuffer(1000, 1000) {
+ @Override
+ public int receiveEnergy(int amount, boolean simulate) {
+ return simulate ? amount : 0;
+ }
+ };
+ require(
+ AutomationTransfers.transport(ResourceType.ENERGY)
+ .orElseThrow()
+ .move(
+ energy,
+ refusedEnergy,
+ 750,
+ value -> true,
+ pending(a, 6, input),
+ h.getLevel().registryAccess())
+ == 0,
+ "Energy delivery refused after extraction");
+ require(energy.stored() == 0, "Pending energy is owned only by escrow");
+ }
+
+ @GameTest(template = "empty")
+ public static void cachesInvalidateAndUnchangedInventoriesSkipRevalidation(GameTestHelper h) {
+ var machine = place(h);
+ var a = machine.automation;
+ var pos = machine.getBlockPos().north();
+ h.getLevel().setBlock(pos, Blocks.CHEST.defaultBlockState(), 3);
+ TestAutomationCapabilities.LOOKUPS.remove(pos);
+ for (int i = 0; i < 100; i++)
+ require(
+ a.neighbour(TestAutomationCapabilities.ITEMS, Direction.NORTH) != null,
+ "Cached neighbour exists");
+ require(
+ TestAutomationCapabilities.LOOKUPS.get(pos) == 1,
+ "Repeated queries resolve provider only once");
+ h.getLevel().invalidateCapabilities(pos);
+ a.neighbour(TestAutomationCapabilities.ITEMS, Direction.NORTH);
+ require(
+ TestAutomationCapabilities.LOOKUPS.get(pos) == 2,
+ "Explicit invalidation refreshes provider");
+ h.getLevel().destroyBlock(pos, false);
+ require(
+ a.neighbour(TestAutomationCapabilities.ITEMS, Direction.NORTH) == null,
+ "Removal invalidates cached neighbour");
+ h.getLevel().setBlock(pos, Blocks.CHEST.defaultBlockState(), 3);
+ require(
+ a.neighbour(TestAutomationCapabilities.ITEMS, Direction.NORTH) != null
+ && TestAutomationCapabilities.LOOKUPS.get(pos) == 3,
+ "Replacement supplies a fresh capability");
+ a.slots().setStackInSlot(0, new ItemStack(Items.BLAZE_ROD));
+ a.revalidate();
+ int specifications = TestAutomationCapabilities.SPECIFICATIONS.get();
+ long revision = a.viewRevision();
+ for (int i = 0; i < 100; i++) a.revalidate();
+ require(
+ TestAutomationCapabilities.SPECIFICATIONS.get() == specifications
+ && a.viewRevision() == revision,
+ "Unchanged inventory does not rescan addon capabilities or change its view revision");
+ a.invalidateAddons();
+ a.revalidate();
+ require(
+ TestAutomationCapabilities.SPECIFICATIONS.get() > specifications,
+ "Explicit invalidation rechecks definitions");
+ a.group(0).sides().set(RelativeFace.FRONT, IoMode.DISABLED);
+ require(
+ a.viewRevision() > revision
+ && a.mode(ResourceType.ITEMS, 0, Direction.NORTH) == IoMode.DISABLED
+ && a.mode(ResourceType.ITEMS, 1, Direction.NORTH) == IoMode.OUTPUT,
+ "Indexed membership preserves independent modes and notifies menu revision");
+ h.succeed();
+ }
+
+ @GameTest(template = "empty")
+ public static void unknownRecoveryPayloadAndFailedHandoffAreLossless(GameTestHelper h) {
+ var machine = place(h);
+ var a = machine.automation;
+ var state = a.transferState("removed_group", 0);
+ state.putString("resource", "absent_mod:gas");
+ state.putInt("amount", 1234);
+ state.put("item", new ItemStack(Items.DIAMOND).save(h.getLevel().registryAccess()));
+ var before = a.save(h.getLevel().registryAccess());
+ try {
+ a.dropAll(
+ payload -> {
+ throw new IllegalStateException("Recovery sink refused");
+ });
+ throw new AssertionError("Expected refused handoff");
+ } catch (IllegalStateException expected) {
+ require(
+ a.save(h.getLevel().registryAccess()).equals(before),
+ "Failed handoff leaves pending data intact");
+ }
+ var captured = new java.util.concurrent.atomic.AtomicReference();
+ a.dropAll(captured::set);
+ var entry = captured.get().getList("resources", Tag.TAG_COMPOUND).getCompound(0);
+ require(
+ entry.getString("type").equals("absent_mod:gas") && entry.getCompound("data").equals(state),
+ "Unknown transport schema is preserved verbatim");
+ require(
+ h.getLevel()
+ .getEntitiesOfClass(ItemEntity.class, new AABB(machine.getBlockPos()).inflate(2))
+ .isEmpty(),
+ "A custom payload's item field must not also be interpreted as a vanilla item drop");
+ var capsule = RecoveryCapsuleItem.create(captured.get());
+ require(
+ !RecoveryCapsuleItem.restore(capsule, machine) && !capsule.isEmpty(),
+ "Unsupported resources stay in the recovery capsule");
+ a.dropAll(
+ payload -> {
+ throw new AssertionError("Recovery payload handed off twice");
+ });
+ h.succeed();
+ }
+
+ @GameTest(template = "empty")
+ public static void inboundEscrowCompletesAfterSourceRemovalAndReload(GameTestHelper h) {
+ var machine = place(h);
+ var source = machine.getBlockPos().north();
+ h.getLevel().setBlock(source, Blocks.CHEST.defaultBlockState(), 3);
+ stage(h, machine.automation, true);
+ h.getLevel().destroyBlock(source, false);
+ var a = machine.automation;
+ a.load(a.save(h.getLevel().registryAccess()), h.getLevel().registryAccess());
+ for (var group : a.groups())
+ for (var face : RelativeFace.values()) group.sides().set(face, IoMode.DISABLED);
+ AutomationTransfers.tick(a, machine.energy);
+ AutomationTransfers.tick(a, machine.energy);
+ require(
+ machine.items.getStackInSlot(0).getCount() == 8,
+ "Owned inbound items need no source or enabled external side");
+ require(
+ machine.fluids.getFluidInTank(0).getAmount() == 750,
+ "Inbound fluid completes without original neighbour");
+ require(
+ machine.energy.stored() == 750, "Inbound energy completes once without original neighbour");
+ h.succeed();
+ }
+
+ @GameTest(template = "empty")
+ public static void outboundEscrowReroutesToAnotherEnabledNeighbour(GameTestHelper h) {
+ var machine = place(h);
+ var a = machine.automation;
+ var original = machine.getBlockPos().north();
+ place(h, original);
+ stage(h, a, false);
+ // Populate caches before breaking the destination.
+ require(
+ a.neighbour(Capabilities.ItemHandler.BLOCK, Direction.NORTH) != null,
+ "Original neighbour cached");
+ h.getLevel().destroyBlock(original, false);
+ AutomationTransfers.tick(a, machine.energy);
+ require(a.transferState("tank_2", 0).contains("fluid"), "No destination retains pending fluid");
+ a.load(a.save(h.getLevel().registryAccess()), h.getLevel().registryAccess());
+ var destination = place(h, machine.getBlockPos().east());
+ for (int g : new int[] {1, 4, 6}) {
+ for (var face : RelativeFace.values()) a.group(g).sides().set(face, IoMode.DISABLED);
+ a.group(g).sides().set(RelativeFace.of(Direction.NORTH, Direction.EAST), IoMode.OUTPUT);
+ }
+ AutomationTransfers.tick(a, machine.energy);
+ AutomationTransfers.tick(a, machine.energy);
+ require(
+ destination.items.getStackInSlot(0).getCount() == 8, "Pending items reroute exactly once");
+ require(
+ destination.fluids.getFluidInTank(0).getAmount() == 750,
+ "Pending fluid reroutes exactly once");
+ require(destination.energy.stored() == 750, "Pending energy reroutes exactly once");
+ require(machine.items.getStackInSlot(1).isEmpty(), "Rerouting never extracts a new batch");
+ h.succeed();
+ }
+
+ @GameTest(template = "empty")
+ public static void clippedRefundSurvivesSaveAndReturnsExactlyOnce(GameTestHelper h) {
+ var machine = place(h);
+ var power = new EnergyBuffer(1000, 1000);
+ power.setStored(1000);
+ var destination =
+ new ItemStackHandler(1) {
+ @Override
+ public ItemStack insertItem(int slot, ItemStack stack, boolean simulate) {
+ if (!simulate)
+ power.setStored(1000); // Reentrant handler fills the reserved refund space.
+ return stack;
+ }
+ };
+ var group =
+ new ResourceGroup<>(
+ "input",
+ Component.literal("Input"),
+ 0,
+ ResourceType.ITEMS,
+ IoMode.INPUT,
+ List.of(0),
+ List.of(),
+ new SideConfig(IoMode.INPUT));
+ group.bind(() -> Direction.NORTH, side -> destination);
+ var a = new AutomationInventory(machine, List.of(group));
+ var state = pending(a, 0, true);
+ state.put("item", new ItemStack(Items.DIAMOND, 8).save(h.getLevel().registryAccess()));
+ state.putInt("batch", 8);
+ state.putLong("cost", 4);
+ AutomationTransfers.tick(a, power);
+ require(
+ power.stored() == 1000 && a.pendingRefund() == 32,
+ "Rejected refund is retained, not clipped");
+ a.load(a.save(h.getLevel().registryAccess()), h.getLevel().registryAccess());
+ require(a.pendingRefund() == 32, "Refund persists through save/reload");
+ power.extract(100, false);
+ a.settleRefund(power);
+ a.settleRefund(power);
+ require(
+ power.stored() == 932 && a.pendingRefund() == 0,
+ "Refund returns once when room becomes available");
+ h.succeed();
+ }
+
+ @GameTest(template = "empty")
+ public static void breakingPreservesFluidsEnergyRefundsAndPartialCapsuleRecovery(
+ GameTestHelper h) {
+ var machine = place(h);
+ stage(h, machine.automation, false);
+ machine.energy.setStored(machine.energy.capacity());
+ machine.automation.refund(machine.energy, 50);
+ machine.automation.load(
+ machine.automation.save(h.getLevel().registryAccess()), h.getLevel().registryAccess());
+ h.getLevel().destroyBlock(machine.getBlockPos(), true);
+ machine.dropContents(h.getLevel(), machine.getBlockPos());
+ var drops =
+ h.getLevel()
+ .getEntitiesOfClass(ItemEntity.class, new AABB(machine.getBlockPos()).inflate(2));
+ var capsules =
+ drops.stream().filter(e -> e.getItem().is(CompanionAddons.RECOVERY_CAPSULE.get())).toList();
+ require(
+ capsules.size() == 1 && capsules.getFirst().getItem().getCount() == 1,
+ "Exactly one recovery capsule");
+ require(
+ drops.stream()
+ .filter(e -> e.getItem().is(Items.DIAMOND))
+ .mapToInt(e -> e.getItem().getCount())
+ .sum()
+ == 8,
+ "Pending items still drop normally exactly once");
+ var capsule =
+ ItemStack.parseOptional(
+ h.getLevel().registryAccess(),
+ (CompoundTag)
+ capsules.getFirst().getItem().saveOptional(h.getLevel().registryAccess()));
+ var target = place(h);
+ target.energy.setStored(target.energy.capacity());
+ for (int i = 0; i < 2; i++) target.fluids.tank(i).setFluid(new FluidStack(Fluids.WATER, 4000));
+ var before = RecoveryCapsuleItem.contents(capsule);
+ require(
+ !RecoveryCapsuleItem.restore(capsule, target)
+ && before.equals(RecoveryCapsuleItem.contents(capsule)),
+ "Full destination leaves recovery item unchanged");
+ target.fluids.tank(0).drain(250, FluidAction.EXECUTE);
+ target.energy.extract(125, false);
+ require(
+ RecoveryCapsuleItem.restore(capsule, target) && !capsule.isEmpty(),
+ "Partial recovery retains remainder");
+ var remaining = RecoveryCapsuleItem.contents(capsule);
+ require(remaining.getLong("refund") == 50, "Refund waits behind recovered energy without loss");
+ int fluid = 0, energy = 0;
+ for (var entry : remaining.getList("resources", Tag.TAG_COMPOUND)) {
+ var data = ((CompoundTag) entry).getCompound("data");
+ fluid +=
+ FluidStack.parseOptional(h.getLevel().registryAccess(), data.getCompound("fluid"))
+ .getAmount();
+ energy += data.getInt("energy");
+ }
+ require(
+ fluid == 500 && energy == 625,
+ "Partial capsule contains exact fluid and energy remainders");
+ target.fluids.tank(0).setFluid(FluidStack.EMPTY);
+ target.fluids.tank(1).setFluid(FluidStack.EMPTY);
+ target.energy.setStored(0);
+ require(
+ RecoveryCapsuleItem.restore(capsule, target) && capsule.isEmpty(),
+ "Fully restored capsule is consumed");
+ require(
+ target.fluids.getFluidInTank(0).getAmount() == 500 && target.energy.stored() == 675,
+ "All remaining fluid, transferred energy and refund are recoverable");
+ require(
+ !RecoveryCapsuleItem.restore(capsule, target),
+ "Consumed capsule cannot duplicate resources");
+ h.succeed();
+ }
+
+ @GameTest(template = "empty")
+ public static void unsidedEnergyIgnoresFaceModesAndSnapshotsExcludeAddonSlots(GameTestHelper h)
+ throws ReflectiveOperationException {
+ var pos = h.absolutePos(new BlockPos(2, 2, 2));
+ h.getLevel().setBlock(pos, CompanionMod.CRUSHER.block.get().defaultBlockState(), 3);
+ var machine = (ProcessorEntity) h.getLevel().getBlockEntity(pos);
+ machine.energy.setRates(1000, 1000);
+ machine.energy.setStored(1000);
+ var unsided = h.getLevel().getCapability(Capabilities.EnergyStorage.BLOCK, pos, null);
+ var sided = h.getLevel().getCapability(Capabilities.EnergyStorage.BLOCK, pos, Direction.NORTH);
+ require(
+ unsided.extractEnergy(100, false) == 100 && sided.extractEnergy(100, false) == 0,
+ "Unsided energy preserves buffer operations while sided access honours input role");
+ // Exercise a host with more underlying storage than its two displayed slots.
+ var field = ProcessorEntity.class.getDeclaredField("items");
+ field.setAccessible(true);
+ var original = machine.items;
+ try {
+ field.set(machine, new ItemStorage(5));
+ var player = h.makeMockServerPlayerInLevel();
+ var menu = new MachineMenu(91, player.getInventory(), machine);
+ require(
+ menu.automation.slotStart() == 2 && menu.reservationSnapshot().size() == 2,
+ "Reservation snapshot includes only displayed base slots, not hidden underlying slots or"
+ + " addon indices");
+ } finally {
+ field.set(machine, original);
+ }
+ h.succeed();
+ }
+}
diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/AutomationRoleTests.java b/src/companionTest/java/zone/moddev/mc/companion/test/AutomationRoleTests.java
new file mode 100644
index 00000000..803e932c
--- /dev/null
+++ b/src/companionTest/java/zone/moddev/mc/companion/test/AutomationRoleTests.java
@@ -0,0 +1,221 @@
+package zone.moddev.mc.companion.test;
+
+import java.util.*;
+
+import net.minecraft.core.BlockPos;
+import net.minecraft.gametest.framework.*;
+import net.minecraft.nbt.Tag;
+import net.minecraft.world.entity.item.ItemEntity;
+import net.minecraft.world.inventory.ClickType;
+import net.minecraft.world.item.*;
+import net.minecraft.world.phys.AABB;
+
+import net.neoforged.neoforge.gametest.*;
+import net.neoforged.neoforge.items.ItemStackHandler;
+
+import zone.moddev.mc.companion.*;
+import zone.moddev.mc.lib.api.automation.*;
+import zone.moddev.mc.lib.gui.runtime.*;
+
+@GameTestHolder(CompanionMod.ID)
+@PrefixGameTestTemplate(false)
+public final class AutomationRoleTests {
+ private static ProcessorEntity place(GameTestHelper h) {
+ var pos = h.absolutePos(new BlockPos(2, 2, 2));
+ h.getLevel().setBlock(pos, CompanionMod.LOGISTICS.block.get().defaultBlockState(), 3);
+ return (ProcessorEntity) h.getLevel().getBlockEntity(pos);
+ }
+
+ private static void require(boolean condition, String message) {
+ if (!condition) throw new AssertionError(message);
+ }
+
+ @GameTest(template = "empty")
+ public static void duplicateRolesRejectedByStorageAndMenu(GameTestHelper h) {
+ var machine = place(h);
+ var a = machine.automation;
+ var puller = new ItemStack(CompanionAddons.PULLER.get());
+ require(a.slots().insertItem(0, puller.copy(), false).isEmpty(), "First puller installs");
+ a.slots().setStackInSlot(4, new ItemStack(CompanionAddons.ITEM_FILTER.get()));
+ for (boolean simulate : new boolean[] {true, false})
+ require(
+ a.slots().insertItem(1, puller.copy(), simulate).getCount() == 1,
+ "Duplicate puller is refused, including simulation");
+ try {
+ a.slots().setStackInSlot(1, puller.copy());
+ throw new AssertionError("Direct assignment accepted a duplicate role");
+ } catch (IllegalArgumentException expected) {
+ require(
+ a.addon(0, 1).isEmpty() && !a.filter(0, 0).isEmpty(),
+ "Refusal leaves installed addon and filter intact");
+ }
+ require(a.accepts(0, puller), "Replacing the existing role in its own slot is allowed");
+
+ // The menu's client snapshot must enforce the same rule using its own synced contents.
+ var client = new ItemStackHandler(a.slots().getSlots());
+ client.setStackInSlot(0, puller.copy());
+ require(
+ !AutomationInventory.acceptsAddon(a.group(0), client, 1, puller),
+ "Client prediction refuses duplicate roles");
+ for (int group : new int[] {2, 3}) {
+ int slot = AutomationInventory.addonSlot(group, 0);
+ var pump = new ItemStack(CompanionAddons.INPUT_PUMP.get());
+ require(
+ a.slots().insertItem(slot, pump.copy(), false).isEmpty(),
+ "Independent inventories may each have an input pump");
+ require(!a.accepts(slot + 1, pump), "Only one input pump per inventory");
+ }
+ int energy = AutomationInventory.addonSlot(6, 0);
+ a.slots().setStackInSlot(energy, new ItemStack(CompanionAddons.IMPORT_RELAY.get()));
+ a.slots().setStackInSlot(energy + 1, new ItemStack(CompanionAddons.EXPORT_RELAY.get()));
+ require(
+ !a.accepts(energy + 2, new ItemStack(CompanionAddons.IMPORT_RELAY.get()))
+ && !a.accepts(energy + 2, new ItemStack(CompanionAddons.EXPORT_RELAY.get())),
+ "Input and output coexist, but neither role may be duplicated");
+
+ var player = h.makeMockServerPlayerInLevel();
+ player.setPos(
+ machine.getBlockPos().getX(), machine.getBlockPos().getY(), machine.getBlockPos().getZ());
+ var menu = new MachineMenu(71, player.getInventory(), machine);
+ player.containerMenu = menu;
+ menu.automation.action(
+ player,
+ new AutomationNetwork.Action(
+ 71, menu.widgetSession(), 1, "select", 0, -1, 0, 0, "", 0, ""));
+ int destination = menu.automation.slotStart() + 1;
+ menu.setCarried(puller.copy());
+ menu.clicked(destination, 0, ClickType.PICKUP, player);
+ require(
+ menu.getCarried().is(puller.getItem()) && a.addon(0, 1).isEmpty(),
+ "Cursor insertion refuses duplicate role");
+ menu.setCarried(ItemStack.EMPTY);
+ player.getInventory().setItem(0, puller.copy());
+ menu.clicked(destination, 0, ClickType.SWAP, player);
+ require(
+ player.getInventory().getItem(0).is(puller.getItem()) && a.addon(0, 1).isEmpty(),
+ "Hotbar swap refuses duplicate role");
+ // Keep the normal resource inventory full so shift-click only tests addon destinations.
+ machine.items.setStackInSlot(0, new ItemStack(Items.STONE, 64));
+ var source =
+ menu.slots.stream()
+ .filter(s -> s.container == player.getInventory() && s.getContainerSlot() == 0)
+ .findFirst()
+ .orElseThrow();
+ menu.clicked(source.index, 0, ClickType.QUICK_MOVE, player);
+ for (int c = 1; c < 4; c++)
+ require(a.addon(0, c).isEmpty(), "Shift-click cannot add a second puller");
+ require(
+ player.getInventory().countItem(puller.getItem()) == 1, "Refused addon stays with player");
+ player.containerMenu = player.inventoryMenu;
+ h.succeed();
+ }
+
+ @GameTest(template = "empty")
+ public static void legacyDuplicateRolesRecoverWithoutLosingFilters(GameTestHelper h) {
+ var machine = place(h);
+ var a = machine.automation;
+ a.slots().setStackInSlot(0, new ItemStack(CompanionAddons.PULLER.get()));
+ a.slots().setStackInSlot(4, new ItemStack(CompanionAddons.ITEM_FILTER.get()));
+ var saved = a.save(h.getLevel().registryAccess());
+ var contents =
+ saved.getCompound("groups").getCompound("input").getList("contents", Tag.TAG_COMPOUND);
+ contents.set(1, contents.getCompound(0).copy());
+ contents.set(5, contents.getCompound(4).copy());
+ a.load(saved, h.getLevel().registryAccess());
+ require(
+ !a.addon(0, 0).isEmpty() && a.addon(0, 1).isEmpty() && a.filter(0, 1).isEmpty(),
+ "Reload keeps the first addon and detaches the duplicate pair");
+ a.load(a.save(h.getLevel().registryAccess()), h.getLevel().registryAccess());
+ var player = h.makeMockServerPlayerInLevel();
+ a.recover(player);
+ a.recover(player);
+ require(
+ player.getInventory().countItem(CompanionAddons.PULLER.get()) == 1
+ && player.getInventory().countItem(CompanionAddons.ITEM_FILTER.get()) == 1,
+ "Duplicate addon and filter survive save/reload and recover exactly once");
+ h.succeed();
+ }
+
+ @GameTest(template = "empty")
+ public static void breakingMachineDropsEveryAddonAndConfiguredFilterOnce(GameTestHelper h) {
+ var machine = place(h);
+ var a = machine.automation;
+ var expected = new HashMap- ();
+ Item[] modules = {
+ CompanionAddons.PULLER.get(),
+ CompanionAddons.PUSHER.get(),
+ CompanionAddons.INPUT_PUMP.get(),
+ CompanionAddons.INPUT_PUMP.get(),
+ CompanionAddons.OUTPUT_PUMP.get(),
+ CompanionAddons.OUTPUT_PUMP.get(),
+ CompanionAddons.IMPORT_RELAY.get()
+ };
+ for (int g = 0; g < modules.length; g++) {
+ a.slots().setStackInSlot(AutomationInventory.addonSlot(g, 0), new ItemStack(modules[g]));
+ expected.merge(modules[g], 1, Integer::sum);
+ }
+ a.slots()
+ .setStackInSlot(
+ AutomationInventory.addonSlot(6, 1), new ItemStack(CompanionAddons.EXPORT_RELAY.get()));
+ expected.put(CompanionAddons.EXPORT_RELAY.get(), 1);
+ // Include a detached filter still in recovery, not just currently visible child slots.
+ a.slots().setStackInSlot(4, new ItemStack(CompanionAddons.TAG_FILTER.get()));
+ a.slots().setStackInSlot(0, new ItemStack(CompanionAddons.PULLER.get()));
+ expected.put(CompanionAddons.TAG_FILTER.get(), 1);
+ Item[] filters = {
+ CompanionAddons.ITEM_FILTER.get(),
+ CompanionAddons.LARGE_FILTER.get(),
+ CompanionAddons.FLUID_FILTER.get()
+ };
+ for (int g = 0; g < filters.length; g++) {
+ a.slots().setStackInSlot(AutomationInventory.filterSlot(g, 0), new ItemStack(filters[g]));
+ expected.put(filters[g], 1);
+ }
+ require(
+ a.edit(
+ 0,
+ 0,
+ a.generation(0, 0),
+ a.revision(0, 0),
+ "samples",
+ 0,
+ "sample",
+ new ItemStack(Items.DIAMOND),
+ h.getLevel().registryAccess()),
+ "Configure installed filter");
+ machine.items.setStackInSlot(0, new ItemStack(Items.DIAMOND, 3));
+ a.transferState("input", 0)
+ .put("item", new ItemStack(Items.IRON_INGOT, 2).save(h.getLevel().registryAccess()));
+ expected.put(Items.DIAMOND, 3);
+ expected.put(Items.IRON_INGOT, 2);
+ h.getLevel().destroyBlock(machine.getBlockPos(), true);
+ a.dropAll(
+ payload -> {
+ throw new AssertionError("No non-item pending resources in this test");
+ });
+ var actual = new HashMap
- ();
+ var drops =
+ h.getLevel()
+ .getEntitiesOfClass(ItemEntity.class, new AABB(machine.getBlockPos()).inflate(2));
+ for (var drop : drops)
+ actual.merge(drop.getItem().getItem(), drop.getItem().getCount(), Integer::sum);
+ for (var entry : expected.entrySet())
+ require(
+ Objects.equals(actual.get(entry.getKey()), entry.getValue()),
+ "Exact break drops for " + entry.getKey());
+ var filter =
+ drops.stream()
+ .map(ItemEntity::getItem)
+ .filter(s -> s.is(CompanionAddons.ITEM_FILTER.get()))
+ .findFirst()
+ .orElseThrow();
+ var provider = filter.getCapability(AddonCapabilities.FILTER);
+ require(
+ provider.matches(filter, new ItemStack(Items.DIAMOND), h.getLevel().registryAccess())
+ && !provider.matches(filter, new ItemStack(Items.DIRT), h.getLevel().registryAccess()),
+ "Broken-out filter retains its configuration");
+ for (int s = 0; s < a.slots().getSlots(); s++)
+ require(a.slots().getStackInSlot(s).isEmpty(), "Dropped slots are cleared");
+ h.succeed();
+ }
+}
diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/CompanionClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/CompanionClientSmoke.java
index fe36bb56..9ff77b4d 100644
--- a/src/companionTest/java/zone/moddev/mc/companion/test/CompanionClientSmoke.java
+++ b/src/companionTest/java/zone/moddev/mc/companion/test/CompanionClientSmoke.java
@@ -21,6 +21,7 @@
import net.neoforged.fml.common.EventBusSubscriber;
import net.neoforged.neoforge.client.event.ClientTickEvent;
import net.neoforged.neoforge.client.event.RenderFrameEvent;
+import net.neoforged.neoforge.client.event.ScreenEvent;
import net.neoforged.neoforge.fluids.FluidStack;
import net.neoforged.neoforge.fluids.capability.IFluidHandler.FluidAction;
@@ -38,78 +39,153 @@ public final class CompanionClientSmoke {
private static boolean started, opening, menuRequested, finished;
private static int stage, frames, waitingTicks, drawerStage, fluidStage, controlStage;
+ @SubscribeEvent
+ public static void render(ScreenEvent.Render.Pre event) {
+ if (Boolean.getBoolean("mmdlib.companionSmoke")
+ && !finished
+ && opening
+ && stage == 4
+ && event.getScreen() instanceof MachineScreen screen
+ && AutomationClientSmoke.renderTooltipCapture(
+ screen, event.getGuiGraphics(), event.getPartialTick())) event.setCanceled(true);
+ }
+
@SubscribeEvent
public static void tick(ClientTickEvent.Post event) {
if (!Boolean.getBoolean("mmdlib.companionSmoke") || finished) return;
var mc = Minecraft.getInstance();
try {
- if (System.currentTimeMillis() > DEADLINE) throw new AssertionError("Timed out on " + mc.screen);
+ if (System.currentTimeMillis() > DEADLINE)
+ throw new AssertionError("Timed out on " + mc.screen);
if (!started && mc.screen instanceof TitleScreen) {
started = true;
- var settings = new LevelSettings("Companion smoke", GameType.CREATIVE, false,
- Difficulty.PEACEFUL, true, new GameRules(), WorldDataConfiguration.DEFAULT);
- mc.createWorldOpenFlows().createFreshLevel("companion-" + System.currentTimeMillis(),
- settings, new WorldOptions(1, false, false),
- registries -> registries.registryOrThrow(Registries.WORLD_PRESET)
- .getHolderOrThrow(WorldPresets.FLAT).value().createWorldDimensions(), mc.screen);
+ var settings =
+ new LevelSettings(
+ "Companion smoke",
+ GameType.CREATIVE,
+ false,
+ Difficulty.PEACEFUL,
+ true,
+ new GameRules(),
+ WorldDataConfiguration.DEFAULT);
+ mc.createWorldOpenFlows()
+ .createFreshLevel(
+ "companion-" + System.currentTimeMillis(),
+ settings,
+ new WorldOptions(1, false, false),
+ registries ->
+ registries
+ .registryOrThrow(Registries.WORLD_PRESET)
+ .getHolderOrThrow(WorldPresets.FLAT)
+ .value()
+ .createWorldDimensions(),
+ mc.screen);
}
if (mc.player == null || mc.level == null) return;
if (opening) {
if (++waitingTicks > 400)
- throw new AssertionError("Menu " + stage + " did not render; screen=" + mc.screen
- + ", block entity=" + mc.level.getBlockEntity(POS)
- + ", control stage=" + controlStage + ", fluid stage=" + fluidStage
- + (mc.screen instanceof MachineScreen screen
- ? ", cycle time=" + screen.getMenu().bound.screen().find("progress")
- .map(element -> screen.getMenu().value(element, true)).orElse("missing")
- : ""));
+ throw new AssertionError(
+ "Menu "
+ + stage
+ + " did not render; screen="
+ + mc.screen
+ + ", block entity="
+ + mc.level.getBlockEntity(POS)
+ + ", control stage="
+ + controlStage
+ + ", fluid stage="
+ + fluidStage
+ + (mc.screen instanceof MachineScreen screen
+ ? ", cycle time="
+ + screen
+ .getMenu()
+ .bound
+ .screen()
+ .find("progress")
+ .map(element -> screen.getMenu().value(element, true))
+ .orElse("missing")
+ : ""));
var entity = mc.level.getBlockEntity(POS);
- if (!menuRequested && entity != null
+ if (!menuRequested
+ && entity != null
&& entity.getType() == CompanionMod.MACHINES.get(stage).type.get()) {
menuRequested = true;
- mc.getSingleplayerServer().execute(() -> {
- try {
- var player = mc.getSingleplayerServer().getPlayerList().getPlayers().getFirst();
- if (!ScreenRuntime.open(player, player.serverLevel().getBlockEntity(POS)))
- throw new AssertionError("Menu did not open");
- } catch (Throwable error) {
- fail(error);
- }
- });
+ mc.getSingleplayerServer()
+ .execute(
+ () -> {
+ try {
+ var player =
+ mc.getSingleplayerServer().getPlayerList().getPlayers().getFirst();
+ if (!ScreenRuntime.open(player, player.serverLevel().getBlockEntity(POS)))
+ throw new AssertionError("Menu did not open");
+ } catch (Throwable error) {
+ fail(error);
+ }
+ });
}
return;
}
if (stage == CompanionMod.MACHINES.size()) {
finished = true;
- MMDLib.LOGGER.info("COMPANION_CLIENT_PASS: all four menus rendered and received their values");
+ MMDLib.LOGGER.info(
+ "COMPANION_CLIENT_PASS: all five menus rendered and received their values");
mc.stop();
return;
}
opening = true;
waitingTicks = 0;
int current = stage;
- mc.getSingleplayerServer().execute(() -> {
- try {
- var player = mc.getSingleplayerServer().getPlayerList().getPlayers().getFirst();
- var level = player.serverLevel();
- player.closeContainer();
- level.getChunkAt(POS);
- level.setBlock(POS.offset(0, 0, 2),
- net.minecraft.world.level.block.Blocks.STONE.defaultBlockState(), 3);
- level.setBlock(POS, CompanionMod.MACHINES.get(current).block.get().defaultBlockState(), 3);
- var entity = level.getBlockEntity(POS);
- if (entity instanceof ProcessorEntity machine) {
- machine.energy.setStored(current == 0 ? 80_000 : current == 1 ? 40_000 : 10_000);
- if (current == 0) machine.fluids.fill(new FluidStack(Fluids.WATER, 2_500), FluidAction.EXECUTE);
- machine.items.insertItem(0, new ItemStack(current == 1 ? Items.IRON_INGOT : Items.COPPER_ORE,
- current == 1 ? 1 : 16), false);
- for (int i = 0; i < 40; i++) machine.serverTick();
- }
- player.teleportTo(POS.getX() + 0.5, POS.getY() + 1, POS.getZ() + 2.5);
- } catch (Throwable error) {
- fail(error);
- }
- });
+ mc.getSingleplayerServer()
+ .execute(
+ () -> {
+ try {
+ var player = mc.getSingleplayerServer().getPlayerList().getPlayers().getFirst();
+ var level = player.serverLevel();
+ player.closeContainer();
+ level.getChunkAt(POS);
+ level.setBlock(
+ POS.offset(0, 0, 2),
+ net.minecraft.world.level.block.Blocks.STONE.defaultBlockState(),
+ 3);
+ level.setBlock(
+ POS, CompanionMod.MACHINES.get(current).block.get().defaultBlockState(), 3);
+ var entity = level.getBlockEntity(POS);
+ if (entity instanceof ProcessorEntity machine) {
+ machine.energy.setStored(
+ current == 0 ? 80_000 : current == 1 ? 40_000 : 10_000);
+ if (current == 0)
+ machine.fluids.fill(new FluidStack(Fluids.WATER, 2_500), FluidAction.EXECUTE);
+ machine.items.insertItem(
+ 0,
+ new ItemStack(
+ current == 1 ? Items.IRON_INGOT : Items.COPPER_ORE,
+ current == 1 ? 1 : 16),
+ false);
+ for (int i = 0; i < 40; i++) machine.serverTick();
+ if (current == 4) {
+ level.setBlock(
+ POS.north(),
+ net.minecraft.world.level.block.Blocks.CHEST.defaultBlockState(),
+ 3);
+ level.setBlock(
+ POS.west(),
+ net.minecraft.world.level.block.Blocks.FURNACE.defaultBlockState(),
+ 3);
+ level.setBlock(
+ POS.east(),
+ net.minecraft.world.level.block.Blocks.GLASS.defaultBlockState(),
+ 3);
+ level.setBlock(
+ POS.below(),
+ net.minecraft.world.level.block.Blocks.HOPPER.defaultBlockState(),
+ 3);
+ }
+ }
+ player.teleportTo(POS.getX() + 0.5, POS.getY() + 1, POS.getZ() + 2.5);
+ } catch (Throwable error) {
+ fail(error);
+ }
+ });
} catch (Throwable error) {
fail(error);
}
@@ -124,17 +200,38 @@ public static void frame(RenderFrameEvent.Post event) {
var expected = CompanionMod.MACHINES.get(stage).type.get();
if (menu.blockEntity.getType() != expected || ++frames < 25) return;
try {
+ if (stage == 4 && !AutomationClientSmoke.run(screen)) {
+ frames = 0;
+ waitingTicks = 0;
+ return;
+ }
if (stage == 0 && !CompanionWidgetSmoke.run(screen)) {
frames = 0;
waitingTicks = 0;
return;
}
+ if (menu.automation != null) {
+ var row = screen.layout.inventoryColors().orElseThrow();
+ var inventory = screen.layout.inventory().orElseThrow();
+ if (row.x() != inventory.x())
+ throw new AssertionError("Colours align with inventory left edge");
+ for (String id : new String[] {"redstone", "toggle_0", "toggle_1"}) {
+ var button = menu.bound.screen().find(id);
+ if (button.isPresent()) {
+ var rect = screen.layout.bounds(button.get());
+ if (rect.y() != row.y() || rect.x() <= row.right() || rect.right() > inventory.right())
+ throw new AssertionError(
+ "Colour selectors and machine controls share one non-overlapping row");
+ }
+ }
+ }
var energy = menu.bound.screen().find("energy").orElseThrow();
if (!(energy instanceof zone.moddev.mc.lib.gui.model.Element.EnergyGauge))
throw new AssertionError("Every companion screen must use the reusable battery gauge");
if (screen.layout.bounds(energy).right() != screen.layout.panel().width() - 7)
throw new AssertionError("Energy must sit against the right panel margin");
- if (!(menu.value(energy, false) instanceof Number)) throw new AssertionError("Energy was not synced");
+ if (!(menu.value(energy, false) instanceof Number))
+ throw new AssertionError("Energy was not synced");
if (stage == 0 && controlStage < 5) {
var dial = menu.bound.screen().find("progress").orElseThrow();
if (!(dial instanceof zone.moddev.mc.lib.gui.model.Element.Custom)
@@ -149,14 +246,16 @@ public static void frame(RenderFrameEvent.Post event) {
var bounds = screen.bounds(dial);
screen.mouseClicked(bounds.x() + 10, bounds.y() + 10, 0);
} else if (controlStage == 1 && paused) {
- Screenshot.grab(mc.gameDirectory, "crusher-paused.png", mc.getMainRenderTarget(), message -> {});
+ Screenshot.grab(
+ mc.gameDirectory, "crusher-paused.png", mc.getMainRenderTarget(), message -> {});
var bounds = screen.bounds(redstone);
screen.mouseClicked(bounds.x() + 10, bounds.y() + 10, 0);
} else if (controlStage == 2 && mode.intValue() == 1) {
var bounds = screen.bounds(dial);
screen.mouseClicked(bounds.x() + 10, bounds.y() + 10, 0);
} else if (controlStage == 3 && !paused && mode.intValue() == 1) {
- Screenshot.grab(mc.gameDirectory, "crusher-redstone.png", mc.getMainRenderTarget(), message -> {});
+ Screenshot.grab(
+ mc.gameDirectory, "crusher-redstone.png", mc.getMainRenderTarget(), message -> {});
var bounds = screen.bounds(redstone);
screen.mouseClicked(bounds.x() + 10, bounds.y() + 10, 0);
screen.mouseClicked(bounds.x() + 10, bounds.y() + 10, 0);
@@ -167,7 +266,8 @@ public static void frame(RenderFrameEvent.Post event) {
frames = 0;
return;
}
- if (stage < 3) {
+ // The empty-tank/empty-energy screenshot intentionally leaves the crusher idle.
+ if (stage < 3 && !(stage == 0 && fluidStage == 5)) {
var progress = menu.bound.screen().find("progress").orElseThrow();
if (!(menu.value(progress, true) instanceof Number n) || n.intValue() <= 0)
// Resume reserves the cycle on a later server tick. An idle snapshot between cycles
@@ -179,24 +279,29 @@ public static void frame(RenderFrameEvent.Post event) {
if (stage == 0) {
var tank = menu.bound.screen().find("tank").orElseThrow();
var expectedFluid = fluidStage == 0 ? Fluids.WATER : Fluids.LAVA;
- int expectedAmount = fluidStage == 0 ? 2_500 : fluidStage == 3 ? 500 : fluidStage == 5 ? 0 : 1_500;
+ int expectedAmount =
+ fluidStage == 0 ? 2_500 : fluidStage == 3 ? 500 : fluidStage == 5 ? 0 : 1_500;
if (!(menu.value(tank, false) instanceof FluidStack fluid)
- || (expectedAmount > 0 && fluid.getFluid() != expectedFluid) || fluid.getAmount() != expectedAmount) {
+ || (expectedAmount > 0 && fluid.getFluid() != expectedFluid)
+ || fluid.getAmount() != expectedAmount) {
if (fluidStage > 0) return; // Wait for fluid changes to reach the menu.
throw new AssertionError("Water was not synced into the tank gauge");
}
if (!(menu.value(tank, true) instanceof Number capacity) || capacity.intValue() != 4_000)
throw new AssertionError("Tank capacity was not synced");
if (fluidStage == 1) {
- Screenshot.grab(mc.gameDirectory, "crusher-lava.png", mc.getMainRenderTarget(), message -> {});
+ Screenshot.grab(
+ mc.gameDirectory, "crusher-lava.png", mc.getMainRenderTarget(), message -> {});
fluidStage = 2;
frames = 0;
- mc.getSingleplayerServer().execute(() -> {
- var player = mc.getSingleplayerServer().getPlayerList().getPlayers().getFirst();
- player.getAbilities().instabuild = false;
- player.containerMenu.setCarried(new ItemStack(Items.BUCKET));
- player.containerMenu.broadcastChanges();
- });
+ mc.getSingleplayerServer()
+ .execute(
+ () -> {
+ var player = mc.getSingleplayerServer().getPlayerList().getPlayers().getFirst();
+ player.getAbilities().instabuild = false;
+ player.containerMenu.setCarried(new ItemStack(Items.BUCKET));
+ player.containerMenu.broadcastChanges();
+ });
return;
}
if (fluidStage == 2 || fluidStage == 3) {
@@ -211,23 +316,28 @@ public static void frame(RenderFrameEvent.Post event) {
}
if (fluidStage == 4) {
if (!menu.getCarried().is(Items.BUCKET)) return;
- Screenshot.grab(mc.gameDirectory, "crusher-container.png", mc.getMainRenderTarget(), message -> {});
+ Screenshot.grab(
+ mc.gameDirectory, "crusher-container.png", mc.getMainRenderTarget(), message -> {});
fluidStage = 5;
frames = 0;
- mc.getSingleplayerServer().execute(() -> {
- var player = mc.getSingleplayerServer().getPlayerList().getPlayers().getFirst();
- var machine = (ProcessorEntity) player.serverLevel().getBlockEntity(POS);
- machine.fluids.drain(Integer.MAX_VALUE, FluidAction.EXECUTE);
- machine.energy.setStored(0);
- player.containerMenu.setCarried(ItemStack.EMPTY);
- player.getAbilities().instabuild = true;
- player.containerMenu.broadcastChanges();
- });
+ mc.getSingleplayerServer()
+ .execute(
+ () -> {
+ var player = mc.getSingleplayerServer().getPlayerList().getPlayers().getFirst();
+ var machine = (ProcessorEntity) player.serverLevel().getBlockEntity(POS);
+ machine.fluids.drain(Integer.MAX_VALUE, FluidAction.EXECUTE);
+ machine.energy.setStored(0);
+ player.containerMenu.setCarried(ItemStack.EMPTY);
+ player.getAbilities().instabuild = true;
+ player.containerMenu.broadcastChanges();
+ });
return;
}
if (fluidStage == 5) {
- if (!menu.getCarried().isEmpty() || ((Number) menu.value(energy, false)).longValue() != 0) return;
- Screenshot.grab(mc.gameDirectory, "crusher-empty.png", mc.getMainRenderTarget(), message -> {});
+ if (!menu.getCarried().isEmpty() || ((Number) menu.value(energy, false)).longValue() != 0)
+ return;
+ Screenshot.grab(
+ mc.gameDirectory, "crusher-empty.png", mc.getMainRenderTarget(), message -> {});
frames = 0;
stage++;
opening = false;
@@ -236,8 +346,9 @@ public static void frame(RenderFrameEvent.Post event) {
}
}
if (stage < 3) {
- var info = (zone.moddev.mc.lib.gui.model.Element.Popout)
- menu.bound.screen().find("info").orElseThrow();
+ var info =
+ (zone.moddev.mc.lib.gui.model.Element.Popout)
+ menu.bound.screen().find("info").orElseThrow();
var tab = screen.bounds(info);
if (!info.iconOrDefault().toString().equals("mmdlib:icon_info"))
throw new AssertionError("The information drawer must use the info icon");
@@ -245,16 +356,20 @@ public static void frame(RenderFrameEvent.Post event) {
Screenshot.grab(mc.gameDirectory, name + ".png", mc.getMainRenderTarget(), message -> {});
screen.mouseClicked(tab.x() + 10, tab.y() + 10, 0);
var exclusions = screen.exclusions();
- if (exclusions.size() != 2 || exclusions.get(1).right() != tab.x())
+ if (exclusions.size() != 3 || exclusions.get(1).right() != tab.x())
throw new AssertionError("The drawer must extend left while retaining its icon tab");
if (exclusions.get(0).bottom() != exclusions.get(1).bottom()
- || exclusions.get(1).bottom() != screen.guiTop() + screen.layout.panel().height())
- throw new AssertionError("The drawer and its rail must reach the machine panel's bottom");
+ || exclusions.get(1).bottom() > screen.guiTop() + screen.layout.panel().height()
+ || exclusions.get(1).bottom() > screen.drawerBottom())
+ throw new AssertionError(
+ "The info drawer must fit its content within the main panel and screen height"
+ + " limits");
drawerStage = 1;
frames = 0;
return;
}
- Screenshot.grab(mc.gameDirectory, name + "-drawer.png", mc.getMainRenderTarget(), message -> {});
+ Screenshot.grab(
+ mc.gameDirectory, name + "-drawer.png", mc.getMainRenderTarget(), message -> {});
if (!screen.mouseClicked(tab.x() + 10, screen.exclusions().getFirst().bottom() - 8, 0))
throw new AssertionError("Clicks on the drawer rail must not pass through the screen");
// Vanilla returns true even for unused buttons. Observe its recorded press to verify
@@ -273,22 +388,26 @@ public static void frame(RenderFrameEvent.Post event) {
screen.mouseClicked(point[0], point[1], button);
if (lastButton.getInt(screen) != button)
throw new AssertionError("Drawer swallowed mouse button " + button);
- if (screen.exclusions().size() != 2)
+ if (screen.exclusions().size() != 3)
throw new AssertionError("Extra mouse button changed the drawer state");
}
screen.mouseScrolled(tab.x() - 10, tab.y() + 10, 0, -1);
screen.mouseClicked(tab.x() + 10, tab.y() + 10, 0);
- if (screen.exclusions().size() != 1)
+ if (screen.exclusions().size() != 2)
throw new AssertionError("The icon tab did not close its drawer");
drawerStage = 0;
if (stage == 0) {
fluidStage = 1;
frames = 0;
- mc.getSingleplayerServer().execute(() -> {
- var machine = (ProcessorEntity) mc.getSingleplayerServer().overworld().getBlockEntity(POS);
- machine.fluids.drain(Integer.MAX_VALUE, FluidAction.EXECUTE);
- machine.fluids.fill(new FluidStack(Fluids.LAVA, 1_500), FluidAction.EXECUTE);
- });
+ mc.getSingleplayerServer()
+ .execute(
+ () -> {
+ var machine =
+ (ProcessorEntity)
+ mc.getSingleplayerServer().overworld().getBlockEntity(POS);
+ machine.fluids.drain(Integer.MAX_VALUE, FluidAction.EXECUTE);
+ machine.fluids.fill(new FluidStack(Fluids.LAVA, 1_500), FluidAction.EXECUTE);
+ });
return;
}
} else {
diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/TestAutomationCapabilities.java b/src/companionTest/java/zone/moddev/mc/companion/test/TestAutomationCapabilities.java
new file mode 100644
index 00000000..2de2c506
--- /dev/null
+++ b/src/companionTest/java/zone/moddev/mc/companion/test/TestAutomationCapabilities.java
@@ -0,0 +1,54 @@
+package zone.moddev.mc.companion.test;
+
+import java.util.*;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import net.minecraft.core.*;
+import net.minecraft.resources.ResourceLocation;
+import net.minecraft.world.item.Items;
+import net.minecraft.world.level.block.entity.BlockEntityType;
+
+import net.neoforged.bus.api.SubscribeEvent;
+import net.neoforged.fml.common.EventBusSubscriber;
+import net.neoforged.neoforge.capabilities.*;
+import net.neoforged.neoforge.items.*;
+
+import zone.moddev.mc.companion.CompanionMod;
+import zone.moddev.mc.lib.api.automation.*;
+import zone.moddev.mc.lib.api.capabilities.IoMode;
+
+/** Test-only counters prove steady-state work is skipped and invalidation is observed. */
+@EventBusSubscriber(modid = CompanionMod.ID, bus = EventBusSubscriber.Bus.MOD)
+public final class TestAutomationCapabilities {
+ public static final BlockCapability ITEMS =
+ BlockCapability.createSided(
+ ResourceLocation.parse("mmdlib_companion:test_counted_items"), IItemHandler.class);
+ public static final Map LOOKUPS = new HashMap<>();
+ public static final AtomicInteger SPECIFICATIONS = new AtomicInteger();
+
+ @SubscribeEvent
+ public static void register(RegisterCapabilitiesEvent event) {
+ event.registerBlockEntity(
+ ITEMS,
+ BlockEntityType.CHEST,
+ (chest, side) -> {
+ LOOKUPS.merge(chest.getBlockPos(), 1, Integer::sum);
+ return new ItemStackHandler(1);
+ });
+ event.registerItem(
+ AddonCapabilities.ADDON,
+ (stack, context) ->
+ item -> {
+ SPECIFICATIONS.incrementAndGet();
+ return new AddonSpec(
+ ResourceLocation.parse("mmdlib_companion:test_puller"),
+ ResourceType.ITEMS,
+ IoMode.INPUT,
+ 8,
+ 20,
+ 4,
+ false);
+ },
+ Items.BLAZE_ROD);
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/lib/api/automation/AddonCapabilities.java b/src/main/java/zone/moddev/mc/lib/api/automation/AddonCapabilities.java
new file mode 100644
index 00000000..2ac7596c
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/lib/api/automation/AddonCapabilities.java
@@ -0,0 +1,19 @@
+package zone.moddev.mc.lib.api.automation;
+
+import net.minecraft.resources.ResourceLocation;
+
+import net.neoforged.neoforge.capabilities.ItemCapability;
+
+/**
+ * Register providers through RegisterCapabilitiesEvent.registerItem, including third-party items.
+ */
+public final class AddonCapabilities {
+ public static final ItemCapability ADDON =
+ ItemCapability.createVoid(
+ ResourceLocation.parse("mmdlib:inventory_addon"), InventoryAddon.class);
+ public static final ItemCapability FILTER =
+ ItemCapability.createVoid(
+ ResourceLocation.parse("mmdlib:inventory_filter"), InventoryFilter.class);
+
+ private AddonCapabilities() {}
+}
diff --git a/src/main/java/zone/moddev/mc/lib/api/automation/AddonSpec.java b/src/main/java/zone/moddev/mc/lib/api/automation/AddonSpec.java
new file mode 100644
index 00000000..ffe3df4d
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/lib/api/automation/AddonSpec.java
@@ -0,0 +1,36 @@
+package zone.moddev.mc.lib.api.automation;
+
+import java.util.Objects;
+
+import net.minecraft.resources.ResourceLocation;
+
+import zone.moddev.mc.lib.api.capabilities.IoMode;
+
+/** The same direction/content predicate is used by the menu, storage and running addon. */
+public record AddonSpec(
+ ResourceLocation type,
+ ResourceType> resource,
+ IoMode direction,
+ int batch,
+ int interval,
+ long energyPerUnit,
+ boolean filters) {
+ public AddonSpec {
+ Objects.requireNonNull(type);
+ Objects.requireNonNull(resource);
+ Objects.requireNonNull(direction);
+ if (direction == IoMode.DISABLED || batch <= 0 || interval <= 0 || energyPerUnit < 0)
+ throw new IllegalArgumentException("Invalid transfer addon specification");
+ }
+
+ public boolean accepts(ResourceGroup> group) {
+ return group.type() == resource && group.supports(direction);
+ }
+
+ /** Transfer roles are independent of item ID or tier; BOTH occupies both roles. */
+ public boolean conflictsWith(AddonSpec other) {
+ return resource == other.resource
+ && ((direction.canInsert() && other.direction.canInsert())
+ || (direction.canExtract() && other.direction.canExtract()));
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/lib/api/automation/AutomationInventory.java b/src/main/java/zone/moddev/mc/lib/api/automation/AutomationInventory.java
new file mode 100644
index 00000000..946e6d8a
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/lib/api/automation/AutomationInventory.java
@@ -0,0 +1,514 @@
+package zone.moddev.mc.lib.api.automation;
+
+import java.util.*;
+import java.util.function.Consumer;
+
+import it.unimi.dsi.fastutil.ints.Int2ObjectOpenHashMap;
+
+import net.minecraft.core.HolderLookup;
+import net.minecraft.core.Direction;
+import net.minecraft.nbt.*;
+import net.minecraft.server.level.ServerLevel;
+import net.minecraft.world.Containers;
+import net.minecraft.world.entity.player.Player;
+import net.minecraft.world.item.ItemStack;
+import net.minecraft.world.level.block.entity.BlockEntity;
+
+import net.neoforged.neoforge.items.IItemHandler;
+import net.neoforged.neoforge.items.ItemStackHandler;
+import net.neoforged.neoforge.capabilities.BlockCapability;
+import net.neoforged.neoforge.capabilities.BlockCapabilityCache;
+
+import zone.moddev.mc.lib.api.capabilities.*;
+
+/**
+ * Persistent group side modes and four addon/filter pairs per group. Selection belongs to menus.
+ */
+public final class AutomationInventory {
+ public static final int COLUMNS = 4;
+ public static final int SLOTS_PER_GROUP = 8;
+ public static final int MAX_GROUPS = 256;
+ private final BlockEntity owner;
+ private final List> groups;
+ private final Map ids = new LinkedHashMap<>();
+ private final Map, Int2ObjectOpenHashMap>> members =
+ new IdentityHashMap<>();
+ private final BitSet dirtyGroups = new BitSet();
+ private long viewRevision, refund;
+ private ServerLevel cacheLevel;
+ private final Map<
+ BlockCapability, Direction>, EnumMap>>
+ neighbours = new IdentityHashMap<>();
+ private final long[] generation;
+ private final long[] revision;
+ private final List recovery = new ArrayList<>();
+ private CompoundTag unknown = new CompoundTag();
+ private CompoundTag transfers = new CompoundTag();
+ private boolean loading;
+ private final ItemStackHandler slots;
+
+ public AutomationInventory(BlockEntity owner, List> groups) {
+ if (groups.isEmpty() || groups.size() > MAX_GROUPS)
+ throw new IllegalArgumentException("Group count");
+ this.owner = Objects.requireNonNull(owner);
+ this.groups = List.copyOf(groups);
+ generation = new long[groups.size() * COLUMNS];
+ revision = new long[generation.length];
+ var membership = new HashSet();
+ for (int i = 0; i < groups.size(); i++) {
+ var group = groups.get(i);
+ if (ids.putIfAbsent(group.id(), i) != null)
+ throw new IllegalArgumentException("Duplicate group ID");
+ for (int member : group.members())
+ if (!membership.add(group.type().id() + "/" + member))
+ throw new IllegalArgumentException("Overlapping resource membership: " + group.id());
+ var indexed = members.computeIfAbsent(group.type(), ignored -> new Int2ObjectOpenHashMap<>());
+ for (int member : group.members()) indexed.put(member, group);
+ group.sides().onChange(this::viewChanged);
+ }
+ slots =
+ new ItemStackHandler(groups.size() * SLOTS_PER_GROUP) {
+ @Override
+ public int getSlotLimit(int slot) {
+ return 1;
+ }
+
+ @Override
+ public boolean isItemValid(int slot, ItemStack stack) {
+ return accepts(slot, stack);
+ }
+
+ @Override
+ public void setStackInSlot(int slot, ItemStack stack) {
+ if (stack.getCount() > 1)
+ throw new IllegalArgumentException("One addon/filter per slot");
+ if (!loading && !stack.isEmpty() && !accepts(slot, stack))
+ throw new IllegalArgumentException("Incompatible or duplicate addon/filter role");
+ // Even replacing an identical stack is a new attachment generation.
+ if (!loading && slot % SLOTS_PER_GROUP < COLUMNS)
+ detach(slot / SLOTS_PER_GROUP, slot % COLUMNS);
+ super.setStackInSlot(slot, stack);
+ }
+
+ @Override
+ protected void onContentsChanged(int slot) {
+ if (loading) return;
+ int group = slot / SLOTS_PER_GROUP, column = slot % COLUMNS;
+ dirtyGroups.set(group);
+ int key = group * COLUMNS + column;
+ if (slot % SLOTS_PER_GROUP < COLUMNS) {
+ generation[key]++;
+ // extractItem mutates without calling setStackInSlot.
+ var addon = addon(group, column);
+ var filter = filter(group, column);
+ if (!filter.isEmpty() && addon.isEmpty()) detach(group, column);
+ } else {
+ generation[key]++;
+ revision[key]++;
+ }
+ viewChanged();
+ }
+ };
+ }
+
+ public BlockEntity owner() {
+ return owner;
+ }
+
+ public List> groups() {
+ return groups;
+ }
+
+ public ResourceGroup> group(int index) {
+ return groups.get(index);
+ }
+
+ public int index(String id) {
+ return ids.getOrDefault(id, -1);
+ }
+
+ public ItemStackHandler slots() {
+ return slots;
+ }
+
+ private void viewChanged() {
+ viewRevision++;
+ owner.setChanged();
+ }
+
+ public long viewRevision() {
+ return viewRevision;
+ }
+
+ /** Call after external capability/definition changes; normal slot edits invalidate themselves. */
+ public void invalidateAddons() {
+ dirtyGroups.set(0, groups.size());
+ Arrays.setAll(generation, i -> generation[i] + 1);
+ viewChanged();
+ }
+
+ /** Persist any refund that cannot currently fit; retry before the next transfer. */
+ public void refund(EnergyBuffer power, long amount) {
+ if (amount < 0) throw new IllegalArgumentException("Negative refund");
+ if (amount == 0) return;
+ refund = Math.addExact(refund, amount - power.insert(amount, false));
+ owner.setChanged();
+ }
+
+ public long pendingRefund() {
+ return refund;
+ }
+
+ public void settleRefund(EnergyBuffer power) {
+ if (refund == 0) return;
+ long restored = power.insert(refund, false);
+ refund -= restored;
+ if (restored != 0) owner.setChanged();
+ }
+
+ /** Per-owner caches follow neighbour removal, capability invalidation and chunk reloads. */
+ @SuppressWarnings("unchecked")
+ public T neighbour(BlockCapability capability, Direction side) {
+ if (!(owner.getLevel() instanceof ServerLevel level) || owner.isRemoved()) return null;
+ if (cacheLevel != level) {
+ neighbours.clear();
+ cacheLevel = level;
+ }
+ var bySide = neighbours.computeIfAbsent(capability, ignored -> new EnumMap<>(Direction.class));
+ var cache =
+ (BlockCapabilityCache)
+ bySide.computeIfAbsent(
+ side,
+ direction ->
+ BlockCapabilityCache.create(
+ capability,
+ level,
+ owner.getBlockPos().relative(direction),
+ direction.getOpposite(),
+ () -> !owner.isRemoved() && owner.getLevel() == level,
+ () -> {}));
+ return cache.getCapability();
+ }
+
+ public CompoundTag existingTransferState(String group, int column) {
+ String key = group + "/" + column;
+ return transfers.contains(key, Tag.TAG_COMPOUND) ? transfers.getCompound(key) : null;
+ }
+
+ public CompoundTag transferState(String group, int column) {
+ String key = group + "/" + column;
+ if (!transfers.contains(key, Tag.TAG_COMPOUND)) {
+ var state = new CompoundTag();
+ int index = index(group);
+ if (index >= 0) state.putString("resource", group(index).type().id().toString());
+ transfers.put(key, state);
+ }
+ return transfers.getCompound(key);
+ }
+
+ public static int addonSlot(int group, int column) {
+ return group * SLOTS_PER_GROUP + column;
+ }
+
+ public static int filterSlot(int group, int column) {
+ return addonSlot(group, column) + COLUMNS;
+ }
+
+ public ItemStack addon(int group, int column) {
+ return slots.getStackInSlot(addonSlot(group, column));
+ }
+
+ public ItemStack filter(int group, int column) {
+ return slots.getStackInSlot(filterSlot(group, column));
+ }
+
+ public long generation(int group, int column) {
+ return generation[group * COLUMNS + column];
+ }
+
+ public long revision(int group, int column) {
+ return revision[group * COLUMNS + column];
+ }
+
+ public boolean filterSupported(int group, int column) {
+ var stack = addon(group, column);
+ var capability = stack.getCapability(AddonCapabilities.ADDON);
+ return capability != null
+ && capability.specification(stack).accepts(group(group))
+ && capability.specification(stack).filters();
+ }
+
+ public boolean accepts(int slot, ItemStack stack) {
+ if (slot < 0 || slot >= groups.size() * SLOTS_PER_GROUP || stack.isEmpty()) return false;
+ int group = slot / SLOTS_PER_GROUP, sub = slot % SLOTS_PER_GROUP;
+ if (sub < COLUMNS) return acceptsAddon(group(group), slots, slot, stack);
+ return filterSupported(group, sub - COLUMNS)
+ && acceptsFilter(addon(group, sub - COLUMNS), stack);
+ }
+
+ /** Shared by authoritative storage and the menu's separate client-side slot snapshot. */
+ public static boolean acceptsAddon(
+ ResourceGroup> group, IItemHandler contents, int slot, ItemStack stack) {
+ if (slot < 0
+ || slot >= contents.getSlots()
+ || slot % SLOTS_PER_GROUP >= COLUMNS
+ || stack.isEmpty()) return false;
+ var capability = stack.getCapability(AddonCapabilities.ADDON);
+ if (capability == null) return false;
+ var spec = capability.specification(stack);
+ if (!spec.accepts(group)) return false;
+ int base = slot - slot % SLOTS_PER_GROUP;
+ for (int c = 0; c < COLUMNS; c++) {
+ if (base + c == slot) continue;
+ var other = contents.getStackInSlot(base + c);
+ var provider = other.getCapability(AddonCapabilities.ADDON);
+ if (provider != null && spec.conflictsWith(provider.specification(other))) return false;
+ }
+ return true;
+ }
+
+ private boolean acceptsFilter(ItemStack addon, ItemStack filter) {
+ var capability = addon.getCapability(AddonCapabilities.ADDON);
+ return capability != null && capability.acceptsFilter(addon, filter);
+ }
+
+ private void detach(int group, int column) {
+ var filter = filter(group, column);
+ if (!filter.isEmpty()) {
+ recovery.add(filter.copy());
+ slots.setStackInSlot(filterSlot(group, column), ItemStack.EMPTY);
+ }
+ generation[group * COLUMNS + column]++;
+ }
+
+ /** At the end of the authoritative menu interaction, after the source slot has been freed. */
+ public void recover(Player player) {
+ if (owner.getLevel() == null || owner.getLevel().isClientSide) return;
+ for (var stack : recovery) {
+ if (player != null)
+ stack =
+ net.neoforged.neoforge.items.ItemHandlerHelper.insertItemStacked(
+ new net.neoforged.neoforge.items.wrapper.PlayerMainInvWrapper(
+ player.getInventory()),
+ stack,
+ false);
+ if (!stack.isEmpty())
+ Containers.dropItemStack(
+ owner.getLevel(),
+ owner.getBlockPos().getX() + .5,
+ owner.getBlockPos().getY() + .5,
+ owner.getBlockPos().getZ() + .5,
+ stack);
+ }
+ if (!recovery.isEmpty()) owner.setChanged();
+ recovery.clear();
+ }
+
+ /** Preserve incompatible items; recover duplicate roles and orphaned filters without loss. */
+ public void revalidate() {
+ for (int g = dirtyGroups.nextSetBit(0); g >= 0; g = dirtyGroups.nextSetBit(g + 1)) {
+ var claimed = new ArrayList();
+ for (int c = 0; c < COLUMNS; c++) {
+ var stack = addon(g, c);
+ var provider = stack.getCapability(AddonCapabilities.ADDON);
+ if (provider != null) {
+ var spec = provider.specification(stack);
+ if (claimed.stream().anyMatch(spec::conflictsWith)) {
+ // Keep the first role after reload; recover duplicates and their attached filters.
+ recovery.add(stack.copy());
+ slots.setStackInSlot(addonSlot(g, c), ItemStack.EMPTY);
+ } else claimed.add(spec);
+ }
+ if (!filter(g, c).isEmpty() && addon(g, c).isEmpty()) detach(g, c);
+ }
+ dirtyGroups.clear(g);
+ }
+ }
+
+ public boolean edit(
+ int group,
+ int column,
+ long expectedGeneration,
+ long expectedRevision,
+ String control,
+ int entry,
+ String value,
+ ItemStack cursor,
+ HolderLookup.Provider registries) {
+ if (group < 0
+ || group >= groups.size()
+ || column < 0
+ || column >= COLUMNS
+ || generation(group, column) != expectedGeneration
+ || revision(group, column) != expectedRevision
+ || !filterSupported(group, column)) return false;
+ var original = filter(group, column);
+ var capability = original.getCapability(AddonCapabilities.FILTER);
+ if (capability == null || !acceptsFilter(addon(group, column), original)) return false;
+ var candidate = original.copy();
+ if (!capability.edit(candidate, control, entry, value, cursor.copy(), registries)) return false;
+ // A config edit is not an identity replacement.
+ loading = true;
+ try {
+ slots.setStackInSlot(filterSlot(group, column), candidate);
+ } finally {
+ loading = false;
+ }
+ revision[group * COLUMNS + column]++;
+ dirtyGroups.set(group);
+ viewChanged();
+ return true;
+ }
+
+ public IoMode mode(ResourceType> type, int member, net.minecraft.core.Direction side) {
+ var indexed = members.get(type);
+ var group = indexed == null ? null : indexed.get(member);
+ return group == null ? IoMode.DISABLED : group.mode(side);
+ }
+
+ public CompoundTag save(HolderLookup.Provider registries) {
+ var data = unknown.copy();
+ for (int i = 0; i < groups.size(); i++) {
+ var group = groups.get(i);
+ var saved = new CompoundTag();
+ var modes = new CompoundTag();
+ for (var face : RelativeFace.values())
+ modes.putString(face.getSerializedName(), group.sides().get(face).getSerializedName());
+ saved.put("sides", modes);
+ var contents = new ListTag();
+ for (int s = 0; s < SLOTS_PER_GROUP; s++)
+ contents.add(slots.getStackInSlot(i * SLOTS_PER_GROUP + s).saveOptional(registries));
+ saved.put("contents", contents);
+ data.put(group.id(), saved);
+ }
+ var result = new CompoundTag();
+ result.putInt("version", 1);
+ result.put("groups", data);
+ result.put("transfers", transfers.copy());
+ result.putLong("refund", refund);
+ var pending = new ListTag();
+ for (var item : recovery) pending.add(item.saveOptional(registries));
+ result.put("recovery", pending);
+ return result;
+ }
+
+ public void load(CompoundTag data, HolderLookup.Provider registries) {
+ loading = true;
+ try {
+ recovery.clear();
+ transfers = data.getCompound("transfers").copy();
+ refund = Math.max(0, data.getLong("refund"));
+ for (int i = 0; i < slots.getSlots(); i++) slots.setStackInSlot(i, ItemStack.EMPTY);
+ unknown = data.getCompound("groups").copy();
+ for (int i = 0; i < groups.size(); i++) {
+ var group = groups.get(i);
+ if (!unknown.contains(group.id(), Tag.TAG_COMPOUND)) continue;
+ var saved = unknown.getCompound(group.id());
+ var modes = saved.getCompound("sides");
+ var loaded = new EnumMap(RelativeFace.class);
+ for (var face : RelativeFace.values()) {
+ IoMode mode = group.sides().get(face);
+ for (var candidate : IoMode.values())
+ if (candidate.getSerializedName().equals(modes.getString(face.getSerializedName())))
+ mode = candidate;
+ loaded.put(face, ResourceGroup.intersect(mode, group.supported()));
+ }
+ group.sides().copyFrom(new SideConfig(loaded));
+ var contents = saved.getList("contents", Tag.TAG_COMPOUND);
+ for (int s = 0; s < SLOTS_PER_GROUP; s++) {
+ var stack = ItemStack.parseOptional(registries, contents.getCompound(s));
+ if (stack.getCount() > 1) {
+ recovery.add(stack.copyWithCount(stack.getCount() - 1));
+ stack.setCount(1);
+ }
+ slots.setStackInSlot(i * SLOTS_PER_GROUP + s, stack);
+ }
+ unknown.remove(group.id());
+ }
+ for (var tag : data.getList("recovery", Tag.TAG_COMPOUND))
+ recovery.add(ItemStack.parseOptional(registries, (CompoundTag) tag));
+ } finally {
+ loading = false;
+ }
+ invalidateAddons();
+ revalidate();
+ }
+
+ private String transferType(String key, CompoundTag state) {
+ String type = state.getString("resource");
+ if (!type.isEmpty()) return type;
+ int slash = key.lastIndexOf('/');
+ int group = slash < 0 ? -1 : index(key.substring(0, slash));
+ if (group >= 0) return group(group).type().id().toString();
+ if (state.contains("fluid")) return ResourceType.FLUIDS.id().toString();
+ if (state.contains("energy")) return ResourceType.ENERGY.id().toString();
+ if (state.contains("item")) return ResourceType.ITEMS.id().toString();
+ return type;
+ }
+
+ /** The host must preserve non-item payloads in a dropped container before this clears storage. */
+ public void dropAll(Consumer recoverResources) {
+ if (owner.getLevel() == null || owner.getLevel().isClientSide) return;
+ Objects.requireNonNull(recoverResources);
+ var payload = new CompoundTag();
+ var resources = new ListTag();
+ for (String key : transfers.getAllKeys()) {
+ var state = transfers.getCompound(key);
+ String type = transferType(key, state);
+ if (type.equals(ResourceType.ITEMS.id().toString())) continue;
+ var pending = AutomationTransfers.recovery(type, state);
+ if (!pending.isEmpty()) {
+ var entry = new CompoundTag();
+ entry.putString("type", type);
+ entry.put("data", pending);
+ resources.add(entry);
+ }
+ }
+ if (!resources.isEmpty()) payload.put("resources", resources);
+ if (refund > 0) payload.putLong("refund", refund);
+ // The host must take ownership before anything is cleared or ordinary item drops begin.
+ if (!payload.isEmpty()) recoverResources.accept(payload);
+ loading = true;
+ try {
+ for (String id : unknown.getAllKeys())
+ for (var tag : unknown.getCompound(id).getList("contents", Tag.TAG_COMPOUND)) {
+ var item = ItemStack.parseOptional(owner.getLevel().registryAccess(), (CompoundTag) tag);
+ if (!item.isEmpty()) recovery.add(item);
+ }
+ unknown = new CompoundTag();
+ for (String key : transfers.getAllKeys()) {
+ if (!transferType(key, transfers.getCompound(key))
+ .equals(ResourceType.ITEMS.id().toString())) continue;
+ var item =
+ ItemStack.parseOptional(
+ owner.getLevel().registryAccess(), transfers.getCompound(key).getCompound("item"));
+ if (!item.isEmpty()) recovery.add(item);
+ }
+ transfers = new CompoundTag();
+ refund = 0;
+ for (int i = 0; i < slots.getSlots(); i++) {
+ var stack = slots.getStackInSlot(i);
+ if (!stack.isEmpty()) recovery.add(stack.copy());
+ slots.setStackInSlot(i, ItemStack.EMPTY);
+ }
+ } finally {
+ loading = false;
+ }
+ recover(null);
+ }
+
+ /** Explicit destructive clearing, for a host's vanilla Clearable hook. */
+ public void clear() {
+ loading = true;
+ try {
+ for (int i = 0; i < slots.getSlots(); i++) slots.setStackInSlot(i, ItemStack.EMPTY);
+ } finally {
+ loading = false;
+ }
+ recovery.clear();
+ unknown = new CompoundTag();
+ transfers = new CompoundTag();
+ refund = 0;
+ invalidateAddons();
+ }
+}
diff --git a/src/main/java/zone/moddev/mc/lib/api/automation/AutomationTransfers.java b/src/main/java/zone/moddev/mc/lib/api/automation/AutomationTransfers.java
new file mode 100644
index 00000000..31866aee
--- /dev/null
+++ b/src/main/java/zone/moddev/mc/lib/api/automation/AutomationTransfers.java
@@ -0,0 +1,340 @@
+package zone.moddev.mc.lib.api.automation;
+
+import java.util.*;
+import java.util.function.Predicate;
+
+import net.minecraft.core.Direction;
+import net.minecraft.core.HolderLookup;
+import net.minecraft.nbt.CompoundTag;
+import net.minecraft.world.item.ItemStack;
+
+import net.neoforged.neoforge.capabilities.*;
+import net.neoforged.neoforge.energy.IEnergyStorage;
+import net.neoforged.neoforge.fluids.FluidStack;
+import net.neoforged.neoforge.fluids.capability.IFluidHandler;
+import net.neoforged.neoforge.items.IItemHandler;
+
+import zone.moddev.mc.lib.api.capabilities.*;
+
+/**
+ * Server-only, paid transfer scheduler. Extracted resources remain owned by persistent escrow until
+ * a destination accepts them, including handlers that disagree with their simulation.
+ */
+public final class AutomationTransfers {
+ public interface Transport {
+ BlockCapability capability();
+
+ /** Override for a custom resource's escrow schema. */
+ default boolean pending(CompoundTag state) {
+ return state.contains("item") || state.contains("fluid") || state.contains("energy");
+ }
+
+ /**
+ * Delivers escrow first; otherwise stages one bounded extraction. Returns actual delivery.
+ * Implementations must preserve every unaccepted resource in escrow and respect max.
+ */
+ int move(
+ T from,
+ T to,
+ int max,
+ Predicate