diff --git a/build.gradle b/build.gradle index 9a26e6c9..406c15a9 100644 --- a/build.gradle +++ b/build.gradle @@ -25,6 +25,14 @@ repositories { mavenCentral() // JEI (optional integration; compile-time API only) maven { url = 'https://maven.blamejared.com' } + maven { + url = 'https://maven.theillusivec4.top/' + content { includeGroup 'top.theillusivec4.curios' } + } + maven { + url = 'https://api.modrinth.com/maven' + content { includeGroup 'maven.modrinth' } + } } // Dev-only runtime dependencies: on the runtime classpath, but kept out of the published POM. @@ -34,6 +42,8 @@ configurations { } dependencies { + // Optional equipment discovery; neither bundled nor a transitive runtime requirement. + compileOnly "top.theillusivec4.curios:curios-neoforge:${curios_version}:api" // Optional JEI integration: compiled against the API only, never bundled or required. compileOnly "mezz.jei:jei-1.21.1-common-api:${jei_version}" compileOnly "mezz.jei:jei-1.21.1-neoforge-api:${jei_version}" @@ -54,6 +64,13 @@ neoForge { runs { configureEach { gameDirectory = file('run') + // Optional user-level preferences apply only to manual development clients. + if (name in ['client', 'companionClient']) { + if (providers.gradleProperty('mmdlibClientWidth').isPresent()) + programArguments.addAll '--width', providers.gradleProperty('mmdlibClientWidth').get() + if (providers.gradleProperty('mmdlibClientHeight').isPresent()) + programArguments.addAll '--height', providers.gradleProperty('mmdlibClientHeight').get() + } } client { client() @@ -92,6 +109,7 @@ tasks.named('processResources', ProcessResources) { neoforge_version_range: neoforge_version_range, loader_version_range: loader_version_range, jei_version_range: jei_version_range, + curios_version_range: curios_version_range, mod_id: mod_id, mod_name: mod_name, mod_license: mod_license, diff --git a/docs/gui-screens.md b/docs/gui-screens.md index 7ba7e6de..0fad73f1 100644 --- a/docs/gui-screens.md +++ b/docs/gui-screens.md @@ -338,7 +338,7 @@ payload contains both lane index and state. The server restricts that selector t in its XML property. These settings persist with the machine. The graphical smoke run checks focus, typing, cancellation, resizing, view changes, release outside bounds and server rejection. -The expanded menu protocol is version **2**, including a session token in menu-opening data. +The expanded menu protocol is version **6**, including a session token in menu-opening data. Client and server must use matching MMDLib builds. ## Files and IDs @@ -431,14 +431,26 @@ a compact meter, a few controls, an upgrade tab, and a side-configuration view: ``` -The standard player inventory (three rows of nine, a 4 px gap, then the hotbar) is added at the -bottom automatically. Write `` only to place it somewhere else, or set -`inventory="none"` on `` to leave it out. +The standard player inventory is added at the bottom automatically: three rows of nine, +a 4 px gap, then the hotbar. A column on the right contains helmet, chestplate, leggings and +boots, with the offhand slot beside the hotbar. Empty equipment slots show vanilla icons; +armor uses NeoForge's equipment checks, a one-item limit, and vanilla binding-curse protection. +The complete inventory panel is 184 by 94 GUI pixels, including its toolbar row. There is no +inventory label: the toolbar shares the helmet's row, directly above the three storage rows. +Write `` only to place it somewhere else, or set `inventory="none"` on +`` to leave out the inventory and equipment together. + +Shift-clicking equipment moves it into the ordinary inventory/hotbar. Machine outputs also +go there; they do not automatically equip armor or fill the offhand. Shift-clicking ordinary +inventory slots keeps the machine's `screenDestinations` routing. JEI recipe transfer uses +only the ordinary inventory and hotbar, leaving equipped items alone. 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. +scrollable overflow. Both sides align with the nine-column storage grid, leaving the equipment +column clear. Flat controls fit in the inventory's existing top row; taller controls add only +the extra height they need. 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. @@ -473,7 +485,9 @@ requires a player inventory and is available to every screen, not just companion - A button with `shows=":"` switches that context area to the view. Pressing it again, or the back button drawn in the corner of every non-default view, returns to the default view. Escape also returns to the default view first, then closes the screen. - - The area is sized to its largest view, so switching never moves the rest of the screen. + - The area measures the active view and fills the available width in vertical layouts. + Switching views resizes and recentres the panel, moving the inventory, toolbar and drawers + with it. Hidden settings do not reserve empty space; menu slot identities stay unchanged. - A view's `title` is drawn at its top, so the player can always tell which view is showing. - The shown view is remembered per screen file for the session. - Slots in a hidden view exist on the server menu at all times, but are hidden and refuse @@ -711,7 +725,9 @@ 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 - fits its text, capped at the main panel's bottom and the screen edge. Text wraps to the available width; scroll inside + fits its text. Info and inventory drawers share a 4-pixel inset from the main panel's top + and bottom, and stop earlier if the screen or an overlay limits the space. The side-config + drawer shares the top inset but can grow below the main panel to fit its filter controls. 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. @@ -724,6 +740,83 @@ When JEI is installed: sends a smeltable item to its ingredient slot and other fuel to its fuel slot. MMDLib only keeps slots in hidden views out of that routing. +Drawer choices are remembered across MMDLib machine screens for the current connection. +The left drawer (info, backpack or Curios) and right automation drawer are independent. +Backpack selection uses its equipment slot address, and automation selection uses its resource +group ID. Missing features are skipped; explicit closes are remembered. Joining another world +or server resets these preferences. Filter-editor drafts are not carried between machines. + +## Equipped storage drawer (optional Curios integration) + +When Curios is installed, machine screens with a player inventory automatically show a +bottom-left tab for an equipped item exposing NeoForge's `Capabilities.ItemHandler.ITEM`. +The tab renders the actual item, including its model/tint components and custom name in +the tooltip. No screen XML changes or backpack-specific API are needed. + +The server config `inventory.container_slots` selects functional Curios slots to inspect; +the defaults are `back` and `backpack`. Cosmetic slots are excluded. Empty slots, ordinary +items and zero-slot capabilities show no tab. Curios remains optional, and neither Curios +nor any backpack mod is bundled with MMDLib. + +Click the tab to open storage on the left. Left/right clicks take a stack/half stack or +insert the carried stack/one item. Shift-click a storage cell to move items into the player +inventory; **while storage is open, shift-clicking the ordinary player inventory stores +items in the selected container**. Machine-output and equipment transfers keep their usual +behavior. Drag with the left button to divide the carried stack evenly, or with the right +button to place one item in each visited slot. Drags starting in the backpack can include ordinary +player inventory slots. Gestures starting in the normal inventory retain Minecraft's usual drag +and double-click behavior. Press a configured hotbar key over a backpack slot to exchange its contents with that +hotbar slot. Double-click a backpack stack to collect matching items from the entire selected backpack +and ordinary player inventory, up to the carried item's stack limit. Item components, +insertion/extraction restrictions and slot limits are respected. All transfers use only the +container's insert/extract capability. A container may redirect insertions (for example, +merging into existing stacks instead of the hovered empty slots), including during drags +and hotbar swaps. Drag previews highlight backpack targets; final counts come from the container +after release. The drawer does not bypass that behavior with direct slot setters. + +Backpack-specific upgrades/settings/crafting screens are not provided. Ordinary clicks with +different carried items do not swap stacks; use a hotbar key for an explicit swap. + +Storage uses six slots per row inside a drawer filling the panel height between the shared +4-pixel top and bottom insets, with the same tinted frame and +darker icon rail as the other drawers. Large inventories are paged with the arrows or mouse wheel. Click the header to cycle +between eligible equipped containers. The main panel stays stationary, opening storage +closes a left information drawer, and the right automation drawer remains independent. +If the window has insufficient space, the tab explains how to make room. Unequipping, +replacing or resizing a container closes the drawer; stale clicks are rejected by the server. +The drawer respects the capability's insertion/extraction filters and limits. + +Some items expose storage only after being initialized through their own normal UI. Such +items show the tab only after initialization. The library does not initialize third-party +storage or interpret its private data. + +For development, `runCompanionClient` and `runCompanionClientSmoke` include Curios, +Sophisticated Backpacks and Sophisticated Core as runtime fixtures. Use +`-PwithoutBackpacks` to omit them. These dependencies do not enter either published jar. +Server GameTests run without these fixtures because Curios login sync requires a negotiated +client connection; the client smoke test covers real backpack discovery and networked transfers. + +## Curios equipment drawer + +With Curios installed, screens that show player inventory also gain a **Curios** tab on +the left. It sits above the backpack tab when an equipped container is available. The +full-height drawer lists the player's visible Curios slot groups in their configured order, +using Curios' names and empty-slot icons. The **E** column contains functional equipment; +the **C** column contains cosmetic equipment where the slot type supports it. A dash means +that slot has no cosmetic counterpart. Arrows and the mouse wheel page through longer lists. + +Left/right clicking equips or unequips through the actual Curios handlers. Shift-clicking +an accessory moves it into ordinary player storage. While this drawer is open, shift-clicking +player storage attempts to equip the item in a compatible **functional** slot; place cosmetic +items directly in the cosmetic column. Existing equipment is not swapped automatically. +Curios' validation, equip/unequip events and removal restrictions still apply. + +Opening this drawer closes backpack storage or the left information drawer; the right +automation drawer remains independent. Escape closes the equipment drawer first. A change +to the available slot groups or counts closes the old view and invalidates stale requests; +reopening shows the updated slots. Curios stays optional, with no tab or class-loading +requirement when absent. This drawer does not change slot definitions or rendering toggles. + ## Proper use These are guidance for screen authors, not rules MMDLib can check: diff --git a/docs/inventory-automation.md b/docs/inventory-automation.md index 737b6fe3..c3a6289d 100644 --- a/docs/inventory-automation.md +++ b/docs/inventory-automation.md @@ -20,10 +20,11 @@ on the left; inventory side configuration stays on the right. Filter controls oc 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 +visible controls and grows below the main panel as needed to fit an open filter, stopping at +the screen margin or JEI toolbar limit; 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 +height. All drawers share the same frame renderer and four-pixel top inset. The configuration +drawer can grow to the available screen height; inventory drawers retain the panel's bottom inset. Info drawers fit their text up to the inset bottom or the screen edge, whichever comes first. ## Define independent resource groups diff --git a/docs/machine-companion.md b/docs/machine-companion.md index 438bc211..0c2e3b4d 100644 --- a/docs/machine-companion.md +++ b/docs/machine-companion.md @@ -7,6 +7,12 @@ in `src/companion` and are packaged separately from the library. ## Play Run `./gradlew runCompanionClient`, create a creative world, and open the **MMDLib Machines** tab. + +For a personal window size, add `mmdlibClientWidth=2560` and `mmdlibClientHeight=1440` to +`~/.gradle/gradle.properties` (or the file in your `GRADLE_USER_HOME`). These optional settings +apply to `runClient` and `runCompanionClient` only and stay outside the repository. Without them, +Minecraft uses its default window size. Automated smoke runs retain their own fixed dimensions. + Place a **Creative Power Source** next to a machine, then right-click the machine to open its screen. The source supplies all six adjacent faces. JEI is included in development runs; add `-PwithoutJei` to run without it. diff --git a/gradle.properties b/gradle.properties index ce599b0d..37d916dd 100644 --- a/gradle.properties +++ b/gradle.properties @@ -31,6 +31,12 @@ parchment_mappings_version = 2024.11.17 jei_version = 19.57.0.451 jei_version_range = [19,) +# Optional equipment API; backpack mods are companion runtime fixtures only. +curios_version = 9.5.1+1.21.1 +curios_version_range = [9.5.1,10) +sophisticated_backpacks_version = 1.21.1-3.26.20.2244 +sophisticated_core_version = 1.21.1-1.5.28.2458 + # Gradle org.gradle.jvmargs = -Xmx3G org.gradle.daemon = false diff --git a/scripts/companion.gradle b/scripts/companion.gradle index 7fa38bde..161c0953 100644 --- a/scripts/companion.gradle +++ b/scripts/companion.gradle @@ -7,10 +7,26 @@ sourceSets { compileClasspath += sourceSets.companion.output + sourceSets.companion.compileClasspath runtimeClasspath += sourceSets.companion.output + sourceSets.companion.runtimeClasspath } + // Mock GameTest connections cannot negotiate Curios' login-sync payloads. + // Reuse the compiled fixtures, but keep optional mods for real client/server smoke runs. + companionServerTest { + runtimeClasspath += sourceSets.companionTest.output + sourceSets.companion.output + sourceSets.main.output + sourceSets.main.runtimeClasspath + } } neoForge.addModdingDependenciesTo(sourceSets.companion) neoForge.addModdingDependenciesTo(sourceSets.companionTest) +neoForge.addModdingDependenciesTo(sourceSets.companionServerTest) + +// Development fixtures only: do not add backpack APIs or required mod metadata to either jar. +// -PwithoutBackpacks retains a companion run without Curios or backpack mods. +if (!providers.gradleProperty('withoutBackpacks').isPresent()) { + dependencies { + companionRuntimeOnly "top.theillusivec4.curios:curios-neoforge:${curios_version}" + companionRuntimeOnly "maven.modrinth:sophisticated-backpacks:${sophisticated_backpacks_version}" + companionRuntimeOnly "maven.modrinth:sophisticated-core:${sophisticated_core_version}" + } +} neoForge { mods { @@ -40,7 +56,7 @@ neoForge { companionGameTestServer { type = 'gameTestServer' gameDirectory = layout.buildDirectory.dir('companion-gametest') - sourceSet = sourceSets.companionTest + sourceSet = sourceSets.companionServerTest loadedMods = [neoForge.mods.named(mod_id).get(), neoForge.mods.mmdlib_companion_test] systemProperty 'neoforge.enabledGameTestNamespaces', 'mmdlib_companion' } @@ -69,7 +85,7 @@ tasks.matching { it.name == 'runCompanionClientSmoke' }.configureEach { doFirst { def smokeDir = layout.buildDirectory.dir('companion-client-smoke').get().asFile smokeDir.mkdirs() - new File(smokeDir, 'options.txt').text = 'guiScale:2\nmaxFps:30\nonboardAccessibility:false\n' + new File(smokeDir, 'options.txt').text = 'guiScale:2\nmaxFps:30\nonboardAccessibility:false\npauseOnLostFocus:false\n' } doLast { def log = layout.buildDirectory.file('companion-client-smoke/logs/latest.log').get().asFile @@ -94,6 +110,13 @@ tasks.register('verifyCompanionPackaging') { new java.util.zip.ZipFile(tasks.named('jar').get().archiveFile.get().asFile).withCloseable { jar -> def leaked = jar.entries().findAll { it.name.contains('mmdlib_companion') || it.name.startsWith('zone/moddev/mc/companion/') } if (leaked) throw new GradleException('Companion content in MMDLib jar: ' + leaked*.name) + def bundledIntegrations = jar.entries().findAll { it.name.startsWith('top/theillusivec4/') || it.name.startsWith('net/p3pp3rf1y/') } + if (bundledIntegrations) throw new GradleException('Optional integration classes bundled in MMDLib: ' + bundledIntegrations*.name) + def metadata = jar.getInputStream(jar.getEntry('META-INF/neoforge.mods.toml')).getText('UTF-8') + if (!(metadata =~ /(?s)modId="curios"\s+type="optional"/).find()) + throw new GradleException('Curios must be declared optional in the release jar') + if (metadata.contains('sophisticatedbackpacks') || metadata.contains('sophisticatedcore')) + throw new GradleException('Backpack test fixtures must not be dependencies of MMDLib') } new java.util.zip.ZipFile(companionJar.get().archiveFile.get().asFile).withCloseable { jar -> def leaked = jar.entries().findAll { it.name.startsWith('zone/moddev/mc/lib/') || it.name.startsWith('zone/moddev/mc/companion/test/') || it.name.contains('/structure/') } diff --git a/scripts/gui-smoke.init.gradle b/scripts/gui-smoke.init.gradle index 282f205c..d2963800 100644 --- a/scripts/gui-smoke.init.gradle +++ b/scripts/gui-smoke.init.gradle @@ -9,7 +9,7 @@ allprojects { def smokeDir = file(providers.gradleProperty('withoutJei').isPresent() ? 'build/gui-smoke-without-jei' : 'build/gui-smoke') smokeDir.mkdirs() new File(smokeDir, 'options.txt').text = 'guiScale:2\nmaxFps:30\nonboardAccessibility:false\n' - neoForge.runs.client.programArguments.addAll '--width', '1280', '--height', '960' + neoForge.runs.client.programArguments.set(['--width', '1280', '--height', '960']) neoForge.runs.client.gameDirectory = smokeDir neoForge.runs.client.systemProperty 'mmdlib.guiSmoke', 'true' tasks.configureEach { task -> diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/AutomationClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/AutomationClientSmoke.java index e9c5e064..17269c50 100644 --- a/src/companionTest/java/zone/moddev/mc/companion/test/AutomationClientSmoke.java +++ b/src/companionTest/java/zone/moddev/mc/companion/test/AutomationClientSmoke.java @@ -17,8 +17,15 @@ final class AutomationClientSmoke { private static int stage; private static int compactHeight; + private static zone.moddev.mc.lib.gui.layout.Rect automationBounds(MachineScreen screen) { + return screen.exclusions().stream() + .filter(rect -> rect.x() >= screen.panelBounds().right() && rect.width() > 22) + .findFirst() + .orElseThrow(); + } + private static void toggleFilter(MachineScreen screen) { - var drawer = screen.exclusions().getLast(); + var drawer = automationBounds(screen); 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"); @@ -37,7 +44,7 @@ static boolean renderTooltipCapture(MachineScreen screen, GuiGraphics graphics, return true; } if (stage != 8 || !screen.getMenu().automation.open()) return false; - var drawer = screen.exclusions().getLast(); + var drawer = automationBounds(screen); screen.renderWithTooltip(graphics, drawer.x() + 81, drawer.y() + 52, partial); return true; } @@ -68,7 +75,7 @@ static boolean run(MachineScreen screen) { 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 drawer = automationBounds(screen); 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"); @@ -110,19 +117,19 @@ static boolean run(MachineScreen screen) { if (stage == 3) { if (a.editor() != 0) throw new AssertionError("Filter editor opens"); var exclusions = screen.exclusions(); - if (exclusions.size() != 3) + if (exclusions.stream().filter(rect -> rect.x() >= screen.panelBounds().right()).count() != 2) throw new AssertionError("Filter must remain inside the existing drawer exclusion"); - var drawer = exclusions.getLast(); + var drawer = automationBounds(screen); 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"); + "A large filter should grow beyond the panel when the screen permits it"); Screenshot.grab( mc.gameDirectory, "automation-filter-grid.png", mc.getMainRenderTarget(), m -> {}); screen.resize(mc, screen.width, 360); - var limited = screen.exclusions().getLast(); + var limited = automationBounds(screen); 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)) @@ -162,7 +169,7 @@ static boolean run(MachineScreen screen) { return false; } if (stage == 6) { - if (screen.exclusions().getLast().height() >= screen.panelBounds().height()) + if (automationBounds(screen).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 -> {}); @@ -191,7 +198,7 @@ static boolean run(MachineScreen screen) { || 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) + if (automationBounds(screen).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( @@ -256,7 +263,7 @@ static boolean run(MachineScreen screen) { 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(); + var drawer = automationBounds(screen); if (!screen.mouseClicked( drawer.x() + AutomationLayout.EDITOR_X + 10, drawer.y() + AutomationLayout.EDITOR_Y + AutomationLayout.EDITOR_PADDING + 5, diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/BackpackClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/BackpackClientSmoke.java new file mode 100644 index 00000000..7b99a727 --- /dev/null +++ b/src/companionTest/java/zone/moddev/mc/companion/test/BackpackClientSmoke.java @@ -0,0 +1,136 @@ +package zone.moddev.mc.companion.test; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.Screenshot; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.neoforged.neoforge.capabilities.Capabilities; +import zone.moddev.mc.lib.gui.client.MachineScreen; +import zone.moddev.mc.lib.gui.layout.ContainerDrawerLayout; +import zone.moddev.mc.lib.gui.runtime.ContainerDrawerMenu; +import zone.moddev.mc.lib.gui.runtime.MachineMenu; + +/** Real negotiated client/server coverage for the optional Curios and backpack runtime fixtures. */ +final class BackpackClientSmoke { + private static int step; + private static volatile boolean verified; + + static boolean run(MachineScreen screen) { + if (!BackpackFixture.installed()) { + require( + screen.getMenu().containerDrawer.state().icons().isEmpty(), + "No backpack button without optional mods"); + return true; + } + var mc = Minecraft.getInstance(); + var menu = screen.getMenu(); + var drawer = menu.containerDrawer; + if (drawer.pending()) return false; + var state = drawer.state(); + if (step == 5) return verified && state.icons().isEmpty(); + if (state.icons().isEmpty()) return false; + var layout = + ContainerDrawerLayout.of( + screen.panelBounds(), screen.drawerBottom(), state.sizes().get(state.selected())); + require(layout.pageSize() > 0, "Smoke window has room for storage"); + require(layout.columns() == 6, "Backpack storage wraps at six columns"); + require( + layout.body().y() == screen.panelBounds().y() + 4 + && layout.body().bottom() == screen.panelBounds().bottom() - 4, + "Drawer spans the full machine height"); + switch (step) { + case 0 -> { + require( + state.icons().getFirst().getHoverName().getString().equals("Copper supplies"), + "Actual named equipped item is the tab icon"); + click(screen, layout.tab(), 0); + step++; + } + case 1 -> { + if (!state.open() || state.pageSize() != layout.pageSize()) return false; + require(state.contents().getFirst().getCount() == 24, "Backpack contents reached client"); + require(screen.exclusions().contains(layout.body()), "Drawer is excluded from JEI"); + require( + screen.exclusions().contains(layout.rail()), + "Full-height icon rail is excluded from JEI"); + Screenshot.grab( + mc.gameDirectory, "backpack-drawer.png", mc.getMainRenderTarget(), message -> {}); + click(screen, layout.cell(0), 1); + step++; + } + case 2 -> { + if (menu.getCarried().getCount() != 12) return false; + require( + menu.getCarried().is(Items.IRON_INGOT) && state.contents().getFirst().getCount() == 12, + "Right click transfers half through the network"); + click( + screen, + new zone.moddev.mc.lib.gui.layout.Rect( + layout.rail().x(), layout.rail().y() + 60, 22, 24), + 0); + require( + menu.getCarried().getCount() == 12 && !drawer.pending(), + "Rail clicks retain carried items"); + click(screen, layout.cell(0), 0); + step++; + } + case 3 -> { + if (!menu.getCarried().isEmpty() || state.contents().getFirst().getCount() != 24) + return false; + drawer + .request(ContainerDrawerMenu.QUICK_TAKE, 0, state.pageSize()) + .ifPresent(net.neoforged.neoforge.network.PacketDistributor::sendToServer); + step++; + } + case 4 -> { + if (!BackpackInteractionClientSmoke.run(screen)) return false; + if (!state.contents().getFirst().isEmpty()) return false; + require( + mc.player.getInventory().countItem(Items.IRON_INGOT) == 24, + "Shift transfer reaches player inventory"); + step++; + mc.getSingleplayerServer() + .execute( + () -> { + try { + var player = mc.getSingleplayerServer().getPlayerList().getPlayers().getFirst(); + var source = + zone.moddev.mc.lib.gui.runtime.EquippedContainers.find(player).getFirst(); + var saved = source.stack().save(player.registryAccess()); + var restored = ItemStack.parse(player.registryAccess(), saved).orElseThrow(); + require( + restored + .getCapability(Capabilities.ItemHandler.ITEM) + .getStackInSlot(0) + .isEmpty(), + "Modified storage survives item serialization"); + require( + restored + .getCapability(Capabilities.ItemHandler.ITEM) + .getStackInSlot(1) + .getCount() + == 12, + "Other storage remains intact"); + BackpackFixture.unequip(player); + ((MachineMenu) player.containerMenu).containerDrawer.broadcast(); + verified = true; + } catch (Throwable error) { + CompanionClientSmoke.fail(error); + } + }); + } + default -> throw new AssertionError("Unexpected backpack test step"); + } + return false; + } + + private static void click( + MachineScreen screen, zone.moddev.mc.lib.gui.layout.Rect rect, int button) { + require(screen.mouseClicked(rect.x() + 5, rect.y() + 5, button), "Drawer click handled"); + screen.mouseReleased(rect.x() + 5, rect.y() + 5, button); + } + + private static void require(boolean ok, String message) { + if (!ok) throw new AssertionError(message); + } +} diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/BackpackFixture.java b/src/companionTest/java/zone/moddev/mc/companion/test/BackpackFixture.java new file mode 100644 index 00000000..4b8c0f77 --- /dev/null +++ b/src/companionTest/java/zone/moddev/mc/companion/test/BackpackFixture.java @@ -0,0 +1,68 @@ +package zone.moddev.mc.companion.test; + +import net.minecraft.core.component.DataComponents; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionHand; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.neoforged.fml.ModList; +import net.neoforged.neoforge.capabilities.Capabilities; +import top.theillusivec4.curios.api.CuriosApi; + +/** Test-only fixture: exercises the real optional runtime mods without linking a backpack API. */ +final class BackpackFixture { + static boolean installed() { + return ModList.get().isLoaded("sophisticatedbackpacks"); + } + + static ItemStack equip(ServerPlayer player) { + var bag = + new ItemStack( + BuiltInRegistries.ITEM.get(ResourceLocation.parse("sophisticatedbackpacks:backpack"))); + require(!bag.isEmpty(), "Runtime backpack fixture registered"); + bag.set(DataComponents.CUSTOM_NAME, Component.literal("Copper supplies")); + var previous = player.getMainHandItem(); + player.setItemInHand(InteractionHand.MAIN_HAND, bag); + // Normal use creates the backpack's own storage identity and initializes its capability. + bag.use(player.level(), player, InteractionHand.MAIN_HAND); + player.closeContainer(); + player.setItemInHand(InteractionHand.MAIN_HAND, previous); + var slots = + CuriosApi.getCuriosInventory(player) + .orElseThrow() + .getStacksHandler("back") + .orElseThrow() + .getStacks(); + slots.setStackInSlot(0, bag); + var handler = bag.getCapability(Capabilities.ItemHandler.ITEM); + require( + handler != null && handler.getSlots() >= 27, + "Initialized backpack exposes generic item storage"); + require( + handler.insertItem(0, new ItemStack(Items.IRON_INGOT, 24), false).isEmpty(), + "Fixture accepts iron"); + require( + handler.insertItem(1, new ItemStack(Items.COPPER_INGOT, 12), false).isEmpty(), + "Fixture accepts copper"); + require( + handler.insertItem(2, new ItemStack(Items.REDSTONE, 32), false).isEmpty(), + "Fixture accepts redstone"); + return bag; + } + + static void unequip(ServerPlayer player) { + CuriosApi.getCuriosInventory(player) + .orElseThrow() + .getStacksHandler("back") + .orElseThrow() + .getStacks() + .setStackInSlot(0, ItemStack.EMPTY); + } + + private static void require(boolean ok, String message) { + if (!ok) throw new AssertionError(message); + } +} diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/BackpackInteractionClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/BackpackInteractionClientSmoke.java new file mode 100644 index 00000000..26f70313 --- /dev/null +++ b/src/companionTest/java/zone/moddev/mc/companion/test/BackpackInteractionClientSmoke.java @@ -0,0 +1,254 @@ +package zone.moddev.mc.companion.test; + +import net.minecraft.client.Minecraft; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import zone.moddev.mc.lib.gui.client.MachineScreen; +import zone.moddev.mc.lib.gui.layout.ContainerDrawerLayout; +import zone.moddev.mc.lib.gui.layout.Rect; +import zone.moddev.mc.lib.gui.runtime.EquippedContainers; + +/** Real mouse gestures and hotbar keys, including a double-click before pickup acknowledgement. */ +final class BackpackInteractionClientSmoke { + private static int step, reported = -1; + private static volatile boolean ready; + + static boolean run(MachineScreen screen) { + var mc = Minecraft.getInstance(); + var menu = screen.getMenu(); + var state = menu.containerDrawer.state(); + if (reported != step) { + reported = step; + zone.moddev.mc.lib.MMDLib.LOGGER.info("BACKPACK_INTERACTION_STEP {}", step); + } + if (menu.containerDrawer.pending()) return false; + var layout = + ContainerDrawerLayout.of( + screen.panelBounds(), screen.drawerBottom(), state.sizes().getFirst()); + switch (step) { + case 0 -> + setup( + player -> { + var handler = EquippedContainers.find(player).getFirst().items(); + for (int i = 0; i < handler.getSlots(); i++) + handler.extractItem(i, Integer.MAX_VALUE, false); + player.getInventory().clearContent(); + player.containerMenu.setCarried(new ItemStack(Items.IRON_INGOT, 10)); + }); + case 1 -> { + if (!ready || menu.getCarried().getCount() != 10) return false; + drag(screen, 0, layout.cell(0), layout.cell(3), layout.cell(4)); + } + case 2 -> { + require( + menu.getCarried().getCount() == 1 && state.contents().get(0).getCount() == 9, + "Left drag preserves remainder while the capability routes insertions"); + setup(player -> player.containerMenu.setCarried(new ItemStack(Items.IRON_INGOT, 5))); + } + case 3 -> { + if (!ready || menu.getCarried().getCount() != 5) return false; + drag(screen, 1, layout.cell(5), layout.cell(6)); + } + case 4 -> { + require( + menu.getCarried().getCount() == 3 && state.contents().get(0).getCount() == 11, + "Right drag inserts one per target while respecting capability routing"); + setup( + player -> { + player.containerMenu.setCarried(ItemStack.EMPTY); + var handler = EquippedContainers.find(player).getFirst().items(); + ((net.neoforged.neoforge.items.IItemHandlerModifiable) handler) + .setStackInSlot(7, new ItemStack(Items.IRON_INGOT, 10)); + ((net.neoforged.neoforge.items.IItemHandlerModifiable) handler) + .setStackInSlot(8, new ItemStack(Items.IRON_INGOT, 20)); + player.getInventory().setItem(9, new ItemStack(Items.IRON_INGOT, 12)); + }); + } + case 5 -> { + if (!ready || !menu.getCarried().isEmpty() || state.contents().get(7).getCount() != 10) + return false; + click(screen, layout.cell(7)); + click(screen, layout.cell(7)); + } + case 6 -> { + require( + menu.getCarried().getCount() == 53, + "Collection cursor=" + menu.getCarried() + ", contents=" + state.contents()); + require( + state.contents().stream().noneMatch(s -> s.is(Items.IRON_INGOT)) + && mc.player.getInventory().getItem(9).isEmpty(), + "Double click collects backpack and player stacks"); + setup( + player -> { + player.containerMenu.setCarried(ItemStack.EMPTY); + player.getInventory().setItem(0, new ItemStack(Items.DIAMOND, 2)); + ((net.neoforged.neoforge.items.IItemHandlerModifiable) + EquippedContainers.find(player).getFirst().items()) + .setStackInSlot(9, new ItemStack(Items.COPPER_INGOT, 5)); + }); + } + case 7 -> { + if (!ready + || !menu.getCarried().isEmpty() + || !mc.player.getInventory().getItem(0).is(Items.DIAMOND)) return false; + screen.mouseMoved(layout.cell(9).x() + 5, layout.cell(9).y() + 5); + require(screen.keyPressed(49, 0, 0), "Number key handled over backpack"); + } + case 8 -> { + require( + state.contents().stream() + .filter(s -> s.is(Items.DIAMOND)) + .mapToInt(ItemStack::getCount) + .sum() + == 2 + && mc.player.getInventory().getItem(0).is(Items.COPPER_INGOT) + && mc.player.getInventory().getItem(0).getCount() == 5, + "Number key swaps with hotbar"); + setup( + player -> { + player.getInventory().clearContent(); + player.containerMenu.setCarried(new ItemStack(Items.IRON_INGOT, 10)); + }); + } + case 9 -> { + if (!ready || menu.getCarried().getCount() != 10) return false; + beginDrag(screen, 0, playerCell(screen, 9), playerCell(screen, 10), playerCell(screen, 11)); + } + case 10 -> release(screen, 0, playerCell(screen, 11)); + case 11 -> { + require( + menu.getCarried().getCount() == 1 + && mc.player.getInventory().getItem(9).getCount() == 3 + && mc.player.getInventory().getItem(10).getCount() == 3 + && mc.player.getInventory().getItem(11).getCount() == 3, + "Normal inventory left drag survives frames and release with backpack open"); + setup( + player -> { + player.getInventory().clearContent(); + player.containerMenu.setCarried(new ItemStack(Items.IRON_INGOT, 5)); + }); + } + case 12 -> { + if (!ready || menu.getCarried().getCount() != 5) return false; + beginDrag(screen, 1, playerCell(screen, 12), playerCell(screen, 13)); + } + case 13 -> release(screen, 1, playerCell(screen, 13)); + case 14 -> { + require( + menu.getCarried().getCount() == 3 + && mc.player.getInventory().getItem(12).getCount() == 1 + && mc.player.getInventory().getItem(13).getCount() == 1, + "Normal inventory right drag survives frames and release with backpack open"); + setup( + player -> { + player.containerMenu.setCarried(ItemStack.EMPTY); + player.getInventory().clearContent(); + player.getInventory().setItem(9, new ItemStack(Items.IRON_INGOT, 10)); + player.getInventory().setItem(10, new ItemStack(Items.IRON_INGOT, 20)); + }); + } + case 15 -> { + if (!ready + || !menu.getCarried().isEmpty() + || mc.player.getInventory().getItem(9).getCount() != 10) return false; + click(screen, playerCell(screen, 9)); + click(screen, playerCell(screen, 9)); + } + case 16 -> { + require( + menu.getCarried().getCount() == 30 + && mc.player.getInventory().getItem(9).isEmpty() + && mc.player.getInventory().getItem(10).isEmpty(), + "Normal inventory double click collects with backpack open"); + setup(player -> player.containerMenu.setCarried(new ItemStack(Items.IRON_INGOT, 10))); + } + case 17 -> { + if (!ready || menu.getCarried().getCount() != 10) return false; + beginDrag(screen, 0, layout.cell(3), playerCell(screen, 9), playerCell(screen, 10)); + } + case 18 -> release(screen, 0, playerCell(screen, 10)); + case 19 -> { + require( + menu.getCarried().getCount() == 1 + && mc.player.getInventory().getItem(9).getCount() == 3 + && mc.player.getInventory().getItem(10).getCount() == 3 + && state.contents().stream() + .filter(s -> s.is(Items.IRON_INGOT)) + .mapToInt(ItemStack::getCount) + .sum() + == 3, + "Backpack drag into player slots survives frames and release"); + setup( + player -> { + var handler = EquippedContainers.find(player).getFirst().items(); + for (int i = 0; i < handler.getSlots(); i++) + handler.extractItem(i, Integer.MAX_VALUE, false); + // Seed the fixture's exact slots; production intentionally uses only I/O. + ((net.neoforged.neoforge.items.IItemHandlerModifiable) handler) + .setStackInSlot(1, new ItemStack(Items.COPPER_INGOT, 12)); + player.getInventory().clearContent(); + player.getInventory().setItem(0, new ItemStack(Items.IRON_INGOT, 24)); + player.containerMenu.setCarried(ItemStack.EMPTY); + }); + } + default -> { + return ready + && state.contents().get(1).getCount() == 12 + && state.contents().get(9).isEmpty(); + } + } + step++; + return false; + } + + private static void setup( + java.util.function.Consumer action) { + ready = false; + var server = Minecraft.getInstance().getSingleplayerServer(); + server.execute( + () -> { + try { + var player = server.getPlayerList().getPlayers().getFirst(); + action.accept(player); + player.containerMenu.broadcastChanges(); + ready = true; + } catch (Throwable error) { + CompanionClientSmoke.fail(error); + } + }); + } + + private static void drag(MachineScreen screen, int button, Rect... cells) { + beginDrag(screen, button, cells); + release(screen, button, cells[cells.length - 1]); + } + + private static void beginDrag(MachineScreen screen, int button, Rect... cells) { + var first = cells[0]; + require(screen.mouseClicked(first.x() + 5, first.y() + 5, button), "Drag press handled"); + for (var cell : cells) screen.mouseDragged(cell.x() + 5, cell.y() + 5, button, 18, 0); + } + + private static void release(MachineScreen screen, int button, Rect last) { + require(screen.mouseReleased(last.x() + 5, last.y() + 5, button), "Drag release handled"); + } + + private static Rect playerCell(MachineScreen screen, int index) { + var inventory = Minecraft.getInstance().player.getInventory(); + var slot = + screen.getMenu().slots.stream() + .filter(s -> s.container == inventory && s.getContainerSlot() == index) + .findFirst() + .orElseThrow(); + return new Rect(screen.guiLeft() + slot.x, screen.guiTop() + slot.y, 16, 16); + } + + private static void click(MachineScreen screen, Rect cell) { + screen.mouseClicked(cell.x() + 5, cell.y() + 5, 0); + screen.mouseReleased(cell.x() + 5, cell.y() + 5, 0); + } + + private static void require(boolean ok, String message) { + if (!ok) throw new AssertionError(message); + } +} 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 9ff77b4d..58fcbe67 100644 --- a/src/companionTest/java/zone/moddev/mc/companion/test/CompanionClientSmoke.java +++ b/src/companionTest/java/zone/moddev/mc/companion/test/CompanionClientSmoke.java @@ -35,9 +35,11 @@ @EventBusSubscriber(modid = CompanionMod.ID, value = Dist.CLIENT) public final class CompanionClientSmoke { private static final BlockPos POS = new BlockPos(0, 100, 0); - private static final long DEADLINE = System.currentTimeMillis() + 180_000; + private static final long DEADLINE = System.currentTimeMillis() + 240_000; private static boolean started, opening, menuRequested, finished; private static int stage, frames, waitingTicks, drawerStage, fluidStage, controlStage; + private static boolean controlFixtureRequested; + private static volatile boolean controlFixtureReady; @SubscribeEvent public static void render(ScreenEvent.Render.Pre event) { @@ -126,6 +128,7 @@ public static void tick(ClientTickEvent.Post event) { return; } if (stage == CompanionMod.MACHINES.size()) { + if (!DrawerMemoryClientSmoke.run()) return; finished = true; MMDLib.LOGGER.info( "COMPANION_CLIENT_PASS: all five menus rendered and received their values"); @@ -142,6 +145,10 @@ public static void tick(ClientTickEvent.Post event) { var player = mc.getSingleplayerServer().getPlayerList().getPlayers().getFirst(); var level = player.serverLevel(); player.closeContainer(); + if (current == 0 && BackpackFixture.installed()) { + BackpackFixture.equip(player); + CuriosFixture.setup(player); + } level.getChunkAt(POS); level.setBlock( POS.offset(0, 0, 2), @@ -200,6 +207,11 @@ 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 == 0 && (!CuriosClientSmoke.run(screen) || !BackpackClientSmoke.run(screen))) { + frames = 0; + waitingTicks = 0; + return; + } if (stage == 4 && !AutomationClientSmoke.run(screen)) { frames = 0; waitingTicks = 0; @@ -210,16 +222,27 @@ public static void frame(RenderFrameEvent.Post event) { waitingTicks = 0; return; } + if (stage == 0 && !prepareControlFixture()) { + 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"); + var firstStorage = zone.moddev.mc.lib.gui.layout.ScreenLayout.playerSlot(inventory, 9); + var lastStorage = zone.moddev.mc.lib.gui.layout.ScreenLayout.playerSlot(inventory, 17); + if (row.y() != inventory.y() || row.bottom() + 4 != firstStorage.y()) + throw new AssertionError("Toolbar shares the helmet row directly above inventory slots"); 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()) + if (rect.y() != row.y() + || rect.x() <= row.right() + || rect.right() > lastStorage.right()) throw new AssertionError( "Colour selectors and machine controls share one non-overlapping row"); } @@ -356,10 +379,11 @@ 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() != 3 || exclusions.get(1).right() != tab.x()) + if (exclusions.size() != 3 + (menu.curiosDrawer.state().icons().isEmpty() ? 0 : 1) + || 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() + || exclusions.get(1).bottom() > screen.panelBounds().bottom() - 4 || exclusions.get(1).bottom() > screen.drawerBottom()) throw new AssertionError( "The info drawer must fit its content within the main panel and screen height" @@ -370,7 +394,10 @@ public static void frame(RenderFrameEvent.Post event) { } Screenshot.grab( mc.gameDirectory, name + "-drawer.png", mc.getMainRenderTarget(), message -> {}); - if (!screen.mouseClicked(tab.x() + 10, screen.exclusions().getFirst().bottom() - 8, 0)) + // The compact panel puts equipment tabs at the bottom of the same rail. + // Exercise empty rail below the info tab, not another drawer's tab. + int railY = tab.bottom() + 4; + if (!screen.mouseClicked(tab.x() + 10, railY, 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 // these clicks reached inventory handling instead of being consumed by the drawer. @@ -381,19 +408,20 @@ public static void frame(RenderFrameEvent.Post event) { for (int button : new int[] {2, 3, 4}) for (var point : new double[][] { - {tab.x() + 10, screen.exclusions().getFirst().bottom() - 8}, + {tab.x() + 10, railY}, {tab.x() - 10, tab.y() + 10} }) { lastButton.setInt(screen, -1); screen.mouseClicked(point[0], point[1], button); if (lastButton.getInt(screen) != button) throw new AssertionError("Drawer swallowed mouse button " + button); - if (screen.exclusions().size() != 3) + if (screen.exclusions().size() + != 3 + (menu.curiosDrawer.state().icons().isEmpty() ? 0 : 1)) 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() != 2) + if (screen.exclusions().size() != 2 + (menu.curiosDrawer.state().icons().isEmpty() ? 0 : 1)) throw new AssertionError("The icon tab did not close its drawer"); drawerStage = 0; if (stage == 0) { @@ -422,7 +450,40 @@ public static void frame(RenderFrameEvent.Post event) { } } - private static void fail(Throwable error) { + private static boolean prepareControlFixture() { + if (controlFixtureReady) return true; + if (!controlFixtureRequested) { + controlFixtureRequested = true; + var server = Minecraft.getInstance().getSingleplayerServer(); + server.execute( + () -> { + try { + var machine = (ProcessorEntity) server.overworld().getBlockEntity(POS); + // Reproduce a slow inventory/widget prelude without sleeping: its original recipe + // inputs must be allowed to run out before we prepare the independent control phase. + for (int i = 0; i < 2_000; i++) machine.serverTick(); + if (!machine.items.getStackInSlot(0).isEmpty()) + throw new AssertionError( + "Slow-prelude regression did not exhaust the original input"); + machine.lanes.forEach(zone.moddev.mc.lib.machine.feature.RecipeProcessor::reset); + for (int i = 0; i < machine.items.getSlots(); i++) + machine.items.setStackInSlot(i, ItemStack.EMPTY); + machine.items.setStackInSlot(0, new ItemStack(Items.COPPER_ORE, 16)); + machine.energy.setStored(80_000); + for (int i = 0; i < 40; i++) machine.serverTick(); + if (machine.lanes.getFirst().time() <= 0) + throw new AssertionError("Fresh control fixture did not start processing"); + server.getPlayerList().getPlayers().getFirst().containerMenu.broadcastChanges(); + controlFixtureReady = true; + } catch (Throwable error) { + fail(error); + } + }); + } + return false; + } + + static void fail(Throwable error) { finished = true; MMDLib.LOGGER.error("COMPANION_CLIENT_FAIL", error); Minecraft.getInstance().execute(() -> Minecraft.getInstance().stop()); diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/CompanionWidgetSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/CompanionWidgetSmoke.java index 14e697df..03dc445d 100644 --- a/src/companionTest/java/zone/moddev/mc/companion/test/CompanionWidgetSmoke.java +++ b/src/companionTest/java/zone/moddev/mc/companion/test/CompanionWidgetSmoke.java @@ -13,6 +13,8 @@ /** Real client focus, text editing, captures, resize, navigation and acknowledged actions. */ final class CompanionWidgetSmoke { private static int step; + private static zone.moddev.mc.lib.gui.layout.Rect compactPanel; + private static java.util.List originalSlots; private CompanionWidgetSmoke() {} @@ -46,13 +48,45 @@ static boolean run(MachineScreen s) { var n = s.bounds(name); var r = s.bounds(slider); var l = s.bounds(selector); + if (originalSlots != null) { + for (int i = 0; i < menu.slots.size(); i++) { + var slot = menu.slots.get(i); + if (slot != originalSlots.get(i)) + throw new AssertionError("View change replaced menu slot"); + if (slot.container == mc.player.getInventory()) { + var expected = + zone.moddev.mc.lib.gui.layout.ScreenLayout.playerSlot( + s.layout.inventory().orElseThrow(), slot.getSlotIndex()); + if (slot.x != expected.x() + 1 || slot.y != expected.y() + 1) + throw new AssertionError("Resized player slot does not match its background"); + } + } + for (var element : menu.elements) { + if (element instanceof Element.Slot slotElement) { + var slot = menu.slots.get(slotElement.index()); + var expected = + zone.moddev.mc.lib.gui.layout.ScreenLayout.itemPosition(s.layout.bounds(element)); + if (slot.x != expected[0] || slot.y != expected[1]) + throw new AssertionError("Resized machine slot does not match its background"); + } + } + if (s.guiLeft() != (s.width - s.panelBounds().width()) / 2 + || s.guiTop() != (s.height - s.panelBounds().height()) / 2) + throw new AssertionError("Resized panel is not centred"); + } switch (step) { case 0 -> { + compactPanel = s.panelBounds(); + originalSlots = java.util.List.copyOf(menu.slots); + Screenshot.grab( + mc.gameDirectory, "widget-collapsed.png", mc.getMainRenderTarget(), message -> {}); var open = s.bounds(menu.bound.screen().find("open_settings").orElseThrow()); click(s, open.x() + 8, open.y() + 8); } case 1 -> { if (!s.visible(name) || !(menu.value(name, false) instanceof Component)) return false; + if (s.panelBounds().height() <= compactPanel.height()) + throw new AssertionError("Settings did not expand the panel"); // A recipe browser may remove the screen and later reinitialize the same object. s.removed(); s.resize(mc, s.width, s.height); @@ -91,6 +125,8 @@ static boolean run(MachineScreen s) { } case 5 -> { if (s.visible(name)) return false; + if (!s.panelBounds().equals(compactPanel)) + throw new AssertionError("Closing Settings did not restore compact panel bounds"); if (s.charTyped('z', 0)) throw new AssertionError("Hidden widget retained keyboard focus"); var open = s.bounds(menu.bound.screen().find("open_settings").orElseThrow()); click(s, open.x() + 8, open.y() + 8); diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/CuriosClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/CuriosClientSmoke.java new file mode 100644 index 00000000..8d7b4875 --- /dev/null +++ b/src/companionTest/java/zone/moddev/mc/companion/test/CuriosClientSmoke.java @@ -0,0 +1,247 @@ +package zone.moddev.mc.companion.test; + +import java.util.function.Consumer; +import net.minecraft.client.Minecraft; +import net.minecraft.client.Screenshot; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.neoforged.fml.ModList; +import net.neoforged.neoforge.network.PacketDistributor; +import zone.moddev.mc.lib.gui.client.MachineScreen; +import zone.moddev.mc.lib.gui.layout.ContainerDrawerLayout; +import zone.moddev.mc.lib.gui.layout.Rect; +import zone.moddev.mc.lib.gui.runtime.ContainerDrawerMenu; + +/** Networked functional/cosmetic equipment, optional absence, paging and dynamic-slot checks. */ +final class CuriosClientSmoke { + private static int step, originalSize; + private static int reportedStep = -1; + private static volatile boolean staleChecked; + + static boolean run(MachineScreen screen) { + var mc = Minecraft.getInstance(); + var menu = screen.getMenu(); + var drawer = menu.curiosDrawer; + if (reportedStep != step) { + reportedStep = step; + zone.moddev.mc.lib.MMDLib.LOGGER.info("CURIOS_DRAWER_STEP {}", step); + } + if (!ModList.get().isLoaded("curios")) { + require(drawer.state().icons().isEmpty(), "Curios is optional on the client"); + return true; + } + if (step == 20) return !drawer.state().open(); + if (drawer.pending() || menu.containerDrawer.pending()) return false; + var state = drawer.state(); + if (state.icons().isEmpty()) return false; + var layout = + ContainerDrawerLayout.equipment( + screen.panelBounds(), + screen.drawerBottom(), + state.sizes().getFirst(), + !menu.containerDrawer.state().icons().isEmpty()); + require( + layout.pageSize() > 0 && layout.body().height() == screen.panelBounds().height() - 8, + "Full-height equipment drawer fits"); + switch (step) { + case 0 -> { + var bag = + ContainerDrawerLayout.of( + screen.panelBounds(), + screen.drawerBottom(), + menu.containerDrawer.state().sizes().getFirst()); + click(screen, bag.tab(), 0); + step++; + } + case 1 -> { + if (!menu.containerDrawer.state().open()) return false; + click(screen, layout.tab(), 0); + step++; + } + case 2 -> { + if (!state.open() || state.pageSize() != layout.pageSize()) return false; + require(!menu.containerDrawer.state().open(), "Opening Curios closes backpack storage"); + require( + state.descriptions().getFirst().identifier().equals("ring") + && !state.descriptions().getFirst().cosmetic(), + "Slot groups use registered order"); + require( + state.contents().get(0).is(Items.DIAMOND) && state.contents().get(1).is(Items.EMERALD), + "Functional and cosmetic contents are distinct"); + require( + !state.descriptions().get(4).available() && !state.descriptions().get(5).available(), + "Inactive functional/cosmetic pair is disabled on client"); + require( + !state.descriptions().get(7).available(), "Missing cosmetic counterpart is disabled"); + require( + screen.exclusions().contains(layout.body()) + && screen.exclusions().contains(layout.rail()), + "Equipment body and rail are excluded from JEI"); + Screenshot.grab( + mc.gameDirectory, "curios-drawer.png", mc.getMainRenderTarget(), message -> {}); + click(screen, layout.cell(0), 0); + originalSize = state.sizes().getFirst(); + step++; + } + case 3 -> { + if (!menu.getCarried().is(Items.DIAMOND)) return false; + require(state.contents().get(0).isEmpty(), "Unequip functional item"); + click(screen, layout.cell(1), 0); + step++; + } + case 4 -> { + require( + menu.getCarried().is(Items.DIAMOND) && state.contents().get(1).is(Items.EMERALD), + "Incompatible swap does not lose either item"); + click(screen, layout.cell(0), 0); + step++; + } + case 5 -> { + if (!menu.getCarried().isEmpty()) return false; + require(state.contents().get(0).is(Items.DIAMOND), "Re-equip functional item"); + click(screen, layout.cell(1), 0); + step++; + } + case 6 -> { + if (!menu.getCarried().is(Items.EMERALD)) return false; + require(state.contents().get(1).isEmpty(), "Unequip cosmetic item"); + click(screen, layout.cell(1), 0); + step++; + } + case 7 -> { + if (!menu.getCarried().isEmpty()) return false; + require(state.contents().get(1).is(Items.EMERALD), "Re-equip cosmetic item"); + drawer + .request(ContainerDrawerMenu.QUICK_TAKE, 0, state.pageSize()) + .ifPresent(PacketDistributor::sendToServer); + step++; + } + case 8 -> { + if (!state.contents().get(0).isEmpty()) return false; + int index = -1; + for (int i = 0; i < 36; i++) + if (mc.player.getInventory().getItem(i).is(Items.DIAMOND)) { + index = i; + break; + } + require(index >= 0, "Shift unequip reaches player inventory"); + drawer + .request(ContainerDrawerMenu.QUICK_STORE, index, state.pageSize()) + .ifPresent(PacketDistributor::sendToServer); + step++; + } + case 9 -> { + if (!state.contents().get(0).is(Items.DIAMOND)) return false; + server( + player -> { + player.containerMenu.setCarried(new ItemStack(Items.STONE)); + player.containerMenu.broadcastChanges(); + }); + step++; + } + case 10 -> { + if (!menu.getCarried().is(Items.STONE)) return false; + click(screen, layout.cell(4), 0); + step++; + } + case 11 -> { + require( + menu.getCarried().is(Items.STONE) && state.contents().get(4).isEmpty(), + "Curios slot validation rejects wrong items"); + server( + player -> { + player.containerMenu.setCarried(ItemStack.EMPTY); + player.containerMenu.broadcastChanges(); + }); + step++; + } + case 12 -> { + if (!menu.getCarried().isEmpty()) return false; + click(screen, layout.cell(2), 0); + step++; + } + case 13 -> { + require( + menu.getCarried().isEmpty() && state.contents().get(2).is(Items.DIAMOND), + "Curios unequip events can forbid removal"); + click(screen, layout.next(), 0); + step++; + } + case 14 -> { + if (state.offset() == 0) return false; + if (state.contents().stream().noneMatch(stack -> stack.is(Items.AMETHYST_SHARD))) { + require( + state.offset() + state.pageSize() < state.sizes().getFirst(), + "Later equipment page contains its actual items"); + click(screen, layout.next(), 0); + return false; + } + Screenshot.grab( + mc.gameDirectory, "curios-page-two.png", mc.getMainRenderTarget(), message -> {}); + click(screen, layout.previous(), 0); + step++; + } + case 15 -> { + if (state.offset() != 0) { + click(screen, layout.previous(), 0); + return false; + } + server(CuriosFixture::resize); + step++; + } + case 16 -> { + if (state.sizes().getFirst() != originalSize + 2) return false; + require(!state.open(), "Changing slot count closes the old equipment view"); + server( + player -> { + CuriosFixture.checkStale(player); + staleChecked = true; + }); + step++; + } + case 17 -> { + if (!staleChecked) return false; + click(screen, layout.tab(), 0); + step++; + } + case 18 -> { + if (!state.open() || state.pageSize() != layout.pageSize()) return false; + require( + state.descriptions().get(6).identifier().equals("ring") + && state.descriptions().get(6).index() == 3, + "New equipment slots appear"); + require(screen.keyPressed(256, 0, 0), "Escape closes equipment drawer"); + step++; + } + case 19 -> { + if (state.open()) return false; + require(mc.screen == screen, "Closing drawer retains machine screen"); + step++; + } + default -> throw new AssertionError("Unexpected Curios smoke step"); + } + return false; + } + + private static void server(Consumer action) { + var server = Minecraft.getInstance().getSingleplayerServer(); + server.execute( + () -> { + try { + action.accept(server.getPlayerList().getPlayers().getFirst()); + } catch (Throwable error) { + CompanionClientSmoke.fail(error); + } + }); + } + + private static void click(MachineScreen screen, Rect rect, int button) { + require(screen.mouseClicked(rect.x() + 5, rect.y() + 5, button), "Curios click handled"); + screen.mouseReleased(rect.x() + 5, rect.y() + 5, button); + } + + private static void require(boolean ok, String message) { + if (!ok) throw new AssertionError(message); + } +} diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/CuriosFixture.java b/src/companionTest/java/zone/moddev/mc/companion/test/CuriosFixture.java new file mode 100644 index 00000000..33c68847 --- /dev/null +++ b/src/companionTest/java/zone/moddev/mc/companion/test/CuriosFixture.java @@ -0,0 +1,143 @@ +package zone.moddev.mc.companion.test; + +import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.ai.attributes.AttributeModifier; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.neoforged.neoforge.common.NeoForge; +import net.neoforged.neoforge.common.util.TriState; +import top.theillusivec4.curios.api.CuriosApi; +import top.theillusivec4.curios.api.event.CurioCanUnequipEvent; +import zone.moddev.mc.lib.gui.runtime.ContainerDrawerMenu; +import zone.moddev.mc.lib.gui.runtime.ContainerDrawerNetwork; +import zone.moddev.mc.lib.gui.runtime.MachineMenu; + +/** Only loaded in the real optional-mod client run. Slot definitions/tags are test resources. */ +final class CuriosFixture { + private static ContainerDrawerNetwork.Request stale; + + static void setup(ServerPlayer player) { + var inventory = CuriosApi.getCuriosInventory(player).orElseThrow(); + var rings = inventory.getStacksHandler("ring").orElseThrow(); + require( + rings.hasCosmetic() && rings.getStacks().getSlots() == 3, + "Cosmetic fixture slots registered"); + require( + rings.getStacks().insertItem(0, new ItemStack(Items.DIAMOND), false).isEmpty(), + "Functional fixture accepts tagged item"); + var locked = new ItemStack(Items.DIAMOND); + locked.set( + net.minecraft.core.component.DataComponents.CUSTOM_NAME, + net.minecraft.network.chat.Component.literal("Locked ring")); + rings.getStacks().insertItem(1, locked, false); + rings.getCosmeticStacks().insertItem(0, new ItemStack(Items.EMERALD), false); + inventory + .getStacksHandler("necklace") + .orElseThrow() + .getStacks() + .insertItem(0, new ItemStack(Items.GOLD_INGOT), false); + inventory + .getStacksHandler("charm") + .orElseThrow() + .getStacks() + .insertItem(11, new ItemStack(Items.AMETHYST_SHARD), false); + checkInactive(player); + // Keep one empty pair inactive for the client presentation/interaction checks. + inventory.setSlotActive("ring", 2, false); + NeoForge.EVENT_BUS.addListener( + (CurioCanUnequipEvent event) -> { + if (event.getSlotContext().entity() == player + && event.getSlotContext().identifier().equals("ring") + && event.getSlotContext().index() == 1) event.setUnequipResult(TriState.FALSE); + }); + } + + private static void checkInactive(ServerPlayer player) { + var inventory = CuriosApi.getCuriosInventory(player).orElseThrow(); + var identity = new ItemStack(Items.CHEST); + var live = + zone.moddev.mc.lib.compat.curios.CuriosEquipment.find(player, identity).getFirst().items(); + inventory.setSlotActive("ring", 0, false); + for (int slot : new int[] {0, 1}) { + require( + live.extractItem(slot, 1, true).isEmpty() && live.extractItem(slot, 1, false).isEmpty(), + "Retained handler rejects inactive extraction"); + require( + !live.isItemValid(slot, new ItemStack(Items.DIAMOND)) + && live.insertItem(slot, new ItemStack(Items.DIAMOND), true).getCount() == 1 + && live.insertItem(slot, new ItemStack(Items.DIAMOND), false).getCount() == 1, + "Retained handler rejects inactive insertion"); + } + var fresh = + (zone.moddev.mc.lib.gui.runtime.EquippedContainers.DescribedHandler) + zone.moddev.mc.lib.compat.curios.CuriosEquipment.find(player, identity) + .getFirst() + .items(); + require( + !fresh.describe(0).available() && !fresh.describe(1).available(), + "Both inactive functional/cosmetic slots are presented as unavailable"); + inventory.setSlotActive("ring", 0, true); + require( + live.getStackInSlot(0).is(Items.DIAMOND) && live.getStackInSlot(1).is(Items.EMERALD), + "Inactive attempts preserve functional and cosmetic contents"); + inventory.setSlotActive("ring", 2, false); + fresh = + (zone.moddev.mc.lib.gui.runtime.EquippedContainers.DescribedHandler) + zone.moddev.mc.lib.compat.curios.CuriosEquipment.find(player, identity) + .getFirst() + .items(); + for (int slot : new int[] {4, 5}) + require( + fresh.insertItem(slot, new ItemStack(Items.DIAMOND), false).getCount() == 1, + "Inactive empty pair refuses otherwise valid items"); + inventory.setSlotActive("ring", 2, true); + inventory.setSlotActive("back", 0, false); + require( + zone.moddev.mc.lib.gui.runtime.EquippedContainers.find(player).isEmpty(), + "Inactive backpack is not exposed as storage"); + inventory.setSlotActive("back", 0, true); + } + + static void resize(ServerPlayer player) { + var menu = (MachineMenu) player.containerMenu; + var drawer = menu.curiosDrawer; + stale = + new ContainerDrawerNetwork.Request( + menu.containerId, + true, + menu.widgetSession(), + drawer.state().revision(), + drawer.state().acknowledged() + 1, + ContainerDrawerMenu.PICKUP, + 0, + drawer.state().pageSize(), + ItemStack.EMPTY); + CuriosApi.getCuriosInventory(player) + .orElseThrow() + .addTransientSlotModifier( + "ring", + ResourceLocation.parse("mmdlib_companion:drawer_test"), + 1, + AttributeModifier.Operation.ADD_VALUE); + } + + static void checkStale(ServerPlayer player) { + var menu = (MachineMenu) player.containerMenu; + menu.curiosDrawer.action(player, stale); + require(menu.getCarried().isEmpty(), "Pre-resize request cannot mutate the new topology"); + require( + CuriosApi.getCuriosInventory(player) + .orElseThrow() + .getStacksHandler("ring") + .orElseThrow() + .getStacks() + .getStackInSlot(0) + .is(Items.DIAMOND), + "Stale click left equipment intact"); + } + + private static void require(boolean ok, String message) { + if (!ok) throw new AssertionError(message); + } +} diff --git a/src/companionTest/java/zone/moddev/mc/companion/test/DrawerMemoryClientSmoke.java b/src/companionTest/java/zone/moddev/mc/companion/test/DrawerMemoryClientSmoke.java new file mode 100644 index 00000000..0e6a3ff1 --- /dev/null +++ b/src/companionTest/java/zone/moddev/mc/companion/test/DrawerMemoryClientSmoke.java @@ -0,0 +1,213 @@ +package zone.moddev.mc.companion.test; + +import net.minecraft.client.Minecraft; +import net.minecraft.core.BlockPos; +import zone.moddev.mc.companion.CompanionMod; +import zone.moddev.mc.lib.gui.client.MachineScreen; +import zone.moddev.mc.lib.gui.layout.ContainerDrawerLayout; +import zone.moddev.mc.lib.gui.layout.Rect; +import zone.moddev.mc.lib.gui.runtime.ScreenRuntime; + +/** Reopens different real machine types on the same connection, including an explicit close. */ +final class DrawerMemoryClientSmoke { + private static final BlockPos FIRST = new BlockPos(4, 100, 0), SECOND = FIRST.east(2); + private static int step, waitTicks, reported = -1; + private static boolean requested; + + static boolean run() { + if (!BackpackFixture.installed() || step > 11) return true; + var mc = Minecraft.getInstance(); + if (reported != step) { + reported = step; + zone.moddev.mc.lib.MMDLib.LOGGER.info("DRAWER_MEMORY_STEP {}", step); + } + if (++waitTicks > 160) throw new AssertionError("Drawer memory timed out at step " + step); + if (step == 0) { + server( + player -> { + BackpackFixture.equip(player); + player + .serverLevel() + .setBlock(FIRST, CompanionMod.MACHINES.get(0).block.get().defaultBlockState(), 3); + player + .serverLevel() + .setBlock(SECOND, CompanionMod.MACHINES.get(1).block.get().defaultBlockState(), 3); + player.teleportTo(5, 101, 1); + }); + advance(); + return false; + } + if (step == 1 && !requested) { + if (mc.level.getBlockEntity(FIRST) == null || mc.level.getBlockEntity(SECOND) == null) + return false; + requested = true; + open(FIRST); + return false; + } + if (!(mc.screen instanceof MachineScreen screen)) return false; + var menu = screen.getMenu(); + boolean first = menu.blockEntity.getBlockPos().equals(FIRST); + boolean second = menu.blockEntity.getBlockPos().equals(SECOND); + if (!first && !second + || menu.containerDrawer.pending() + || menu.curiosDrawer.pending() + || menu.automation != null && menu.automation.pending()) return false; + var bag = menu.containerDrawer.state(); + var curios = menu.curiosDrawer.state(); + if (bag.keys().isEmpty()) return false; + var bagLayout = + ContainerDrawerLayout.of( + screen.panelBounds(), screen.drawerBottom(), bag.sizes().getFirst()); + switch (step) { + case 1 -> { + if (!first) return false; + if (!bag.open()) click(screen, bagLayout.tab()); + advance(); + } + case 2 -> { + if (!bag.open()) return false; + open(SECOND); + advance(); + } + case 3 -> { + if (!second || !bag.open()) return false; + require( + bag.keys().get(bag.selected()).equals("back:0"), + "Same equipped container restored across machines"); + screen.keyPressed(256, 0, 0); + advance(); + } + case 4 -> { + if (bag.open()) return false; + open(FIRST); + advance(); + } + case 5 -> { + if (!first || waitTicks < 15) return false; + require(!bag.open(), "Explicit close is remembered on another machine"); + if (curios.icons().isEmpty()) return false; + var layout = + ContainerDrawerLayout.equipment( + screen.panelBounds(), screen.drawerBottom(), curios.sizes().getFirst(), true); + click(screen, layout.tab()); + advance(); + } + case 6 -> { + if (!curios.open()) return false; + open(SECOND); + advance(); + } + case 7 -> { + if (!second || !curios.open()) return false; + require(!bag.open(), "Curios restoration keeps backpack drawer closed"); + screen.keyPressed(256, 0, 0); + var panel = screen.panelBounds(); + click(screen, new Rect(panel.right(), panel.y() + 4, 22, 24)); + advance(); + } + case 8 -> { + if (curios.open()) return false; + if (menu.automation == null) throw new AssertionError("Fixture has automation"); + if (!menu.automation.open()) { + var panel = screen.panelBounds(); + click(screen, new Rect(panel.right(), panel.y() + 4, 22, 24)); + return false; + } + server( + player -> { + player + .getInventory() + .setItem( + 9, + new net.minecraft.world.item.ItemStack( + net.minecraft.world.item.Items.DIRT, 16)); + ScreenRuntime.open(player, player.serverLevel().getBlockEntity(FIRST)); + }); + advance(); + } + case 9 -> { + if (!first || menu.automation == null || !menu.automation.open()) return false; + require( + !bag.open() && !curios.open(), + "Closed left drawers remain closed while right drawer restores"); + // Deliberately drop a configuration request instead of sending it to the server. + menu.containerDrawer + .request(zone.moddev.mc.lib.gui.runtime.ContainerDrawerMenu.SYNC, 0, bag.pageSize()) + .orElseThrow(); + var slot = + menu.slots.stream() + .filter(s -> s.container == mc.player.getInventory() && s.getContainerSlot() == 9) + .findFirst() + .orElseThrow(); + click(screen, new Rect(screen.guiLeft() + slot.x, screen.guiTop() + slot.y, 16, 16)); + require( + menu.getCarried().getCount() == 16, + "Dropped config request does not block vanilla pickup"); + click(screen, new Rect(screen.guiLeft() + slot.x, screen.guiTop() + slot.y, 16, 16)); + advance(); + } + case 10 -> { + // The normal pending guard above waits for the one-second SYNC recovery. + require(waitTicks >= 15, "Lost acknowledgment exercised timeout recovery"); + menu.containerDrawer + .request(zone.moddev.mc.lib.gui.runtime.ContainerDrawerMenu.PICKUP, 0, bag.pageSize()) + .orElseThrow(); + int exclusions = screen.exclusions().size(); + var panel = screen.panelBounds(); + click(screen, new Rect(panel.x() - 22, panel.y() + 4, 22, 24)); + require( + screen.exclusions().size() > exclusions, "Pending transfer does not block info drawer"); + screen.keyPressed(256, 0, 0); + require( + mc.screen != screen, "Escape closes screen immediately during dropped item request"); + MachineScreen.PendingActions.logout(null); + open(SECOND); + advance(); + } + case 11 -> { + if (!second || waitTicks < 15) return false; + require( + !bag.open() && !curios.open() && !menu.automation.open(), + "Logout clears drawer preferences before the next screen"); + mc.player.closeContainer(); + advance(); + return true; + } + default -> { + return true; + } + } + return false; + } + + private static void advance() { + step++; + waitTicks = 0; + } + + private static void click(MachineScreen screen, Rect rect) { + screen.mouseClicked(rect.x() + 5, rect.y() + 5, 0); + screen.mouseReleased(rect.x() + 5, rect.y() + 5, 0); + } + + private static void open(BlockPos pos) { + server(player -> ScreenRuntime.open(player, player.serverLevel().getBlockEntity(pos))); + } + + private static void server( + java.util.function.Consumer action) { + var server = Minecraft.getInstance().getSingleplayerServer(); + server.execute( + () -> { + try { + action.accept(server.getPlayerList().getPlayers().getFirst()); + } catch (Throwable error) { + CompanionClientSmoke.fail(error); + } + }); + } + + private static void require(boolean ok, String message) { + if (!ok) throw new AssertionError(message); + } +} diff --git a/src/companionTest/resources/data/curios/tags/item/charm.json b/src/companionTest/resources/data/curios/tags/item/charm.json new file mode 100644 index 00000000..ea3586ad --- /dev/null +++ b/src/companionTest/resources/data/curios/tags/item/charm.json @@ -0,0 +1 @@ +{"replace":false,"values":["minecraft:amethyst_shard"]} diff --git a/src/companionTest/resources/data/curios/tags/item/necklace.json b/src/companionTest/resources/data/curios/tags/item/necklace.json new file mode 100644 index 00000000..f0b14cfc --- /dev/null +++ b/src/companionTest/resources/data/curios/tags/item/necklace.json @@ -0,0 +1 @@ +{"replace":false,"values":["minecraft:gold_ingot"]} diff --git a/src/companionTest/resources/data/curios/tags/item/ring.json b/src/companionTest/resources/data/curios/tags/item/ring.json new file mode 100644 index 00000000..7be42531 --- /dev/null +++ b/src/companionTest/resources/data/curios/tags/item/ring.json @@ -0,0 +1 @@ +{"replace":false,"values":["minecraft:diamond","minecraft:emerald"]} diff --git a/src/companionTest/resources/data/mmdlib_companion/curios/entities/player.json b/src/companionTest/resources/data/mmdlib_companion/curios/entities/player.json new file mode 100644 index 00000000..97aa56ff --- /dev/null +++ b/src/companionTest/resources/data/mmdlib_companion/curios/entities/player.json @@ -0,0 +1 @@ +{"entities":["minecraft:player"],"slots":["ring","necklace","charm"]} diff --git a/src/companionTest/resources/data/mmdlib_companion/curios/slots/charm.json b/src/companionTest/resources/data/mmdlib_companion/curios/slots/charm.json new file mode 100644 index 00000000..9bf30493 --- /dev/null +++ b/src/companionTest/resources/data/mmdlib_companion/curios/slots/charm.json @@ -0,0 +1 @@ +{"size":12,"operation":"SET","order":30,"add_cosmetic":true,"icon":"curios:slot/empty_charm_slot"} diff --git a/src/companionTest/resources/data/mmdlib_companion/curios/slots/necklace.json b/src/companionTest/resources/data/mmdlib_companion/curios/slots/necklace.json new file mode 100644 index 00000000..788a8d5d --- /dev/null +++ b/src/companionTest/resources/data/mmdlib_companion/curios/slots/necklace.json @@ -0,0 +1 @@ +{"size":1,"operation":"SET","order":20,"icon":"curios:slot/empty_necklace_slot"} diff --git a/src/companionTest/resources/data/mmdlib_companion/curios/slots/ring.json b/src/companionTest/resources/data/mmdlib_companion/curios/slots/ring.json new file mode 100644 index 00000000..f818d79b --- /dev/null +++ b/src/companionTest/resources/data/mmdlib_companion/curios/slots/ring.json @@ -0,0 +1 @@ +{"size":3,"operation":"SET","order":10,"add_cosmetic":true,"icon":"curios:slot/empty_ring_slot"} diff --git a/src/main/java/zone/moddev/mc/lib/MMDLibConfig.java b/src/main/java/zone/moddev/mc/lib/MMDLibConfig.java index 7bd17aff..df8d8f2a 100644 --- a/src/main/java/zone/moddev/mc/lib/MMDLibConfig.java +++ b/src/main/java/zone/moddev/mc/lib/MMDLibConfig.java @@ -15,6 +15,9 @@ private MMDLibConfig() {} */ public static final ModConfigSpec.IntValue CRACKHAMMER_CRUSH_COUNT; + /** Functional Curios slot types searched for equipped item containers, in priority order. */ + public static final ModConfigSpec.ConfigValue> CONTAINER_SLOTS; + static { var builder = new ModConfigSpec.Builder(); builder.push("crackhammer"); @@ -25,6 +28,16 @@ private MMDLibConfig() {} + " crushes the whole stack.") .defineInRange("crush_count", 8, 1, 99); builder.pop(); + builder.push("inventory"); + CONTAINER_SLOTS = + builder + .comment("Curios slot types eligible for the machine's container drawer.") + .defineListAllowEmpty( + "container_slots", + java.util.List.of("back", "backpack"), + () -> "back", + value -> value instanceof String name && !name.isBlank() && name.length() <= 64); + builder.pop(); SERVER_SPEC = builder.build(); } } diff --git a/src/main/java/zone/moddev/mc/lib/client/jei/MachineJei.java b/src/main/java/zone/moddev/mc/lib/client/jei/MachineJei.java index b00a1341..40439d17 100644 --- a/src/main/java/zone/moddev/mc/lib/client/jei/MachineJei.java +++ b/src/main/java/zone/moddev/mc/lib/client/jei/MachineJei.java @@ -254,7 +254,11 @@ public List getRecipeSlots(MachineMenu menu, R recipe) { public List getInventorySlots(MachineMenu menu, R recipe) { return menu.slots.stream() - .filter(s -> s.container == player.getInventory() && s.isActive()) + .filter( + s -> + s.container == player.getInventory() + && s.getContainerSlot() < 36 + && s.isActive()) .toList(); } }; diff --git a/src/main/java/zone/moddev/mc/lib/compat/curios/CuriosContainers.java b/src/main/java/zone/moddev/mc/lib/compat/curios/CuriosContainers.java new file mode 100644 index 00000000..d17bc924 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/compat/curios/CuriosContainers.java @@ -0,0 +1,48 @@ +package zone.moddev.mc.lib.compat.curios; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; + +import net.minecraft.world.entity.player.Player; +import net.neoforged.neoforge.capabilities.Capabilities; + +import top.theillusivec4.curios.api.CuriosApi; + +import zone.moddev.mc.lib.MMDLibConfig; +import zone.moddev.mc.lib.gui.runtime.EquippedContainers.Source; +import zone.moddev.mc.lib.gui.runtime.ContainerDrawerMenu; + +/** Loaded only after the Curios mod-presence check. Cosmetic slots are intentionally excluded. */ +public final class CuriosContainers { + private CuriosContainers() {} + + public static List find(Player player) { + var result = new ArrayList(); + CuriosApi.getCuriosInventory(player) + .ifPresent( + inventory -> { + for (String name : new LinkedHashSet<>(MMDLibConfig.CONTAINER_SLOTS.get())) { + inventory + .getStacksHandler(name) + .ifPresent( + slots -> { + var stacks = slots.getStacks(); + for (int i = 0; + i < stacks.getSlots() + && result.size() < ContainerDrawerMenu.MAX_CONTAINERS; + i++) { + var stack = stacks.getStackInSlot(i); + if (stack.isEmpty() || !inventory.isSlotActive(name, i)) continue; + var handler = stack.getCapability(Capabilities.ItemHandler.ITEM); + if (handler != null + && handler.getSlots() > 0 + && handler.getSlots() <= ContainerDrawerMenu.MAX_SLOTS) + result.add(new Source(name + ":" + i, stack, handler)); + } + }); + } + }); + return List.copyOf(result); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/compat/curios/CuriosEquipment.java b/src/main/java/zone/moddev/mc/lib/compat/curios/CuriosEquipment.java new file mode 100644 index 00000000..f3f3cf6c --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/compat/curios/CuriosEquipment.java @@ -0,0 +1,145 @@ +package zone.moddev.mc.lib.compat.curios; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.neoforged.neoforge.items.IItemHandler; +import top.theillusivec4.curios.api.CuriosApi; +import top.theillusivec4.curios.api.SlotContext; +import top.theillusivec4.curios.api.type.ISlotType; +import zone.moddev.mc.lib.gui.runtime.ContainerDrawerMenu; +import zone.moddev.mc.lib.gui.runtime.EquippedContainers; +import zone.moddev.mc.lib.gui.runtime.EquippedContainers.SlotInfo; + +/** Optional adapter. Every mutation goes through Curios' validating dynamic stack handlers. */ +public final class CuriosEquipment { + private CuriosEquipment() {} + + private record Entry(SlotInfo info, IItemHandler handler, boolean active, boolean render) {} + + public static List find(Player player, ItemStack identity) { + var entries = new ArrayList(); + var topology = new StringBuilder(); + CuriosApi.getCuriosInventory(player) + .ifPresent( + inventory -> { + var groups = new ArrayList<>(inventory.getCurios().entrySet()); + groups.sort( + Comparator + .> + comparingInt( + entry -> + CuriosApi.getSlot(entry.getKey(), player.level()) + .map(ISlotType::getOrder) + .orElse(1000)) + .thenComparing(java.util.Map.Entry::getKey)); + for (var group : groups) { + var stacks = group.getValue(); + if (!stacks.isVisible()) continue; + var type = CuriosApi.getSlot(group.getKey(), player.level()); + var icon = + type.map(ISlotType::getIcon) + .orElse(ResourceLocation.parse("curios:slot/empty_curio_slot")); + for (int i = 0; + i < stacks.getStacks().getSlots() + && entries.size() + 2 <= ContainerDrawerMenu.MAX_SLOTS; + i++) { + boolean cosmetic = + stacks.hasCosmetic() && i < stacks.getCosmeticStacks().getSlots(); + boolean active = inventory.isSlotActive(group.getKey(), i); + boolean render = i >= stacks.getRenders().size() || stacks.getRenders().get(i); + entries.add( + new Entry( + new SlotInfo(group.getKey(), i, false, active, icon), + stacks.getStacks(), + active, + render)); + entries.add( + new Entry( + new SlotInfo( + group.getKey(), + i, + true, + cosmetic && active, + ResourceLocation.parse("curios:slot/empty_cosmetic_slot")), + cosmetic ? stacks.getCosmeticStacks() : null, + active, + true)); + topology + .append(group.getKey()) + .append(':') + .append(i) + .append(':') + .append(cosmetic) + .append(';'); + } + } + }); + if (entries.isEmpty()) return List.of(); + var handler = + new EquippedContainers.DescribedHandler() { + private boolean valid(int slot) { + if (slot < 0 || slot >= entries.size()) return false; + var entry = entries.get(slot); + return entry.info.available() + && entry.info.index() < entry.handler.getSlots() + && CuriosApi.getCuriosInventory(player) + .map( + inventory -> + inventory.isSlotActive(entry.info.identifier(), entry.info.index())) + .orElse(false); + } + + public int getSlots() { + return entries.size(); + } + + public SlotInfo describe(int slot) { + return entries.get(slot).info; + } + + public ItemStack getStackInSlot(int slot) { + return valid(slot) + ? entries.get(slot).handler.getStackInSlot(describe(slot).index()) + : ItemStack.EMPTY; + } + + public int getSlotLimit(int slot) { + return valid(slot) ? entries.get(slot).handler.getSlotLimit(describe(slot).index()) : 0; + } + + public boolean isItemValid(int slot, ItemStack stack) { + return valid(slot) + && entries.get(slot).handler.isItemValid(describe(slot).index(), stack); + } + + public ItemStack extractItem(int slot, int amount, boolean simulate) { + return valid(slot) + ? entries.get(slot).handler.extractItem(describe(slot).index(), amount, simulate) + : ItemStack.EMPTY; + } + + public ItemStack insertItem(int slot, ItemStack stack, boolean simulate) { + if (!valid(slot)) return stack; + var entry = entries.get(slot); + var result = entry.handler.insertItem(entry.info.index(), stack, simulate); + if (!simulate + && result.getCount() < stack.getCount() + && entry.active + && !entry.info.cosmetic()) { + var context = + new SlotContext( + entry.info.identifier(), player, entry.info.index(), false, entry.render); + CuriosApi.getCurio(stack).ifPresent(curio -> curio.onEquipFromUse(context)); + } + return result; + } + }; + return List.of(new EquippedContainers.Source(topology.toString(), identity, handler)); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/AutomationDrawer.java b/src/main/java/zone/moddev/mc/lib/gui/client/AutomationDrawer.java index 73e89ffb..c1be3f15 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/client/AutomationDrawer.java +++ b/src/main/java/zone/moddev/mc/lib/gui/client/AutomationDrawer.java @@ -53,10 +53,17 @@ public AutomationDrawer(MachineScreen screen) { new InventoryColorButtonBar( screen.getMenu().bound.screen(), model.inventory.groups(), - group -> model.send("select", group, -1, "", 0, "")); + group -> send("select", group, -1, "", 0, "")); displayOrder = colourBar.order(); } + private boolean send( + String kind, int group, int column, String control, int entry, String value) { + if (!model.send(kind, group, column, control, entry, value)) return false; + screen.rememberAutomationIntent(kind, group); + return true; + } + private Rect panel() { return screen.panelBounds(); } @@ -107,8 +114,15 @@ public void layout() { ? Math.max( baseHeight, AutomationLayout.EDITOR_Y + editorHeight + AutomationLayout.PADDING) : baseHeight; - int availableHeight = Math.max(0, screen.drawerBottom() - tab.y()); - body = new Rect(tab.right(), tab.y(), WIDTH, Math.min(desiredHeight, availableHeight)); + body = + SideDrawerLayout.body( + panel, + screen.drawerBottom(), + tab.right(), + tab.y(), + WIDTH, + desiredHeight, + SideDrawerLayout.HeightLimit.SCREEN); int shown = Math.max( 1, @@ -246,13 +260,6 @@ private static void sprite(GuiGraphics g, String name, Rect r) { g.blitSprite(ResourceLocation.parse("mmdlib:" + name), r.x(), r.y(), r.width(), r.height()); } - private static void tint(GuiGraphics g, String name, Rect r, int colour) { - g.setColor( - ((colour >> 16) & 255) / 255f, ((colour >> 8) & 255) / 255f, (colour & 255) / 255f, 1); - sprite(g, name, r); - g.setColor(1, 1, 1, 1); - } - private void icon(GuiGraphics g, String name, Rect r) { sprite(g, name, r); } @@ -282,7 +289,10 @@ public void drawBackground(GuiGraphics g, int mx, int my) { layout(); tooltip = null; faceTooltip = List.of(); - tint(g, "drawer_tab", tab, 0x819bc2); + if (model.open()) + SideDrawerRenderer.open( + g, tab, body, zone.moddev.mc.lib.gui.model.Element.Edge.RIGHT, 0x819bc2); + else SideDrawerRenderer.tab(g, tab, 0x819bc2); icon(g, "icon_side_config", new Rect(tab.x() + 3, tab.y() + 4, 16, 16)); for (int index = 0; index < count(); index++) { var group = model.inventory.group(index); @@ -308,8 +318,6 @@ public void drawBackground(GuiGraphics g, int mx, int my) { highlight(g, hoveredGroup); } if (!model.open()) return; - tint(g, "drawer_panel", body, 0x819bc2); - g.fill(tab.x() + 2, tab.bottom(), tab.right() - 1, body.bottom(), 0xff425878); g.fill( body.x() + AutomationLayout.SELECTOR_WIDTH, body.y() + 5, @@ -617,7 +625,7 @@ public void tooltip(GuiGraphics g, int mx, int my) { public List exclusions() { layout(); var result = new ArrayList(); - result.add(tab); + result.add(model.open() ? SideDrawerLayout.rail(tab, body) : tab); if (model.open()) { result.add(body); } @@ -626,7 +634,8 @@ public List exclusions() { public boolean contains(double x, double y) { layout(); - return tab.contains(x, y) || model.open() && body.contains(x, y); + return tab.contains(x, y) + || model.open() && (body.contains(x, y) || SideDrawerLayout.rail(tab, body).contains(x, y)); } /** Returns true only for controls/background. Real item slots must reach vanilla handling. */ @@ -635,11 +644,12 @@ public boolean click(double x, double y, int button) { selectorFocused = false; if (button > 1) return false; if (tab.contains(x, y)) { - model.send(model.open() ? "close" : "select", model.selected(), -1, "", 0, ""); + send(model.open() ? "close" : "select", model.selected(), -1, "", 0, ""); return true; } if (colourBar.click(x, y)) return true; if (!model.open()) return false; + if (SideDrawerLayout.rail(tab, body).contains(x, y) && !screen.onDrawerTab(x, y)) return true; if (!body.contains(x, y)) closeEditorInput(); if (x >= body.x() + 2 && x < body.x() + AutomationLayout.SELECTOR_WIDTH @@ -651,13 +661,13 @@ else if (y >= selector.bottom()) verticalOffset = Math.min(Math.max(0, count() - shown), verticalOffset + 1); else { int index = verticalOffset + ((int) y - selector.y()) / AutomationLayout.SELECTOR_PITCH; - if (index < count()) model.send("select", displayOrder.get(index), -1, "", 0, ""); + if (index < count()) send("select", displayOrder.get(index), -1, "", 0, ""); } return true; } for (int f = 0; f < 6; f++) if (face(f).contains(x, y)) { - model.send( + send( "face", model.selected(), -1, @@ -670,7 +680,7 @@ else if (y >= selector.bottom()) if (model.supportsFilter(model.selected(), c) && !model.filter(model.selected(), c).isEmpty() && filterToggle(c).contains(x, y)) { - model.send("editor", model.selected(), c, "", 0, ""); + send("editor", model.selected(), c, "", 0, ""); return true; } if (module(c).contains(x, y) @@ -732,7 +742,7 @@ && filterToggle(c).contains(x, y)) { } private void edit(String id, int entry, String value) { - model.send("edit", model.selected(), model.editor(), id, entry, value); + send("edit", model.selected(), model.editor(), id, entry, value); } public boolean scroll(double x, double y, double amount) { @@ -773,7 +783,7 @@ public boolean key(int key, int scan, int modifiers) { return true; } if (model.open() && key == 256) { - model.send(model.editor() >= 0 ? "collapse" : "close", model.selected(), -1, "", 0, ""); + send(model.editor() >= 0 ? "collapse" : "close", model.selected(), -1, "", 0, ""); return true; } if (key == 295) { // F6 moves to the inventory selector; arrows move between inventories. @@ -789,12 +799,12 @@ public boolean key(int key, int scan, int modifiers) { Math.floorMod( displayOrder.indexOf(keyboardGroup) + (key == 264 || key == 262 ? 1 : -1), count())); - model.send("select", keyboardGroup, -1, "", 0, ""); + send("select", keyboardGroup, -1, "", 0, ""); narrateSelection(); return true; } if (selectorFocused && (key == 257 || key == 335)) { - model.send("select", keyboardGroup, -1, "", 0, ""); + send("select", keyboardGroup, -1, "", 0, ""); return true; } return false; diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/ContainerDrawer.java b/src/main/java/zone/moddev/mc/lib/gui/client/ContainerDrawer.java new file mode 100644 index 00000000..b1e58f8b --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/ContainerDrawer.java @@ -0,0 +1,590 @@ +package zone.moddev.mc.lib.gui.client; + +import java.util.List; +import java.util.LinkedHashSet; +import net.minecraft.world.item.ItemStack; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.minecraft.resources.ResourceLocation; +import net.neoforged.neoforge.network.PacketDistributor; + +import zone.moddev.mc.lib.gui.layout.ContainerDrawerLayout; +import zone.moddev.mc.lib.gui.layout.Rect; +import zone.moddev.mc.lib.gui.runtime.ContainerDrawerMenu; + +/** Item-rendered equipment tab and paged, server-synced storage cells. */ +final class ContainerDrawer { + private final boolean equipment; + private final int color; + private final MachineScreen screen; + private final ContainerDrawerMenu menu; + private ContainerDrawerLayout layout; + private boolean handledPress; + private int mouseX, mouseY; + private final LinkedHashSet dragSlots = new LinkedHashSet<>(); + private int dragButton = -1, firstSlot = -1, lastClickSlot = -1; + private long dragRevision, lastClickTime; + private ItemStack dragStack = ItemStack.EMPTY, lastClickStack = ItemStack.EMPTY; + private Rect dragBody; + private boolean collectQueued; + private String lastSource = ""; + + private int slotAt(double x, double y) { + if (!bodyVisible()) return -1; + var state = menu.state(); + for (int i = 0; i < Math.min(state.contents().size(), layout.pageSize()); i++) + if (layout.cell(i).contains(x, y)) return state.offset() + i; + return -1; + } + + private boolean inventoryPending() { + return screen.getMenu().containerDrawer.pendingInventory() + || screen.getMenu().curiosDrawer.pendingInventory(); + } + + private boolean inventoryTarget(double x, double y) { + // Outside clicks can drop the carried stack. Settings inside the panel remain usable. + return !screen.onDrawerTab(x, y) + && screen.hasClickedOutside(x, y, screen.guiLeft(), screen.guiTop(), 0) + || screen.getMenu().slots.stream().anyMatch(slot -> overSlot(slot, x, y)); + } + + private boolean overSlot(net.minecraft.world.inventory.Slot slot, double x, double y) { + return x >= screen.guiLeft() + slot.x + && x < screen.guiLeft() + slot.x + 16 + && y >= screen.guiTop() + slot.y + && y < screen.guiTop() + slot.y + 16; + } + + private int playerSlotAt(double x, double y) { + for (var slot : screen.getMenu().slots) + if (slot.container == Minecraft.getInstance().player.getInventory() + && slot.getContainerSlot() < 36 + && overSlot(slot, x, y)) return ContainerDrawerMenu.MAX_SLOTS + slot.getContainerSlot(); + return -1; + } + + void cancelGesture() { + dragButton = -1; + dragSlots.clear(); + dragStack = ItemStack.EMPTY; + collectQueued = false; + lastClickSlot = -1; + } + + private boolean gestureValid() { + return bodyVisible() + && !menu.pending() + && menu.state().revision() == dragRevision + && layout.body().equals(dragBody) + && ItemStack.matches(screen.getMenu().getCarried(), dragStack); + } + + private void addDragSlot(int slot) { + if (slot < 0 || dragSlots.size() >= ContainerDrawerMenu.MAX_PAGE + 36) return; + var stored = + slot >= ContainerDrawerMenu.MAX_SLOTS + ? Minecraft.getInstance() + .player + .getInventory() + .getItem(slot - ContainerDrawerMenu.MAX_SLOTS) + : menu.state().contents().get(slot - menu.state().offset()); + if ((stored.isEmpty() || ItemStack.isSameItemSameComponents(stored, dragStack)) + && stored.getCount() < dragStack.getMaxStackSize() + && dragSlots.size() < dragStack.getCount()) dragSlots.add(slot); + } + + private void beginDrag(int slot, int button) { + dragButton = button; + firstSlot = slot; + dragRevision = menu.state().revision(); + dragBody = layout.body(); + dragStack = screen.getMenu().getCarried().copy(); + dragSlots.clear(); + addDragSlot(slot); + handledPress = true; + } + + private void collect() { + if (!menu.pending()) { + collectQueued = false; + var state = menu.state(); + if (bodyVisible() + && state.keys().get(state.selected()).equals(lastSource) + && lastClickSlot >= state.offset() + && lastClickSlot < state.offset() + state.contents().size() + && ItemStack.isSameItemSameComponents(screen.getMenu().getCarried(), lastClickStack)) + send(ContainerDrawerMenu.COLLECT, lastClickSlot, state.pageSize()); + } + } + + ContainerDrawer(MachineScreen screen) { + this(screen, false); + } + + ContainerDrawer(MachineScreen screen, boolean equipment) { + this.equipment = equipment; + color = equipment ? 0xa6a0ca : 0xc5aa7b; + this.screen = screen; + menu = equipment ? screen.getMenu().curiosDrawer : screen.getMenu().containerDrawer; + } + + private boolean available() { + return !menu.state().icons().isEmpty(); + } + + private void layout() { + int slots = available() ? menu.state().sizes().get(menu.state().selected()) : 0; + layout = + equipment + ? ContainerDrawerLayout.equipment( + screen.panelBounds(), + screen.drawerBottom(), + slots, + !screen.getMenu().containerDrawer.state().icons().isEmpty()) + : ContainerDrawerLayout.of(screen.panelBounds(), screen.drawerBottom(), slots); + } + + private boolean bodyVisible() { + return available() && menu.state().open() && layout.pageSize() > 0; + } + + private void send(int kind, int argument, int size) { + menu.request(kind, argument, size) + .ifPresent( + request -> { + screen.rememberContainerIntent(equipment, menu.state(), kind, argument); + PacketDistributor.sendToServer(request); + }); + } + + void close() { + if (menu.state().open() && !menu.pending()) + send(ContainerDrawerMenu.TOGGLE, 0, menu.state().pageSize()); + } + + List exclusions() { + layout(); + if (!available()) return List.of(); + return bodyVisible() ? List.of(layout.rail(), layout.body()) : List.of(layout.tab()); + } + + boolean contains(double x, double y) { + layout(); + return available() + && (layout.tab().contains(x, y) + || bodyVisible() && (layout.body().contains(x, y) || layout.rail().contains(x, y))); + } + + boolean tabContains(double x, double y) { + layout(); + return available() && layout.tab().contains(x, y); + } + + private Component title() { + return equipment + ? Component.translatable("screen.mmdlib.curios.title") + : menu.state().icons().get(menu.state().selected()).getHoverName(); + } + + void renderTab(GuiGraphics g) { + layout(); + if (!available()) return; + SideDrawerRenderer.tab(g, layout.tab(), color); + if (equipment) + emptyIcon( + g, + ResourceLocation.parse("curios:slot/empty_curio_slot"), + layout.tab().x() + 3, + layout.tab().y() + 4); + else + g.renderItem( + menu.state().icons().get(menu.state().selected()), + layout.tab().x() + 3, + layout.tab().y() + 4); + } + + private static void emptyIcon(GuiGraphics g, ResourceLocation icon, int x, int y) { + g.blit( + x, + y, + 0, + 16, + 16, + Minecraft.getInstance() + .getTextureAtlas(net.minecraft.world.inventory.InventoryMenu.BLOCK_ATLAS) + .apply(icon)); + } + + private static String slotName(zone.moddev.mc.lib.gui.runtime.EquippedContainers.SlotInfo info) { + String key = "curios.identifier." + info.identifier(); + return (net.minecraft.client.resources.language.I18n.exists(key) + ? Component.translatable(key).getString() + : info.identifier()) + + (info.index() == 0 ? "" : " " + (info.index() + 1)); + } + + void render(GuiGraphics g, int mx, int my) { + layout(); + mouseX = mx; + mouseY = my; + if (!available()) return; + var state = menu.state(); + var icon = state.icons().get(state.selected()); + if (bodyVisible()) + SideDrawerRenderer.open( + g, layout.tab(), layout.body(), zone.moddev.mc.lib.gui.model.Element.Edge.LEFT, color); + renderTab(g); + if (dragButton >= 0 && !gestureValid()) cancelGesture(); + if (collectQueued) collect(); + if (!bodyVisible()) return; + if (layout.pageSize() != state.pageSize() && !menu.pending()) + send(ContainerDrawerMenu.PAGE, state.offset(), layout.pageSize()); + var font = Minecraft.getInstance().font; + var body = layout.body(); + // The selected item itself is also the drawer header, including item tint/model components. + if (equipment) + emptyIcon( + g, ResourceLocation.parse("curios:slot/empty_curio_slot"), body.x() + 4, body.y() + 3); + else g.renderItem(icon, body.x() + 4, body.y() + 3); + if (body.width() > 45) + g.drawString( + font, + font.plainSubstrByWidth(title().getString(), body.width() - 28), + body.x() + 24, + body.y() + 7, + 0xff404040, + false); + if (equipment) { + g.drawString( + font, + Component.translatable("screen.mmdlib.curios.equipment"), + body.x() + 6, + body.y() + 22, + 0xff404040, + false); + g.drawString( + font, + Component.translatable("screen.mmdlib.curios.functional.short"), + body.right() - 40, + body.y() + 22, + 0xff404040, + false); + g.drawString( + font, + Component.translatable("screen.mmdlib.curios.cosmetic.short"), + body.right() - 20, + body.y() + 22, + 0xff404040, + false); + } + for (int i = 0; i < Math.min(state.contents().size(), layout.pageSize()); i++) { + var cell = layout.cell(i); + var info = i < state.descriptions().size() ? state.descriptions().get(i) : null; + if (equipment && info == null) continue; + if (equipment && i % 2 == 0) + g.drawString( + font, + font.plainSubstrByWidth(slotName(info), body.width() - 54), + body.x() + 6, + cell.y() + 5, + 0xff404040, + false); + if (info != null && !info.available()) { + g.drawString(font, "-", cell.x() + 6, cell.y() + 5, 0xff666666, false); + continue; + } + g.blitSprite(ResourceLocation.parse("mmdlib:slot"), cell.x(), cell.y(), 18, 18); + var stack = state.contents().get(i); + if (stack.isEmpty() && info != null) emptyIcon(g, info.icon(), cell.x() + 1, cell.y() + 1); + if (!stack.isEmpty()) { + g.renderItem(stack, cell.x() + 1, cell.y() + 1); + g.renderItemDecorations(font, stack, cell.x() + 1, cell.y() + 1); + } + // Generic insertion may route to another slot. Highlight targets without inventing counts. + if (cell.contains(mx, my) || dragButton >= 0 && dragSlots.contains(state.offset() + i)) + g.fill(cell.x() + 1, cell.y() + 1, cell.right() - 1, cell.bottom() - 1, 0x80ffffff); + } + var previous = layout.previous(); + var next = layout.next(); + g.drawString( + font, + "<", + previous.x() + 3, + previous.y() + 3, + state.offset() > 0 ? 0xff404040 : 0xff999999, + false); + g.drawString( + font, + ">", + next.x() + 3, + next.y() + 3, + state.offset() + state.pageSize() < state.sizes().get(state.selected()) + ? 0xff404040 + : 0xff999999, + false); + if (body.width() >= 70) { + String page = + (state.offset() / state.pageSize() + 1) + + "/" + + ((state.sizes().get(state.selected()) + state.pageSize() - 1) / state.pageSize()); + g.drawString( + font, + page, + body.x() + (body.width() - font.width(page)) / 2, + previous.y() + 3, + 0xff404040, + false); + } + } + + void tooltip(GuiGraphics g, int mx, int my) { + if (!contains(mx, my)) return; + var font = Minecraft.getInstance().font; + var state = menu.state(); + if (layout.tab().contains(mx, my)) { + var lines = new java.util.ArrayList(); + lines.add(title()); + lines.add( + Component.translatable( + equipment ? "screen.mmdlib.curios.toggle" : "screen.mmdlib.container.toggle")); + if (state.open()) + lines.add( + Component.translatable( + equipment ? "screen.mmdlib.curios.store" : "screen.mmdlib.container.store")); + if (!equipment) lines.add(Component.translatable("screen.mmdlib.container.gestures")); + if (layout.pageSize() == 0) lines.add(Component.translatable("screen.mmdlib.container.room")); + g.renderComponentTooltip(font, lines, mx, my); + } else if (bodyVisible()) { + if (equipment) { + for (int i = 0; i < Math.min(state.descriptions().size(), layout.pageSize()); i++) { + var info = state.descriptions().get(i); + var cell = layout.cell(i); + if (cell.contains(mx, my) && state.contents().get(i).isEmpty()) { + g.renderComponentTooltip( + font, + List.of( + Component.literal(slotName(info)), + Component.translatable( + !info.available() + ? "screen.mmdlib.curios.unavailable" + : info.cosmetic() + ? "screen.mmdlib.curios.cosmetic" + : "screen.mmdlib.curios.functional")), + mx, + my); + return; + } + } + if (my < layout.body().y() + 34) { + g.renderComponentTooltip( + font, + List.of( + title(), + Component.translatable("screen.mmdlib.curios.columns"), + Component.translatable("screen.mmdlib.curios.store")), + mx, + my); + return; + } + } + for (int i = 0; i < Math.min(state.contents().size(), layout.pageSize()); i++) + if (layout.cell(i).contains(mx, my) && !state.contents().get(i).isEmpty()) { + g.renderTooltip(font, state.contents().get(i), mx, my); + return; + } + if (layout.selection().contains(mx, my)) + g.renderComponentTooltip( + font, + List.of( + state.icons().get(state.selected()).getHoverName(), + Component.translatable( + state.icons().size() > 1 + ? "screen.mmdlib.container.select" + : "screen.mmdlib.container.store")), + mx, + my); + } + } + + boolean click(double x, double y, int button) { + layout(); + mouseX = (int) x; + mouseY = (int) y; + int hit = slotAt(x, y); + long now = net.minecraft.Util.getMillis(); + if (!equipment + && button == 0 + && hit >= 0 + && hit == lastClickSlot + && now - lastClickTime < 250 + && !Screen.hasShiftDown()) { + collectQueued = true; + handledPress = true; + collect(); + lastClickTime = 0; + return true; + } + if (screen.getMenu().containerDrawer.pending() || screen.getMenu().curiosDrawer.pending()) { + // Configuration requests do not touch vanilla slots. Only serialize item-changing gestures. + boolean blocked = contains(x, y) || inventoryPending() && inventoryTarget(x, y); + handledPress = blocked; + return blocked; + } + if (!available()) return false; + var state = menu.state(); + if (layout.tab().contains(x, y)) { + handledPress = true; + if (button == 0) { + if (!state.open()) screen.closeLeftDrawers(); + send(ContainerDrawerMenu.TOGGLE, 0, state.pageSize()); + } + return true; + } + if (!bodyVisible()) return false; + if (!equipment + && button >= 0 + && button <= 1 + && !Screen.hasShiftDown() + && !screen.getMenu().getCarried().isEmpty()) { + // Let AbstractContainerScreen own complete gestures that start in the normal inventory. + // Intercepting just their press breaks vanilla quick-craft and double-click tracking. + if (hit >= 0) { + beginDrag(hit, button); + return true; + } + } + if (layout.rail().contains(x, y) && !screen.onDrawerTab(x, y)) { + handledPress = true; + return true; + } + if (bodyVisible() && layout.body().contains(x, y)) { + handledPress = true; + if (button < 0 || button > 1) return true; + if (layout.pageSize() != state.pageSize()) return true; + if (layout.selection().contains(x, y)) { + if (state.icons().size() > 1) + send( + ContainerDrawerMenu.SELECT, + (state.selected() + 1) % state.icons().size(), + state.pageSize()); + } else if (layout.previous().contains(x, y)) page(-1); + else if (layout.next().contains(x, y)) page(1); + else + for (int i = 0; i < Math.min(state.contents().size(), layout.pageSize()); i++) + if (layout.cell(i).contains(x, y)) { + if (!equipment && button == 0 && !Screen.hasShiftDown()) { + lastClickSlot = state.offset() + i; + lastClickTime = now; + lastClickStack = state.contents().get(i).copy(); + lastSource = state.keys().get(state.selected()); + } else lastClickSlot = -1; + send( + Screen.hasShiftDown() ? ContainerDrawerMenu.QUICK_TAKE : ContainerDrawerMenu.PICKUP, + (state.offset() + i) * 2 + button, + state.pageSize()); + break; + } + return true; + } + if (button == 0 && Screen.hasShiftDown()) { + int slot = playerSlotAt(x, y); + if (slot >= 0) { + send( + ContainerDrawerMenu.QUICK_STORE, + slot - ContainerDrawerMenu.MAX_SLOTS, + state.pageSize()); + handledPress = true; + return true; + } + } + return false; + } + + private void page(int direction) { + var state = menu.state(); + int offset = Math.max(0, state.offset() + direction * state.pageSize()); + if (offset < state.sizes().get(state.selected()) && offset != state.offset()) + send(ContainerDrawerMenu.PAGE, offset, state.pageSize()); + } + + boolean scroll(double x, double y, double amount) { + if (!contains(x, y) || !menu.state().open()) return false; + if (!menu.pending() && amount != 0) page(amount < 0 ? 1 : -1); + return true; + } + + boolean key(int key, int scan) { + layout(); + if (key == 256 && dragButton >= 0) { + cancelGesture(); + return true; + } + if (!equipment && bodyVisible() && !menu.pending()) { + int slot = slotAt(mouseX, mouseY); + if (slot >= 0) { + for (int hotbar = 0; hotbar < 9; hotbar++) + if (Minecraft.getInstance().options.keyHotbarSlots[hotbar].matches(key, scan)) { + cancelGesture(); + menu.request(ContainerDrawerMenu.SWAP, hotbar, menu.state().pageSize(), List.of(slot)) + .ifPresent(PacketDistributor::sendToServer); + return true; + } + } + } + if (key == 256 && menu.state().open()) { + close(); + return true; + } + if (key == 256) return false; + if (inventoryPending() + && (Minecraft.getInstance().options.keyDrop.matches(key, scan) + || java.util.Arrays.stream(Minecraft.getInstance().options.keyHotbarSlots) + .anyMatch(binding -> binding.matches(key, scan)))) return true; + return contains(mouseX, mouseY) + && !Minecraft.getInstance().options.keyInventory.matches(key, scan); + } + + void mouseMoved(double x, double y) { + mouseX = (int) x; + mouseY = (int) y; + } + + boolean drag(double x, double y, int button) { + layout(); + if (dragButton >= 0) { + if (!gestureValid() || button != dragButton) cancelGesture(); + else { + int slot = slotAt(x, y); + addDragSlot(slot >= 0 ? slot : playerSlotAt(x, y)); + } + return true; + } + return handledPress; + } + + boolean release(double x, double y, int button) { + boolean handled = handledPress; + handledPress = false; + layout(); + if (dragButton >= 0) { + if (gestureValid() && button == dragButton) { + if (dragSlots.size() > 1) { + menu.request( + ContainerDrawerMenu.DISTRIBUTE, + button, + menu.state().pageSize(), + List.copyOf(dragSlots)) + .ifPresent(PacketDistributor::sendToServer); + } else if (slotAt(x, y) == firstSlot) + send(ContainerDrawerMenu.PICKUP, firstSlot * 2 + button, menu.state().pageSize()); + } + cancelGesture(); + return true; + } + return handled; + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/DrawerMemory.java b/src/main/java/zone/moddev/mc/lib/gui/client/DrawerMemory.java new file mode 100644 index 00000000..22aad370 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/client/DrawerMemory.java @@ -0,0 +1,177 @@ +package zone.moddev.mc.lib.gui.client; + +import java.util.EnumMap; +import java.util.Map; +import net.minecraft.client.Minecraft; +import net.neoforged.neoforge.network.PacketDistributor; +import zone.moddev.mc.lib.gui.model.Element; +import zone.moddev.mc.lib.gui.runtime.ContainerDrawerMenu; + +/** Connection-scoped drawer preferences shared by every MMDLib machine screen. */ +final class DrawerMemory { + private record Selection(String kind, String key, int offset) {} + + private static java.lang.ref.WeakReference connection = + new java.lang.ref.WeakReference<>(null); + private static final Map remembered = new EnumMap<>(Element.Edge.class); + private final Map restoring = new EnumMap<>(Element.Edge.class); + private int ticks; + + DrawerMemory() { + Object current = Minecraft.getInstance().getConnection(); + if (connection.get() != current) { + connection = new java.lang.ref.WeakReference<>(current); + remembered.clear(); + } + restoring.putAll(remembered); + } + + static void disconnected() { + connection.clear(); + remembered.clear(); + } + + void userInput() { + restoring.clear(); + } + + void restore(MachineScreen screen, java.util.Set open) { + if (++ticks > 100) { + restoring.clear(); + return; + } + var menu = screen.getMenu(); + for (var edge : Element.Edge.values()) { + var selection = restoring.get(edge); + if (selection == null) continue; + if (selection.kind().equals("backpack") || selection.kind().equals("curios")) { + boolean equipment = selection.kind().equals("curios"); + var drawer = equipment ? menu.curiosDrawer : menu.containerDrawer; + if (!menu.bound.screen().playerInventory()) { + restoring.remove(edge); + continue; + } + var state = drawer.state(); + if (state.keys().isEmpty() || drawer.pending()) continue; + int index = state.keys().indexOf(selection.key()); + if (index < 0) { + restoring.remove(edge); + continue; + } + if (!state.open()) { + drawer + .request(ContainerDrawerMenu.TOGGLE, 0, state.pageSize()) + .ifPresent(PacketDistributor::sendToServer); + } else if (state.selected() != index) { + drawer + .request(ContainerDrawerMenu.SELECT, index, state.pageSize()) + .ifPresent(PacketDistributor::sendToServer); + } else { + int offset = Math.min(selection.offset(), Math.max(0, state.sizes().get(index) - 1)); + offset = offset / state.pageSize() * state.pageSize(); + if (state.offset() != offset) + drawer + .request(ContainerDrawerMenu.PAGE, offset, state.pageSize()) + .ifPresent(PacketDistributor::sendToServer); + restoring.remove(edge); + screen.closeLeftDrawers(); + } + } else if (selection.kind().equals("automation")) { + var automation = menu.automation; + if (automation == null) { + restoring.remove(edge); + continue; + } + if (automation.pending()) continue; + int group = automation.inventory.index(selection.key()); + if (group < 0) group = 0; + automation.send("select", group, -1, "", 0, ""); + restoring.remove(edge); + } else { + menu.bound.screen().tabs().stream() + .filter( + e -> + e instanceof Element.Popout p + && p.edge() == edge + && p.kind().name().equals(selection.kind())) + .map(Element.Popout.class::cast) + .findFirst() + .ifPresent(open::add); + restoring.remove(edge); + } + } + } + + void containerIntent( + boolean equipment, + zone.moddev.mc.lib.gui.runtime.ContainerDrawerNetwork.State state, + int kind, + int argument) { + if (state.keys().isEmpty()) return; + if (kind == ContainerDrawerMenu.TOGGLE && state.open()) remembered.remove(Element.Edge.LEFT); + else if (kind == ContainerDrawerMenu.TOGGLE + || kind == ContainerDrawerMenu.SELECT + || kind == ContainerDrawerMenu.PAGE) { + int index = kind == ContainerDrawerMenu.SELECT ? argument : state.selected(); + remembered.put( + Element.Edge.LEFT, + new Selection( + equipment ? "curios" : "backpack", + state.keys().get(index), + kind == ContainerDrawerMenu.PAGE ? argument : state.offset())); + } + } + + void automationIntent(String kind, String group) { + if (kind.equals("close")) remembered.remove(Element.Edge.RIGHT); + else if (kind.equals("select")) + remembered.put(Element.Edge.RIGHT, new Selection("automation", group, 0)); + } + + void popoutIntent(Element.Popout popout, boolean open) { + if (open) remembered.put(popout.edge(), new Selection(popout.kind().name(), "", 0)); + else remembered.remove(popout.edge()); + } + + void capture(MachineScreen screen, java.util.Set open) { + var menu = screen.getMenu(); + for (var edge : Element.Edge.values()) { + // Do not erase a preference while waiting for a fresh menu's equipment snapshot. + if (restoring.containsKey(edge) + || edge == Element.Edge.LEFT + && (menu.containerDrawer.pending() || menu.curiosDrawer.pending()) + || edge == Element.Edge.RIGHT && menu.automation != null && menu.automation.pending()) + continue; + Selection selection = null; + if (edge == Element.Edge.LEFT) { + for (var drawer : java.util.List.of(menu.containerDrawer, menu.curiosDrawer)) { + var state = drawer.state(); + if (state.open() && !state.keys().isEmpty()) + selection = + new Selection( + state.equipment() ? "curios" : "backpack", + state.keys().get(state.selected()), + state.offset()); + } + } else if (menu.automation != null && menu.automation.open()) { + var a = menu.automation; + selection = new Selection("automation", a.inventory.group(a.selected()).id(), 0); + } + if (selection == null) + for (var popout : open) + if (popout.edge() == edge) selection = new Selection(popout.kind().name(), "", 0); + if (selection != null) remembered.put(edge, selection); + else { + var previous = remembered.get(edge); + boolean unavailable = + previous != null + && (previous.kind().equals("backpack") + && menu.containerDrawer.state().keys().isEmpty() + || previous.kind().equals("curios") + && menu.curiosDrawer.state().keys().isEmpty() + || previous.kind().equals("automation") && menu.automation == null); + if (!unavailable) remembered.remove(edge); + } + } + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/MachineScreen.java b/src/main/java/zone/moddev/mc/lib/gui/client/MachineScreen.java index b01c504a..ead69fb4 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/client/MachineScreen.java +++ b/src/main/java/zone/moddev/mc/lib/gui/client/MachineScreen.java @@ -22,8 +22,10 @@ /** Client presentation of a data-defined machine; getters are never invoked here. */ public class MachineScreen extends AbstractContainerScreen { - public final ScreenLayout layout; + public ScreenLayout layout; private final AutomationDrawer automation; + private final ContainerDrawer containerDrawer; + private final ContainerDrawer curiosDrawer; private WidgetInput widgetInput; private boolean widgetsRemoved; private final Set open = Collections.newSetFromMap(new IdentityHashMap<>()); @@ -50,19 +52,18 @@ public int drawerBottom() { private Element hovered; private boolean restored; + private final DrawerMemory drawerMemory = new DrawerMemory(); public MachineScreen(MachineMenu menu, Inventory inventory, Component title) { super(menu, inventory, title); WidgetRenderers.GLOBAL.validate(menu.bound.screen()); - layout = - ScreenLayout.of( - menu.bound.screen(), - text -> net.minecraft.client.Minecraft.getInstance().font.width(text(text)), - menu.automation != null); + layout = menu.layout(); imageWidth = layout.panel().width(); imageHeight = layout.panel().height(); widgetInput = createWidgets(); automation = menu.automation == null ? null : new AutomationDrawer(this); + containerDrawer = new ContainerDrawer(this); + curiosDrawer = new ContainerDrawer(this, true); } private WidgetInput createWidgets() { @@ -120,11 +121,19 @@ public static void register(RegisterMenuScreensEvent event) { @EventBusSubscriber(modid = "mmdlib", value = Dist.CLIENT) public static final class PendingActions { + @SubscribeEvent + public static void logout( + net.neoforged.neoforge.client.event.ClientPlayerNetworkEvent.LoggingOut event) { + DrawerMemory.disconnected(); + } + @SubscribeEvent public static void tick(net.neoforged.neoforge.client.event.ClientTickEvent.Post event) { var player = net.minecraft.client.Minecraft.getInstance().player; if (player != null && player.containerMenu instanceof MachineMenu menu) { menu.tickWidgetActions(); + menu.containerDrawer.tickClient(); + menu.curiosDrawer.tickClient(); if (menu.automation != null) menu.automation.tick(); } } @@ -133,6 +142,8 @@ public static void tick(net.neoforged.neoforge.client.event.ClientTickEvent.Post @Override protected void init() { super.init(); + containerDrawer.cancelGesture(); + curiosDrawer.cancelGesture(); if (automation != null) automation.closeEditorInput(); // Recipe browsers can return to the same screen object after removed() was called. if (widgetsRemoved) { @@ -174,14 +185,52 @@ public Component text(Text text) { } public Rect bounds(Element e) { + updateLayout(); return layout.bounds(e).offset(leftPos, topPos); } public Rect panelBounds() { + updateLayout(); return layout.panel().offset(leftPos, topPos); } + private void updateLayout() { + if (layout == menu.layout()) return; + layout = menu.layout(); + imageWidth = layout.panel().width(); + imageHeight = layout.panel().height(); + leftPos = (width - imageWidth) / 2; + topPos = (height - imageHeight) / 2; + hoveredSlot = null; + quickCraftSlots.clear(); + isQuickCrafting = false; + widgetInput.resized(); + containerDrawer.cancelGesture(); + curiosDrawer.cancelGesture(); + if (automation != null) automation.closeEditorInput(); + } + + void rememberContainerIntent( + boolean equipment, ContainerDrawerNetwork.State state, int kind, int argument) { + drawerMemory.containerIntent(equipment, state, kind, argument); + } + + void rememberAutomationIntent(String kind, int group) { + drawerMemory.automationIntent(kind, menu.automation.inventory.group(group).id()); + } + + void closeLeftDrawers() { + open.removeIf(p -> p.edge() == Element.Edge.LEFT); + } + + boolean onDrawerTab(double x, double y) { + return containerDrawer.tabContains(x, y) + || curiosDrawer.tabContains(x, y) + || menu.bound.screen().tabs().stream().anyMatch(e -> bounds(e).contains(x, y)); + } + private void positionAutomation() { + updateLayout(); if (automation == null) return; if (menu.automation.open() && !menu.automation.pending()) open.removeIf(p -> p.edge() == Element.Edge.RIGHT); @@ -255,23 +304,14 @@ protected void renderBg(GuiGraphics g, float partial, int mx, int my) { .inventory() .ifPresent( r -> { - caption( - g, - Component.translatable("container.inventory"), - leftPos + r.x(), - topPos + r.y()); - for (int row = 0; row < 4; row++) - for (int col = 0; col < 9; col++) - sprite( - g, - "slot", - new Rect( - leftPos + r.x() + col * 18, - topPos + r.y() + 11 + row * 18 + (row == 3 ? 4 : 0), - 18, - 18)); + for (int index = 0; index <= 40; index++) + sprite(g, "slot", ScreenLayout.playerSlot(r, index).offset(leftPos, topPos)); }); if (automation != null) automation.drawBackground(g, mx, my); + containerDrawer.render(g, mx, my); + curiosDrawer.render(g, mx, my); + containerDrawer.renderTab(g); + curiosDrawer.renderTab(g); } private void draw(GuiGraphics g, Element e, Rect r, int mx, int my, float partial) { @@ -524,6 +564,8 @@ public List exclusions() { } else result.add(bounds(e)); } if (automation != null) result.addAll(automation.exclusions()); + result.addAll(containerDrawer.exclusions()); + result.addAll(curiosDrawer.exclusions()); return result; } @@ -536,14 +578,20 @@ private int popWidth(Element.Popout p) { private Rect popBounds(Element.Popout p) { Rect tab = bounds(p); int w = popWidth(p); - int limit = Math.max(0, Math.min(topPos + imageHeight, drawerBottom()) - tab.y()); - int h = Math.min(limit, Math.max(ScreenLayout.POPOUT_HEIGHT, popLines(p).size() * 10 + 12)); - return new Rect(p.edge() == Element.Edge.LEFT ? tab.x() - w : tab.right(), tab.y(), w, h); + int top = SideDrawerLayout.top(panelBounds()); + int h = Math.max(tab.bottom() - top, popLines(p).size() * 10 + 12); + return SideDrawerLayout.body( + panelBounds(), + drawerBottom(), + p.edge() == Element.Edge.LEFT ? tab.x() - w : tab.right(), + top, + w, + h); } private Rect drawerRail(Element.Popout p) { Rect tab = bounds(p); - return new Rect(tab.x(), tab.y(), tab.width(), popBounds(p).height()); + return SideDrawerLayout.rail(tab, popBounds(p)); } private List popLines(Element.Popout p) { @@ -607,6 +655,8 @@ public void render(GuiGraphics g, int mx, int my, float partial) { renderTooltip(g, mx, my); if (hovered != null) tooltip(g, hovered, mx, my); if (automation != null) automation.tooltip(g, mx, my); + containerDrawer.tooltip(g, mx, my); + curiosDrawer.tooltip(g, mx, my); } private void hover(Element e, boolean enter) { @@ -696,6 +746,11 @@ private void send(Element e, int kind, int button, int option) { @Override public boolean mouseClicked(double mx, double my, int button) { + drawerMemory.userInput(); + if (containerDrawer.click(mx, my, button) || curiosDrawer.click(mx, my, button)) { + widgetInput.clearFocus(); + return true; + } positionAutomation(); if (automation != null && automation.click(mx, my, button)) { widgetInput.clearFocus(); @@ -723,12 +778,17 @@ public boolean mouseClicked(double mx, double my, int button) { return true; if (button > 1) continue; if (e instanceof Element.Popout p) { + if (p.edge() == Element.Edge.LEFT) { + containerDrawer.close(); + curiosDrawer.close(); + } if (automation != null && p.edge() == Element.Edge.RIGHT && menu.automation.open()) menu.automation.send("close", menu.automation.selected(), -1, "", 0, ""); if (!open.remove(p)) { open.removeIf(other -> other.edge() == p.edge()); open.add(p); } + drawerMemory.popoutIntent(p, open.contains(p)); return true; } if (e instanceof Element.Slot s && s.kind() == Element.SlotKind.GHOST) { @@ -802,6 +862,18 @@ private void back(Element.Context c) { @Override public boolean keyPressed(int key, int scan, int modifiers) { + drawerMemory.userInput(); + // A lost drawer acknowledgment must never trap the player in the screen. + if ((menu.containerDrawer.pending() || menu.curiosDrawer.pending()) + && (key == 256 + || net.minecraft.client.Minecraft.getInstance() + .options + .keyInventory + .matches(key, scan))) { + onClose(); + return true; + } + if (containerDrawer.key(key, scan) || curiosDrawer.key(key, scan)) return true; if (automation != null && automation.key(key, scan, modifiers)) return true; updateWidgets(); if (widgetInput.keyPressed(key, scan, modifiers)) return true; @@ -819,6 +891,9 @@ public boolean keyPressed(int key, int scan, int modifiers) { @Override public void removed() { + drawerMemory.capture(this, open); + containerDrawer.cancelGesture(); + curiosDrawer.cancelGesture(); if (automation != null) automation.removed(); widgetInput.removed(); widgetsRemoved = true; @@ -829,6 +904,7 @@ public void removed() { @Override public boolean mouseScrolled(double mx, double my, double dx, double dy) { + if (curiosDrawer.scroll(mx, my, dy) || containerDrawer.scroll(mx, my, dy)) return true; if (automation != null && automation.scroll(mx, my, dy)) return true; updateWidgets(); for (var p : open) @@ -840,6 +916,7 @@ public boolean mouseScrolled(double mx, double my, double dx, double dy) { } private void updateWidgets() { + updateLayout(); widgetInput.update(); for (var e : menu.elements) if (e instanceof Element.Custom custom) { @@ -851,12 +928,21 @@ private void updateWidgets() { @Override protected void containerTick() { super.containerTick(); + drawerMemory.restore(this, open); updateWidgets(); widgetInput.tick(); } + @Override + public void mouseMoved(double x, double y) { + containerDrawer.mouseMoved(x, y); + curiosDrawer.mouseMoved(x, y); + super.mouseMoved(x, y); + } + @Override public boolean mouseDragged(double x, double y, int button, double dx, double dy) { + if (containerDrawer.drag(x, y, button) || curiosDrawer.drag(x, y, button)) return true; if (automation != null && automation.drag(x, y, button, dx, dy)) return true; updateWidgets(); return widgetInput.drag(x, y, button, dx, dy) || super.mouseDragged(x, y, button, dx, dy); @@ -864,6 +950,8 @@ public boolean mouseDragged(double x, double y, int button, double dx, double dy @Override public boolean mouseReleased(double x, double y, int button) { + boolean curiosReleased = curiosDrawer.release(x, y, button); + if (containerDrawer.release(x, y, button) || curiosReleased) return true; if (automation != null && automation.release(x, y, button)) return true; updateWidgets(); return widgetInput.release(x, y, button) || super.mouseReleased(x, y, button); @@ -885,6 +973,8 @@ public boolean keyReleased(int key, int scan, int modifiers) { @Override protected boolean hasClickedOutside(double x, double y, int left, int top, int button) { return (automation == null || !automation.contains(x, y)) + && !containerDrawer.contains(x, y) + && !curiosDrawer.contains(x, y) && super.hasClickedOutside(x, y, left, top, button); } diff --git a/src/main/java/zone/moddev/mc/lib/gui/client/SideDrawerRenderer.java b/src/main/java/zone/moddev/mc/lib/gui/client/SideDrawerRenderer.java index 9df5ff67..b898aaae 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/client/SideDrawerRenderer.java +++ b/src/main/java/zone/moddev/mc/lib/gui/client/SideDrawerRenderer.java @@ -25,28 +25,35 @@ private static int color(PopoutKind kind) { private static void sprite(GuiGraphics graphics, ResourceLocation sprite, Rect rect, int color) { com.mojang.blaze3d.systems.RenderSystem.enableBlend(); com.mojang.blaze3d.systems.RenderSystem.defaultBlendFunc(); - graphics.setColor(((color >> 16) & 255) / 255f, - ((color >> 8) & 255) / 255f, (color & 255) / 255f, 1); + graphics.setColor( + ((color >> 16) & 255) / 255f, ((color >> 8) & 255) / 255f, (color & 255) / 255f, 1); graphics.blitSprite(sprite, rect.x(), rect.y(), rect.width(), rect.height()); graphics.setColor(1, 1, 1, 1); com.mojang.blaze3d.systems.RenderSystem.disableBlend(); } public static void tab(GuiGraphics graphics, Rect tab, PopoutKind kind) { - sprite(graphics, TAB, tab, color(kind)); + tab(graphics, tab, color(kind)); + } + + public static void tab(GuiGraphics graphics, Rect tab, int color) { + sprite(graphics, TAB, tab, color); } public static void open(GuiGraphics graphics, Rect tab, Rect body, Edge edge, PopoutKind kind) { + open(graphics, tab, body, edge, color(kind)); + } + + public static void open(GuiGraphics graphics, Rect tab, Rect body, Edge edge, int color) { int x = Math.min(tab.x(), body.x()); var whole = new Rect(x, body.y(), body.width() + tab.width(), body.height()); - int color = color(kind); sprite(graphics, PANEL, whole, color); // The narrow, darker rail retains the active icon beside the main machine panel. int rail = 0xff000000 | (((color & 0xfefefe) >> 1) + ((color & 0xfcfcfc) >> 2)); - graphics.fill(tab.x() + 2, tab.bottom() - 1, tab.right() - 2, body.bottom() - 2, rail); + graphics.fill(tab.x() + 2, body.y() + 2, tab.right() - 2, body.bottom() - 2, rail); int seam = edge == Edge.LEFT ? tab.x() : tab.right() - 1; - graphics.fill(seam, tab.bottom() - 1, seam + 1, body.bottom() - 2, - 0xff000000 | ((color & 0xfefefe) >> 1)); - tab(graphics, tab, kind); + graphics.fill( + seam, body.y() + 2, seam + 1, body.bottom() - 2, 0xff000000 | ((color & 0xfefefe) >> 1)); + tab(graphics, tab, color); } } diff --git a/src/main/java/zone/moddev/mc/lib/gui/layout/ContainerDrawerLayout.java b/src/main/java/zone/moddev/mc/lib/gui/layout/ContainerDrawerLayout.java new file mode 100644 index 00000000..6a2b2caf --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/layout/ContainerDrawerLayout.java @@ -0,0 +1,71 @@ +package zone.moddev.mc.lib.gui.layout; + +/** Six-column left drawer filling the shared inset panel height, with a tab beside the hotbar. */ +public record ContainerDrawerLayout(Rect tab, Rect body, int columns, int rows, boolean equipment) { + private static final int BODY_WIDTH = 6 * 18 + 12; + + public ContainerDrawerLayout(Rect tab, Rect body, int columns, int rows) { + this(tab, body, columns, rows, false); + } + + public static ContainerDrawerLayout equipment( + Rect panel, int bottom, int slots, boolean backpack) { + var base = of(panel, bottom, slots); + var tab = base.tab().offset(0, backpack ? -24 : 0); + var body = base.body(); + int height = body.height(); + int rows = Math.min(27, Math.max(0, (height - 56) / 22)); + if (tab.x() - BODY_WIDTH < 4 || rows == 0) + return new ContainerDrawerLayout(tab, new Rect(0, 0, 0, 0), 0, 0, true); + rows = Math.min(rows, Math.max(1, (slots + 1) / 2)); + return new ContainerDrawerLayout(tab, body, 2, rows, true); + } + + public static ContainerDrawerLayout of(Rect panel, int bottom, int slots) { + var tab = + new Rect( + Math.max(0, panel.x() - 22), + Math.max(SideDrawerLayout.top(panel), SideDrawerLayout.bottom(panel, bottom) - 24), + 22, + 24); + int columns = 6; + int width = BODY_WIDTH; + var body = + SideDrawerLayout.body( + panel, bottom, tab.x() - width, SideDrawerLayout.top(panel), width, Integer.MAX_VALUE); + int height = body.height(); + int rows = Math.min(9, Math.max(0, (height - 44) / 18)); + if (tab.x() - width < 4 || rows == 0) + return new ContainerDrawerLayout(tab, new Rect(0, 0, 0, 0), 0, 0); + rows = Math.min(rows, Math.max(1, (slots + columns - 1) / columns)); + return new ContainerDrawerLayout(tab, body, columns, rows); + } + + public int pageSize() { + return columns * rows; + } + + public Rect rail() { + return SideDrawerLayout.rail(tab, body); + } + + public Rect cell(int visible) { + if (equipment) + return new Rect( + body.right() - 44 + visible % 2 * 20, body.y() + 34 + visible / 2 * 22, 18, 18); + return new Rect( + body.x() + 6 + visible % columns * 18, body.y() + 22 + visible / columns * 18, 18, 18); + } + + public Rect previous() { + return new Rect(body.x() + 3, body.bottom() - 18, 10, 14); + } + + public Rect next() { + return new Rect(body.right() - 13, body.bottom() - 18, 10, 14); + } + + public Rect selection() { + return new Rect(body.x() + 4, body.y() + 4, body.width() - 8, 16); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/layout/ScreenLayout.java b/src/main/java/zone/moddev/mc/lib/gui/layout/ScreenLayout.java index 29856cc1..d0a7b5b0 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/layout/ScreenLayout.java +++ b/src/main/java/zone/moddev/mc/lib/gui/layout/ScreenLayout.java @@ -16,17 +16,31 @@ *

The main panel stacks the screen's top-level elements, then (unless the screen places it or * leaves it out) the player inventory, centred. Boxes stack their children; inside a {@code vbox} a * child {@code hbox} stretches to the full width, and inside an {@code hbox} a child {@code vbox} - * stretches to the full height, so {@code spacer}s have room to fill. A context area is as large as - * its largest view, and every view is placed at the same spot. Side tabs and pop-out tabs stack - * down their panel edge, side tabs first. + * stretches to the full height, so {@code spacer}s have room to fill. A context area sizes itself + * to its selected view and fills the available width in vertical layouts. Side tabs and pop-out + * tabs stack down their panel edge, side tabs first. * *

Fixed sizes come from the sprites ({@code docs/gui-sprites.md}): slots 18 px (26 large), * gauges 18 px thick, progress arrows 24x16, checkboxes and radios 10 px, buttons 20 px high, * side-configuration faces 20 px in a 3x3 grid. */ public final class ScreenLayout { - /** Width and height of the player inventory: three rows and the hotbar, plus its title. */ - public static final int INVENTORY_WIDTH = 162, INVENTORY_HEIGHT = 11 + 3 * 18 + 4 + 18; + /** Three inventory rows and a hotbar, with a toolbar and equipment column on the right. */ + public static final int INVENTORY_WIDTH = 162 + 4 + 18, INVENTORY_HEIGHT = 18 + 3 * 18 + 4 + 18; + + /** Slot background for a vanilla player inventory index (0..40), relative to the panel. */ + public static Rect playerSlot(Rect inventory, int index) { + if (index < 0 || index > 40) throw new IllegalArgumentException("Player slot: " + index); + int x, y; + if (index < 36) { + x = (index % 9) * 18; + y = index < 9 ? 76 : 18 + (index / 9 - 1) * 18; + } else { + x = 166; + y = index == 40 ? 76 : (39 - index) * 18; + } + return new Rect(inventory.x() + x, inventory.y() + y, 18, 18); + } /** Margin around the panel's content, as vanilla screens. */ public static final int PANEL_MARGIN = 7; @@ -35,7 +49,7 @@ public final class ScreenLayout { public static final int GAP = 4; /** Space around the content of a side tab, and the tab's overlap with the panel. */ - public static final int TAB_PADDING = 4, TAB_OVERLAP = 4, TAB_BORDER = 3; + public static final int TAB_PADDING = SideDrawerLayout.MARGIN, TAB_OVERLAP = 4, TAB_BORDER = 3; /** Width of a pop-out edge tab: a 16 px icon with a 3 px border. */ public static final int POPOUT_TAB = 22; @@ -56,12 +70,14 @@ public final class ScreenLayout { private final java.util.Set alternateViews = java.util.Collections.newSetFromMap(new IdentityHashMap<>()); private final TextMeasure text; + private final Map views; private Rect panel; private Rect inventory; private Rect inventoryColors; - private ScreenLayout(TextMeasure text) { + private ScreenLayout(TextMeasure text, Map views) { this.text = text; + this.views = Map.copyOf(views); } /** @@ -73,9 +89,15 @@ public static ScreenLayout of(Screen screen, TextMeasure text) { return of(screen, text, false); } - /** Reserves an actual row above the player inventory for resource-group selectors. */ + /** Places the inventory toolbar beside the helmet, directly above the storage slots. */ public static ScreenLayout of(Screen screen, TextMeasure text, boolean automation) { - var layout = new ScreenLayout(text); + return of(screen, text, automation, Map.of()); + } + + /** Measures selected context views; omitted selections use each context's default view. */ + public static ScreenLayout of( + Screen screen, TextMeasure text, boolean automation, Map views) { + var layout = new ScreenLayout(text, views); layout.place(screen); var controls = screen.body().stream() @@ -92,22 +114,23 @@ public static ScreenLayout of(Screen screen, TextMeasure text, boolean automatio Math.max( AutomationLayout.SELECTOR_ROW_HEIGHT, controls == null ? 0 : layout.size(controls)[1] + GAP); - int colourWidth = layout.inventory.width() - (controlsWidth == 0 ? 0 : controlsWidth + GAP); + int storageRight = playerSlot(layout.inventory, 17).right(); + int colourWidth = + storageRight - layout.inventory.x() - (controlsWidth == 0 ? 0 : controlsWidth + GAP); if (colourWidth < (automation ? 34 : 0)) throw new IllegalArgumentException( "Inventory controls leave too little room for colour selectors"); + // The inventory already reserves one slot row beside the helmet. Only taller controls + // need more space; ordinary flat controls and colour selectors use that existing row. + int extraHeight = Math.max(0, rowHeight - 18); layout.bounds.replaceAll( - (element, rect) -> rect.y() >= start ? rect.offset(0, rowHeight) : rect); + (element, rect) -> rect.y() >= start ? rect.offset(0, extraHeight) : rect); layout.inventoryColors = new Rect(layout.inventory.x(), start, colourWidth, rowHeight - GAP); if (controls != null) layout.placeAt( - controls, - layout.inventory.right() - controlsWidth, - start, - controlsWidth, - rowHeight - GAP); - layout.inventory = layout.inventory.offset(0, rowHeight); - layout.panel = new Rect(0, 0, layout.panel.width(), layout.panel.height() + rowHeight); + controls, storageRight - controlsWidth, start, controlsWidth, rowHeight - GAP); + layout.inventory = layout.inventory.offset(0, extraHeight); + layout.panel = new Rect(0, 0, layout.panel.width(), layout.panel.height() + extraHeight); } if (automation) { layout.bounds.replaceAll( @@ -125,7 +148,7 @@ public Rect panel() { return panel; } - /** Where the player inventory's title starts, if the screen shows the inventory. */ + /** Bounds of the player inventory, including the toolbar row and equipment column. */ public Optional inventory() { return Optional.ofNullable(inventory); } @@ -200,15 +223,7 @@ private int[] size(Element element) { Math.max(inner[0], group.label().map(text::width).orElse(0)), inner[1] + label }; } - case Context context -> { - int width = 0, height = 0; - for (var view : context.views()) { - int[] size = size(view); - width = Math.max(width, size[0]); - height = Math.max(height, size[1]); - } - yield new int[] {width, height}; - } + case Context context -> size(selectedView(context)); case View view -> { int[] inner = stack(view.children(), GAP); int title = viewHeader(view); @@ -273,6 +288,14 @@ private int viewHeader(View view) { : 0; } + private View selectedView(Context context) { + String selected = views.get(context.common().id().orElse("")); + return context.views().stream() + .filter(view -> view.common().id().orElse("").equals(selected)) + .findFirst() + .orElse(context.defaultView()); + } + private void place(Screen screen) { screen.forEach( e -> { @@ -294,8 +317,9 @@ private void place(Screen screen) { int y = titleHeight; for (var element : content) { int[] size = size(element); - // Top-level boxes span the panel, as a vbox's children do. - int w = element instanceof Box || fills(element) ? width : size[0]; + // Top-level boxes and context areas span the panel, as a vbox's children do. + int w = + element instanceof Box || element instanceof Context || fills(element) ? width : size[0]; placeAt(element, 0, y, w, size[1]); y += size[1] + GAP; } @@ -335,7 +359,16 @@ private void placeAt(Element element, int x, int y, int width, int height) { placeStack(group.children(), x, top, width, 2); } case Context context -> { - for (var view : context.views()) placeAt(view, x, y, width, height); + for (var view : context.views()) { + // Hidden elements still need bounds for their persistent menu slots and widgets. + int[] size = size(view); + placeAt( + view, + x, + y, + Math.max(width, size[0]), + view == selectedView(context) ? height : size[1]); + } } case View view -> { int top = y + (viewHeader(view)); @@ -358,7 +391,10 @@ private void placeAt(Element element, int x, int y, int width, int height) { private void placeStack(List children, int x, int y, int width, int spacing) { for (var child : children) { int[] size = size(child); - int w = (child instanceof Box box && !box.vertical()) || fills(child) ? width : size[0]; + int w = + (child instanceof Box box && !box.vertical()) || child instanceof Context || fills(child) + ? width + : size[0]; placeAt(child, x, y, w, size[1]); y += size[1] + spacing; } @@ -388,7 +424,8 @@ private void placeBox(Box box, int x, int y, int width, int height) { main = leftover / spacers + (spacerIndex++ < leftover % spacers ? 1 : 0); } if (box.vertical()) { - boolean stretch = (child instanceof Box b && !b.vertical()) || fills(child); + boolean stretch = + (child instanceof Box b && !b.vertical()) || child instanceof Context || fills(child); int w = stretch ? innerW : size[0]; placeAt(child, innerX + align(box.align(), innerW, w), position, w, main); } else { diff --git a/src/main/java/zone/moddev/mc/lib/gui/layout/SideDrawerLayout.java b/src/main/java/zone/moddev/mc/lib/gui/layout/SideDrawerLayout.java new file mode 100644 index 00000000..c03514c2 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/layout/SideDrawerLayout.java @@ -0,0 +1,41 @@ +package zone.moddev.mc.lib.gui.layout; + +/** Shared panel insets and bounds for drawers with independent content and tab placement. */ +public final class SideDrawerLayout { + public static final int MARGIN = 4; + + /** Content drawers can grow to the viewport; inventory drawers stay aligned with the panel. */ + public enum HeightLimit { + PANEL, + SCREEN + } + + private SideDrawerLayout() {} + + public static int top(Rect panel) { + return Math.max(MARGIN, panel.y() + MARGIN); + } + + /** The screen/overlay limit already includes its own margin. */ + public static int bottom(Rect panel, int screenBottom) { + return Math.min(panel.bottom() - MARGIN, screenBottom); + } + + /** Fits a content-sized or full-height body between the shared panel and screen limits. */ + public static Rect body( + Rect panel, int screenBottom, int x, int y, int width, int desiredHeight) { + return body(panel, screenBottom, x, y, width, desiredHeight, HeightLimit.PANEL); + } + + public static Rect body( + Rect panel, int screenBottom, int x, int y, int width, int desiredHeight, HeightLimit limit) { + int top = Math.max(top(panel), y); + int bottom = limit == HeightLimit.SCREEN ? screenBottom : bottom(panel, screenBottom); + int available = Math.max(0, bottom - top); + return new Rect(x, top, width, Math.min(Math.max(0, desiredHeight), available)); + } + + public static Rect rail(Rect tab, Rect body) { + return new Rect(tab.x(), body.y(), tab.width(), body.height()); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/model/Screen.java b/src/main/java/zone/moddev/mc/lib/gui/model/Screen.java index b822e026..ff22b0fe 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/model/Screen.java +++ b/src/main/java/zone/moddev/mc/lib/gui/model/Screen.java @@ -57,7 +57,19 @@ public Optional find(String id) { /** Stable model fingerprint used to reject differing client/server screen definitions. */ public String fingerprint() { String model = - id + "|" + title + "|" + blockEntity + "|" + playerInventory + "|" + body + "|" + tabs; + // The implicit equipment slots also change menu numbering, even with identical XML. + "equipment-v1|" + + id + + "|" + + title + + "|" + + blockEntity + + "|" + + playerInventory + + "|" + + body + + "|" + + tabs; try { return java.util.HexFormat.of() .formatHex( diff --git a/src/main/java/zone/moddev/mc/lib/gui/runtime/ContainerDrawerMenu.java b/src/main/java/zone/moddev/mc/lib/gui/runtime/ContainerDrawerMenu.java new file mode 100644 index 00000000..67f58d3a --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/runtime/ContainerDrawerMenu.java @@ -0,0 +1,539 @@ +package zone.moddev.mc.lib.gui.runtime; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.function.Supplier; + +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.neoforged.neoforge.items.IItemHandler; +import net.neoforged.neoforge.network.PacketDistributor; + +/** Per-viewer, server-authoritative access to an equipped container's generic I/O capability. */ +public final class ContainerDrawerMenu { + public static final int MAX_CONTAINERS = 16, MAX_SLOTS = 4096, MAX_PAGE = 54; + public static final int TOGGLE = 0, + SELECT = 1, + PAGE = 2, + PICKUP = 3, + QUICK_TAKE = 4, + QUICK_STORE = 5, + SYNC = 6, + DISTRIBUTE = 7, + COLLECT = 8, + SWAP = 9; + + private final MachineMenu menu; + private final boolean equipment; + private final Player owner; + private final Supplier> discover; + private List sources = List.of(); + private List sourceSizes = List.of(); + private int selected, + offset, + pageSize = 27, + lastRequest, + nextRequest, + pendingRequest, + pendingTicks; + private boolean open, pendingInventory; + private long revision, actionTick = Long.MIN_VALUE; + private int actions; + private ContainerDrawerNetwork.State state; + private boolean dirty; + + public ContainerDrawerMenu(MachineMenu menu, Player owner) { + this( + menu, + owner, + () -> menu.bound.screen().playerInventory() ? EquippedContainers.find(owner) : List.of()); + } + + /** A discovery seam for runtime tests and alternative equipment providers. */ + public ContainerDrawerMenu( + MachineMenu menu, Player owner, Supplier> discover) { + this(menu, owner, discover, false); + } + + public ContainerDrawerMenu( + MachineMenu menu, + Player owner, + Supplier> discover, + boolean equipment) { + this.equipment = equipment; + if (equipment) pageSize = 20; + this.menu = menu; + this.owner = owner; + this.discover = discover; + state = + new ContainerDrawerNetwork.State( + menu.containerId, + equipment, + menu.widgetSession(), + 0, + 0, + false, + 0, + 0, + pageSize, + List.of(), + List.of(), + List.of(), + List.of(), + List.of()); + } + + public ContainerDrawerNetwork.State state() { + return state; + } + + public boolean pending() { + return pendingRequest != 0; + } + + /** Only item-changing requests need to serialize vanilla inventory gestures. */ + public boolean pendingInventory() { + return pending() && pendingInventory; + } + + public Optional request(int kind, int argument, int size) { + return request(kind, argument, size, List.of()); + } + + public Optional request( + int kind, int argument, int size, List slots) { + if (pending() || nextRequest == Integer.MAX_VALUE) return Optional.empty(); + pendingInventory |= + kind == PICKUP + || kind == QUICK_TAKE + || kind == QUICK_STORE + || kind == DISTRIBUTE + || kind == COLLECT + || kind == SWAP; + pendingRequest = ++nextRequest; + pendingTicks = 0; + return Optional.of( + new ContainerDrawerNetwork.Request( + menu.containerId, + equipment, + menu.widgetSession(), + state.revision(), + pendingRequest, + kind, + argument, + size, + menu.getCarried().copy(), + slots)); + } + + public void receive(ContainerDrawerNetwork.State update) { + if (update.equipment() != equipment + || update.menu() != menu.containerId + || update.session() != menu.widgetSession() + || update.revision() < state.revision()) return; + state = update; + nextRequest = Math.max(nextRequest, update.acknowledged()); + if (update.acknowledged() >= pendingRequest) { + pendingRequest = 0; + pendingInventory = false; + } + } + + public void tickClient() { + if (pending() && ++pendingTicks >= 20) { + pendingRequest = 0; + request(SYNC, 0, state.pageSize()).ifPresent(PacketDistributor::sendToServer); + } + } + + /** Re-resolves equipment and capabilities; replacing a stack invalidates the open session. */ + public void refresh() { + var found = + discover.get().stream() + .limit(MAX_CONTAINERS) + .filter( + s -> + !s.stack().isEmpty() + && s.items().getSlots() > 0 + && s.items().getSlots() <= MAX_SLOTS) + .toList(); + boolean replaced = found.size() != sources.size(); + if (!replaced) + for (int i = 0; i < found.size(); i++) { + if (!found.get(i).key().equals(sources.get(i).key()) + || found.get(i).stack() != sources.get(i).stack() + || found.get(i).items().getSlots() != sourceSizes.get(i)) { + replaced = true; + break; + } + } + // Capabilities may be replaced without replacing the equipped stack. Always use the live one. + sources = found; + if (replaced) { + sourceSizes = found.stream().map(s -> s.items().getSlots()).toList(); + open = false; + offset = 0; + selected = 0; + revision++; + dirty = true; + } + boolean iconsChanged = found.size() != state.icons().size(); + if (!iconsChanged) + for (int i = 0; i < found.size(); i++) + if (!ItemStack.isSameItemSameComponents(found.get(i).stack(), state.icons().get(i))) { + iconsChanged = true; + break; + } + IItemHandler items = open && !found.isEmpty() ? found.get(selected).items() : null; + if (items != null) offset = Math.clamp(offset, 0, Math.max(0, items.getSlots() - 1)); + int count = items == null ? 0 : Math.min(pageSize, items.getSlots() - offset); + var described = items instanceof EquippedContainers.DescribedHandler d ? d : null; + boolean contentsChanged = count != state.contents().size(); + boolean descriptionsChanged = (described == null ? 0 : count) != state.descriptions().size(); + for (int i = 0; i < count; i++) { + if (!contentsChanged + && !ItemStack.matches(items.getStackInSlot(offset + i), state.contents().get(i))) + contentsChanged = true; + if (!descriptionsChanged + && described != null + && !described.describe(offset + i).equals(state.descriptions().get(i))) + descriptionsChanged = true; + } + if (iconsChanged + || contentsChanged + || descriptionsChanged + || open != state.open() + || selected != state.selected() + || offset != state.offset() + || pageSize != state.pageSize()) { + revision++; + dirty = true; + } + // Scan live handlers, but copy immutable packet data only when something actually changed. + if (revision == state.revision() && lastRequest == state.acknowledged()) return; + var icons = + iconsChanged ? found.stream().map(s -> s.stack().copyWithCount(1)).toList() : state.icons(); + List contents = state.contents(); + if (contentsChanged) { + contents = new ArrayList<>(count); + for (int i = 0; i < count; i++) contents.add(items.getStackInSlot(offset + i).copy()); + } + List descriptions = state.descriptions(); + if (descriptionsChanged) { + descriptions = new ArrayList<>(); + if (described != null) + for (int i = 0; i < count; i++) descriptions.add(described.describe(offset + i)); + } + state = + new ContainerDrawerNetwork.State( + menu.containerId, + equipment, + menu.widgetSession(), + revision, + lastRequest, + open, + selected, + offset, + pageSize, + icons, + replaced + ? found.stream().map(s -> equipment ? "curios" : s.key()).toList() + : state.keys(), + sourceSizes, + contents, + descriptions); + } + + public void broadcast() { + if (owner.level().isClientSide) return; + refresh(); + if (dirty + && owner instanceof ServerPlayer player + && player.connection.hasChannel(ContainerDrawerNetwork.State.TYPE)) { + PacketDistributor.sendToPlayer(player, state); + dirty = false; + } + } + + public void action(Player player, ContainerDrawerNetwork.Request request) { + if (request.equipment() != equipment + || player != owner + || player.containerMenu != menu + || !menu.stillValid(player) + || request.menu() != menu.containerId + || request.session() != menu.widgetSession() + || request.request() <= lastRequest) return; + lastRequest = request.request(); + refresh(); + long tick = player.level().getGameTime(); + if (tick != actionTick) { + actionTick = tick; + actions = 0; + } + if (++actions <= 32 && request.revision() == revision) { + if (request.kind() == TOGGLE && !sources.isEmpty()) { + open = !open; + if (open) (equipment ? menu.containerDrawer : menu.curiosDrawer).closeServer(); + } else if (open && !sources.isEmpty()) { + var source = sources.get(selected); + int argument = request.argument(); + switch (request.kind()) { + case SELECT -> { + if (argument >= 0 && argument < sources.size()) { + selected = argument; + offset = 0; + } + } + case PAGE -> { + if (request.pageSize() >= 1 + && request.pageSize() <= MAX_PAGE + && (!equipment || request.pageSize() % 2 == 0 && argument % 2 == 0) + && argument >= 0 + && argument < source.items().getSlots()) { + pageSize = request.pageSize(); + offset = argument; + } + } + case PICKUP, QUICK_TAKE -> { + int slot = argument / 2; + if (argument >= 0 + && slot >= offset + && slot < Math.min(offset + pageSize, source.items().getSlots()) + && ItemStack.matches(request.carried(), menu.getCarried())) { + if (request.kind() == QUICK_TAKE) takeToPlayer(source.items(), slot); + else click(source, slot, argument % 2 == 1); + } + } + case QUICK_STORE -> { + if (argument >= 0 + && argument < 36 + && ItemStack.matches(request.carried(), menu.getCarried())) + storeFromPlayer(source, argument); + } + case DISTRIBUTE -> { + if (!equipment + && argument <= 1 + && validTargets(request.slots(), source) + && ItemStack.matches(request.carried(), menu.getCarried())) + distribute(source, request.slots(), argument == 1); + } + case COLLECT -> { + if (!equipment + && visibleSlot(argument, source) + && ItemStack.matches(request.carried(), menu.getCarried())) collect(source); + } + case SWAP -> { + if (!equipment + && argument < 9 + && request.slots().size() == 1 + && visibleSlot(request.slots().getFirst(), source) + && ItemStack.matches(request.carried(), menu.getCarried())) + swap(source, request.slots().getFirst(), argument); + } + default -> {} + } + } + } + revision++; + dirty = true; + // Vanilla slot/cursor changes precede the acknowledgment, so pending input can resume safely. + if (request.kind() == SYNC) menu.sendAllDataToRemote(); + menu.broadcastChanges(); + broadcast(); + } + + public void closeServer() { + if (open) { + open = false; + revision++; + dirty = true; + } + } + + private boolean visibleSlot(int slot, EquippedContainers.Source source) { + return slot >= offset && slot < Math.min(offset + pageSize, source.items().getSlots()); + } + + private boolean validTargets(List slots, EquippedContainers.Source source) { + return !slots.isEmpty() + && slots.stream().distinct().count() == slots.size() + && slots.stream() + .allMatch(i -> visibleSlot(i, source) || i >= MAX_SLOTS && i < MAX_SLOTS + 36); + } + + private void distribute(EquippedContainers.Source source, List slots, boolean right) { + var carried = menu.getCarried(); + if (carried.isEmpty() || ItemStack.isSameItemSameComponents(carried, source.stack())) return; + var inventory = new net.neoforged.neoforge.items.wrapper.InvWrapper(owner.getInventory()); + var eligible = + slots.stream() + .filter( + i -> { + var handler = i >= MAX_SLOTS ? inventory : source.items(); + int slot = i >= MAX_SLOTS ? i - MAX_SLOTS : i; + return handler.insertItem(slot, carried.copyWithCount(1), true).isEmpty(); + }) + .toList(); + if (eligible.isEmpty()) return; + int amount = right ? 1 : carried.getCount() / eligible.size(); + if (amount == 0) return; + var remaining = carried.copy(); + for (int i : eligible) { + var handler = i >= MAX_SLOTS ? inventory : source.items(); + int slot = i >= MAX_SLOTS ? i - MAX_SLOTS : i; + int count = Math.min(amount, remaining.getCount()); + var rest = handler.insertItem(slot, remaining.copyWithCount(count), false); + remaining.shrink(count - rest.getCount()); + if (remaining.isEmpty()) break; + } + menu.setCarried(remaining); + } + + private void collect(EquippedContainers.Source source) { + var carried = menu.getCarried().copy(); + if (carried.isEmpty()) return; + var inventory = new net.neoforged.neoforge.items.wrapper.InvWrapper(owner.getInventory()); + // Vanilla collects partial stacks first. Include all storage pages and ordinary player slots. + for (int pass = 0; pass < 2; pass++) { + for (var handler : List.of(source.items(), inventory)) { + int count = handler == inventory ? 36 : handler.getSlots(); + for (int slot = 0; slot < count && carried.getCount() < carried.getMaxStackSize(); slot++) { + var stored = handler.getStackInSlot(slot); + if (!ItemStack.isSameItemSameComponents(stored, carried) + || pass == 0 && stored.getCount() >= stored.getMaxStackSize()) continue; + var taken = + handler.extractItem(slot, carried.getMaxStackSize() - carried.getCount(), false); + if (ItemStack.isSameItemSameComponents(taken, carried)) carried.grow(taken.getCount()); + else recover(handler, slot, taken); + } + } + } + menu.setCarried(carried); + } + + private void swap(EquippedContainers.Source source, int slot, int hotbar) { + var handler = source.items(); + var incoming = owner.getInventory().getItem(hotbar).copy(); + var outgoing = handler.getStackInSlot(slot).copy(); + if (!incoming.isEmpty() + && (ItemStack.isSameItemSameComponents(incoming, source.stack()) + || !handler.isItemValid(slot, incoming) + || incoming.getCount() + > Math.min(handler.getSlotLimit(slot), incoming.getMaxStackSize()))) return; + if (!outgoing.isEmpty() + && (outgoing.getCount() > outgoing.getMaxStackSize() + || !ItemStack.matches(handler.extractItem(slot, outgoing.getCount(), true), outgoing))) + return; + var taken = handler.extractItem(slot, outgoing.getCount(), false); + if (!ItemStack.matches(taken, outgoing)) { + recover(handler, slot, taken); + return; + } + owner.getInventory().setItem(hotbar, ItemStack.EMPTY); + var remainder = + incoming.isEmpty() ? ItemStack.EMPTY : handler.insertItem(slot, incoming, false); + if (!incoming.isEmpty() && ItemStack.matches(remainder, incoming)) { + owner.getInventory().setItem(hotbar, incoming); + recover(handler, slot, taken); + } else { + owner.getInventory().setItem(hotbar, remainder.isEmpty() ? taken : remainder); + if (!remainder.isEmpty()) giveToPlayer(taken); + } + owner.getInventory().setChanged(); + } + + private void recover(IItemHandler handler, int slot, ItemStack stack) { + if (!stack.isEmpty()) giveToPlayer(handler.insertItem(slot, stack, false)); + } + + private void giveToPlayer(ItemStack stack) { + insertIntoPlayer(stack); + if (!stack.isEmpty()) owner.drop(stack, false); + } + + private void insertIntoPlayer(ItemStack stack) { + // Inventory.add can discard overflow in creative mode or merge into the offhand. + // Recovery must preserve every item and use only ordinary player storage. + for (int pass = 0; pass < 2; pass++) + for (int i = 0; i < 36 && !stack.isEmpty(); i++) { + var existing = owner.getInventory().getItem(i); + if (!(pass == 0 ? ItemStack.isSameItemSameComponents(existing, stack) : existing.isEmpty())) + continue; + int room = + Math.max( + 0, + Math.min(owner.getInventory().getMaxStackSize(), stack.getMaxStackSize()) + - existing.getCount()); + int amount = Math.min(room, stack.getCount()); + if (amount > 0) { + owner.getInventory().setItem(i, stack.copyWithCount(existing.getCount() + amount)); + stack.shrink(amount); + } + } + owner.getInventory().setChanged(); + } + + private void click(EquippedContainers.Source source, int slot, boolean right) { + var handler = source.items(); + var carried = menu.getCarried(); + if (carried.isEmpty()) { + var shown = handler.getStackInSlot(slot); + if (shown.isEmpty()) return; + int amount = + Math.min( + shown.getMaxStackSize(), + right ? shown.getCount() / 2 + shown.getCount() % 2 : shown.getCount()); + menu.setCarried(handler.extractItem(slot, amount, false)); + } else if (equipment || !ItemStack.isSameItemSameComponents(carried, source.stack())) { + int amount = right ? 1 : carried.getCount(); + var remainder = handler.insertItem(slot, carried.copyWithCount(amount), false); + int inserted = amount - remainder.getCount(); + if (inserted > 0) menu.setCarried(carried.copyWithCount(carried.getCount() - inserted)); + } + } + + private void storeFromPlayer(EquippedContainers.Source source, int index) { + var original = owner.getInventory().getItem(index); + if (original.isEmpty() + || !equipment && ItemStack.isSameItemSameComponents(original, source.stack())) return; + var remaining = original.copy(); + for (int pass = 0; pass < 2; pass++) + for (int slot = 0; slot < source.items().getSlots() && !remaining.isEmpty(); slot++) { + if (source.items() instanceof EquippedContainers.DescribedHandler described + && !described.quickInsert(slot)) continue; + var stored = source.items().getStackInSlot(slot); + if (pass == 0 ? ItemStack.isSameItemSameComponents(stored, remaining) : stored.isEmpty()) + remaining = source.items().insertItem(slot, remaining, false); + } + owner.getInventory().setItem(index, remaining); + owner.getInventory().setChanged(); + } + + private void takeToPlayer(IItemHandler handler, int slot) { + var shown = handler.getStackInSlot(slot); + if (shown.isEmpty()) return; + var simulated = handler.extractItem(slot, shown.getMaxStackSize(), true); + if (simulated.isEmpty()) return; + int room = 0; + for (int i = 0; i < 36; i++) { + var existing = owner.getInventory().getItem(i); + if (existing.isEmpty() || ItemStack.isSameItemSameComponents(existing, simulated)) + room += + Math.max( + 0, + Math.min(owner.getInventory().getMaxStackSize(), simulated.getMaxStackSize()) + - existing.getCount()); + } + if (room == 0) return; + var taken = handler.extractItem(slot, Math.min(room, simulated.getCount()), false); + insertIntoPlayer(taken); + // Preserve items even if a third-party extraction callback unexpectedly changed the + // destination. + if (!taken.isEmpty()) { + var leftover = handler.insertItem(slot, taken, false); + if (!leftover.isEmpty()) owner.drop(leftover, false); + } + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/runtime/ContainerDrawerNetwork.java b/src/main/java/zone/moddev/mc/lib/gui/runtime/ContainerDrawerNetwork.java new file mode 100644 index 00000000..07b95133 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/runtime/ContainerDrawerNetwork.java @@ -0,0 +1,262 @@ +package zone.moddev.mc.lib.gui.runtime; + +import java.util.ArrayList; +import java.util.List; + +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.item.ItemStack; +import net.neoforged.neoforge.network.registration.PayloadRegistrar; + +/** Bounded snapshots and session/revision-bound requests for generic equipped containers. */ +public final class ContainerDrawerNetwork { + private ContainerDrawerNetwork() {} + + public record Request( + int menu, + boolean equipment, + long session, + long revision, + int request, + int kind, + int argument, + int pageSize, + ItemStack carried, + List slots) + implements CustomPacketPayload { + public Request( + int menu, + long session, + long revision, + int request, + int kind, + int argument, + int pageSize, + ItemStack carried) { + this(menu, false, session, revision, request, kind, argument, pageSize, carried, List.of()); + } + + public Request( + int menu, + boolean equipment, + long session, + long revision, + int request, + int kind, + int argument, + int pageSize, + ItemStack carried) { + this( + menu, equipment, session, revision, request, kind, argument, pageSize, carried, + List.of()); + } + + public Request { + slots = List.copyOf(slots); + if (revision < 0 + || request < 1 + || kind < 0 + || kind > ContainerDrawerMenu.SWAP + || argument < 0 + || argument >= ContainerDrawerMenu.MAX_SLOTS * 2 + || pageSize < 1 + || pageSize > ContainerDrawerMenu.MAX_PAGE + || slots.size() > ContainerDrawerMenu.MAX_PAGE + 36 + || slots.stream().anyMatch(n -> n < 0 || n >= ContainerDrawerMenu.MAX_SLOTS + 36)) + throw new IllegalArgumentException("Invalid container drawer request"); + } + + public static final Type TYPE = + new Type<>(ResourceLocation.parse("mmdlib:container_action")); + public static final StreamCodec CODEC = + StreamCodec.of( + (b, p) -> { + b.writeVarInt(p.menu); + b.writeBoolean(p.equipment); + b.writeLong(p.session); + b.writeVarLong(p.revision); + b.writeVarInt(p.request); + b.writeVarInt(p.kind); + b.writeVarInt(p.argument); + b.writeVarInt(p.pageSize); + ItemStack.OPTIONAL_STREAM_CODEC.encode(b, p.carried); + b.writeVarInt(p.slots.size()); + for (int slot : p.slots) b.writeVarInt(slot); + }, + b -> + new Request( + b.readVarInt(), + b.readBoolean(), + b.readLong(), + b.readVarLong(), + b.readVarInt(), + b.readVarInt(), + b.readVarInt(), + b.readVarInt(), + ItemStack.OPTIONAL_STREAM_CODEC.decode(b), + readSlots(b))); + + public Type type() { + return TYPE; + } + } + + private static List readSlots(RegistryFriendlyByteBuf b) { + int count = boundedCount(b, ContainerDrawerMenu.MAX_PAGE + 36); + var slots = new ArrayList(count); + for (int i = 0; i < count; i++) slots.add(b.readVarInt()); + return slots; + } + + public record State( + int menu, + boolean equipment, + long session, + long revision, + int acknowledged, + boolean open, + int selected, + int offset, + int pageSize, + List icons, + List keys, + List sizes, + List contents, + List descriptions) + implements CustomPacketPayload { + public State { + descriptions = List.copyOf(descriptions); + icons = List.copyOf(icons); + keys = List.copyOf(keys); + sizes = List.copyOf(sizes); + contents = List.copyOf(contents); + if ((!descriptions.isEmpty() && descriptions.size() != contents.size()) + || descriptions.size() > ContainerDrawerMenu.MAX_PAGE + || (!equipment && !descriptions.isEmpty()) + || revision < 0 + || acknowledged < 0 + || pageSize < 1 + || pageSize > ContainerDrawerMenu.MAX_PAGE + || icons.size() > ContainerDrawerMenu.MAX_CONTAINERS + || icons.size() != sizes.size() + || icons.size() != keys.size() + || keys.stream().anyMatch(k -> k.isEmpty() || k.length() > 256) + || selected < 0 + || selected >= Math.max(1, icons.size()) + || offset < 0 + || offset >= ContainerDrawerMenu.MAX_SLOTS + || contents.size() > pageSize + || sizes.stream().anyMatch(n -> n < 1 || n > ContainerDrawerMenu.MAX_SLOTS) + || (open && icons.isEmpty())) + throw new IllegalArgumentException("Invalid container snapshot"); + } + + public static final Type TYPE = + new Type<>(ResourceLocation.parse("mmdlib:container_state")); + public static final StreamCodec CODEC = + StreamCodec.of( + (b, p) -> { + b.writeVarInt(p.menu); + b.writeBoolean(p.equipment); + b.writeLong(p.session); + b.writeVarLong(p.revision); + b.writeVarInt(p.acknowledged); + b.writeBoolean(p.open); + b.writeVarInt(p.selected); + b.writeVarInt(p.offset); + b.writeVarInt(p.pageSize); + b.writeVarInt(p.icons.size()); + for (int i = 0; i < p.icons.size(); i++) { + ItemStack.OPTIONAL_STREAM_CODEC.encode(b, p.icons.get(i)); + b.writeVarInt(p.sizes.get(i)); + b.writeUtf(p.keys.get(i), 256); + } + b.writeVarInt(p.contents.size()); + for (var stack : p.contents) ItemStack.OPTIONAL_STREAM_CODEC.encode(b, stack); + b.writeVarInt(p.descriptions.size()); + for (var info : p.descriptions) { + b.writeUtf(info.identifier(), 128); + b.writeVarInt(info.index()); + b.writeBoolean(info.cosmetic()); + b.writeBoolean(info.available()); + b.writeResourceLocation(info.icon()); + } + }, + b -> { + int menu = b.readVarInt(); + boolean equipment = b.readBoolean(); + long session = b.readLong(), revision = b.readVarLong(); + int ack = b.readVarInt(); + boolean open = b.readBoolean(); + int selected = b.readVarInt(), offset = b.readVarInt(), page = b.readVarInt(); + int count = boundedCount(b, ContainerDrawerMenu.MAX_CONTAINERS); + var icons = new ArrayList(); + var sizes = new ArrayList(); + var keys = new ArrayList(); + for (int i = 0; i < count; i++) { + icons.add(ItemStack.OPTIONAL_STREAM_CODEC.decode(b)); + sizes.add(b.readVarInt()); + keys.add(b.readUtf(256)); + } + int length = boundedCount(b, ContainerDrawerMenu.MAX_PAGE); + var contents = new ArrayList(); + for (int i = 0; i < length; i++) + contents.add(ItemStack.OPTIONAL_STREAM_CODEC.decode(b)); + int descriptionCount = boundedCount(b, ContainerDrawerMenu.MAX_PAGE); + var descriptions = new ArrayList(); + for (int i = 0; i < descriptionCount; i++) + descriptions.add( + new EquippedContainers.SlotInfo( + b.readUtf(128), + b.readVarInt(), + b.readBoolean(), + b.readBoolean(), + b.readResourceLocation())); + return new State( + menu, + equipment, + session, + revision, + ack, + open, + selected, + offset, + page, + icons, + keys, + sizes, + contents, + descriptions); + }); + + public Type type() { + return TYPE; + } + } + + private static int boundedCount(RegistryFriendlyByteBuf buffer, int max) { + int count = buffer.readVarInt(); + if (count < 0 || count > max) + throw new IllegalArgumentException("Container snapshot too large"); + return count; + } + + public static void register(PayloadRegistrar registrar) { + registrar.playToServer( + Request.TYPE, + Request.CODEC, + (p, c) -> { + if (c.player().containerMenu instanceof MachineMenu menu) + (p.equipment() ? menu.curiosDrawer : menu.containerDrawer).action(c.player(), p); + }); + registrar.playToClient( + State.TYPE, + State.CODEC, + (p, c) -> { + if (c.player().containerMenu instanceof MachineMenu menu) + (p.equipment() ? menu.curiosDrawer : menu.containerDrawer).receive(p); + }); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/runtime/EquippedContainers.java b/src/main/java/zone/moddev/mc/lib/gui/runtime/EquippedContainers.java new file mode 100644 index 00000000..84b1d05b --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/runtime/EquippedContainers.java @@ -0,0 +1,50 @@ +package zone.moddev.mc.lib.gui.runtime; + +import java.util.List; + +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.neoforged.fml.ModList; +import net.neoforged.neoforge.items.IItemHandler; + +/** Equipment discovery boundary. Storage access uses only the standard item capability. */ +public final class EquippedContainers { + private EquippedContainers() {} + + /** The live equipped stack, its stable equipment address, and its current capability. */ + public record Source(String key, ItemStack stack, IItemHandler items) {} + + /** Public slot presentation, independent of the optional Curios classes. */ + public record SlotInfo( + String identifier, + int index, + boolean cosmetic, + boolean available, + net.minecraft.resources.ResourceLocation icon) { + public SlotInfo { + if (identifier.isBlank() + || identifier.length() > 128 + || index < 0 + || index >= ContainerDrawerMenu.MAX_SLOTS) + throw new IllegalArgumentException("Invalid equipment slot"); + } + } + + public interface DescribedHandler extends IItemHandler { + SlotInfo describe(int slot); + + default boolean quickInsert(int slot) { + return !describe(slot).cosmetic(); + } + } + + public static List equipment(Player player, ItemStack identity) { + if (!ModList.get().isLoaded("curios")) return List.of(); + return zone.moddev.mc.lib.compat.curios.CuriosEquipment.find(player, identity); + } + + public static List find(Player player) { + if (!ModList.get().isLoaded("curios")) return List.of(); + return zone.moddev.mc.lib.compat.curios.CuriosContainers.find(player); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/runtime/MachineMenu.java b/src/main/java/zone/moddev/mc/lib/gui/runtime/MachineMenu.java index fb549ace..4b1e0d0e 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/runtime/MachineMenu.java +++ b/src/main/java/zone/moddev/mc/lib/gui/runtime/MachineMenu.java @@ -15,6 +15,7 @@ import net.neoforged.neoforge.network.PacketDistributor; import zone.moddev.mc.lib.api.*; +import zone.moddev.mc.lib.api.automation.AutomationInventory; import zone.moddev.mc.lib.api.capabilities.*; import zone.moddev.mc.lib.gui.*; import zone.moddev.mc.lib.gui.layout.*; @@ -40,6 +41,8 @@ public class MachineMenu extends AbstractContainerMenu { @Nullable private final ReservedSlots reserved; private final Player owner; public final AutomationMenu automation; + public final ContainerDrawerMenu containerDrawer; + public final ContainerDrawerMenu curiosDrawer; private final int baseMachineSlots; private boolean initial = true; private boolean interacting; @@ -221,6 +224,17 @@ protected MachineMenu(int id, Inventory inventory, BlockEntity entity, boolean c if (!bound.blockEntityClass().isInstance(entity)) throw new IllegalArgumentException("Wrong screen block entity"); elements = bound.screen().elements(); + containerDrawer = new ContainerDrawerMenu(this, owner); + var equipmentIdentity = new ItemStack(net.minecraft.world.item.Items.ENDER_EYE); + curiosDrawer = + new ContainerDrawerMenu( + this, + owner, + () -> + bound.screen().playerInventory() + ? EquippedContainers.equipment(owner, equipmentIdentity) + : java.util.List.of(), + true); for (int i = 0; i < elements.size(); i++) elementIndexes.put(elements.get(i), i); if (elements.size() > 4096) throw new IllegalArgumentException("A screen may have at most 4096 elements"); @@ -252,7 +266,7 @@ protected MachineMenu(int id, Inventory inventory, BlockEntity entity, boolean c baseMachineSlots = count; machineSlots = count + (automation == null ? 0 : automation.slotCount()); reserved = items instanceof ReservedSlots slots ? slots : null; - var layout = + layout = ScreenLayout.of( bound.screen(), owner.level().isClientSide ? ScreenRuntime.textMeasure : TextMeasure.NONE, @@ -329,16 +343,16 @@ public int getMaxStackSize(ItemStack stack) { .inventory() .ifPresent( r -> { - for (int row = 0; row < 3; row++) - for (int col = 0; col < 9; col++) - addSlot( - new Slot( - inventory, - 9 + row * 9 + col, - r.x() + col * 18 + 1, - r.y() + 11 + row * 18 + 1)); - for (int col = 0; col < 9; col++) - addSlot(new Slot(inventory, col, r.x() + col * 18 + 1, r.y() + 11 + 58 + 1)); + // Keep the ordinary 36 slots contiguous and in their existing menu order. + for (int n = 0; n < 36; n++) { + int index = (n + 9) % 36; + int[] xy = ScreenLayout.itemPosition(ScreenLayout.playerSlot(r, index)); + addSlot(new Slot(inventory, index, xy[0], xy[1])); + } + for (int index : new int[] {39, 38, 37, 36, 40}) { + int[] xy = ScreenLayout.itemPosition(ScreenLayout.playerSlot(r, index)); + addSlot(new PlayerEquipmentSlot(inventory, index, xy[0], xy[1])); + } }); } @@ -346,6 +360,51 @@ public Map views() { return Map.copyOf(views); } + private ScreenLayout layout; + + /** Current geometry, shared by the screen and its persistent menu slots. */ + public ScreenLayout layout() { + return layout; + } + + private void relayout() { + layout = + ScreenLayout.of( + bound.screen(), + owner.level().isClientSide ? ScreenRuntime.textMeasure : TextMeasure.NONE, + automation != null, + views); + for (var element : elements) { + if (element instanceof Element.Slot slot) { + int[] xy = ScreenLayout.itemPosition(layout.bounds(element)); + slots.get(slot.index()).x = xy[0]; + slots.get(slot.index()).y = xy[1]; + } + } + if (automation != null) { + for (int i = 0; i < automation.slotCount(); i++) { + var r = + AutomationLayout.item(i % AutomationInventory.SLOTS_PER_GROUP) + .offset(layout.panel().width() + AutomationLayout.RAIL, AutomationLayout.TOP); + var slot = slots.get(baseMachineSlots + i); + slot.x = r.x(); + slot.y = r.y(); + } + } + layout + .inventory() + .ifPresent( + r -> { + for (int i = machineSlots; i < slots.size(); i++) { + var slot = slots.get(i); + int[] xy = + ScreenLayout.itemPosition(ScreenLayout.playerSlot(r, slot.getSlotIndex())); + slot.x = xy[0]; + slot.y = xy[1]; + } + }); + } + public int index(Element element) { var index = elementIndexes.get(element); if (index == null) throw new IllegalArgumentException("Element is not on this screen"); @@ -404,6 +463,7 @@ public void navigate(String context, String view) { && !Objects.equals(views.get(context), view)) { views.put(context, view); visibility.clear(); + relayout(); } } @@ -454,8 +514,9 @@ private ItemStack moveStack(Player player, int index) { var stack = source.getItem(); var original = stack.copy(); if (index < machineSlots && planned(index)) return quickMovePlanned(player, source); - if (index < machineSlots) { - if (!moveItemStackTo(stack, machineSlots, slots.size(), true)) return ItemStack.EMPTY; + if (index < machineSlots || index >= machineSlots + 36) { + // Outputs go into storage/hotbar; shift-clicking equipment unequips it into that range. + if (!moveItemStackTo(stack, machineSlots, playerStorageEnd(), true)) return ItemStack.EMPTY; } else { if (host == null) return ItemStack.EMPTY; var destinations = new ArrayList(); @@ -477,7 +538,7 @@ private ItemStack moveStack(Player player, int index) { moveItemStackTo(stack, slot.index, slot.index + 1, false); if (stack.getCount() == original.getCount()) return ItemStack.EMPTY; } - if (stack.isEmpty()) source.setByPlayer(ItemStack.EMPTY); + if (stack.isEmpty()) source.setByPlayer(ItemStack.EMPTY, original); else source.setChanged(); source.onTake(player, stack); return original; @@ -490,7 +551,7 @@ private ItemStack quickMovePlanned(Player player, Slot source) { if (free <= 0) return ItemStack.EMPTY; var moving = source.getItem().copyWithCount(free); var original = moving.copy(); - if (!moveItemStackTo(moving, machineSlots, slots.size(), true)) return ItemStack.EMPTY; + if (!moveItemStackTo(moving, machineSlots, playerStorageEnd(), true)) return ItemStack.EMPTY; int moved = free - moving.getCount(); var taken = source.remove(moved); source.onTake(player, taken); @@ -505,6 +566,10 @@ private boolean planned(int index) { && reserved.hasPlan(index); } + private int playerStorageEnd() { + return Math.min(machineSlots + 36, slots.size()); + } + @Override public boolean canDragTo(Slot slot) { return slot.isActive() && super.canDragTo(slot); @@ -519,6 +584,8 @@ public boolean canTakeItemForPickAll(ItemStack stack, Slot slot) { public void broadcastChanges() { if (automation != null) automation.beforeBroadcast(); super.broadcastChanges(); + containerDrawer.broadcast(); + curiosDrawer.broadcast(); if (automation != null) automation.broadcast(); if (!(owner instanceof net.minecraft.server.level.ServerPlayer player) || !stillValid(owner) diff --git a/src/main/java/zone/moddev/mc/lib/gui/runtime/PlayerEquipmentSlot.java b/src/main/java/zone/moddev/mc/lib/gui/runtime/PlayerEquipmentSlot.java new file mode 100644 index 00000000..b57b2e90 --- /dev/null +++ b/src/main/java/zone/moddev/mc/lib/gui/runtime/PlayerEquipmentSlot.java @@ -0,0 +1,71 @@ +package zone.moddev.mc.lib.gui.runtime; + +import com.mojang.datafixers.util.Pair; + +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.EquipmentSlot; +import net.minecraft.world.entity.player.Inventory; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.InventoryMenu; +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.enchantment.EnchantmentEffectComponents; +import net.minecraft.world.item.enchantment.EnchantmentHelper; + +/** Player-owned armor and offhand slots, with the same rules and callbacks as InventoryMenu. */ +final class PlayerEquipmentSlot extends Slot { + private final Player owner; + private final EquipmentSlot equipment; + private final ResourceLocation icon; + + PlayerEquipmentSlot(Inventory inventory, int index, int x, int y) { + super(inventory, index, x, y); + owner = inventory.player; + equipment = + switch (index) { + case 39 -> EquipmentSlot.HEAD; + case 38 -> EquipmentSlot.CHEST; + case 37 -> EquipmentSlot.LEGS; + case 36 -> EquipmentSlot.FEET; + case 40 -> EquipmentSlot.OFFHAND; + default -> throw new IllegalArgumentException("Equipment slot: " + index); + }; + icon = + switch (equipment) { + case HEAD -> InventoryMenu.EMPTY_ARMOR_SLOT_HELMET; + case CHEST -> InventoryMenu.EMPTY_ARMOR_SLOT_CHESTPLATE; + case LEGS -> InventoryMenu.EMPTY_ARMOR_SLOT_LEGGINGS; + case FEET -> InventoryMenu.EMPTY_ARMOR_SLOT_BOOTS; + default -> InventoryMenu.EMPTY_ARMOR_SLOT_SHIELD; + }; + } + + @Override + public void setByPlayer(ItemStack stack, ItemStack previous) { + owner.onEquipItem(equipment, previous, stack); + super.setByPlayer(stack, previous); + } + + @Override + public int getMaxStackSize() { + return equipment == EquipmentSlot.OFFHAND ? super.getMaxStackSize() : 1; + } + + @Override + public boolean mayPlace(ItemStack stack) { + return equipment == EquipmentSlot.OFFHAND || stack.canEquip(equipment, owner); + } + + @Override + public boolean mayPickup(Player player) { + return (equipment == EquipmentSlot.OFFHAND + || player.isCreative() + || !EnchantmentHelper.has(getItem(), EnchantmentEffectComponents.PREVENT_ARMOR_CHANGE)) + && super.mayPickup(player); + } + + @Override + public Pair getNoItemIcon() { + return Pair.of(InventoryMenu.BLOCK_ATLAS, icon); + } +} diff --git a/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenNetwork.java b/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenNetwork.java index 80137367..42d49082 100644 --- a/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenNetwork.java +++ b/src/main/java/zone/moddev/mc/lib/gui/runtime/ScreenNetwork.java @@ -173,8 +173,9 @@ public Type type() { } public static void register(RegisterPayloadHandlersEvent event) { - var r = event.registrar("3"); + var r = event.registrar("6"); AutomationNetwork.register(r); + ContainerDrawerNetwork.register(r); r.playToServer( Widget.TYPE, Widget.CODEC, diff --git a/src/main/resources/META-INF/accesstransformer.cfg b/src/main/resources/META-INF/accesstransformer.cfg new file mode 100644 index 00000000..adde41ce --- /dev/null +++ b/src/main/resources/META-INF/accesstransformer.cfg @@ -0,0 +1,3 @@ +# Keep menu slot identities and sync state while context views resize the screen. +public-f net.minecraft.world.inventory.Slot x +public-f net.minecraft.world.inventory.Slot y diff --git a/src/main/resources/META-INF/neoforge.mods.toml b/src/main/resources/META-INF/neoforge.mods.toml index 72819662..0968d0a4 100644 --- a/src/main/resources/META-INF/neoforge.mods.toml +++ b/src/main/resources/META-INF/neoforge.mods.toml @@ -12,6 +12,8 @@ loaderVersion="${loader_version_range}" #mandatory This is typically bumped ever license="${mod_license}" # A URL to refer people to when problems occur with this mod issueTrackerURL="${issue_url}" #optional +[[accessTransformers]] +file="META-INF/accesstransformer.cfg" # A list of mods - how many allowed here is determined by the individual mod loader [[mods]] #mandatory # The modid of the mod @@ -63,6 +65,14 @@ ${mod_description} versionRange="${minecraft_version_range}" ordering="NONE" side="BOTH" +# Optional: equipped item storage is available when Curios is installed. +[[dependencies.${mod_id}]] + modId="curios" + type="optional" + versionRange="${curios_version_range}" + ordering="AFTER" + side="BOTH" + # Optional: JEI shows crackhammer crushing recipes when installed; never required. [[dependencies.${mod_id}]] modId="jei" diff --git a/src/main/resources/assets/mmdlib/lang/en_us.json b/src/main/resources/assets/mmdlib/lang/en_us.json index 9f3d6c82..95abf62f 100644 --- a/src/main/resources/assets/mmdlib/lang/en_us.json +++ b/src/main/resources/assets/mmdlib/lang/en_us.json @@ -285,5 +285,20 @@ "screen.mmdlib.automation.sample": "Click with a sample; right-click to clear", "screen.mmdlib.automation.empty": "No neighbouring block", "screen.mmdlib.automation.unloaded": "Neighbour not loaded", - "screen.mmdlib.automation.addon": "Compatible addon" + "screen.mmdlib.automation.addon": "Compatible addon", + "screen.mmdlib.container.toggle": "Open or close equipped storage", + "screen.mmdlib.container.room": "Reduce GUI scale or enlarge the window to show storage.", + "screen.mmdlib.container.select": "Click to select the next equipped container.", + "screen.mmdlib.container.store": "Shift-click inventory items to store them here.", + "screen.mmdlib.container.gestures": "Drag to distribute; double-click to collect; hotbar keys swap slots.", + "screen.mmdlib.curios.title": "Curios", + "screen.mmdlib.curios.toggle": "Open or close equipped accessories", + "screen.mmdlib.curios.store": "Shift-click inventory items to equip them; shift-click accessories to unequip.", + "screen.mmdlib.curios.equipment": "Slot", + "screen.mmdlib.curios.functional.short": "E", + "screen.mmdlib.curios.cosmetic.short": "C", + "screen.mmdlib.curios.functional": "Equipped slot", + "screen.mmdlib.curios.cosmetic": "Cosmetic slot", + "screen.mmdlib.curios.columns": "E: Equipped • C: Cosmetic", + "screen.mmdlib.curios.unavailable": "This slot has no cosmetic counterpart." } diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ContainerDrawerSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ContainerDrawerSmoke.java new file mode 100644 index 00000000..ee4c5dfd --- /dev/null +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ContainerDrawerSmoke.java @@ -0,0 +1,243 @@ +package zone.moddev.mc.lib.smoke; + +import java.util.ArrayList; +import java.util.List; +import net.minecraft.core.BlockPos; +import net.minecraft.core.component.DataComponents; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.neoforged.neoforge.items.IItemHandler; +import net.neoforged.neoforge.items.ItemStackHandler; +import zone.moddev.mc.lib.gui.runtime.ContainerDrawerMenu; +import zone.moddev.mc.lib.gui.runtime.ContainerDrawerNetwork; +import zone.moddev.mc.lib.gui.runtime.EquippedContainers; +import zone.moddev.mc.lib.gui.runtime.MachineMenu; + +/** Exercises authoritative transfers through a capability that deliberately has no setter API. */ +final class ContainerDrawerSmoke { + static void check(ServerLevel level, BlockPos pos, Player player) { + require(!net.neoforged.fml.ModList.get().isLoaded("curios"), "Standalone run has no Curios"); + require(EquippedContainers.find(player).isEmpty(), "Absent optional API is safe"); + level.setBlock(pos, BuiltInRegistries.BLOCK.get(CrusherSmoke.ID).defaultBlockState(), 3); + player.setPos(pos.getX() + 0.5, pos.getY() + 1, pos.getZ() + 0.5); + var menu = new MachineMenu(93, player.getInventory(), level.getBlockEntity(pos)); + player.containerMenu = menu; + menu.curiosDrawer.refresh(); + require( + menu.curiosDrawer.state().equipment() && menu.curiosDrawer.state().icons().isEmpty(), + "Equipment controller loads safely with Curios absent"); + player.getInventory().clearContent(); + var backing = new ItemStackHandler(70); + var handler = + new IItemHandler() { + public int getSlots() { + return backing.getSlots(); + } + + public ItemStack getStackInSlot(int slot) { + return backing.getStackInSlot(slot); + } + + public ItemStack insertItem(int slot, ItemStack stack, boolean simulate) { + return isItemValid(slot, stack) ? backing.insertItem(slot, stack, simulate) : stack; + } + + public ItemStack extractItem(int slot, int amount, boolean simulate) { + return slot == 2 ? ItemStack.EMPTY : backing.extractItem(slot, amount, simulate); + } + + public int getSlotLimit(int slot) { + return backing.getSlotLimit(slot); + } + + public boolean isItemValid(int slot, ItemStack stack) { + return stack.is(Items.IRON_INGOT); + } + }; + var icon = new ItemStack(Items.LEATHER_CHESTPLATE); + icon.set(DataComponents.CUSTOM_NAME, Component.literal("Actual equipped item")); + var sources = new ArrayList<>(List.of(new EquippedContainers.Source("back:0", icon, handler))); + var drawer = new ContainerDrawerMenu(menu, player, () -> sources); + drawer.refresh(); + require( + ItemStack.matches(icon, drawer.state().icons().getFirst()), "Icon preserves components"); + var unchanged = drawer.state(); + drawer.refresh(); + require(drawer.state() == unchanged, "Unchanged closed drawer reuses its snapshot"); + backing.setStackInSlot(0, new ItemStack(Items.IRON_INGOT, 15)); + send(drawer, menu, player, ContainerDrawerMenu.PICKUP, 0); + require(menu.getCarried().isEmpty(), "Closed drawer cannot transfer"); + send(drawer, menu, player, ContainerDrawerMenu.TOGGLE, 0); + unchanged = drawer.state(); + drawer.refresh(); + require(drawer.state() == unchanged, "Unchanged open drawer reuses its snapshot"); + var oldContents = unchanged.contents(); + backing.getStackInSlot(0).grow(1); + drawer.refresh(); + require( + drawer.state() != unchanged && oldContents.getFirst().getCount() == 15, + "In-place count changes refresh without mutating previous snapshots"); + backing.getStackInSlot(0).shrink(1); + drawer.refresh(); + unchanged = drawer.state(); + icon.set(DataComponents.CUSTOM_NAME, Component.literal("Renamed equipped item")); + drawer.refresh(); + require( + drawer.state() != unchanged && drawer.state().contents() == unchanged.contents(), + "Icon changes reuse unchanged content copies"); + + send(drawer, menu, player, ContainerDrawerMenu.PICKUP, 1); + require( + menu.getCarried().getCount() == 8 && backing.getStackInSlot(0).getCount() == 7, + "Right pickup splits"); + send(drawer, menu, player, ContainerDrawerMenu.PICKUP, 1); + require( + menu.getCarried().getCount() == 7 && backing.getStackInSlot(0).getCount() == 8, + "Right insert places one"); + backing.setStackInSlot(0, new ItemStack(Items.IRON_INGOT, 62)); + send(drawer, menu, player, ContainerDrawerMenu.PICKUP, 0); + require( + menu.getCarried().getCount() == 5 && backing.getStackInSlot(0).getCount() == 64, + "Partial insertion keeps remainder"); + menu.setCarried(new ItemStack(Items.DIAMOND, 3)); + send(drawer, menu, player, ContainerDrawerMenu.PICKUP, 2); + require( + menu.getCarried().getCount() == 3 && backing.getStackInSlot(1).isEmpty(), + "Insertion filter honored"); + menu.setCarried(ItemStack.EMPTY); + backing.setStackInSlot(2, new ItemStack(Items.IRON_INGOT, 9)); + send(drawer, menu, player, ContainerDrawerMenu.PICKUP, 4); + require(menu.getCarried().isEmpty(), "Extraction filter honored"); + var stale = request(drawer, menu, ContainerDrawerMenu.PICKUP, 0); + backing.setStackInSlot(0, new ItemStack(Items.IRON_INGOT, 60)); + drawer.action(player, stale); + require( + menu.getCarried().isEmpty() && backing.getStackInSlot(0).getCount() == 60, + "Stale revision refused"); + var wrongCursor = request(drawer, menu, ContainerDrawerMenu.PICKUP, 0); + menu.setCarried(new ItemStack(Items.DIAMOND)); + drawer.action(player, wrongCursor); + require(backing.getStackInSlot(0).getCount() == 60, "Cursor mismatch refused"); + menu.setCarried(ItemStack.EMPTY); + player.getInventory().setItem(0, new ItemStack(Items.IRON_INGOT, 10)); + send(drawer, menu, player, ContainerDrawerMenu.QUICK_STORE, 0); + require( + player.getInventory().getItem(0).isEmpty() + && backing.getStackInSlot(0).getCount() == 64 + && backing.getStackInSlot(2).getCount() == 15, + "Shift store merges before empty slots"); + for (int i = 0; i < 36; i++) player.getInventory().setItem(i, new ItemStack(Items.STONE, 64)); + send(drawer, menu, player, ContainerDrawerMenu.QUICK_TAKE, 0); + require( + backing.getStackInSlot(0).getCount() == 64 && player.getInventory().getItem(40).isEmpty(), + "Full inventory does not spill into equipment"); + player.getInventory().setItem(0, new ItemStack(Items.IRON_INGOT, 61)); + var transfer = request(drawer, menu, ContainerDrawerMenu.QUICK_TAKE, 0); + drawer.action(player, transfer); + drawer.action(player, transfer); + require( + backing.getStackInSlot(0).getCount() == 61 + && player.getInventory().getItem(0).getCount() == 64, + "Partial extraction and duplicate request conserve items"); + backing.setStackInSlot(60, new ItemStack(Items.IRON_INGOT, 2)); + send(drawer, menu, player, ContainerDrawerMenu.PICKUP, 120); + require(menu.getCarried().isEmpty(), "Off-page slot refused"); + send(drawer, menu, player, ContainerDrawerMenu.PAGE, 54); + send(drawer, menu, player, ContainerDrawerMenu.PICKUP, 120); + require(menu.getCarried().getCount() == 2, "Paged slot is accessible"); + var old = request(drawer, menu, ContainerDrawerMenu.PICKUP, 120); + sources.set(0, new EquippedContainers.Source("back:0", icon.copy(), handler)); + drawer.action(player, old); + require( + !drawer.state().open() && menu.getCarried().getCount() == 2, + "Replacing identical item closes drawer"); + sources.add( + new EquippedContainers.Source( + "back:1", new ItemStack(Items.CHEST), new ItemStackHandler(1))); + drawer.refresh(); + send(drawer, menu, player, ContainerDrawerMenu.TOGGLE, 0); + send(drawer, menu, player, ContainerDrawerMenu.SELECT, 1); + require( + drawer.state().selected() == 1 && drawer.state().contents().size() == 1, + "Select another equipped container"); + var buf = + new net.minecraft.network.RegistryFriendlyByteBuf( + io.netty.buffer.Unpooled.buffer(), level.registryAccess()); + try { + ContainerDrawerNetwork.State.CODEC.encode(buf, drawer.state()); + var decoded = ContainerDrawerNetwork.State.CODEC.decode(buf); + require( + decoded.selected() == 1 + && decoded.keys().get(1).equals("back:1") + && ItemStack.matches(decoded.icons().getFirst(), icon), + "Snapshot codec retains item components"); + var receiver = new ContainerDrawerMenu(menu, player, () -> sources); + receiver.receive(decoded); + require( + receiver.request(ContainerDrawerMenu.SYNC, 0, 27).orElseThrow().request() + > decoded.acknowledged(), + "Snapshot acknowledgments advance the next request sequence"); + require(!receiver.pendingInventory(), "Sync does not block vanilla inventory"); + var acknowledgement = + new ContainerDrawerNetwork.State( + decoded.menu(), + decoded.equipment(), + decoded.session(), + decoded.revision(), + decoded.acknowledged() + 1, + decoded.open(), + decoded.selected(), + decoded.offset(), + decoded.pageSize(), + decoded.icons(), + decoded.keys(), + decoded.sizes(), + decoded.contents(), + decoded.descriptions()); + receiver.receive(acknowledgement); + receiver.request(ContainerDrawerMenu.PICKUP, 0, 27).orElseThrow(); + require(receiver.pendingInventory(), "Item transfers wait for authoritative cursor updates"); + menu.curiosDrawer.receive(decoded); + require( + menu.curiosDrawer.state().equipment() && menu.curiosDrawer.state().icons().isEmpty(), + "Backpack snapshots cannot overwrite the equipment channel"); + } finally { + buf.release(); + } + sources.clear(); + drawer.refresh(); + require( + !drawer.state().open() && drawer.state().icons().isEmpty(), "Unequipping removes drawer"); + ContainerInteractionSmoke.check(menu, player); + player.containerMenu = player.inventoryMenu; + menu.setCarried(ItemStack.EMPTY); + player.getInventory().clearContent(); + } + + private static ContainerDrawerNetwork.Request request( + ContainerDrawerMenu drawer, MachineMenu menu, int kind, int arg) { + return new ContainerDrawerNetwork.Request( + menu.containerId, + menu.widgetSession(), + drawer.state().revision(), + drawer.state().acknowledged() + 1, + kind, + arg, + 27, + menu.getCarried().copy()); + } + + private static void send( + ContainerDrawerMenu drawer, MachineMenu menu, Player player, int kind, int arg) { + drawer.refresh(); + drawer.action(player, request(drawer, menu, kind, arg)); + } + + private static void require(boolean ok, String message) { + if (!ok) throw new AssertionError(message); + } +} diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ContainerInteractionSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ContainerInteractionSmoke.java new file mode 100644 index 00000000..4d49f8f3 --- /dev/null +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/ContainerInteractionSmoke.java @@ -0,0 +1,200 @@ +package zone.moddev.mc.lib.smoke; + +import java.util.List; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.neoforged.neoforge.items.ItemStackHandler; +import zone.moddev.mc.lib.gui.runtime.*; + +/** Conservation, filter, replay and off-page checks for batched backpack gestures. */ +final class ContainerInteractionSmoke { + static void check(MachineMenu menu, Player player) { + player.getInventory().clearContent(); + var items = + new ItemStackHandler(70) { + public int getSlotLimit(int slot) { + return slot == 1 ? 4 : 64; + } + + public boolean isItemValid(int slot, ItemStack stack) { + return slot != 2 && !stack.is(Items.DIRT); + } + + public ItemStack extractItem(int slot, int count, boolean simulate) { + return slot == 3 ? ItemStack.EMPTY : super.extractItem(slot, count, simulate); + } + }; + var icon = new ItemStack(Items.CHEST); + var drawer = + new ContainerDrawerMenu( + menu, player, () -> List.of(new EquippedContainers.Source("back:1", icon, items))); + send(drawer, menu, player, ContainerDrawerMenu.TOGGLE, 0, List.of()); + menu.setCarried(new ItemStack(Items.IRON_INGOT, 20)); + send(drawer, menu, player, ContainerDrawerMenu.DISTRIBUTE, 0, List.of(0, 1, 2, 4096)); + require( + items.getStackInSlot(0).getCount() == 6 + && items.getStackInSlot(1).getCount() == 4 + && items.getStackInSlot(2).isEmpty() + && player.getInventory().getItem(0).getCount() == 6 + && menu.getCarried().getCount() == 4, + "Even drag respects filters, limits and remainder"); + send(drawer, menu, player, ContainerDrawerMenu.DISTRIBUTE, 1, List.of(0, 4)); + require( + items.getStackInSlot(0).getCount() == 7 + && items.getStackInSlot(4).getCount() == 1 + && menu.getCarried().getCount() == 2, + "Right drag places one per target"); + send(drawer, menu, player, ContainerDrawerMenu.DISTRIBUTE, 0, List.of(0, 0)); + send(drawer, menu, player, ContainerDrawerMenu.DISTRIBUTE, 0, List.of(0, 60)); + require( + menu.getCarried().getCount() == 2, "Duplicate and off-page targets reject whole gesture"); + drawer.refresh(); + var request = request(drawer, menu, ContainerDrawerMenu.DISTRIBUTE, 1, List.of(0, 4)); + drawer.action(player, request); + drawer.action(player, request); + require( + menu.getCarried().isEmpty() + && items.getStackInSlot(0).getCount() == 8 + && items.getStackInSlot(4).getCount() == 2, + "Replayed drag cannot duplicate items"); + menu.setCarried(new ItemStack(Items.IRON_INGOT, 1)); + items.setStackInSlot(3, new ItemStack(Items.IRON_INGOT, 9)); + items.setStackInSlot(60, new ItemStack(Items.IRON_INGOT, 20)); + var named = new ItemStack(Items.IRON_INGOT, 5); + named.set( + net.minecraft.core.component.DataComponents.CUSTOM_NAME, + net.minecraft.network.chat.Component.literal("Keep")); + items.setStackInSlot(5, named); + send(drawer, menu, player, ContainerDrawerMenu.COLLECT, 0, List.of()); + require( + menu.getCarried().getCount() == 41 + && items.getStackInSlot(60).isEmpty() + && player.getInventory().getItem(0).isEmpty() + && items.getStackInSlot(3).getCount() == 9 + && ItemStack.matches(items.getStackInSlot(5), named), + "Collect includes other pages and player slots but honors components/extraction"); + items.setStackInSlot(61, new ItemStack(Items.IRON_INGOT, 50)); + send(drawer, menu, player, ContainerDrawerMenu.COLLECT, 0, List.of()); + require( + menu.getCarried().getCount() == 64 && items.getStackInSlot(61).getCount() == 27, + "Collect caps cursor stack"); + menu.setCarried(ItemStack.EMPTY); + player.getInventory().setItem(0, new ItemStack(Items.DIAMOND, 2)); + items.setStackInSlot(0, new ItemStack(Items.COPPER_INGOT, 5)); + send(drawer, menu, player, ContainerDrawerMenu.SWAP, 0, List.of(0)); + require( + items.getStackInSlot(0).is(Items.DIAMOND) + && items.getStackInSlot(0).getCount() == 2 + && player.getInventory().getItem(0).is(Items.COPPER_INGOT) + && player.getInventory().getItem(0).getCount() == 5, + "Hotbar swap exchanges whole stacks"); + player.getInventory().setItem(0, new ItemStack(Items.DIRT, 3)); + send(drawer, menu, player, ContainerDrawerMenu.SWAP, 0, List.of(0)); + require( + items.getStackInSlot(0).is(Items.DIAMOND) + && player.getInventory().getItem(0).getCount() == 3, + "Swap rejects invalid insertion"); + player.getInventory().setItem(0, ItemStack.EMPTY); + send(drawer, menu, player, ContainerDrawerMenu.SWAP, 0, List.of(3)); + require(player.getInventory().getItem(0).isEmpty(), "Swap honors locked extraction"); + send(drawer, menu, player, ContainerDrawerMenu.SWAP, 0, List.of(0)); + require( + items.getStackInSlot(0).isEmpty() && player.getInventory().getItem(0).is(Items.DIAMOND), + "Swap into empty hotbar"); + drawer.refresh(); + var stale = request(drawer, menu, ContainerDrawerMenu.SWAP, 0, List.of(0)); + items.setStackInSlot(6, new ItemStack(Items.STONE)); + drawer.action(player, stale); + require(items.getStackInSlot(0).isEmpty(), "Stale swap rejected"); + var rejecting = + new ItemStackHandler(1) { + public ItemStack insertItem(int slot, ItemStack stack, boolean simulate) { + return stack.is(Items.DIAMOND) ? stack : super.insertItem(slot, stack, simulate); + } + }; + rejecting.setStackInSlot(0, new ItemStack(Items.IRON_INGOT, 5)); + var rollback = + new ContainerDrawerMenu( + menu, player, () -> List.of(new EquippedContainers.Source("back:2", icon, rejecting))); + player.getInventory().setItem(0, new ItemStack(Items.DIAMOND, 2)); + send(rollback, menu, player, ContainerDrawerMenu.TOGGLE, 0, List.of()); + send(rollback, menu, player, ContainerDrawerMenu.SWAP, 0, List.of(0)); + require( + rejecting.getStackInSlot(0).getCount() == 5 + && rejecting.getStackInSlot(0).is(Items.IRON_INGOT) + && player.getInventory().getItem(0).getCount() == 2 + && player.getInventory().getItem(0).is(Items.DIAMOND), + "Rejected swap restores the extracted stack without consuming incoming items"); + var partial = + new ItemStackHandler(1) { + public ItemStack insertItem(int slot, ItemStack stack, boolean simulate) { + if (!simulate && stack.is(Items.DIAMOND)) { + var left = + super.insertItem(slot, stack.copyWithCount(Math.min(2, stack.getCount())), false); + return stack.copyWithCount( + stack.getCount() - Math.min(2, stack.getCount()) + left.getCount()); + } + return super.insertItem(slot, stack, simulate); + } + }; + partial.setStackInSlot(0, new ItemStack(Items.IRON_INGOT, 5)); + var partialDrawer = + new ContainerDrawerMenu( + menu, player, () -> List.of(new EquippedContainers.Source("back:3", icon, partial))); + player.getInventory().clearContent(); + player.getInventory().setItem(0, new ItemStack(Items.DIAMOND, 4)); + send(partialDrawer, menu, player, ContainerDrawerMenu.TOGGLE, 0, List.of()); + send(partialDrawer, menu, player, ContainerDrawerMenu.SWAP, 0, List.of(0)); + require( + partial.getStackInSlot(0).getCount() == 2 + && player.getInventory().getItem(0).getCount() == 2 + && player.getInventory().getItem(1).is(Items.IRON_INGOT) + && player.getInventory().getItem(1).getCount() == 5, + "Partial swap preserves both remainders"); + var buffer = + new net.minecraft.network.RegistryFriendlyByteBuf( + io.netty.buffer.Unpooled.buffer(), player.registryAccess()); + try { + ContainerDrawerNetwork.Request.CODEC.encode( + buffer, request(drawer, menu, ContainerDrawerMenu.DISTRIBUTE, 0, List.of(0, 4096))); + require( + ContainerDrawerNetwork.Request.CODEC.decode(buffer).slots().equals(List.of(0, 4096)), + "Gesture codec retains bounded targets"); + } finally { + buffer.release(); + } + menu.setCarried(ItemStack.EMPTY); + player.getInventory().clearContent(); + } + + private static ContainerDrawerNetwork.Request request( + ContainerDrawerMenu drawer, MachineMenu menu, int kind, int arg, List slots) { + return new ContainerDrawerNetwork.Request( + menu.containerId, + false, + menu.widgetSession(), + drawer.state().revision(), + drawer.state().acknowledged() + 1, + kind, + arg, + 27, + menu.getCarried().copy(), + slots); + } + + private static void send( + ContainerDrawerMenu drawer, + MachineMenu menu, + Player player, + int kind, + int arg, + List slots) { + drawer.refresh(); + drawer.action(player, request(drawer, menu, kind, arg, slots)); + } + + private static void require(boolean ok, String message) { + if (!ok) throw new AssertionError(message); + } +} diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/GuiClientSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/GuiClientSmoke.java index 3233801f..beeb2709 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/GuiClientSmoke.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/GuiClientSmoke.java @@ -194,6 +194,25 @@ public static void frame(RenderFrameEvent.Post event) { return; } GuiGaugeSmoke.check(screen); + var inventoryBounds = screen.layout.inventory().orElseThrow(); + int equipment = 0; + for (var slot : screen.getMenu().slots) { + if (slot.container != mc.player.getInventory() || slot.getContainerSlot() < 36) continue; + var expected = + zone.moddev.mc.lib.gui.layout.ScreenLayout.playerSlot( + inventoryBounds, slot.getContainerSlot()); + if (slot.x != expected.x() + 1 + || slot.y != expected.y() + 1 + || slot.getNoItemIcon() == null) { + fail(new AssertionError("Client equipment position or empty icon missing")); + return; + } + equipment++; + } + if (equipment != 5) { + fail(new AssertionError("Client equipment slots missing")); + return; + } Screenshot.grab( mc.gameDirectory, "gui-smoke.png", diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/GuiJeiSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/GuiJeiSmoke.java index 63ad0005..a49aa164 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/GuiJeiSmoke.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/GuiJeiSmoke.java @@ -156,6 +156,26 @@ private static void checkTransfer( false, false); if (error != null) throw new AssertionError("JEI transfer validation failed: " + error); + // The same ingredient in the offhand must not be consumed as a recipe input. + var inventory = Minecraft.getInstance().player.getInventory(); + var ingredient = inventory.getItem(9); + var previousOffhand = inventory.getItem(40); + try { + inventory.setItem(9, ItemStack.EMPTY); + inventory.setItem(40, ingredient); + var equippedOnly = + handler.transferRecipe( + screen.getMenu(), + recipe, + layout.getRecipeSlotsView(), + Minecraft.getInstance().player, + false, + false); + if (equippedOnly == null) throw new AssertionError("JEI used the offhand as recipe storage"); + } finally { + inventory.setItem(9, ingredient); + inventory.setItem(40, previousOffhand); + } error = handler.transferRecipe( screen.getMenu(), diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/GuiRuntimeChecks.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/GuiRuntimeChecks.java index c42194b5..c5459445 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/GuiRuntimeChecks.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/GuiRuntimeChecks.java @@ -33,7 +33,7 @@ public static void install() { MaterialEventTypes.GUI_CLICK, c -> ((Counter) c.menu().blockEntity).clicks++); String xml = - """ +""" @@ -126,7 +126,21 @@ public static void check(ServerPlayer player, Counter entity) { "Hidden side action"); require(menu.ghost(player, ghost, new ItemStack(Items.DIAMOND, 64)), "Ghost filter rejected"); require(entity.ghost.getCount() == 1 && entity.ghost.is(Items.DIAMOND), "Ghost filter count"); + var originalSlots = java.util.List.copyOf(menu.slots); + var originalPanel = menu.layout().panel(); menu.clickMenuButton(player, new ScreenNetwork.Action(73, nav, 1, 0, 0, 0).pack()); + require(!originalPanel.equals(menu.layout().panel()), "Context did not resize panel"); + for (int i = 0; i < menu.slots.size(); i++) { + var slot = menu.slots.get(i); + require(slot == originalSlots.get(i), "Context replaced slot identity"); + if (slot.container == player.getInventory()) { + var r = + zone.moddev.mc.lib.gui.layout.ScreenLayout.playerSlot( + menu.layout().inventory().orElseThrow(), slot.getSlotIndex()); + require( + slot.x == r.x() + 1 && slot.y == r.y() + 1, "Player slot layout after navigation"); + } + } require(menu.slots.get(1).isActive() && !menu.slots.get(0).isActive(), "Context visibility"); require(!menu.ghost(player, ghost, new ItemStack(Items.STONE)), "Hidden ghost changed"); menu.clickMenuButton(player, new ScreenNetwork.Action(73, config, 3, 0, 0, 0).pack()); diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java index 8ded5fa6..9eaa5c32 100644 --- a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/MMDLibGameTests.java @@ -20,6 +20,7 @@ public static void processorIdleSearchBackoff(GameTestHelper helper) { ProcessorSmoke.idleSearchBackoff(helper.getLevel()); helper.succeed(); } + private static final String EMPTY = "empty"; /** Inside the structure, away from its edges. */ @@ -27,6 +28,23 @@ public static void processorIdleSearchBackoff(GameTestHelper helper) { private MMDLibGameTests() {} + @GameTest(template = EMPTY) + public static void equippedContainer(GameTestHelper helper) { + ContainerDrawerSmoke.check(helper.getLevel(), helper.absolutePos(CENTER), + helper.makeMockPlayer(net.minecraft.world.level.GameType.SURVIVAL)); + helper.succeed(); + } + + @GameTest(template = EMPTY) + public static void playerEquipment(GameTestHelper helper) { + PlayerEquipmentSmoke.check( + helper.getLevel(), + helper.absolutePos(CENTER), + helper.makeMockPlayer(net.minecraft.world.level.GameType.SURVIVAL), + helper.makeMockPlayer(net.minecraft.world.level.GameType.CREATIVE)); + helper.succeed(); + } + @GameTest(template = EMPTY) public static void contentRegistered(GameTestHelper helper) { ContentSmoke.registered(); diff --git a/src/runtimeTest/java/zone/moddev/mc/lib/smoke/PlayerEquipmentSmoke.java b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/PlayerEquipmentSmoke.java new file mode 100644 index 00000000..7219faed --- /dev/null +++ b/src/runtimeTest/java/zone/moddev/mc/lib/smoke/PlayerEquipmentSmoke.java @@ -0,0 +1,112 @@ +package zone.moddev.mc.lib.smoke; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.core.registries.Registries; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.inventory.ClickType; +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.item.enchantment.Enchantments; + +import zone.moddev.mc.lib.gui.layout.ScreenLayout; +import zone.moddev.mc.lib.gui.layout.TextMeasure; +import zone.moddev.mc.lib.gui.runtime.MachineMenu; + +/** Real player inventory ownership, armor restrictions and equipment transfer regression checks. */ +final class PlayerEquipmentSmoke { + static void check(ServerLevel level, BlockPos pos, Player player, Player creative) { + level.setBlock(pos, BuiltInRegistries.BLOCK.get(CrusherSmoke.ID).defaultBlockState(), 3); + var entity = (CrusherSmoke.Crusher) level.getBlockEntity(pos); + var menu = new MachineMenu(92, player.getInventory(), entity); + player.getInventory().clearContent(); + var inventory = player.getInventory(); + var bounds = ScreenLayout.of(menu.bound.screen(), TextMeasure.NONE).inventory().orElseThrow(); + require( + menu.slots.stream().filter(s -> s.container == inventory).count() == 41, "41 player slots"); + for (int index = 0; index <= 40; index++) { + var slot = slot(menu, player, index); + var expected = ScreenLayout.itemPosition(ScreenLayout.playerSlot(bounds, index)); + require(slot.x == expected[0] && slot.y == expected[1], "Slot position " + index); + } + var helmet = slot(menu, player, 39); + var offhand = slot(menu, player, 40); + var armor = + new net.minecraft.world.item.Item[] { + Items.DIAMOND_BOOTS, + Items.DIAMOND_LEGGINGS, + Items.DIAMOND_CHESTPLATE, + Items.DIAMOND_HELMET + }; + for (int index = 36; index <= 39; index++) { + var slot = slot(menu, player, index); + require(slot.getMaxStackSize(new ItemStack(armor[index - 36])) == 1, "Armor limit"); + for (int other = 36; other <= 39; other++) + require(slot.mayPlace(new ItemStack(armor[other - 36])) == (index == other), "Armor type"); + require(!slot.mayPlace(new ItemStack(Items.STONE)), "Reject non-armor"); + require(slot.getNoItemIcon() != null, "Empty armor icon"); + } + require( + offhand.mayPlace(new ItemStack(Items.STONE)) + && offhand.getMaxStackSize(new ItemStack(Items.STONE)) == 64, + "Offhand accepts ordinary stacks"); + require(offhand.getNoItemIcon() != null, "Empty offhand icon"); + menu.setCarried(new ItemStack(Items.DIAMOND_HELMET)); + menu.clicked(helmet.index, 0, ClickType.PICKUP, player); + require( + inventory.getItem(39).is(Items.DIAMOND_HELMET) && menu.getCarried().isEmpty(), + "Equip helmet"); + require( + !menu.quickMoveStack(player, helmet.index).isEmpty() && inventory.getItem(39).isEmpty(), + "Shift-click unequips armor"); + require(entity.items.getStackInSlot(0).isEmpty(), "Unequipping does not feed the machine"); + var cursed = new ItemStack(Items.DIAMOND_HELMET); + cursed.enchant( + level + .registryAccess() + .lookupOrThrow(Registries.ENCHANTMENT) + .getOrThrow(Enchantments.BINDING_CURSE), + 1); + helmet.setByPlayer(cursed); + require(!helmet.mayPickup(player), "Binding curse prevents pickup"); + menu.clicked(helmet.index, 0, ClickType.PICKUP, player); + require(menu.getCarried().isEmpty() && helmet.hasItem(), "Cursed click refused"); + require(menu.quickMoveStack(player, helmet.index).isEmpty(), "Cursed shift-click refused"); + require(helmet.mayPickup(creative), "Creative curse exemption"); + helmet.set(ItemStack.EMPTY); + inventory.clearContent(); + menu.setCarried(new ItemStack(Items.COBBLESTONE, 12)); + menu.clicked(offhand.index, 0, ClickType.PICKUP, player); + require(inventory.getItem(40).getCount() == 12, "Place offhand stack"); + menu.quickMoveStack(player, offhand.index); + require( + inventory.getItem(40).isEmpty() && inventory.countItem(Items.COBBLESTONE) == 12, + "Offhand shift-click conserves items"); + inventory.clearContent(); + for (int index = 0; index < 36; index++) + inventory.setItem(index, new ItemStack(Items.STONE, 64)); + entity.items.setStackInSlot(1, new ItemStack(Items.IRON_INGOT, 5)); + require(menu.quickMoveStack(player, 1).isEmpty(), "Full inventory refuses output"); + require( + offhand.getItem().isEmpty() && entity.items.getStackInSlot(1).getCount() == 5, + "Output does not spill into offhand"); + inventory.setItem(0, ItemStack.EMPTY); + require( + !menu.quickMoveStack(player, 1).isEmpty() && inventory.getItem(0).getCount() == 5, + "Output still moves to hotbar"); + inventory.clearContent(); + } + + private static Slot slot(MachineMenu menu, Player player, int index) { + return menu.slots.stream() + .filter(s -> s.container == player.getInventory() && s.getContainerSlot() == index) + .findFirst() + .orElseThrow(); + } + + private static void require(boolean condition, String message) { + if (!condition) throw new AssertionError(message); + } +} diff --git a/src/test/java/zone/moddev/mc/lib/gui/ContainerDrawerLayoutTest.java b/src/test/java/zone/moddev/mc/lib/gui/ContainerDrawerLayoutTest.java new file mode 100644 index 00000000..692f64aa --- /dev/null +++ b/src/test/java/zone/moddev/mc/lib/gui/ContainerDrawerLayoutTest.java @@ -0,0 +1,90 @@ +package zone.moddev.mc.lib.gui; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import zone.moddev.mc.lib.gui.layout.ContainerDrawerLayout; +import zone.moddev.mc.lib.gui.layout.Rect; +import zone.moddev.mc.lib.gui.layout.SideDrawerLayout; + +class ContainerDrawerLayoutTest { + @Test + void equipmentPairsStayTogetherAndUseASeparateTab() { + var panel = new Rect(220, 30, 200, 274); + for (int slots : new int[] {2, 6, 34, 4096}) { + var layout = ContainerDrawerLayout.equipment(panel, 400, slots, true); + var backpack = ContainerDrawerLayout.of(panel, 400, 27); + assertEquals(backpack.body().width(), layout.body().width()); + assertEquals(backpack.body().x(), layout.body().x()); + assertEquals(panel.y() + SideDrawerLayout.MARGIN, layout.body().y()); + assertEquals(panel.height() - 2 * SideDrawerLayout.MARGIN, layout.body().height()); + // A tall text or automation drawer uses exactly the same vertical bounds. + var content = + SideDrawerLayout.body( + panel, + 400, + panel.right() + 22, + panel.y() + SideDrawerLayout.MARGIN, + 150, + Integer.MAX_VALUE); + assertEquals(content.y(), layout.body().y()); + assertEquals(content.bottom(), layout.body().bottom()); + assertEquals(backpack.tab().y(), layout.tab().bottom()); + assertEquals(0, layout.pageSize() % 2); + assertTrue(layout.pageSize() <= 54); + for (int i = 0; i < layout.pageSize(); i += 2) { + assertEquals(layout.cell(i).y(), layout.cell(i + 1).y()); + assertTrue(layout.cell(i).right() < layout.cell(i + 1).x()); + assertTrue(layout.cell(i + 1).right() <= layout.body().right() - 6); + assertTrue(layout.cell(i).bottom() <= layout.previous().y()); + } + } + var alone = ContainerDrawerLayout.equipment(panel, 400, 2, false); + assertEquals(panel.bottom() - SideDrawerLayout.MARGIN, alone.tab().bottom()); + assertEquals( + 0, ContainerDrawerLayout.equipment(new Rect(120, 30, 200, 274), 400, 10, false).pageSize()); + } + + @Test + void storageStaysOnScreenAndLeftOfTheStationaryPanel() { + for (int left : new int[] {150, 220, 350}) { + for (int bottom : new int[] {100, 180, 300}) { + for (int slots : new int[] {1, 27, 54, 120, 4096}) { + var panel = new Rect(left, 20, 200, 280); + var layout = ContainerDrawerLayout.of(panel, bottom, slots); + assertEquals(panel.x(), layout.tab().right()); + assertTrue(layout.tab().bottom() <= bottom); + assertTrue(layout.pageSize() >= 1 && layout.pageSize() <= 54); + assertEquals(6, layout.columns()); + assertEquals(panel.y() + SideDrawerLayout.MARGIN, layout.body().y()); + assertEquals(layout.body().y(), layout.rail().y()); + assertEquals(layout.body().height(), layout.rail().height()); + assertTrue(layout.body().x() >= 4 && layout.body().y() >= 4); + assertEquals(layout.tab().x(), layout.body().right()); + assertTrue(layout.previous().right() <= layout.next().x()); + assertEquals( + Math.min(panel.bottom() - SideDrawerLayout.MARGIN, bottom), layout.body().bottom()); + for (int i = 0; i < layout.pageSize(); i++) { + var cell = layout.cell(i); + assertTrue(cell.x() >= layout.body().x() + 4); + assertTrue(cell.right() <= layout.body().right() - 4); + assertTrue(cell.bottom() <= layout.previous().y()); + } + } + } + } + } + + @Test + void crampedWindowsKeepTheTabWithoutOffscreenSlots() { + for (var panel : + new Rect[] { + new Rect(30, 10, 200, 180), new Rect(120, 10, 200, 180), new Rect(200, 4, 200, 40) + }) { + var layout = ContainerDrawerLayout.of(panel, panel.bottom(), 100); + assertEquals(0, layout.pageSize()); + assertEquals(0, layout.body().width()); + assertTrue(layout.tab().x() >= 0); + } + } +} diff --git a/src/test/java/zone/moddev/mc/lib/gui/InventoryColorButtonBarTest.java b/src/test/java/zone/moddev/mc/lib/gui/InventoryColorButtonBarTest.java index 35abbc1a..86c5b5c9 100644 --- a/src/test/java/zone/moddev/mc/lib/gui/InventoryColorButtonBarTest.java +++ b/src/test/java/zone/moddev/mc/lib/gui/InventoryColorButtonBarTest.java @@ -99,9 +99,9 @@ void sharesExactlyOneRowAndReservesRightHandControls() throws Exception { var inventory = with.inventory().orElseThrow(); var pause = with.bounds(screen.find("pause").orElseThrow()); assertEquals(inventory.x(), row.x()); - assertEquals(inventory.right(), pause.right()); + assertEquals(ScreenLayout.playerSlot(inventory, 17).right(), pause.right()); assertEquals(row.y(), pause.y()); assertEquals(pause.x(), row.right() + ScreenLayout.GAP); - assertEquals(row.bottom() + ScreenLayout.GAP, inventory.y()); + assertEquals(row.bottom() + ScreenLayout.GAP, ScreenLayout.playerSlot(inventory, 9).y()); } } diff --git a/src/test/java/zone/moddev/mc/lib/gui/PlayerInventoryLayoutTest.java b/src/test/java/zone/moddev/mc/lib/gui/PlayerInventoryLayoutTest.java new file mode 100644 index 00000000..b7236b8b --- /dev/null +++ b/src/test/java/zone/moddev/mc/lib/gui/PlayerInventoryLayoutTest.java @@ -0,0 +1,78 @@ +package zone.moddev.mc.lib.gui; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; + +import net.minecraft.resources.ResourceLocation; + +import org.junit.jupiter.api.Test; + +import zone.moddev.mc.lib.gui.layout.Rect; +import zone.moddev.mc.lib.gui.layout.ScreenLayout; +import zone.moddev.mc.lib.gui.layout.TextMeasure; +import zone.moddev.mc.lib.gui.load.ScreenReader; + +class PlayerInventoryLayoutTest { + @Test + void automaticAndExplicitInventoriesKeepAllSlotsInsideWithoutOverlap() throws Exception { + for (String body : + new String[] { + "", + "", + "" + }) { + for (boolean automation : new boolean[] {false, true}) { + var layout = layout("", body, automation); + var inventory = layout.inventory().orElseThrow(); + var slots = new ArrayList(); + for (int index = 0; index <= 40; index++) { + var slot = ScreenLayout.playerSlot(inventory, index); + assertTrue(slot.x() >= inventory.x() && slot.y() >= inventory.y()); + assertTrue(slot.right() <= inventory.right() && slot.bottom() <= inventory.bottom()); + for (var other : slots) + assertFalse( + slot.x() < other.right() + && slot.right() > other.x() + && slot.y() < other.bottom() + && slot.bottom() > other.y()); + slots.add(slot); + } + assertEquals(slots.get(0).y(), slots.get(40).y(), "Offhand aligns with hotbar"); + assertTrue(slots.get(39).y() < slots.get(38).y()); + assertTrue(slots.get(38).y() < slots.get(37).y()); + assertTrue(slots.get(37).y() < slots.get(36).y()); + if (automation) { + var toolbar = layout.inventoryColors().orElseThrow(); + assertEquals(slots.get(39).y(), toolbar.y()); + assertEquals(slots.get(9).y(), toolbar.bottom() + ScreenLayout.GAP); + assertEquals(slots.get(17).right(), toolbar.right()); + } + } + } + } + + @Test + void inventoryNoneOmitsEquipmentToo() throws Exception { + assertTrue(layout(" inventory=\"none\"", "", false).inventory().isEmpty()); + } + + private static ScreenLayout layout(String attributes, String body, boolean automation) + throws Exception { + String xml = + ""; + var screen = + ScreenReader.read( + ResourceLocation.parse("test:equipment"), + "equipment.xml", + new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); + return ScreenLayout.of(screen, TextMeasure.NONE, automation); + } +} diff --git a/src/test/java/zone/moddev/mc/lib/gui/ScreenLoaderTest.java b/src/test/java/zone/moddev/mc/lib/gui/ScreenLoaderTest.java index 6a14c82a..fbe7aa0c 100644 --- a/src/test/java/zone/moddev/mc/lib/gui/ScreenLoaderTest.java +++ b/src/test/java/zone/moddev/mc/lib/gui/ScreenLoaderTest.java @@ -347,8 +347,10 @@ void companionControlsSitAboveInventoryAtRightMargin() throws Exception { layout.bounds( screen.find(name.equals("dual_crusher") ? "toggle_1" : "toggle_0").orElseThrow()); require(controls.y() > layout.bounds(screen.find("energy").orElseThrow()).bottom()); - require(controls.bottom() < layout.inventory().orElseThrow().y()); - require(pause.right() == layout.inventory().orElseThrow().right()); + var inventory = layout.inventory().orElseThrow(); + require(controls.y() == ScreenLayout.playerSlot(inventory, 39).y()); + require(controls.bottom() + ScreenLayout.GAP == ScreenLayout.playerSlot(inventory, 9).y()); + require(pause.right() == ScreenLayout.playerSlot(inventory, 17).right()); var colours = layout.inventoryColors().orElseThrow(); require(colours.x() == layout.inventory().orElseThrow().x()); require(colours.y() == redstone.y() && colours.height() == redstone.height()); @@ -429,6 +431,40 @@ void crusherPlacesProcessingBeforeRightEnergyGauge() throws Exception { require(energy.bottom() < layout.inventory().orElseThrow().y()); } + @Test + void settingsExpandAndCollapseThePanelAndInventory() throws Exception { + String name = "crusher"; + try (var input = + java.nio.file.Files.newInputStream( + java.nio.file.Path.of("src/companion/resources/mmdlib/screen/" + name + ".xml"))) { + var screen = + ScreenReader.read( + ResourceLocation.parse("mmdlib_companion:" + name), + name + ".xml", + input, + companionLayoutWidgets()); + TextMeasure text = t -> t.fallback().orElse(t.key()).length() * 6; + var compact = ScreenLayout.of(screen, text, true); + var expanded = ScreenLayout.of(screen, text, true, java.util.Map.of("panel", "settings")); + var collapsed = ScreenLayout.of(screen, text, true, java.util.Map.of("panel", "machine")); + var context = screen.find("panel").orElseThrow(); + int growth = expanded.panel().height() - compact.panel().height(); + org.junit.jupiter.api.Assertions.assertTrue(growth > 0, name); + require(expanded.bounds(context).height() - compact.bounds(context).height() == growth); + require( + expanded.inventory().orElseThrow().y() - compact.inventory().orElseThrow().y() == growth); + require(compact.panel().equals(collapsed.panel())); + for (var element : screen.elements()) + require(compact.bounds(element).equals(collapsed.bounds(element))); + for (var layout : java.util.List.of(compact, expanded)) { + var inventory = layout.inventory().orElseThrow(); + require(inventory.bottom() == layout.panel().bottom() - ScreenLayout.PANEL_MARGIN); + require( + layout.bounds(screen.find("inventory_controls").orElseThrow()).y() == inventory.y()); + } + } + } + private static WidgetTypes companionLayoutWidgets() { // These checks only measure XML. The actual companion contract is exercised by game tests. var types = new WidgetTypes(); @@ -451,9 +487,11 @@ private static void checkLayout(Screen screen) throws Exception { // Six pixels per character stands in for the font. var layout = ScreenLayout.of(screen, text -> text.key().length() * 6); var panel = layout.panel(); - require(panel.width() >= 176); + require(panel.width() >= ScreenLayout.INVENTORY_WIDTH + 14); var inventory = layout.inventory().orElseThrow(); - require(inventory.x() == (panel.width() - 162) / 2 && inventory.bottom() == panel.height() - 7); + require( + inventory.x() == (panel.width() - ScreenLayout.INVENTORY_WIDTH) / 2 + && inventory.bottom() == panel.height() - 7); // A spacer pushes the rest of the bottom row against the root vbox's 7 px padding. var label = screen.elements().stream() @@ -461,11 +499,12 @@ private static void checkLayout(Screen screen) throws Exception { .findFirst() .orElseThrow(); require(layout.bounds(label).right() == panel.width() - 7); - // Every view of a context area sits at the same place, sized to the largest. + // The context measures its default view; hidden views retain independent bounds. var context = (Element.Context) screen.find("main").orElseThrow(); var machine = layout.bounds(context.views().get(0)); var sides = layout.bounds(context.views().get(1)); - require(machine.equals(sides) && machine.equals(layout.bounds(context))); + require(machine.equals(layout.bounds(context))); + require(machine.x() == sides.x() && machine.y() == sides.y()); // The upgrade tab overlaps the panel's border; its slots start inside the panel's right edge. var tab = layout.bounds(screen.tabs().get(0)); require(tab.x() == panel.width() - 4); diff --git a/src/test/java/zone/moddev/mc/lib/gui/SideDrawerLayoutTest.java b/src/test/java/zone/moddev/mc/lib/gui/SideDrawerLayoutTest.java new file mode 100644 index 00000000..a6e73b79 --- /dev/null +++ b/src/test/java/zone/moddev/mc/lib/gui/SideDrawerLayoutTest.java @@ -0,0 +1,27 @@ +package zone.moddev.mc.lib.gui; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; +import zone.moddev.mc.lib.gui.layout.Rect; +import zone.moddev.mc.lib.gui.layout.SideDrawerLayout; + +class SideDrawerLayoutTest { + @Test + void growingContentFitsBeforeItNeedsScrolling() { + var panel = new Rect(200, 40, 198, 200); + var full = + SideDrawerLayout.body(panel, 450, 420, 44, 112, 300, SideDrawerLayout.HeightLimit.SCREEN); + assertEquals(300, full.height()); + assertEquals(44, full.y()); + assertTrue(full.bottom() > panel.bottom()); + + var clipped = + SideDrawerLayout.body(panel, 280, 420, 44, 112, 300, SideDrawerLayout.HeightLimit.SCREEN); + assertEquals(280, clipped.bottom()); + assertEquals(full.y(), clipped.y()); + + var inventory = SideDrawerLayout.body(panel, 450, 58, 44, 120, 300); + assertEquals(panel.bottom() - SideDrawerLayout.MARGIN, inventory.bottom()); + } +}