diff --git a/Archie-Test/common/src/main/gametest/net/kernelpanicsoft/archie/test/gametest/ArchieTestGameTest.kt b/Archie-Test/common/src/main/gametest/net/kernelpanicsoft/archie/test/gametest/ArchieTestGameTest.kt index 91614773c..ab87661af 100644 --- a/Archie-Test/common/src/main/gametest/net/kernelpanicsoft/archie/test/gametest/ArchieTestGameTest.kt +++ b/Archie-Test/common/src/main/gametest/net/kernelpanicsoft/archie/test/gametest/ArchieTestGameTest.kt @@ -8,6 +8,10 @@ internal fun AEvents.ArchieGameTestBuilder.archieTestGameTests() { client { register() + register() + } + server { + register() } server { register() diff --git a/Archie-Test/common/src/main/gametest/net/kernelpanicsoft/archie/test/gametest/ComposeItemContainerMenuClientTests.kt b/Archie-Test/common/src/main/gametest/net/kernelpanicsoft/archie/test/gametest/ComposeItemContainerMenuClientTests.kt new file mode 100644 index 000000000..a9ea18d25 --- /dev/null +++ b/Archie-Test/common/src/main/gametest/net/kernelpanicsoft/archie/test/gametest/ComposeItemContainerMenuClientTests.kt @@ -0,0 +1,70 @@ +package net.kernelpanicsoft.archie.test.gametest + +import dev.architectury.registry.menu.MenuRegistry +import net.kernelpanicsoft.archie.gametest.* +import net.kernelpanicsoft.archie.gui.item.PlayerInventoryItemAccess +import net.kernelpanicsoft.archie.test.TestItemContainerScreen +import net.kernelpanicsoft.archie.test.TestItemMenu +import net.minecraft.network.chat.Component +import net.minecraft.world.SimpleMenuProvider +import net.minecraft.world.item.ItemStack +import net.minecraft.world.item.Items + +/** + * End-to-end coverage for [TestItemMenu]'s excluded-slot behavior, opened through the *real* + * server-to-client menu flow ([MenuRegistry.openExtendedMenu] + a real [TestItemContainerScreen] + * layout pass) - unlike [ComposeItemContainerMenuTests], which constructs [TestItemMenu] directly + * and so can't exercise anything depending on + * [net.kernelpanicsoft.archie.gui.ComposeContainerMenuBase.updateSlotData] actually having run + * (that method unconditionally sends an outbound `SlotData` packet, which throws from a + * server-only GameTest since packet registration itself is skipped there - see that class's KDoc). + * A client GameTest has both sides' networking registered for real, so this is where that gap is + * actually covered. + */ +@Suppress("unused") +class ComposeItemContainerMenuClientTests +{ + @ClientGameTest + fun ClientGameTestContext.testExcludedSlotBehaviorViaRealMenuOpen() + { + withWorld { + withSingleplayer { + waitForPlayer() + + server.runOnServer { minecraftServer -> + val player = minecraftServer.playerList.players.first() + player.inventory.setItem(0, ItemStack(Items.PAPER)) + MenuRegistry.openExtendedMenu( + player, + SimpleMenuProvider( + { id, inv, p -> TestItemMenu(id, inv, PlayerInventoryItemAccess(p, 0, Items.PAPER)) }, + Component.literal("Test Item Menu"), + ), + ) { buf -> buf.writeVarInt(0) } + } + + clientContext.waitForScreen() + clientContext.waitForComposeIdle() + // One extra tick so the client's just-reported SlotData round-trips back through + // the server (ComposeContainerMenuBase.updateSlotData -> serverbound SlotData -> + // menu.applySlotData()) and the server's own slot list is actually built. + clientContext.waitTicks(2) + + server.runOnServer { minecraftServer -> + val player = minecraftServer.playerList.players.first() + val menu = player.containerMenu as? TestItemMenu + ?: error("Expected the player's current menu to be a TestItemMenu, was ${player.containerMenu}") + + val excludedSlot = menu.slots.firstOrNull { it.container === player.inventory && it.containerSlot == 0 } + ?: error("Expected a player-inventory slot bound to container index 0 among ${menu.slots.size} slots") + if (excludedSlot.mayPlace(ItemStack(Items.STONE))) fail("Expected the excluded slot to reject placement") + if (excludedSlot.mayPickup(player)) fail("Expected the excluded slot to reject pickup") + + val excludedIndex = menu.slots.indexOf(excludedSlot) + val result = menu.quickMoveStack(player, excludedIndex) + if (!result.isEmpty) fail("Expected quickMoveStack on the excluded slot to return an empty stack, got $result") + } + } + } + } +} diff --git a/Archie-Test/common/src/main/gametest/net/kernelpanicsoft/archie/test/gametest/ComposeItemContainerMenuTests.kt b/Archie-Test/common/src/main/gametest/net/kernelpanicsoft/archie/test/gametest/ComposeItemContainerMenuTests.kt new file mode 100644 index 000000000..0f26bbb94 --- /dev/null +++ b/Archie-Test/common/src/main/gametest/net/kernelpanicsoft/archie/test/gametest/ComposeItemContainerMenuTests.kt @@ -0,0 +1,117 @@ +package net.kernelpanicsoft.archie.test.gametest + +import earth.terrarium.common_storage_lib.storage.base.UpdateManager +import net.kernelpanicsoft.archie.gui.item.PlayerInventoryItemAccess +import net.kernelpanicsoft.archie.gametest.internal.EMPTY +import net.kernelpanicsoft.archie.test.TestItemMenu +import net.minecraft.gametest.framework.GameTest +import net.minecraft.gametest.framework.GameTestHelper +import net.minecraft.world.item.ItemStack +import net.minecraft.world.item.Items + +/** + * GameTest coverage for [net.kernelpanicsoft.archie.gui.item.ComposeItemContainerMenu], via the + * fixture [TestItemMenu] (declared directly against a real + * [net.minecraft.gametest.framework.GameTestHelper.makeMockServerPlayerInLevel] player - no + * network round trip, matching [net.kernelpanicsoft.archie.test.gametest.CapabilityLookupTests]'s + * "construct against real infrastructure directly" style). + * + * Every test that constructs a [TestItemMenu] here must explicitly call `menu.removed(player)` + * once done (unless the test is specifically about the auto-close path) - `onMenuOpened()` + * unconditionally registers the menu with the real, process-wide + * [net.kernelpanicsoft.archie.gui.item.ItemStateManager], whose real tick listener is genuinely + * running for the whole GameTest server process, not just this test method. A menu left + * registered keeps getting ticked (and its `itemAccess.stillValid(player)` re-checked) by every + * *later* test in this run, not just this one - confirmed the hard way: an earlier draft of this + * suite left menus registered, and a later test's `itemAccess.stillValid` flip cascaded into an + * unrelated packet-registration crash days after this test method had already returned. + * + * Not covered here - manual-only, matching the Phase 3 plan's own carve-out for full on-screen + * GUI open/close/shift-click flow (real Compose rendering + real player interaction isn't + * GameTest-covered anywhere else in this codebase either): + * - The excluded player-inventory slot's `mayPlace`/`mayPickup`/`quickMoveStack` behavior - see + * [ComposeItemContainerMenuClientTests] instead, which covers exactly this via a real menu-open + * round trip (this class can't: it needs + * [net.kernelpanicsoft.archie.gui.ComposeContainerMenuBase.updateSlotData] to have run, which + * sends an outbound `SlotData` packet unconditionally, and packet registration itself is + * deliberately skipped for a server-only GameTest run - `Archie.kt`'s own + * `ArchieNetworkChannel.init()` gating, unrelated to this feature). + */ +@Suppress("unused") +class ComposeItemContainerMenuTests +{ + @GameTest(template = EMPTY) + fun GameTestHelper.testSlotContentPersistsThroughHolder() + { + val player = makeMockServerPlayerInLevel() + player.inventory.setItem(0, ItemStack(Items.PAPER)) + val menu = TestItemMenu(1, player.inventory, PlayerInventoryItemAccess(player, 0, Items.PAPER)) + + menu.items[0].set(ItemStack(Items.DIAMOND, 5)) + // ArchieItemSlot.set() only mutates in-memory state - persistence is triggered externally, + // the same way ArchieItemMenuSlot.setChanged() (a real vanilla Slot's hook) does it. + UpdateManager.batch(menu.items) + + // A second, independent menu constructed against the same real backing stack re-loads + // fresh from its persisted NBT (ItemStackNBTHolderImpl.init reloads every construction) - + // this is a genuine round trip through the public API, not just reading `menu`'s own + // still-live in-memory ArchieItemStorage back. + val reloaded = TestItemMenu(11, player.inventory, PlayerInventoryItemAccess(player, 0, Items.PAPER)) + val item = reloaded.items[0].getItem() + menu.removed(player) + reloaded.removed(player) + if (item.item != Items.DIAMOND || item.count != 5) + fail("Expected the item written through menu.items to persist onto the real backing stack, got $item") + succeed() + } + + @GameTest(template = EMPTY) + fun GameTestHelper.testStillValidReflectsItemAccess() + { + val player = makeMockServerPlayerInLevel() + player.inventory.setItem(0, ItemStack(Items.PAPER)) + val menu = TestItemMenu(4, player.inventory, PlayerInventoryItemAccess(player, 0, Items.PAPER)) + + val initiallyValid = menu.stillValid(player) + player.inventory.setItem(0, ItemStack(Items.STONE)) + val validAfterCorruption = menu.stillValid(player) + menu.removed(player) + + if (!initiallyValid) fail("Expected stillValid to be true while the backing stack still matches") + if (validAfterCorruption) fail("Expected stillValid to be false once the backing stack no longer matches") + succeed() + } + + @GameTest(template = EMPTY) + fun GameTestHelper.testSyncedFieldRegistersSerializer() + { + val player = makeMockServerPlayerInLevel() + player.inventory.setItem(0, ItemStack(Items.PAPER)) + val menu = TestItemMenu(5, player.inventory, PlayerInventoryItemAccess(player, 0, Items.PAPER)) + + val registered = menu.itemState.propertySerializers.containsKey("counter") + menu.removed(player) + + if (!registered) + fail("Expected the @Sync-annotated `counter` field to have registered a serializer in itemState at declaration time") + succeed() + } + + @GameTest(template = EMPTY) + fun GameTestHelper.testItemStateManagerForceClosesInvalidMenu() + { + val player = makeMockServerPlayerInLevel() + player.inventory.setItem(0, ItemStack(Items.PAPER)) + val menu = TestItemMenu(6, player.inventory, PlayerInventoryItemAccess(player, 0, Items.PAPER)) + player.containerMenu = menu + + // Corrupt the backing stack so itemAccess.stillValid(player) flips false. No explicit + // removed() call here - the whole point of this test is that ItemStateManager's real tick + // loop closes (and so unregisters) it on its own. + player.inventory.setItem(0, ItemStack(Items.STONE)) + + succeedWhen { + if (player.containerMenu === menu) fail("Expected ItemStateManager's tick loop to force-close the menu via player.closeContainer() once itemAccess reports it invalid") + } + } +} diff --git a/Archie-Test/common/src/main/kotlin/net/kernelpanicsoft/archie/test/GuiRegistry.kt b/Archie-Test/common/src/main/kotlin/net/kernelpanicsoft/archie/test/GuiRegistry.kt index 1aee142f6..1037d493d 100644 --- a/Archie-Test/common/src/main/kotlin/net/kernelpanicsoft/archie/test/GuiRegistry.kt +++ b/Archie-Test/common/src/main/kotlin/net/kernelpanicsoft/archie/test/GuiRegistry.kt @@ -1,12 +1,14 @@ package net.kernelpanicsoft.archie.test import net.kernelpanicsoft.archie.Archie +import net.kernelpanicsoft.archie.gui.item.PlayerInventoryItemAccess import net.kernelpanicsoft.archie.registries.ADeferredRegistryHolder import dev.architectury.registry.menu.MenuRegistry import net.minecraft.core.registries.Registries import net.minecraft.network.chat.Component import net.minecraft.world.entity.player.Inventory import net.minecraft.world.inventory.MenuType +import net.minecraft.world.item.Items object GuiRegistry : ADeferredRegistryHolder>(Archie.MOD, Registries.MENU) { @@ -20,9 +22,20 @@ object GuiRegistry : ADeferredRegistryHolder>(Archie.MOD, Registries } } + val TestItemMenu: MenuType by register("test_item_menu") { + MenuRegistry.ofExtended { id, inventory, buf -> + TestItemMenu( + id, + inventory, + PlayerInventoryItemAccess(inventory.player, buf.readVarInt(), Items.PAPER) + ) + } + } + override fun initClient() { MenuRegistry.registerScreenFactory(TestMenu, ::TestScreen) + MenuRegistry.registerScreenFactory(TestItemMenu, ::TestItemContainerScreen) } } \ No newline at end of file diff --git a/Archie-Test/common/src/main/kotlin/net/kernelpanicsoft/archie/test/TestItemContainerScreen.kt b/Archie-Test/common/src/main/kotlin/net/kernelpanicsoft/archie/test/TestItemContainerScreen.kt new file mode 100644 index 000000000..90093aefe --- /dev/null +++ b/Archie-Test/common/src/main/kotlin/net/kernelpanicsoft/archie/test/TestItemContainerScreen.kt @@ -0,0 +1,22 @@ +package net.kernelpanicsoft.archie.test + +import net.kernelpanicsoft.archie.gui.ComposeContainerScreen +import net.kernelpanicsoft.archie.gui.PlayerSlots +import net.kernelpanicsoft.archie.gui.Slots +import net.kernelpanicsoft.archie.gui.theme.Theme +import net.minecraft.network.chat.Component +import net.minecraft.world.entity.player.Inventory + +/** Minimal fixture screen for [TestItemMenu] - just enough layout to drive a real [net.kernelpanicsoft.archie.gui.ComposeContainerMenuBase.updateSlotData] round trip. */ +class TestItemContainerScreen(menu: TestItemMenu, playerInventory: Inventory, title: Component) : ComposeContainerScreen(menu, playerInventory, title) +{ + init + { + start { + Theme { + Slots("inventory", 9, 1) + PlayerSlots() + } + } + } +} diff --git a/Archie-Test/common/src/main/kotlin/net/kernelpanicsoft/archie/test/TestItemMenu.kt b/Archie-Test/common/src/main/kotlin/net/kernelpanicsoft/archie/test/TestItemMenu.kt new file mode 100644 index 000000000..5f4356c81 --- /dev/null +++ b/Archie-Test/common/src/main/kotlin/net/kernelpanicsoft/archie/test/TestItemMenu.kt @@ -0,0 +1,21 @@ +package net.kernelpanicsoft.archie.test + +import net.kernelpanicsoft.archie.gui.item.ComposeItemContainerMenu +import net.kernelpanicsoft.archie.gui.item.ItemContainerAccess +import net.kernelpanicsoft.archie.serialization.Sync +import net.minecraft.world.entity.player.Inventory + +/** Fixture item-backed menu for [net.kernelpanicsoft.archie.test.gametest.ComposeItemContainerMenuTests]/[net.kernelpanicsoft.archie.test.gametest.ComposeItemContainerMenuClientTests]. */ +class TestItemMenu(id: Int, inventory: Inventory, access: ItemContainerAccess) : + ComposeItemContainerMenu(GuiRegistry.TestItemMenu, id, inventory, access) +{ + @Sync + var counter by holder.intField() + + val items by holder.itemField(9) + + override fun registerSlotHandlers() + { + handler("inventory", items) + } +} diff --git a/Archie-Test/common/src/main/kotlin/net/kernelpanicsoft/archie/test/TestMenu.kt b/Archie-Test/common/src/main/kotlin/net/kernelpanicsoft/archie/test/TestMenu.kt index aa29715e8..6c4cf73cb 100644 --- a/Archie-Test/common/src/main/kotlin/net/kernelpanicsoft/archie/test/TestMenu.kt +++ b/Archie-Test/common/src/main/kotlin/net/kernelpanicsoft/archie/test/TestMenu.kt @@ -1,9 +1,9 @@ package net.kernelpanicsoft.archie.test -import net.kernelpanicsoft.archie.gui.ComposeContainerMenu +import net.kernelpanicsoft.archie.gui.ComposeBlockContainerMenu import net.minecraft.world.entity.player.Inventory -class TestMenu(id: Int, inventory: Inventory, tile: TestTile) : ComposeContainerMenu(GuiRegistry.TestMenu, id, inventory, tile) +class TestMenu(id: Int, inventory: Inventory, tile: TestTile) : ComposeBlockContainerMenu(GuiRegistry.TestMenu, id, inventory, tile) { val rows: Int = tile.items.size() / 9 diff --git a/Archie-Test/common/src/main/kotlin/net/kernelpanicsoft/archie/test/TestScreen.kt b/Archie-Test/common/src/main/kotlin/net/kernelpanicsoft/archie/test/TestScreen.kt index 2593047a2..37ed13f38 100644 --- a/Archie-Test/common/src/main/kotlin/net/kernelpanicsoft/archie/test/TestScreen.kt +++ b/Archie-Test/common/src/main/kotlin/net/kernelpanicsoft/archie/test/TestScreen.kt @@ -43,7 +43,7 @@ import net.kernelpanicsoft.archie.gui.util.KColor import net.minecraft.network.chat.Component import net.minecraft.world.entity.player.Inventory -class TestScreen(menu: TestMenu, playerInventory: Inventory, title: Component) : ComposeContainerScreen(menu, playerInventory, +class TestScreen(menu: TestMenu, playerInventory: Inventory, title: Component) : ComposeContainerScreen(menu, playerInventory, title ) { diff --git a/Archie-Test/neoforge/src/main/kotlin/net/kernelpanicsoft/archie/test/ArchieTestNeoForge.kt b/Archie-Test/neoforge/src/main/kotlin/net/kernelpanicsoft/archie/test/ArchieTestNeoForge.kt index d448f4e16..729d3eaf0 100644 --- a/Archie-Test/neoforge/src/main/kotlin/net/kernelpanicsoft/archie/test/ArchieTestNeoForge.kt +++ b/Archie-Test/neoforge/src/main/kotlin/net/kernelpanicsoft/archie/test/ArchieTestNeoForge.kt @@ -5,7 +5,6 @@ import net.neoforged.fml.common.Mod import net.neoforged.fml.event.lifecycle.FMLClientSetupEvent import net.neoforged.fml.event.lifecycle.FMLCommonSetupEvent import net.neoforged.fml.event.lifecycle.FMLConstructModEvent -import net.neoforged.neoforge.client.event.RegisterMenuScreensEvent @Mod(ArchieTest.MOD_ID) object ArchieTestNeoForge @@ -20,8 +19,10 @@ object ArchieTestNeoForge MOD_BUS.addListener { ArchieTest.initCommon() } - MOD_BUS.addListener { - it.register(GuiRegistry.TestMenu, ::TestScreen) - } + // GuiRegistry.initClient() (an ADeferredRegistryHolder override) now registers its own + // screen factories at the correct time on its own - see + // net.kernelpanicsoft.archie.registries.scheduleEarlyClientRegistration. A manual + // RegisterMenuScreensEvent listener duplicating that here would now throw + // "Duplicate attempt to register screen" instead of silently doing nothing. } } \ No newline at end of file diff --git a/Archie/common/build.gradle.kts b/Archie/common/build.gradle.kts index 9d8e50a45..a0a793032 100644 --- a/Archie/common/build.gradle.kts +++ b/Archie/common/build.gradle.kts @@ -125,8 +125,16 @@ tasks { dependsOn(verifyGuiSpriteAssets) } + // Keep stubUnfulfilledExpects()'s generated throwing-actual stubs out of what gets published - + // a consumer with both this jar and a real actual on its classpath must only ever see the + // real one, or Kotlin's actual-resolution can end up preferring the stub. jar { from(sourceSets.main.get().output) + exclude("**/*StubKt.class") + } + + sourcesJar { + exclude("**/*Stub.kt") } test { diff --git a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/Archie.kt b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/Archie.kt index 35f1ce5ed..f52e8685e 100644 --- a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/Archie.kt +++ b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/Archie.kt @@ -21,6 +21,7 @@ import net.kernelpanicsoft.archie.gametest.AGameTestSide import net.kernelpanicsoft.archie.gametest.ThreadingImpl import net.kernelpanicsoft.archie.gametest.internal.ArchieGameTest import net.kernelpanicsoft.archie.gui.blockentity.BlockEntityStateManager +import net.kernelpanicsoft.archie.gui.item.ItemStateManager import net.kernelpanicsoft.archie.gui.theme.ThemeManifestResourceListener import net.kernelpanicsoft.archie.gui.theme.ThemeResourceListener import net.kernelpanicsoft.archie.networking.ArchieNetworkChannel @@ -74,6 +75,7 @@ object Archie ArchieNetworkChannel.init() } BlockEntityStateManager.init() + ItemStateManager.init() ABuiltinIngredients.init() ABuiltinConditions.init() diff --git a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/ComposeBlockContainerMenu.kt b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/ComposeBlockContainerMenu.kt new file mode 100644 index 000000000..002d3e7cb --- /dev/null +++ b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/ComposeBlockContainerMenu.kt @@ -0,0 +1,74 @@ +package net.kernelpanicsoft.archie.gui + +import net.kernelpanicsoft.archie.gui.blockentity.BlockEntityStateManager +import net.kernelpanicsoft.archie.gui.blockentity.ComposeBlockEntityState +import net.kernelpanicsoft.archie.gui.blockentity.getOrCreateBlockEntityState +import net.minecraft.server.level.ServerPlayer +import net.minecraft.world.entity.player.Inventory +import net.minecraft.world.entity.player.Player +import net.minecraft.world.inventory.MenuType +import net.minecraft.world.level.block.entity.BlockEntity + +/** + * Base class for [BlockEntity]-backed Compose container menus. + * + * See [ComposeContainerMenuBase] for slot pre-registration/positioning behavior, shared with + * [net.kernelpanicsoft.archie.gui.item.ComposeItemContainerMenu] - this class only adds the + * [BlockEntity]-specific pieces: holding [tile], deriving [blockEntityState] from its position, + * and registering it with [BlockEntityStateManager]. + * + * ### Subclassing + * ```kotlin + * class MyMenu(id: Int, inventory: Inventory, tile: MyTile) : + * ComposeBlockContainerMenu(MY_MENU_TYPE, id, inventory, tile) { + * + * override fun registerSlotHandlers() { + * handler("inventory", tile.items) // ties the "inventory" slot group to the storage + * } + * } + * ``` + * + * @param T The [BlockEntity] type that owns the storage. + * @param SELF The concrete menu subclass (self-referential for the [MenuType]). + * @param type The registered [MenuType] for this menu. + * @param id The container id assigned by the server. + * @param playerInventory The opening player's inventory. + * @param tile The block entity instance. + */ +abstract class ComposeBlockContainerMenu>( + type: MenuType, + id: Int, + playerInventory: Inventory, + protected val tile: T, +) : ComposeContainerMenuBase(type, id, playerInventory) { + + val blockEntityState: ComposeBlockEntityState = getOrCreateBlockEntityState(tile.blockPos) + + init + { + // Must run here, in this class's own init - not from ComposeContainerMenuBase's, which + // would dispatch into onMenuOpened() before `tile` (this class's own constructor + // property) is actually assigned. See onMenuOpened's KDoc. + onMenuOpened() + } + + override fun onMenuOpened() + { + BlockEntityStateManager.registerBlockEntity(tile) + if (!level.isClientSide) + { + BlockEntityStateManager.addTrackedPlayer(tile, player as ServerPlayer) + } + } + + override fun onMenuClosed(player: Player) + { + BlockEntityStateManager.unregisterBlockEntity(tile) + if (!level.isClientSide) + { + BlockEntityStateManager.removeTrackedPlayer(tile, player as ServerPlayer) + } + } + + override fun stillValid(player: Player): Boolean = true +} diff --git a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/ComposeContainerMenu.kt b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/ComposeContainerMenuBase.kt similarity index 84% rename from Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/ComposeContainerMenu.kt rename to Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/ComposeContainerMenuBase.kt index 174d040ef..9bc0e1068 100644 --- a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/ComposeContainerMenu.kt +++ b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/ComposeContainerMenuBase.kt @@ -3,15 +3,11 @@ package net.kernelpanicsoft.archie.gui import earth.terrarium.common_storage_lib.item.impl.vanilla.AbstractVanillaContainer import earth.terrarium.common_storage_lib.resources.item.ItemResource import earth.terrarium.common_storage_lib.storage.base.CommonStorage -import net.kernelpanicsoft.archie.gui.blockentity.BlockEntityStateManager -import net.kernelpanicsoft.archie.gui.blockentity.ComposeBlockEntityState -import net.kernelpanicsoft.archie.gui.blockentity.getOrCreateBlockEntityState import net.kernelpanicsoft.archie.gui.layout.IntRect import net.kernelpanicsoft.archie.networking.ArchieNetworkChannel import net.kernelpanicsoft.archie.transfer.ArchieItemMenuSlot import net.kernelpanicsoft.archie.transfer.ArchieItemStorage import net.kernelpanicsoft.archie.transfer.VanillaMenuSlot -import net.minecraft.server.level.ServerPlayer import net.minecraft.world.Container import net.minecraft.world.entity.player.Inventory import net.minecraft.world.entity.player.Player @@ -21,11 +17,13 @@ import net.minecraft.world.inventory.MenuType import net.minecraft.world.inventory.Slot import net.minecraft.world.item.ItemStack import net.minecraft.world.level.Level -import net.minecraft.world.level.block.entity.BlockEntity import java.util.function.Predicate /** - * Base class for Compose-backed container menus. + * Holder-agnostic base for Compose-backed container menus: everything about slot layout, + * registration, and vanilla-menu plumbing that doesn't care whether the menu is backed by a + * [net.minecraft.world.level.block.entity.BlockEntity] ([ComposeBlockContainerMenu]) or an + * [net.minecraft.world.item.ItemStack] ([net.kernelpanicsoft.archie.gui.item.ComposeItemContainerMenu]). * * Slots are pre-registered at construction time with placeholder pixel positions so that * [AbstractContainerMenu.initializeContents] (triggered by the server's slot-sync packet) @@ -33,29 +31,15 @@ import java.util.function.Predicate * on-screen positions via [updateSlotData], existing slot objects have their pixel coordinates * updated in-place rather than the slot list being rebuilt from scratch. * - * ### Subclassing - * ```kotlin - * class MyMenu(id: Int, inventory: Inventory, tile: MyTile) : - * ComposeContainerMenu(MY_MENU_TYPE, id, inventory, tile) { - * - * override fun registerSlotHandlers() { - * handler("inventory", tile.items) // ties the "inventory" slot group to the storage - * } - * } - * ``` - * - * @param T The [BlockEntity] type that owns the storage. * @param SELF The concrete menu subclass (self-referential for the [MenuType]). * @param type The registered [MenuType] for this menu. - * @param id The container id assigned by the server. + * @param id The container id assigned by the server. * @param playerInventory The opening player's inventory. - * @param tile The block entity instance. */ -abstract class ComposeContainerMenu>( +abstract class ComposeContainerMenuBase>( type: MenuType, id: Int, protected val playerInventory: Inventory, - protected val tile: T, ) : AbstractContainerMenu(type, id) { /** @@ -82,8 +66,6 @@ abstract class ComposeContainerMenu = emptyList() - val blockEntityState: ComposeBlockEntityState = getOrCreateBlockEntityState(tile.blockPos) - /** * The screen's `leftPos` offset — set by [ComposeContainerScreen] so that absolute * Compose coordinates can be converted to slot-relative coordinates that vanilla's @@ -102,17 +84,33 @@ abstract class ComposeContainerMenu> = mutableMapOf() private val slotFilters: MutableMap> = mutableMapOf() - protected val player: Player = playerInventory.player - protected val level: Level = player.level() + protected val player: Player = playerInventory.player + protected val level: Level = player.level() - init - { - BlockEntityStateManager.registerBlockEntity(tile) - if (!level.isClientSide) - { - BlockEntityStateManager.addTrackedPlayer(tile, player as ServerPlayer) - } - } + /** + * Register whatever state-tracking this menu's holder needs here. **Not called + * automatically** - each concrete subclass must call this from its own `init {}` block, + * after its own constructor-parameter properties (e.g. `tile`) are assigned. Calling it from + * *this* class's own `init {}` instead would dispatch into the subclass's override before + * those properties exist yet (Kotlin/JVM run a subclass's own property initializers only + * after its superclass's constructor - including this class's `init {}` - has fully + * returned), silently observing them as null despite their non-null declared type. + */ + protected abstract fun onMenuOpened() + + /** Called from [removed] - unregister whatever [onMenuOpened] registered here. */ + protected abstract fun onMenuClosed(player: Player) + + /** + * Excludes the player-inventory slot at container-relative [index] (0-35, matching + * [Inventory]'s own numbering: hotbar 0-8, main 9-35) from placement/pickup - frozen in + * place rather than removed from the slot list, to avoid reworking [addPlayerSlots]'s + * hardcoded 36-slot/3x9+9 assumptions elsewhere. Used by + * [net.kernelpanicsoft.archie.gui.item.ComposeItemContainerMenu] to freeze the backpack's + * own slot in the player's inventory while its GUI is open (also special-cased in + * [quickMoveStack], so shift-clicking it doesn't duplicate its contents into itself). + */ + protected open fun isPlayerSlotExcluded(index: Int): Boolean = false // ── Slot registration ────────────────────────────────────────────────── @@ -309,9 +307,6 @@ abstract class ComposeContainerMenu // Use slot-relative coords (subtract screen offset so vanilla adds it back correctly) -// group.slots.forEachIndexed { slot, coords -> -// slot(handler, slot, coords.x - screenLeftPos, coords.y - screenTopPos) -// } slotGrid(group.pos.x - screenLeftPos, group.pos.y - screenTopPos, group.size.width, group.size.height, handler, slotFilters[id] ?: Predicate { true }) } } @@ -324,16 +319,30 @@ abstract class ComposeContainerMenu val menu = context.player.containerMenu - if (menu is ComposeContainerMenu<*, *>) { + if (menu is ComposeContainerMenuBase<*>) { menu.slotData = data // Match slot positions on the server to the client layout menu.applySlotData() diff --git a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/ComposeContainerScreen.kt b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/ComposeContainerScreen.kt index 93583f937..2212be8ab 100644 --- a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/ComposeContainerScreen.kt +++ b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/ComposeContainerScreen.kt @@ -13,6 +13,8 @@ import net.kernelpanicsoft.archie.gui.access.SlotHighlightClipProvider import net.kernelpanicsoft.archie.gui.access.SlotLayerDepthProvider import net.kernelpanicsoft.archie.gui.blockentity.LocalBlockEntityState import net.kernelpanicsoft.archie.gui.composables.containers.RootContainer +import net.kernelpanicsoft.archie.gui.item.ComposeItemContainerMenu +import net.kernelpanicsoft.archie.gui.item.LocalItemState import net.kernelpanicsoft.archie.gui.layer.LayerStackManager import net.kernelpanicsoft.archie.gui.layer.LocalLayerManager import net.kernelpanicsoft.archie.gui.layout.IntCoordinates @@ -30,16 +32,15 @@ import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen import net.minecraft.network.chat.Component import net.minecraft.world.entity.player.Inventory import net.minecraft.world.inventory.Slot -import net.minecraft.world.level.block.entity.BlockEntity import org.lwjgl.glfw.GLFW import kotlin.coroutines.CoroutineContext /** Provides the current [ComposeContainerScreen] to any composable in its tree. */ -val LocalContainerScreen: ProvidableCompositionLocal> = +val LocalContainerScreen: ProvidableCompositionLocal> = compositionLocalOf { throw IllegalStateException("Screen has not been provided") } -/** Provides the current [ComposeContainerMenu] to any composable in its tree. */ -val LocalContainerMenu: ProvidableCompositionLocal> = +/** Provides the current [ComposeContainerMenuBase] to any composable in its tree. */ +val LocalContainerMenu: ProvidableCompositionLocal> = compositionLocalOf { throw IllegalStateException("Screen has not been provided") } /** @@ -52,10 +53,10 @@ val LocalContainerMenu: ProvidableCompositionLocal> = * clipping so scrolled-out-of-view slots don't paint over unrelated content. * * Extend this class and call [start] inside your `init()` override, the same way as - * [ComposeScreen]. + * [ComposeScreen]. Works uniformly for both [ComposeBlockContainerMenu] (BlockEntity-backed) and + * [ComposeItemContainerMenu] (ItemStack-backed) subclasses - nothing here is holder-specific. * - * @param T The concrete [ComposeContainerMenu] subclass driving this screen. - * @param B The [BlockEntity] type backing [T]. + * @param T The concrete [ComposeContainerMenuBase] subclass driving this screen. * @param menu The container menu instance for this screen. * @param playerInventory The opening player's inventory. * @param title The screen title passed to the vanilla [AbstractContainerScreen] constructor. @@ -63,7 +64,7 @@ val LocalContainerMenu: ProvidableCompositionLocal> = * the result is joined at the start of the next frame for smooth, non-blocking updates. * Set to `false` to force synchronous recomposition (simpler but may stutter). */ -abstract class ComposeContainerScreen, B : BlockEntity>( +abstract class ComposeContainerScreen>( menu: T, playerInventory: Inventory, title: Component, val asynchronous: Boolean = true, ) : AbstractContainerScreen(menu, playerInventory, title), @@ -150,7 +151,12 @@ abstract class ComposeContainerScreen, B : BlockE LocalContainerScreen provides this, LocalContainerMenu provides menu, LocalSlotData provides menu.slotData, - LocalBlockEntityState provides menu.blockEntityState, + // Only one of these is non-null for any given menu - LocalBlockEntityState / + // LocalItemState are both nullable-by-default composition locals precisely so + // composables reaching for the "wrong" one for this menu's holder kind get a + // clear null rather than a bogus fallback value. + LocalBlockEntityState provides (menu as? ComposeBlockContainerMenu<*, *>)?.blockEntityState, + LocalItemState provides (menu as? ComposeItemContainerMenu<*>)?.itemState, LocalLayerManager provides layerManager, ) { RootContainer { diff --git a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/Slot.kt b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/Slot.kt index 5374d02c1..4c41abe30 100644 --- a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/Slot.kt +++ b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/Slot.kt @@ -17,7 +17,7 @@ import net.kernelpanicsoft.archie.gui.composables.containers.Scrollable import net.minecraft.client.gui.GuiGraphics /** - * Per-slot-group layout data reported back from the Compose layout to [ComposeContainerMenu]. + * Per-slot-group layout data reported back from the Compose layout to [ComposeContainerMenuBase]. * * Stores the group's absolute screen position, dimensions, slot positions, and clip bounds. */ @@ -93,7 +93,7 @@ val LocalSlotClipBounds = compositionLocalOf { null } * Defines a named region of inventory slots within a [ComposeContainerScreen]. * * This composable tracks its absolute on-screen position and populates the enclosing - * [SlotData] with the group's location and dimensions so that [ComposeContainerMenu] can + * [SlotData] with the group's location and dimensions so that [ComposeContainerMenuBase] can * register the corresponding vanilla [net.minecraft.world.inventory.Slot]s. * * @param id The name that matches the `handler(id, storage)` call in your menu. @@ -158,7 +158,7 @@ fun Slots( /** * Renders a single inventory slot graphic and records its absolute screen position. * - * Triggers [ComposeContainerMenu.updateSlotData] once **all** named groups and the player + * Triggers [ComposeContainerMenuBase.updateSlotData] once **all** named groups and the player * group have reported their positions for this layout pass. * * @param modifier Additional modifiers applied to the slot layout node. @@ -205,7 +205,7 @@ fun Slot(texture: String = "slot", modifier: Modifier = Modifier) { * Renders the standard 4-row player inventory (3 main rows + hotbar) as [Slot] composables. * * The 58-pixel gap between the main inventory and the hotbar matches the pixel offset used - * by [ComposeContainerMenu.addPlayerSlots] so positions reported to the menu are consistent. + * by [ComposeContainerMenuBase.addPlayerSlots] so positions reported to the menu are consistent. */ @Composable fun PlayerSlots() { @@ -281,12 +281,12 @@ private fun PlayerSlot(texture: String = "slot", modifier: Modifier = Modifier) } /** - * Fires [ComposeContainerMenu.updateSlotData] only when every named slot group AND the + * Fires [ComposeContainerMenuBase.updateSlotData] only when every named slot group AND the * player group have all reported their slot positions for this layout pass. * * This prevents partial updates where only some groups are positioned. */ -private fun tryUpdateMenu(data: SlotData, menu: ComposeContainerMenu<*, *>) { +private fun tryUpdateMenu(data: SlotData, menu: ComposeContainerMenuBase<*>) { val namedGroupsFull = data.groups.values.all { g -> g.slots.size >= g.size.width * g.size.height } val playerGroupFull = data.playerGroup.slots.size >= 36 if (namedGroupsFull && playerGroupFull) { diff --git a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/item/ComposeItemContainerMenu.kt b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/item/ComposeItemContainerMenu.kt new file mode 100644 index 000000000..087e5aee9 --- /dev/null +++ b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/item/ComposeItemContainerMenu.kt @@ -0,0 +1,147 @@ +package net.kernelpanicsoft.archie.gui.item + +import kotlinx.serialization.KSerializer +import net.kernelpanicsoft.archie.gui.ComposeContainerMenuBase +import net.kernelpanicsoft.archie.gui.blockentity.BlockEntityStatePacket +import net.kernelpanicsoft.archie.gui.blockentity.toSerializedValue +import net.kernelpanicsoft.archie.networking.ArchieNetworkChannel +import net.kernelpanicsoft.archie.serialization.NBTHolder +import net.minecraft.server.level.ServerPlayer +import net.minecraft.world.entity.player.Inventory +import net.minecraft.world.entity.player.Player +import net.minecraft.world.inventory.MenuType + +/** + * Base class for [net.minecraft.world.item.ItemStack]-backed Compose container menus - e.g. a + * backpack/bag with its own GUI. The [ComposeBlockContainerMenu][net.kernelpanicsoft.archie.gui.ComposeBlockContainerMenu] + * equivalent for items. + * + * See [ComposeContainerMenuBase] for slot pre-registration/positioning behavior, shared with + * [net.kernelpanicsoft.archie.gui.ComposeBlockContainerMenu] - this class only adds the + * item-specific pieces: locating the backing stack via [itemAccess], the [itemState] sync path, + * and periodic validity checking via [ItemStateManager]. + * + * ### Subclassing + * ```kotlin + * class MyBackpackMenu(id: Int, inventory: Inventory, access: ItemContainerAccess) : + * ComposeItemContainerMenu(MY_MENU_TYPE, id, inventory, access) { + * + * @Sync + * var progress by holder.intField() + * + * override fun registerSlotHandlers() { + * handler("inventory", holder.itemField(27)) + * } + * } + * ``` + * + * @param SELF The concrete menu subclass (self-referential for the [MenuType]). + * @param type The registered [MenuType] for this menu. + * @param id The container id assigned by the server. + * @param playerInventory The opening player's inventory. + * @param itemAccess Locates the backing [net.minecraft.world.item.ItemStack] and reports whether + * this menu should stay open. + */ +abstract class ComposeItemContainerMenu>( + type: MenuType, + id: Int, + playerInventory: Inventory, + protected val itemAccess: ItemContainerAccess, +) : ComposeContainerMenuBase(type, id, playerInventory), SyncedItemHolder { + + /** Client- and server-side sync state for this menu's own `@Sync`-annotated [holder] fields. */ + val itemState: ComposeItemState = ComposeItemState(containerId) + + /** + * An [NBTHolder] view of the backing stack, captured **once** at construction - mirroring + * [net.kernelpanicsoft.archie.gui.ComposeBlockContainerMenu]'s `tile` (stable for this menu's + * lifetime, not re-resolved per access). Declare this menu's own `@Sync`-annotated scalar + * fields against it, e.g. `@Sync var progress by holder.intField()`. + * + * Unlike [itemAccess]'s own `getStack()` (re-resolved fresh every call, since slot *contents* + * must always reflect the live inventory slot), this menu's own bookkeeping fields behave the + * same way [net.kernelpanicsoft.archie.gui.ComposeBlockContainerMenu]'s fields do against + * `tile` - captured once, not defended against the backing stack reference being swapped out + * from under an already-open menu. That's an unusual scenario that isn't defended against for + * block-entity-backed menus either (`tile` is captured the same way there). + */ + protected val holder: NBTHolder = NBTHolder.item(itemAccess.getStack()) + + /** Property names changed since the last [tickSync], with their serialized values ready to send. */ + private val dirtyUpdates = mutableMapOf() + + init + { + // Must run here, in this class's own init - not from ComposeContainerMenuBase's, which + // would dispatch into onMenuOpened() before `itemAccess` (this class's own constructor + // property) is actually assigned. See ComposeContainerMenuBase.onMenuOpened's KDoc. + onMenuOpened() + } + + override fun onMenuOpened() + { + if (!level.isClientSide) + ItemStateManager.register(this) + } + + override fun onMenuClosed(player: Player) + { + if (!level.isClientSide) + ItemStateManager.unregister(this) + } + + @Suppress("UNCHECKED_CAST") + override fun registerSyncedProperty(name: String, serializer: KSerializer) + { + // Runs on both sides, at field-declaration time - independent of observeItemProperty(), + // which only ever runs client-side inside a composable. Without this, the server never + // learns a serializer for `name` at all unless a fresh delegate's own initial-value write + // happens to fire onSyncedPropertyChanged first (which it doesn't for a property whose + // value already exists on an already-populated stack). + itemState.propertySerializers[name] = serializer as KSerializer + } + + override fun onSyncedPropertyChanged(name: String, serializer: KSerializer, value: T) + { + dirtyUpdates[name] = value.toSerializedValue(serializer) + } + + /** + * Applies a client-sent [ItemUpdatePacket] edit: a raw, low-level write straight into + * [holder]'s stored data (bypassing whatever property setter owns [name], the same way + * [net.kernelpanicsoft.archie.gui.blockentity.BlockEntityStatePacketRegistry]'s serverbound + * handler calls `blockEntity.updateProperty(...)` rather than going through the property + * setter) - re-entering through [onSyncedPropertyChanged] here would only mark [name] dirty + * without ever actually persisting the new value, since that method is a notification hook, + * not a write path. Marks [name] dirty directly afterward so [tickSync] re-broadcasts it. + */ + internal fun applyRemoteUpdate(name: String, serializer: KSerializer, value: T) + { + holder.updateProperty(name, serializer, value) + dirtyUpdates[name] = value.toSerializedValue(serializer) + } + + /** + * Called once per server tick by [ItemStateManager]: force-closes this menu if [itemAccess] + * reports it's no longer valid, otherwise sends any accumulated [dirtyUpdates] as a single + * [ItemStatePacket]. + */ + internal fun tickSync(currentTick: Long) + { + if (!itemAccess.stillValid(player)) + { + player.closeContainer() + return + } + if (dirtyUpdates.isEmpty()) return + val packet = ItemStatePacket(containerId, dirtyUpdates.toMap(), currentTick) + dirtyUpdates.clear() + (player as? ServerPlayer)?.let { ArchieNetworkChannel.toPlayers(listOf(it), packet) } + } + + override fun stillValid(player: Player): Boolean = itemAccess.stillValid(player) + + /** Freezes the backpack's own slot in the player's inventory while its GUI is open - see [ComposeContainerMenuBase.isPlayerSlotExcluded]. */ + override fun isPlayerSlotExcluded(index: Int): Boolean = + (itemAccess as? PlayerInventoryItemAccess)?.slot == index +} diff --git a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/item/ComposeItemState.kt b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/item/ComposeItemState.kt new file mode 100644 index 000000000..1153b9514 --- /dev/null +++ b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/item/ComposeItemState.kt @@ -0,0 +1,145 @@ +package net.kernelpanicsoft.archie.gui.item + +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import kotlinx.serialization.KSerializer +import net.kernelpanicsoft.archie.gui.blockentity.BlockEntityStatePacket +import net.kernelpanicsoft.archie.gui.blockentity.deserialize +import net.kernelpanicsoft.archie.gui.blockentity.toSerializedValue +import net.kernelpanicsoft.archie.networking.ArchieNetworkChannel + +/** + * Client- and server-side state holder for a [ComposeItemContainerMenu]'s synchronized + * properties. The [ComposeBlockEntityState][net.kernelpanicsoft.archie.gui.blockentity.ComposeBlockEntityState] + * equivalent for item-backed menus, keyed by [containerId] instead of a [net.minecraft.core.BlockPos]. + * + * Unlike block entities - which can be watched by multiple players simultaneously, needing a + * position-keyed global registry on both sides - an item-backed menu is inherently 1:1 with a + * single player's currently-open session. Client and server each already have exactly one live + * instance of "my currently open menu" (this one, owned directly by the [ComposeItemContainerMenu] + * itself), so no equivalent client-side registry is needed here. + * + * @param containerId The owning menu's vanilla [net.minecraft.world.inventory.AbstractContainerMenu.containerId] - + * used purely as a staleness guard against a stray packet arriving after this player closed one + * item menu and opened another, not as a lookup key. + */ +class ComposeItemState( + val containerId: Int, +) { + /** Map of property names to their Compose state values */ + val propertyStates = mutableMapOf>() + + /** Serializers used to encode/decode each observed property, keyed by property name. */ + val propertySerializers = mutableMapOf>() + + @Suppress("UNCHECKED_CAST") + private fun anySerializer(serializer: KSerializer): KSerializer = serializer as KSerializer + + @Suppress("UNCHECKED_CAST") + private fun typedSerializer(propertyName: String): KSerializer? = propertySerializers[propertyName] as? KSerializer + + @Suppress("UNCHECKED_CAST") + private fun getOrCreateState(propertyName: String, initialValue: T?): MutableState { + return propertyStates.computeIfAbsent(propertyName) { + PropertyState(this, propertyName, mutableStateOf(initialValue)) as MutableState + } as MutableState + } + + /** + * Gets or creates a Compose state for a property with a specific type. + * + * @param propertyName The name of the property. + * @param initialValue The initial value (optional, defaults to null). + * @param T The expected type of the property. + * @return A [MutableState] of type T that can be observed in composables. + */ + fun observeProperty( + propertyName: String, + serializer: KSerializer, + initialValue: T? = null, + ): MutableState { + propertySerializers[propertyName] = anySerializer(serializer) + return getOrCreateState(propertyName, initialValue) + } + + /** + * A [MutableState] delegate that forwards writes to [ComposeItemState.sendUpdatedProperty], + * so setting [value] from a composable both updates local state and pushes the change to the server. + */ + class PropertyState(private val state: ComposeItemState, private val propertyName: String, internal val mutableState: MutableState) : MutableState by mutableState + { + override var value: T + get() = mutableState.value + set(value) + { + mutableState.value = value + state.sendUpdatedProperty(propertyName, value) + } + } + + /** + * Updates a property value from a network packet. + * + * If the property doesn't exist yet, it will be created. + * + * @param propertyName The name of the property. + * @param value The new serialized value from the network packet. + */ + fun updateProperty(propertyName: String, value: BlockEntityStatePacket.SerializedValue) { + val deserializedValue = value.deserialize(propertySerializers[propertyName]) + // Must go through getOrCreateState(), not a separate computeIfAbsent - otherwise a + // property whose first appearance is a packet (not observeProperty()) gets stuck with a + // bare state that never forwards writes back to the server. + getOrCreateState(propertyName, deserializedValue).value = deserializedValue + } + + /** + * Updates a property value and sends the change to the server. + * + * This method should be called when a client-side interaction changes a property. + * + * @param propertyName The name of the property. + * @param value The new value. + */ + fun sendUpdatedProperty(propertyName: String, value: T) { + val serializer = typedSerializer(propertyName) ?: run { + println("No serializer found for property $propertyName. Cannot send update to server.") + return + } + + val serializedValue = value.toSerializedValue(serializer) + val packet = ItemUpdatePacket.singleUpdate(containerId, propertyName, serializedValue) + ArchieNetworkChannel.toServer(packet) + } + + /** + * Gets the current value of a property. + * + * @param propertyName The name of the property. + * @return The property value, or null if not tracked. + */ + fun getProperty(propertyName: String): Any? { + return propertyStates[propertyName]?.value + } + + /** + * Gets the current value of a property with type casting. + * + * @param propertyName The name of the property. + * @param T The expected type. + * @return The property value cast to T, or null if not found/wrong type. + */ + @Suppress("UNCHECKED_CAST") + fun getPropertyTyped(propertyName: String): T? { + return propertyStates[propertyName]?.value as? T + } + + /** + * Gets all currently tracked properties. + * + * @return A map of property names to their current values. + */ + fun getAllProperties(): Map { + return propertyStates.mapValues { (_, state) -> state.value } + } +} diff --git a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/item/ItemContainerAccess.kt b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/item/ItemContainerAccess.kt new file mode 100644 index 000000000..ce38c1bb8 --- /dev/null +++ b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/item/ItemContainerAccess.kt @@ -0,0 +1,43 @@ +package net.kernelpanicsoft.archie.gui.item + +import net.minecraft.world.entity.player.Player +import net.minecraft.world.item.Item +import net.minecraft.world.item.ItemStack + +/** + * Locates the [ItemStack] backing a [ComposeItemContainerMenu] and reports whether it's still + * valid to keep the menu open. + */ +interface ItemContainerAccess +{ + /** + * Resolves the current backing [ItemStack]. Must be re-resolved fresh on every call, not + * cached - the underlying stack reference can be swapped out from under the menu (e.g. by + * another mod replacing the inventory slot's stack wholesale), and a cached reference would + * silently go stale rather than reflect that. + */ + fun getStack(): ItemStack + + /** Whether [player] should still be allowed to keep this menu open. */ + fun stillValid(player: Player): Boolean +} + +/** + * An [ItemContainerAccess] for an item sitting in [player]'s own inventory at [slot] (vanilla + * [net.minecraft.world.entity.player.Inventory] numbering: hotbar 0-8, main 9-35). + * + * @param expectedItem Guards [stillValid] against the slot's contents having been swapped out + * for a different item entirely (e.g. dropped and something else picked up into the same + * slot index) while the menu was open. + */ +class PlayerInventoryItemAccess( + private val player: Player, + val slot: Int, + private val expectedItem: Item, +) : ItemContainerAccess +{ + override fun getStack(): ItemStack = player.inventory.getItem(slot) + + override fun stillValid(player: Player): Boolean = + player === this.player && getStack().`is`(expectedItem) +} diff --git a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/item/ItemStateComposables.kt b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/item/ItemStateComposables.kt new file mode 100644 index 000000000..f35ea05ef --- /dev/null +++ b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/item/ItemStateComposables.kt @@ -0,0 +1,49 @@ +package net.kernelpanicsoft.archie.gui.item + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.compositionLocalOf +import kotlinx.serialization.serializer + +/** + * Provides the current [ComposeItemContainerMenu]'s state to composables in the composition + * tree - the [ComposeItemState] equivalent of + * [net.kernelpanicsoft.archie.gui.blockentity.LocalBlockEntityState]. `null` when the current + * screen's menu isn't item-backed. + * + * Use with `LocalItemState.current` to access the state, or use the [observeItemProperty] helper + * for convenience. + */ +val LocalItemState = compositionLocalOf { null } + +/** + * Observes a property on the current [ComposeItemContainerMenu] in the current composition + * context. The [net.kernelpanicsoft.archie.gui.blockentity.observeProperty] equivalent for + * item-backed menus. + * + * Returns a [MutableState] that automatically triggers recomposition when the property changes. + * Must be called where [LocalItemState] has been provided with a non-null value (i.e. inside an + * item-backed menu's screen composition) - otherwise it throws. + * + * ### Example + * ```kotlin + * @Composable + * fun MyComponent() { + * val progressState = observeItemProperty("progress") + * Text("Progress: ${progressState.value}") + * } + * ``` + * + * @param propertyName The name of the property to observe. + * @param T The expected type of the property. + * @return A [MutableState] of type T reflecting the property's current value. + * @throws RuntimeException if no [ComposeItemState] is available in the current composition. + */ +@Composable +inline fun observeItemProperty( + propertyName: String, + initialValue: T? = null, +): MutableState { + val state = LocalItemState.current ?: throw RuntimeException("No item container state available in composition") + return state.observeProperty(propertyName, serializer(), initialValue) +} diff --git a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/item/ItemStateManager.kt b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/item/ItemStateManager.kt new file mode 100644 index 000000000..072da51c7 --- /dev/null +++ b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/item/ItemStateManager.kt @@ -0,0 +1,53 @@ +package net.kernelpanicsoft.archie.gui.item + +import dev.architectury.event.events.common.TickEvent +import org.slf4j.LoggerFactory +import java.util.concurrent.CopyOnWriteArraySet + +private val LOGGER = LoggerFactory.getLogger(ItemStateManager::class.java) + +/** + * Server-side manager for currently-open [ComposeItemContainerMenu]s: drives dirty-property sync + * packets each tick (mirroring [net.kernelpanicsoft.archie.gui.blockentity.BlockEntityStateManager]), + * and force-closes a menu whose [ItemContainerAccess] reports it's no longer valid - e.g. the + * backing item was consumed/dropped while the GUI was passively open. Vanilla's own `stillValid` + * polling only fires reactively on player-initiated clicks otherwise, so without this a stale + * menu could sit open indefinitely against a stack that no longer exists. + * + * Much lighter than [net.kernelpanicsoft.archie.gui.blockentity.BlockEntityStateManager]: an + * item-backed menu is inherently 1:1 with one player's session (unlike a block entity, which can + * be watched by multiple players simultaneously), so there's no position-keyed registry or + * per-menu tracked-player set needed - just the set of currently-open menus themselves. + */ +object ItemStateManager { + private val openMenus: MutableSet> = CopyOnWriteArraySet() + + /** + * Registers the server tick listener that drives per-tick syncing/validity checks. + * + * Must be called once during mod init. + */ + fun init() { + TickEvent.SERVER_POST.register { + val currentTick = it.tickCount.toLong() + // One menu's tickSync() throwing shouldn't abort the loop for every other open menu. + openMenus.forEach { menu -> + try { + menu.tickSync(currentTick) + } catch (e: Exception) { + LOGGER.error("Error syncing item container menu $menu", e) + } + } + } + } + + /** Registers [menu] for tick-driven syncing. Called from [ComposeItemContainerMenu.onMenuOpened]. */ + fun register(menu: ComposeItemContainerMenu<*>) { + openMenus += menu + } + + /** Unregisters [menu]. Called from [ComposeItemContainerMenu.onMenuClosed]. */ + fun unregister(menu: ComposeItemContainerMenu<*>) { + openMenus -= menu + } +} diff --git a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/item/ItemStatePacket.kt b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/item/ItemStatePacket.kt new file mode 100644 index 000000000..dd8a2eaea --- /dev/null +++ b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/item/ItemStatePacket.kt @@ -0,0 +1,38 @@ +package net.kernelpanicsoft.archie.gui.item + +import kotlinx.serialization.Serializable +import net.kernelpanicsoft.archie.gui.blockentity.BlockEntityStatePacket + +/** + * A network packet that carries [ComposeItemContainerMenu] state changes from server to client - + * the item-backed-menu equivalent of [BlockEntityStatePacket]. [containerId] addresses the + * player's currently open menu directly rather than acting as a lookup key: an item-backed menu + * is inherently 1:1 with one player's session, so there's no position-keyed registry to look + * anything up in, unlike a block entity that can be watched by multiple players at once. It's + * checked purely as a staleness guard against a stray packet arriving after this player closed + * one item menu and opened another. + * + * @property containerId The owning menu's vanilla [net.minecraft.world.inventory.AbstractContainerMenu.containerId]. + * @property updates A map of property names to their serialized values. + * @property timestamp Server tick when this packet was created (for ordering/deduplication). + */ +@Serializable +data class ItemStatePacket( + val containerId: Int, + val updates: Map = emptyMap(), + val timestamp: Long = 0, +) { + companion object { + /** Creates a new packet with a single property update. */ + fun singleUpdate( + containerId: Int, + propertyName: String, + value: BlockEntityStatePacket.SerializedValue, + timestamp: Long = 0, + ): ItemStatePacket = ItemStatePacket( + containerId = containerId, + updates = mapOf(propertyName to value), + timestamp = timestamp, + ) + } +} diff --git a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/item/ItemStatePacketRegistry.kt b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/item/ItemStatePacketRegistry.kt new file mode 100644 index 000000000..0145956d8 --- /dev/null +++ b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/item/ItemStatePacketRegistry.kt @@ -0,0 +1,42 @@ +package net.kernelpanicsoft.archie.gui.item + +import kotlinx.serialization.KSerializer +import net.kernelpanicsoft.archie.gui.blockentity.deserialize +import net.kernelpanicsoft.archie.networking.ArchieNetworkChannel + +/** + * Registers [ItemStatePacket]/[ItemUpdatePacket] handlers with [ArchieNetworkChannel] - the + * [net.kernelpanicsoft.archie.gui.blockentity.BlockEntityStatePacketRegistry] equivalent for + * item-backed menus. + * + * Unlike the block-entity path, routing needs no position-keyed lookup on either side - an + * item-backed menu is inherently 1:1 with one player's session, so "the current menu" is always + * just `context.player.containerMenu` (true on both sides: [net.minecraft.world.entity.player.Player] + * has exactly one open [net.minecraft.world.inventory.AbstractContainerMenu] at a time). The + * `containerId` on each packet is checked purely as a staleness guard against a stray packet + * arriving after the player closed one item menu and opened another - a mismatch is silently + * dropped, not an error. + */ +object ItemStatePacketRegistry { + /** Registers the clientbound and serverbound packet handlers described above. */ + fun register() { + ArchieNetworkChannel.clientbound { packet, context -> + val menu = context.player.containerMenu as? ComposeItemContainerMenu<*> ?: return@clientbound + if (menu.containerId != packet.containerId) return@clientbound + packet.updates.forEach { (propertyName, value) -> + menu.itemState.updateProperty(propertyName, value) + } + } + + ArchieNetworkChannel.serverbound { packet, context -> + val menu = context.player.containerMenu as? ComposeItemContainerMenu<*> ?: return@serverbound + if (menu.containerId != packet.containerId) return@serverbound + packet.updates.forEach { (propertyName, serializedValue) -> + val serializer = menu.itemState.propertySerializers[propertyName] ?: return@forEach + val deserializedValue = serializedValue.deserialize(serializer) + @Suppress("UNCHECKED_CAST") + menu.applyRemoteUpdate(propertyName, serializer as KSerializer, deserializedValue) + } + } + } +} diff --git a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/item/ItemUpdatePacket.kt b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/item/ItemUpdatePacket.kt new file mode 100644 index 000000000..ca00f99b3 --- /dev/null +++ b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/item/ItemUpdatePacket.kt @@ -0,0 +1,30 @@ +package net.kernelpanicsoft.archie.gui.item + +import kotlinx.serialization.Serializable +import net.kernelpanicsoft.archie.gui.blockentity.BlockEntityStatePacket + +/** + * A network packet that carries [ComposeItemContainerMenu] state updates from client to server - + * the item-backed-menu equivalent of [net.kernelpanicsoft.archie.gui.blockentity.BlockEntityUpdatePacket]. + * See [ItemStatePacket] for what [containerId] is used for. + * + * @property containerId The owning menu's vanilla [net.minecraft.world.inventory.AbstractContainerMenu.containerId]. + * @property updates A map of property names to their serialized values. + */ +@Serializable +data class ItemUpdatePacket( + val containerId: Int, + val updates: Map, +) { + companion object { + /** Creates a new packet with a single property update. */ + fun singleUpdate( + containerId: Int, + propertyName: String, + value: BlockEntityStatePacket.SerializedValue, + ): ItemUpdatePacket = ItemUpdatePacket( + containerId = containerId, + updates = mapOf(propertyName to value), + ) + } +} diff --git a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/item/SyncedItemHolder.kt b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/item/SyncedItemHolder.kt new file mode 100644 index 000000000..e89d4a89b --- /dev/null +++ b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/gui/item/SyncedItemHolder.kt @@ -0,0 +1,33 @@ +package net.kernelpanicsoft.archie.gui.item + +import kotlinx.serialization.KSerializer + +/** + * Implemented by whatever owns an [net.kernelpanicsoft.archie.serialization.NBTHolder.item]-backed + * holder that wants its `@Sync`-annotated fields to actually push updates somewhere, mirroring + * what [net.minecraft.world.level.block.entity.BlockEntity] gets automatically via + * [net.kernelpanicsoft.archie.gui.blockentity.BlockEntityStateManager]. + * + * [net.kernelpanicsoft.archie.serialization.ItemStackNBTHolderImpl] checks for this on its + * `thisRef` the same way it checks `thisRef is BlockEntity` for the block-entity-backed + * implementation - so any `NBTHolder.item(stack)`-delegated property declared directly on a type + * implementing this interface gets [registerSyncedProperty]/[onSyncedPropertyChanged] calls + * automatically. + */ +interface SyncedItemHolder +{ + /** + * Called once, at property-declaration time, for every `@Sync`-annotated `NBTHolder.item`- + * delegated property named [name] - independent of whether its value has ever actually been + * written. Needed so a serializer is available to decode an incoming edit even for a property + * whose value came from an *existing* stack's already-populated data (where the delegate's own + * initial-value write, which [onSyncedPropertyChanged] would otherwise piggyback on, never + * runs) - mirrors why [net.kernelpanicsoft.archie.gui.blockentity.BlockEntityStateContainer] + * has its own separate `setPropertySerializer` call distinct from `updateProperty`. No-op by + * default for implementations that don't need it. + */ + fun registerSyncedProperty(name: String, serializer: KSerializer) {} + + /** Called on every write to a `@Sync`-annotated `NBTHolder.item`-delegated property named [name]. */ + fun onSyncedPropertyChanged(name: String, serializer: KSerializer, value: T) +} diff --git a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/networking/ArchieNetworkChannel.kt b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/networking/ArchieNetworkChannel.kt index 69781cf74..04b7e3160 100644 --- a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/networking/ArchieNetworkChannel.kt +++ b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/networking/ArchieNetworkChannel.kt @@ -1,13 +1,14 @@ package net.kernelpanicsoft.archie.networking import net.kernelpanicsoft.archie.Archie -import net.kernelpanicsoft.archie.gui.ComposeContainerMenu +import net.kernelpanicsoft.archie.gui.ComposeContainerMenuBase import net.kernelpanicsoft.archie.gui.blockentity.BlockEntityStatePacketRegistry +import net.kernelpanicsoft.archie.gui.item.ItemStatePacketRegistry import net.kernelpanicsoft.archie.util.rem /** * Archie's own [NetworkChannel], used for its internal packets (Compose container menu slot - * syncing, block entity state syncing). Not intended for use by downstream mods; create your + * syncing, block entity/item state syncing). Not intended for use by downstream mods; create your * own [NetworkChannel] instance instead. */ object ArchieNetworkChannel : NetworkChannel(Archie % "main") @@ -18,8 +19,9 @@ object ArchieNetworkChannel : NetworkChannel(Archie % "main") */ fun init() { - ComposeContainerMenu.register() + ComposeContainerMenuBase.register() BlockEntityStatePacketRegistry.register() + ItemStatePacketRegistry.register() register() } } \ No newline at end of file diff --git a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/registries/AClientRegistrationPlatform.common.kt b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/registries/AClientRegistrationPlatform.common.kt new file mode 100644 index 000000000..13d6e55bd --- /dev/null +++ b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/registries/AClientRegistrationPlatform.common.kt @@ -0,0 +1,24 @@ +package net.kernelpanicsoft.archie.registries + +import dev.architectury.platform.Mod + +/** + * Schedules [block] to run on the client at the earliest point client-side registration APIs + * that depend on registries already being populated - like Architectury's + * `MenuRegistry.registerScreenFactory` - are safe to call. Backed by an `actual` per mod loader, + * since the loaders genuinely differ on where that point is; only call this from inside a + * client-only guard (e.g. [net.kernelpanicsoft.archie.util.onClient]) - it does no environment + * checking of its own. + * + * On Fabric there's no staged registry-event model to race, so this runs [block] effectively + * immediately. On NeoForge, [dev.architectury.event.events.common.LifecycleEvent.SETUP]/ + * `FMLCommonSetupEvent` - the timing [ADeferredRegistryHolder.initClient] used to schedule on + * unconditionally - actually runs *after* several client registration-stage events (e.g. + * `RegisterMenuScreensEvent`), so calling `MenuRegistry.registerScreenFactory` from there + * silently never fires: it internally attaches a listener for that exact event, which has + * already fired and moved on by the time Common Setup runs. + * + * @param mod The mod whose event bus [block] should run on (NeoForge only needs this - Fabric's + * `actual` ignores it, since Fabric has no per-mod bus to look up). + */ +expect fun scheduleEarlyClientRegistration(mod: Mod, block: () -> Unit) diff --git a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/registries/ADeferredRegistryHolder.kt b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/registries/ADeferredRegistryHolder.kt index 1307c6056..04a66b104 100644 --- a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/registries/ADeferredRegistryHolder.kt +++ b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/registries/ADeferredRegistryHolder.kt @@ -1,6 +1,5 @@ package net.kernelpanicsoft.archie.registries -import dev.architectury.event.events.common.LifecycleEvent import dev.architectury.platform.Mod import dev.architectury.registry.registries.DeferredRegister import dev.architectury.registry.registries.RegistrySupplier @@ -44,20 +43,22 @@ abstract class ADeferredRegistryHolder private constructor( private val registry: DeferredRegister = DeferredRegister.create(mod.modId, registryKey) /** - * Registers the underlying [DeferredRegister], then schedules [initClient] to run on - * [LifecycleEvent.SETUP] when on the client. Must be called once during mod initialization. + * Registers the underlying [DeferredRegister], then schedules [initClient] to run on the + * client, at the earliest point registration APIs that depend on registries already being + * populated (e.g. Architectury's `MenuRegistry.registerScreenFactory`) are safe to call - see + * [scheduleEarlyClientRegistration]. Must be called once during mod initialization. */ fun init() { registry.register() onClient { - LifecycleEvent.SETUP.register { + scheduleEarlyClientRegistration(mod) { initClient() } } } - /** Client-only setup run after [init], during [LifecycleEvent.SETUP]. No-op by default. */ + /** Client-only setup run after [init] - see [scheduleEarlyClientRegistration] for exactly when. No-op by default. */ open fun initClient() = Unit /** Looks up a registered entry by its unqualified [id] (namespaced under [mod] automatically). */ diff --git a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/serialization/ItemStackNBTHolderImpl.kt b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/serialization/ItemStackNBTHolderImpl.kt index d88dae903..e97bfe7b7 100644 --- a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/serialization/ItemStackNBTHolderImpl.kt +++ b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/serialization/ItemStackNBTHolderImpl.kt @@ -1,9 +1,11 @@ package net.kernelpanicsoft.archie.serialization import net.kernelpanicsoft.archie.config.toSnakeCase +import net.kernelpanicsoft.archie.gui.item.SyncedItemHolder import net.kernelpanicsoft.archie.transfer.ArchieEnergyStorage import net.kernelpanicsoft.archie.transfer.ArchieFluidStorage import net.kernelpanicsoft.archie.transfer.ArchieItemStorage +import earth.terrarium.common_storage_lib.storage.base.UpdateManager import kotlinx.serialization.KSerializer import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.builtins.MapSerializer @@ -17,10 +19,15 @@ import kotlin.properties.PropertyDelegateProvider import kotlin.properties.ReadOnlyProperty import kotlin.properties.ReadWriteProperty import kotlin.reflect.KProperty +import kotlin.reflect.full.hasAnnotation /** * [NBTHolder] implementation backing [NBTHolder.item], persisting field values into [stack]'s * [CustomData] component instead of an in-memory map. + * + * `@Sync`-annotated fields additionally push updates through [SyncedItemHolder] when the + * delegating `thisRef` implements it - the [ItemStack]-holder equivalent of [NBTHolderImpl]'s + * `thisRef is BlockEntity` handling. */ class ItemStackNBTHolderImpl(private val stack: ItemStack) : NBTHolder { @@ -28,6 +35,7 @@ class ItemStackNBTHolderImpl(private val stack: ItemStack) : NBTHolder private val itemStorage: MutableMap = mutableMapOf() private val fluidStorage: MutableMap = mutableMapOf() private val energyStorage: MutableMap = mutableMapOf() + private val sync: MutableSet = mutableSetOf() init { @@ -40,6 +48,13 @@ class ItemStackNBTHolderImpl(private val stack: ItemStack) : NBTHolder ): PropertyDelegateProvider> { return PropertyDelegateProvider { thisRef, property -> + if (property.hasAnnotation()) + { + sync += property.name.toSnakeCase() + if (thisRef is SyncedItemHolder) + thisRef.registerSyncedProperty(property.name.toSnakeCase(), serializer) + } + val delegate = object : ReadWriteProperty { override fun getValue(thisRef: Any?, property: KProperty<*>): T @@ -59,11 +74,17 @@ class ItemStackNBTHolderImpl(private val stack: ItemStack) : NBTHolder override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { data[property.name.toSnakeCase()] = NBT.encodeToNbtTagRootless(serializer, value) + if (thisRef is SyncedItemHolder && property.name.toSnakeCase() in sync) + thisRef.onSyncedPropertyChanged(property.name.toSnakeCase(), serializer, value) saveToStack() } } if (property.name.toSnakeCase() !in data) delegate.setValue(thisRef, property, default()) + else if (thisRef is SyncedItemHolder && property.name.toSnakeCase() in sync) + // Value pre-existed on the stack, so setValue() above never ran - announce it now + // so a menu opened against pre-existing data doesn't start out unsynced. + thisRef.onSyncedPropertyChanged(property.name.toSnakeCase(), serializer, delegate.getValue(thisRef, property)) delegate } @@ -75,6 +96,13 @@ class ItemStackNBTHolderImpl(private val stack: ItemStack) : NBTHolder ): PropertyDelegateProvider>> { return PropertyDelegateProvider { thisRef, property -> + if (property.hasAnnotation()) + { + sync += property.name.toSnakeCase() + if (thisRef is SyncedItemHolder) + thisRef.registerSyncedProperty(property.name.toSnakeCase(), ListSerializer(serializer)) + } + val delegate = object : ReadWriteProperty> { override fun getValue(thisRef: Any?, property: KProperty<*>): MutableList @@ -94,11 +122,16 @@ class ItemStackNBTHolderImpl(private val stack: ItemStack) : NBTHolder override fun setValue(thisRef: Any?, property: KProperty<*>, value: MutableList) { data[property.name.toSnakeCase()] = NBT.encodeToNbtTagRootless(ListSerializer(serializer), value) + if (thisRef is SyncedItemHolder && property.name.toSnakeCase() in sync) + thisRef.onSyncedPropertyChanged(property.name.toSnakeCase(), ListSerializer(serializer), value.toList()) saveToStack() } } if (property.name.toSnakeCase() !in data) delegate.setValue(thisRef, property, default().toMutableList()) + else if (thisRef is SyncedItemHolder && property.name.toSnakeCase() in sync) + // See the equivalent branch in field() above - same pre-existing-data gap. + thisRef.onSyncedPropertyChanged(property.name.toSnakeCase(), ListSerializer(serializer), delegate.getValue(thisRef, property).toList()) delegate } @@ -110,6 +143,13 @@ class ItemStackNBTHolderImpl(private val stack: ItemStack) : NBTHolder ): PropertyDelegateProvider>> { return PropertyDelegateProvider { thisRef, property -> + if (property.hasAnnotation()) + { + sync += property.name.toSnakeCase() + if (thisRef is SyncedItemHolder) + thisRef.registerSyncedProperty(property.name.toSnakeCase(), MapSerializer(String.serializer(), serializer)) + } + val delegate = object : ReadWriteProperty> { override fun getValue(thisRef: Any?, property: KProperty<*>): MutableMap @@ -129,11 +169,16 @@ class ItemStackNBTHolderImpl(private val stack: ItemStack) : NBTHolder override fun setValue(thisRef: Any?, property: KProperty<*>, value: MutableMap) { data[property.name.toSnakeCase()] = NBT.encodeToNbtTagRootless(MapSerializer(String.serializer(), serializer), value) + if (thisRef is SyncedItemHolder && property.name.toSnakeCase() in sync) + thisRef.onSyncedPropertyChanged(property.name.toSnakeCase(), MapSerializer(String.serializer(), serializer), value.toMap()) saveToStack() } } if (property.name.toSnakeCase() !in data) delegate.setValue(thisRef, property, default().toMutableMap()) + else if (thisRef is SyncedItemHolder && property.name.toSnakeCase() in sync) + // See the equivalent branch in field() above - same pre-existing-data gap. + thisRef.onSyncedPropertyChanged(property.name.toSnakeCase(), MapSerializer(String.serializer(), serializer), delegate.getValue(thisRef, property).toMap()) delegate } @@ -142,10 +187,28 @@ class ItemStackNBTHolderImpl(private val stack: ItemStack) : NBTHolder override fun itemField(size: Int): PropertyDelegateProvider> { return PropertyDelegateProvider { thisRef, property -> + if (property.hasAnnotation()) + { + sync += property.name.toSnakeCase() + if (thisRef is SyncedItemHolder) + thisRef.registerSyncedProperty(property.name.toSnakeCase(), ArchieItemStorage.serializer()) + } val onUpdate = { + if (thisRef is SyncedItemHolder && property.name.toSnakeCase() in sync) + thisRef.onSyncedPropertyChanged(property.name.toSnakeCase(), ArchieItemStorage.serializer(), itemStorage[property.name.toSnakeCase()]!!) saveToStack() } - itemStorage[property.name.toSnakeCase()] = ArchieItemStorage(size, onUpdate) + val storage = ArchieItemStorage(size, onUpdate) + // init { loadFromStack() } already ran (before this delegate even existed to be + // hydrated by loadFromTag's itemStorage.forEach loop, unlike a BlockEntity's field + // declarations - which all run in its constructor, before NBTBlockEntity.load() ever + // calls loadFromTag) - so data may already hold this key's raw tag with nothing to + // apply it to yet. Apply it now, directly, instead. + data[property.name.toSnakeCase()]?.let { storage.readSnapshot(it) } + itemStorage[property.name.toSnakeCase()] = storage + // readSnapshot() above never calls onUpdate, so announce the starting contents now. + if (thisRef is SyncedItemHolder && property.name.toSnakeCase() in sync) + thisRef.onSyncedPropertyChanged(property.name.toSnakeCase(), ArchieItemStorage.serializer(), storage) ReadOnlyProperty { _, _ -> itemStorage[property.name.toSnakeCase()]!! } } } @@ -153,10 +216,23 @@ class ItemStackNBTHolderImpl(private val stack: ItemStack) : NBTHolder override fun fluidField(limit: Long, size: Int): PropertyDelegateProvider> { return PropertyDelegateProvider { thisRef, property -> + if (property.hasAnnotation()) + { + sync += property.name.toSnakeCase() + if (thisRef is SyncedItemHolder) + thisRef.registerSyncedProperty(property.name.toSnakeCase(), ArchieFluidStorage.serializer()) + } val onUpdate = { + if (thisRef is SyncedItemHolder && property.name.toSnakeCase() in sync) + thisRef.onSyncedPropertyChanged(property.name.toSnakeCase(), ArchieFluidStorage.serializer(), fluidStorage[property.name.toSnakeCase()]!!) saveToStack() } - fluidStorage[property.name.toSnakeCase()] = ArchieFluidStorage(limit, size, onUpdate) + val storage = ArchieFluidStorage(limit, size, onUpdate) + data[property.name.toSnakeCase()]?.let { storage.readSnapshot(it) } + fluidStorage[property.name.toSnakeCase()] = storage + // See itemField() above - same "storage's initial contents never announced" gap. + if (thisRef is SyncedItemHolder && property.name.toSnakeCase() in sync) + thisRef.onSyncedPropertyChanged(property.name.toSnakeCase(), ArchieFluidStorage.serializer(), storage) ReadOnlyProperty { _, _ -> fluidStorage[property.name.toSnakeCase()]!! } } } @@ -164,10 +240,23 @@ class ItemStackNBTHolderImpl(private val stack: ItemStack) : NBTHolder override fun energyField(capacity: Long): PropertyDelegateProvider> { return PropertyDelegateProvider { thisRef, property -> + if (property.hasAnnotation()) + { + sync += property.name.toSnakeCase() + if (thisRef is SyncedItemHolder) + thisRef.registerSyncedProperty(property.name.toSnakeCase(), ArchieEnergyStorage.serializer()) + } val onUpdate = { + if (thisRef is SyncedItemHolder && property.name.toSnakeCase() in sync) + thisRef.onSyncedPropertyChanged(property.name.toSnakeCase(), ArchieEnergyStorage.serializer(), energyStorage[property.name.toSnakeCase()]!!) saveToStack() } - energyStorage[property.name.toSnakeCase()] = ArchieEnergyStorage(capacity, onUpdate) + val storage = ArchieEnergyStorage(capacity, onUpdate) + data[property.name.toSnakeCase()]?.let { storage.readSnapshot(it) } + energyStorage[property.name.toSnakeCase()] = storage + // See itemField() above - same "storage's initial contents never announced" gap. + if (thisRef is SyncedItemHolder && property.name.toSnakeCase() in sync) + thisRef.onSyncedPropertyChanged(property.name.toSnakeCase(), ArchieEnergyStorage.serializer(), storage) ReadOnlyProperty { _, _ -> energyStorage[property.name.toSnakeCase()]!! } } } @@ -231,11 +320,29 @@ class ItemStackNBTHolderImpl(private val stack: ItemStack) : NBTHolder override fun getSyncTag(): CompoundTag { - return CompoundTag() + return buildCompoundTag { + data.filter { (key, _) -> key in sync } + .forEach { (key, value) -> put(key, value) } + } } override fun updateProperty(propertyName: String, serializer: KSerializer, value: T) { - this.data[propertyName] = NBT.encodeToNbtTagRootless(serializer, value) + // Storage-backed fields (item/fluid/energy) are canonically the *live* storage object, not + // `data` - write through readSnapshot(), or saveToTag() below would just re-derive `data` + // from the untouched live storage and clobber this write. + val storage: UpdateManager? = itemStorage[propertyName] ?: fluidStorage[propertyName] ?: energyStorage[propertyName] + if (storage != null && value is UpdateManager<*>) + { + @Suppress("UNCHECKED_CAST") + storage.readSnapshot((value as UpdateManager).createSnapshot()) + } + else + { + this.data[propertyName] = NBT.encodeToNbtTagRootless(serializer, value) + } + // Unlike NBTHolderImpl's `data` (a BlockEntity's own persisted state), `data` here is only + // a transient copy - must be flushed to the stack explicitly or a remote edit is lost. + saveToStack() } } \ No newline at end of file diff --git a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/transfer/ArchieItemMenuSlot.kt b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/transfer/ArchieItemMenuSlot.kt index e8eeb5191..3dcf42195 100644 --- a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/transfer/ArchieItemMenuSlot.kt +++ b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/transfer/ArchieItemMenuSlot.kt @@ -1,16 +1,16 @@ package net.kernelpanicsoft.archie.transfer import earth.terrarium.common_storage_lib.storage.base.UpdateManager -import net.kernelpanicsoft.archie.gui.ComposeContainerMenu +import net.kernelpanicsoft.archie.gui.ComposeContainerMenuBase import net.minecraft.world.SimpleContainer import net.minecraft.world.inventory.Slot import net.minecraft.world.item.ItemStack import java.util.function.Predicate /** - * A vanilla [Slot] that bridges one slot of an [ArchieItemStorage] into a [ComposeContainerMenu], + * A vanilla [Slot] that bridges one slot of an [ArchieItemStorage] into a [ComposeContainerMenuBase], * so `net.minecraft.world.inventory` machinery (shift-click, drag, etc.) can operate on it - * directly. Created by [ComposeContainerMenu] from a `handler(group, storage, filter)` + * directly. Created by [ComposeContainerMenuBase] from a `handler(group, storage, filter)` * registration; not usually constructed directly. * * @param filter Restricts which stacks [mayPlace] into this slot. @@ -19,7 +19,7 @@ class ArchieItemMenuSlot( private val storage: ArchieItemStorage, val filter: Predicate = Predicate { true }, slot: Int, x: Int, y: Int, - private val owningMenu: ComposeContainerMenu<*, *>, + private val owningMenu: ComposeContainerMenuBase<*>, ) : Slot(SimpleContainer(0), slot, x, y) { override fun isActive(): Boolean = owningMenu.isSlotVisible(index) diff --git a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/transfer/VanillaMenuSlot.kt b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/transfer/VanillaMenuSlot.kt index 4d7f2795b..6c94705cb 100644 --- a/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/transfer/VanillaMenuSlot.kt +++ b/Archie/common/src/main/kotlin/net/kernelpanicsoft/archie/transfer/VanillaMenuSlot.kt @@ -3,7 +3,7 @@ package net.kernelpanicsoft.archie.transfer import earth.terrarium.common_storage_lib.item.impl.vanilla.AbstractVanillaContainer import earth.terrarium.common_storage_lib.item.impl.vanilla.VanillaDelegatingSlot import earth.terrarium.common_storage_lib.storage.base.UpdateManager -import net.kernelpanicsoft.archie.gui.ComposeContainerMenu +import net.kernelpanicsoft.archie.gui.ComposeContainerMenuBase import net.minecraft.world.SimpleContainer import net.minecraft.world.inventory.Slot import net.minecraft.world.item.ItemStack @@ -11,8 +11,8 @@ import java.util.function.Predicate /** * The [ArchieItemMenuSlot] equivalent for adapting an existing vanilla-style - * [AbstractVanillaContainer] (rather than an [ArchieItemStorage]) into a [ComposeContainerMenu]. - * Created by [ComposeContainerMenu] from a `handler(group, storage, filter)` registration; not + * [AbstractVanillaContainer] (rather than an [ArchieItemStorage]) into a [ComposeContainerMenuBase]. + * Created by [ComposeContainerMenuBase] from a `handler(group, storage, filter)` registration; not * usually constructed directly. * * @param filter Restricts which stacks [mayPlace] into this slot. @@ -21,7 +21,7 @@ class VanillaMenuSlot( private val storage: AbstractVanillaContainer, val filter: Predicate = Predicate { true }, slot: Int, x: Int, y: Int, - private val owningMenu: ComposeContainerMenu<*, *>, + private val owningMenu: ComposeContainerMenuBase<*>, ) : Slot(SimpleContainer(0), slot, x, y) { override fun isActive(): Boolean = owningMenu.isSlotVisible(index) diff --git a/Archie/docs/gametest.md b/Archie/docs/gametest.md index 5dbdebdb8..78b6c1711 100644 --- a/Archie/docs/gametest.md +++ b/Archie/docs/gametest.md @@ -296,10 +296,10 @@ entity's synced state — hierarchy shape, hover/click/type behavior, render-sta scroll offsets, modal stacking. Slot/menu rendering and block-entity-backed sync genuinely need a world instead: -`ComposeContainerMenu` and -`ComposeContainerScreen, B : BlockEntity>` are hard-typed to a real -`BlockEntity`, so there's no probe-screen shortcut for them. Those tests open a real world and -place a real block: +`ComposeBlockContainerMenu` is hard-typed to a real `BlockEntity` (its +item-backed sibling, `ComposeItemContainerMenu`, needs a real player inventory instead), and +`ComposeContainerScreen>` works uniformly across both, so there's +no probe-screen shortcut for either. Those tests open a real world and place a real block: ```kotlin class TestScreenGameTest { diff --git a/Archie/docs/gui.md b/Archie/docs/gui.md index 15f66fa4b..0c1ec692e 100644 --- a/Archie/docs/gui.md +++ b/Archie/docs/gui.md @@ -27,7 +27,9 @@ class MyScreen : ComposeScreen(Component.literal("My Screen")) { ### `ComposeContainerScreen` -Base class for screens attached to a container menu (inventory, crafting grid, etc.). +Base class for screens attached to a container menu (inventory, crafting grid, etc.). Works +uniformly whether the menu is backed by a block entity or an item stack (see below) - the type +bound is just `ComposeContainerScreen>`. ```kotlin class MyContainerScreen( @@ -40,6 +42,47 @@ class MyContainerScreen( } ``` +### `ComposeBlockContainerMenu` / `ComposeItemContainerMenu` + +`ComposeContainerMenuBase` holds all the holder-agnostic slot layout/registration machinery +`ComposeContainerScreen` drives (see [transfer.md](transfer.md#wiring-storage-into-a-menu) for the +`registerSlotHandlers()`/`handler()` mechanism itself). Two concrete subclasses back it with an +actual data source: + +- **`ComposeBlockContainerMenu`** - the original, block-entity-backed menu. + Exposes `tile: T` and `blockEntityState: ComposeBlockEntityState`, read via `observeProperty` + (see [Progress, energy, and fluid indicators](#progress-energy-and-fluid-indicators) below). +- **`ComposeItemContainerMenu`** - backs a menu with an `ItemStack` instead, e.g. a + backpack/bag with its own GUI: + + ```kotlin + class MyBackpackMenu( + id: Int, playerInventory: Inventory, access: ItemContainerAccess, + ) : ComposeItemContainerMenu(MY_BACKPACK_MENU_TYPE, id, playerInventory, access) { + @Sync + var progress by holder.intField() + + override fun registerSlotHandlers() { + handler("inventory", holder.itemField(27)) + } + } + ``` + + - `access: ItemContainerAccess` locates the backing stack (`getStack()`, re-resolved fresh on + every call - never cache it) and reports whether the menu should stay open (`stillValid`). + `PlayerInventoryItemAccess(player, slot, expectedItem)` covers the common case: an item + sitting in the opening player's own inventory. Whichever slot it names is automatically + frozen against placement/pickup while the menu is open (so shift-clicking a backpack can't + move it into itself), and periodically re-checked - a menu whose `access.stillValid` goes + false (e.g. the backpack was dropped or consumed while its GUI was passively open) is + force-closed within a tick or two, not left dangling until the next player-initiated click. + - `holder: NBTHolder` is a view of the backing stack, captured once at construction (the same + "stable for this menu's lifetime" trade-off `ComposeBlockContainerMenu`'s `tile` already + makes) - declare this menu's own fields against it exactly like you would on an + `NBTBlockEntity`'s `nbt`, including `@Sync` for anything that should push live updates to the + client, read via `observeItemProperty` (see + [Progress, energy, and fluid indicators](#progress-energy-and-fluid-indicators) below). + --- ## Layout containers @@ -129,9 +172,18 @@ EnergyBar(storage = myBlockEntity.energy) // or EnergyBar(energy = 400, capacity FluidTank(fluid = myBlockEntity.tank[0].getFluid(), capacity = FluidStack.bucketAmount() * 4) ``` -None of these three animate or poll on their own - drive them from an `observeProperty` read -(see [serialization.md](serialization.md#sync)) of a `@Sync`-annotated block entity field for a -live indicator that updates as the block entity changes server-side. +None of these three animate or poll on their own - drive them from a live-updating property read +of a `@Sync`-annotated field (see [serialization.md](serialization.md#sync)) for an indicator that +tracks server-side changes: + +- **Block-entity-backed menus**: `observeProperty("name")`, reading through + `LocalBlockEntityState` (provided automatically by `ComposeContainerScreen`). +- **Item-backed menus**: the equivalent `observeItemProperty("name")`, reading through + `LocalItemState` instead - same shape, same `@Sync`-on-the-holder-field convention, just backed + by `ComposeItemContainerMenu.holder` rather than a block entity's `nbt`. + +Both are `null`/absent unless the current screen's menu actually matches that holder kind - a +`ComposeItemContainerMenu` screen has no `LocalBlockEntityState` to read, and vice versa. ### `TabContainer` diff --git a/Archie/docs/transfer.md b/Archie/docs/transfer.md index 142778286..0fda8057c 100644 --- a/Archie/docs/transfer.md +++ b/Archie/docs/transfer.md @@ -3,8 +3,9 @@ Archie provides cross-platform item and fluid storage abstractions, built on top of [Common Storage Lib](https://github.com/Fuzss/CommonStorageLib)'s resource/slot model, that work on both Fabric and NeoForge without platform-specific code. They're designed to back a block -entity's inventory/tank and expose it through Archie's [GUI framework](gui.md) — the slot types -below plug directly into `ComposeContainerMenu`. +entity's or an item stack's inventory/tank and expose it through Archie's [GUI framework](gui.md) +— the slot types below plug directly into `ComposeContainerMenuBase` (via either +`ComposeBlockContainerMenu` or `ComposeItemContainerMenu`). --- @@ -90,18 +91,31 @@ slot.set(ItemStack(Items.DIAMOND, 4)) ## Wiring storage into a menu -You don't usually build `net.minecraft.world.inventory.Slot`s by hand. `ComposeContainerMenu` +You don't usually build `net.minecraft.world.inventory.Slot`s by hand. `ComposeContainerMenuBase` (see [`gui.md`](gui.md)) takes a declarative approach instead: implement `registerSlotHandlers()` -and register each storage under a named group with `handler(group, storage, filter)`. +and register each storage under a named group with `handler(group, storage, filter)`. The same +mechanism works identically whether the menu is backed by a block entity +(`ComposeBlockContainerMenu`) or an item stack (`ComposeItemContainerMenu`) — only how you get at +the storage in the first place differs. ```kotlin +// Block-entity-backed class MyMenu( id: Int, playerInventory: Inventory, blockEntity: MyBlockEntity, -) : ComposeContainerMenu(MY_MENU_TYPE, id, playerInventory, blockEntity) { +) : ComposeBlockContainerMenu(MY_MENU_TYPE, id, playerInventory, blockEntity) { override fun registerSlotHandlers() { handler("inventory", tile.items) // ties the "inventory" group to the block entity's storage } } + +// Item-backed (e.g. a backpack) - see gui.md's ComposeItemContainerMenu section for the full shape +class MyBackpackMenu( + id: Int, playerInventory: Inventory, access: ItemContainerAccess, +) : ComposeItemContainerMenu(MY_BACKPACK_MENU_TYPE, id, playerInventory, access) { + override fun registerSlotHandlers() { + handler("inventory", holder.itemField(27)) + } +} ``` A `Slot` composable placed in the screen's layout (matched by group name) is what actually @@ -112,7 +126,7 @@ the same for a Common Storage Lib `AbstractVanillaContainer`, for adapting an ex vanilla-style container instead of an `ArchieItemStorage`. Both accept an `ItemStack -> Boolean` filter for `mayPlace`. -See [`gui.md`](gui.md) for how `ComposeContainerMenu`/`ComposeContainerScreen` fit together. +See [`gui.md`](gui.md) for how `ComposeContainerMenuBase`/`ComposeContainerScreen` fit together. --- diff --git a/Archie/fabric/build.gradle.kts b/Archie/fabric/build.gradle.kts index 9f65c6dba..edb2a48e5 100644 --- a/Archie/fabric/build.gradle.kts +++ b/Archie/fabric/build.gradle.kts @@ -18,6 +18,11 @@ actualizer { actualizes(project(":common")) } +// If a `jar { from(project(":common").sourceSets.main.get().output) }` merge (like neoforge's) +// ever gets added here too, exclude("net/kernelpanicsoft/archie/**") from it - that output is +// common's own stub-linked classes, and it won the duplicatesStrategy race on NeoForge (crashed +// at runtime). See neoforge/build.gradle.kts's jar task. + val localProperties = kotlin.runCatching { val localPropsFile = rootDir.resolve("local.properties") val sharedPropsFile = rootDir.resolve("../local.properties") diff --git a/Archie/fabric/src/main/kotlin/net/kernelpanicsoft/archie/registries/AClientRegistrationPlatform.fabric.kt b/Archie/fabric/src/main/kotlin/net/kernelpanicsoft/archie/registries/AClientRegistrationPlatform.fabric.kt new file mode 100644 index 000000000..693faf7c6 --- /dev/null +++ b/Archie/fabric/src/main/kotlin/net/kernelpanicsoft/archie/registries/AClientRegistrationPlatform.fabric.kt @@ -0,0 +1,6 @@ +package net.kernelpanicsoft.archie.registries + +import dev.architectury.platform.Mod + +/** Fabric has no staged registry-event model to race, so [block] just runs immediately. */ +actual fun scheduleEarlyClientRegistration(mod: Mod, block: () -> Unit) = block() diff --git a/Archie/neoforge/build.gradle.kts b/Archie/neoforge/build.gradle.kts index 5361470c3..2a8125933 100644 --- a/Archie/neoforge/build.gradle.kts +++ b/Archie/neoforge/build.gradle.kts @@ -216,7 +216,13 @@ tasks { jar { duplicatesStrategy = DuplicatesStrategy.EXCLUDE - from(project(":common").sourceSets.main.get().output) + from(project(":common").sourceSets.main.get().output) { + // That output is common's own independently-compiled (stub-linked) classes - this + // module's own sourceSets.main.output already has a correctly-actualized copy of all + // of them via actualizes(project(":common")) above. Exclude so the stub-linked copy + // can't win the duplicatesStrategy race - it did, and threw at runtime. + exclude("net/kernelpanicsoft/archie/**") + } } sourcesJar { diff --git a/Archie/neoforge/src/main/kotlin/net/kernelpanicsoft/archie/registries/AClientRegistrationPlatform.neoforge.kt b/Archie/neoforge/src/main/kotlin/net/kernelpanicsoft/archie/registries/AClientRegistrationPlatform.neoforge.kt new file mode 100644 index 000000000..c3c77f5df --- /dev/null +++ b/Archie/neoforge/src/main/kotlin/net/kernelpanicsoft/archie/registries/AClientRegistrationPlatform.neoforge.kt @@ -0,0 +1,18 @@ +package net.kernelpanicsoft.archie.registries + +import dev.architectury.platform.Mod +import dev.architectury.platform.hooks.EventBusesHooks +import net.neoforged.neoforge.client.event.RegisterMenuScreensEvent + +/** + * [RegisterMenuScreensEvent] is the earliest point NeoForge's own client registration-stage + * events fire, and specifically the one `MenuRegistry.registerScreenFactory` itself listens + * for internally - hooking the exact same event here (on [mod]'s own bus, since it's a per-mod + * event) guarantees this fires before that internal listener would otherwise miss it. + */ +actual fun scheduleEarlyClientRegistration(mod: Mod, block: () -> Unit) +{ + EventBusesHooks.whenAvailable(mod.modId) { bus -> + bus.addListener(RegisterMenuScreensEvent::class.java) { block() } + } +}